bindery-cli 0.35.0__tar.gz → 0.37.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/.github/workflows/publish.yml +23 -0
  2. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/CLAUDE.md +5 -4
  3. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/PKG-INFO +45 -5
  4. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/README.md +43 -3
  5. bindery_cli-0.37.0/REPORT-12-Sept.md +119 -0
  6. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/patchnotes.md +98 -0
  7. bindery_cli-0.37.0/plugin/__init__.py +239 -0
  8. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/pyproject.toml +2 -2
  9. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/roadmap.md +156 -5
  10. bindery_cli-0.37.0/scripts/build_plugin.py +89 -0
  11. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/spec.md +62 -4
  12. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/__init__.py +1 -1
  13. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/audit.py +183 -5
  14. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/cli.py +13 -4
  15. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/transforms.py +9 -2
  16. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_audit.py +283 -0
  17. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_cli.py +9 -5
  18. bindery_cli-0.37.0/tests/test_plugin.py +360 -0
  19. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_transforms.py +27 -0
  20. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/uv.lock +5 -5
  21. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/.github/workflows/ci.yml +0 -0
  22. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/.gitignore +0 -0
  23. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/AGENTS.md +0 -0
  24. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/LICENSE +0 -0
  25. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/docs/screenshots/library-sweep.png +0 -0
  26. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/logo.svg +0 -0
  27. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/run_tests.sh +0 -0
  28. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/scripts/FastSweep.java +0 -0
  29. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/scripts/fast_sweep.py +0 -0
  30. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/__main__.py +0 -0
  31. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/epub.py +0 -0
  32. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/library.py +0 -0
  33. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/pagination.py +0 -0
  34. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/reserialize.py +0 -0
  35. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/validate.py +0 -0
  36. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/src/bindery/watermark.py +0 -0
  37. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  38. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/testing_facility/top500candidates/REPORT.md +0 -0
  39. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_epub.py +0 -0
  40. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_library.py +0 -0
  41. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_pagination.py +0 -0
  42. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_reserialize.py +0 -0
  43. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_validate.py +0 -0
  44. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_version.py +0 -0
  45. {bindery_cli-0.35.0 → bindery_cli-0.37.0}/tests/test_watermark.py +0 -0
@@ -43,3 +43,26 @@ jobs:
43
43
 
44
44
  - name: Publish to PyPI
45
45
  uses: pypa/gh-action-pypi-publish@release/v1
46
+
47
+ plugin-zip:
48
+ name: Build the Bindery Repair Calibre plugin zip
49
+ needs: test
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - uses: actions/setup-python@v5
54
+ with:
55
+ python-version: "3.14"
56
+
57
+ - name: Run the suite
58
+ run: |
59
+ pip install .[reserialize]
60
+ PYTHONPATH=src python -m unittest discover -s tests
61
+
62
+ - name: Build the plugin zip from the tagged tree
63
+ run: python scripts/build_plugin.py
64
+
65
+ - name: Attach BinderyRepair zip to the release
66
+ uses: softprops/action-gh-release@v2
67
+ with:
68
+ files: dist/BinderyRepair-*.zip
@@ -73,16 +73,17 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
73
73
 
74
74
  ## Layout
75
75
 
76
- - `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms.
76
+ - `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
77
77
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
78
78
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
79
79
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
80
- - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, and `monolithic` since v0.21.0, `--max-doc-chars N`) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis — a damaged entry reports its own CORRUPT verdict instead of feeding emptytext — and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry — do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story).
80
+ - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis — a damaged entry reports its own CORRUPT verdict instead of feeding emptytext — and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry — do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book.
81
81
  - `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan).
82
82
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
83
- - `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching) — the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched — never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book.
83
+ - `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched — never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book.
84
84
  - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2). Both take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract).
85
- - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, and the version pin.
85
+ - `plugin/__init__.py` + `scripts/build_plugin.py`: the Bindery Repair Calibre plugin (v0.37.0; identity `Bindery Repair` / `bindery_repair`, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendors `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, `reserialize.py` byte-identically into the zip (the zip root is a package, so their relative imports resolve unchanged; the suite's drift test pins the equality) with the version tuple substituted from the single-source VERSION; `publish.yml` attaches `BinderyRepair-v<VERSION>.zip` to each release. The plugin runs ONLY the default pass (epubcheck cannot gate inside Calibre), never raises, is byte-idempotent (zero fixes returns the original path), testzip()-verifies its output, logs one line per book, and takes JSON config via `site_customization` (`log`, `log_path`, `max_size_mb` default 150, `epubcheck_path` experimental validation default OFF). Load it in tests through the `tests/test_plugin.py` stubs: the calibre namespace is installed in sys.modules and the package loads with a two-dir `__path__` (plugin dir first, then src/bindery) exactly like calibre's zipplugin loader shape.
86
+ - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, the version pin, and the Calibre plugin (build drift + run behavior under calibre stubs).
86
87
 
87
88
  ## Conventions
88
89
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.35.0
3
+ Version: 0.37.0
4
4
  Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
5
5
  Author: vrnvctss
6
6
  License: MIT
@@ -13,7 +13,7 @@ Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Classifier: Topic :: Utilities
15
15
  Requires-Python: >=3.14
16
- Requires-Dist: cquarry>=1.17.0
16
+ Requires-Dist: cquarry>=1.19.0
17
17
  Requires-Dist: tqdm
18
18
  Requires-Dist: vir-tui>=2.2.0
19
19
  Provides-Extra: reserialize
@@ -57,8 +57,8 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
57
57
  - **`--unwrap-block-in-inline`**: safely unwraps `<span>` tags that illegally contain a block-level element (e.g. `<div>` or `<p>`), leaving the block element intact.
58
58
  - **`--strip-invalid-value`**: systematically strips invalid `value="..."` attributes from elements like `<div>`, `<span>`, `<p>`, etc.
59
59
  - **`--fix-page-map`**: normalizes legacy page-map markup — drops the non-standard `page-map="..."` attribute from the OPF `<spine>` and adds `class="pages"` to classless NCX `<pageList>` elements (epubcheck rejects both on older HarperCollins / Anna's Archive conversions).
60
- - **`--strip-epub3-attrs`**: scrubs the EPUB3-only attributes epubcheck rejects on an EPUB2 package — `page-progression-direction`, `epub:type`, `aria-label` (a fixed, documented set; rendering is unchanged, and lookalikes like `type` or the wider aria family survive). The edit is anchored to real start tags, so prose that merely mentions `epub:type="chapter"` is preserved.
61
- - **`--downgrade-epub3-tags`**: downgrades EPUB3/HTML5 semantic elements to their EPUB2 equivalents — `figure`/`section` to `div`, `figcaption` to `p` — keeping existing classes and appending the semantic name (`class="figure"`) as the styling hook. Tag names a stylesheet styles as an element selector are protected book-wide, so styled formatting is never destroyed. Both EPUB2-targeted fixes are gated on the package version declared in the OPF: on an EPUB 3 book they do nothing, because their target defects only exist below EPUB 3.
60
+ - **`--strip-epub3-attrs`**: scrubs the EPUB3-only attributes epubcheck rejects on an EPUB2 package: `page-progression-direction`, `epub:type`, `aria-label` (a fixed, documented set; rendering is unchanged, and lookalikes like `type` or the wider aria family survive). The edit is anchored to real start tags, so prose that merely mentions `epub:type="chapter"` is preserved.
61
+ - **`--downgrade-epub3-tags`**: downgrades EPUB3/HTML5 semantic elements to their EPUB2 equivalents (`figure`/`section` to `div`, `figcaption` to `p`), keeping existing classes and appending the semantic name (`class="figure"`) as the styling hook. Tag names a stylesheet styles as an element selector are protected book-wide, so styled formatting is never destroyed. Both EPUB2-targeted fixes are gated on the package version declared in the OPF: on an EPUB 3 book they do nothing, because their target defects only exist below EPUB 3.
62
62
  - **`--unwrap-illegal-tags`**: strips completely invalid or deprecated HTML tags that break EPUB3 validation (`<st>`, `<sentence>`, `<o>`, `<w>`, `<pagebreak>`) while retaining their inner text. Any of those names styled as an *element selector* by an EPUB stylesheet (`.css` entries and inline `<style>` blocks alike; class/id selectors like `.st`/`#w` don't count) is protected for the whole book, guaranteeing format preservation.
63
63
  - **`--prune-missing-resources`**: removes references to files the archive does not contain (RSC-007/PKG-010): dead `<link>` elements, anchors' `href` to absent files (anchor text preserved), absent `<img>` sources (replaced by their alt text when they carry one), and orphaned non-spine OPF manifest items. Spine documents are never pruned: a missing spine document is reported as a damaged fragment, not silently dropped.
64
64
  - **`--strip-broken-anchors`**: strips href attributes that cannot resolve, keeping the anchor text byte-for-byte: a `#fragment` the target document does not define (RSC-020/RSC-012; NCX navTargets fall back to the document target, so chapter navigation survives) and unresolvable URI schemes (`kindle:`, `file:`).
@@ -95,6 +95,41 @@ uv tool install bindery-cli # core tools
95
95
  uv tool install "bindery-cli[reserialize]" # incl. --reserialize
96
96
  ```
97
97
 
98
+ ## The Calibre plugin (Bindery Repair)
99
+
100
+ Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
101
+ repairs EPUBs as they are imported: it is attached to the GitHub release beside
102
+ the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
103
+ from file"). The plugin is the CLI's always-on core pass only: the five
104
+ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
105
+ strips stay CLI-only, because their acceptance is the epubcheck gate and
106
+ epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
107
+
108
+ Behavior and guarantees:
109
+
110
+ - `run()` never raises and never touches the original file or `metadata.db`: a
111
+ repaired copy is built in a persistent temp file and handed back for import;
112
+ any trouble returns the original path and logs one line.
113
+ - Byte-idempotent: an already-clean book yields zero fixes and the original
114
+ path, so re-adding a format re-runs the plugin harmlessly.
115
+ - The rewritten archive is `testzip()`-verified before it is handed back.
116
+ - One log line per book (fixed / no fixes / refused / errored), appended to
117
+ `bindery_repair.log` in Calibre's config directory by default.
118
+ - Files over the size cap are refused (default 150MB) rather than stalling an
119
+ import.
120
+
121
+ Configuration is the plugin's customization string, parsed as JSON:
122
+
123
+ ```json
124
+ {"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
125
+ "epubcheck_path": null}
126
+ ```
127
+
128
+ `epubcheck_path` opts into the experimental on-PATH validation mode: the
129
+ repaired copy is re-measured with your epubcheck binary and refused unless it
130
+ is no worse than the original. It defaults to off; the default pass is safe
131
+ without a gate, and the mode costs two epubcheck runs per imported book.
132
+
98
133
  ## Usage
99
134
 
100
135
  Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
@@ -126,10 +161,15 @@ bindery audit ocr ~/docs/Calibre\ Library
126
161
  # Flag books where a single content document is too large for readers to render
127
162
  bindery audit monolithic ~/docs/Calibre\ Library
128
163
 
164
+ # Spot-check completeness: prose coverage, first/middle/last excerpts, trailing ToC
165
+ bindery audit completeness ~/docs/Calibre\ Library
166
+
129
167
  # Run all audits and generate a comprehensive CSV
130
168
  cd ~/docs/Calibre\ Library && bindery audit all
131
169
  ```
132
170
 
171
+ **Completeness spot-check:** the `completeness` audit answers the pre-import question "does this book reach its end?" It reports, per book: the prose-document count against the spine (docs with 400+ visible characters), the opening and closing text of the first/middle/last prose doc, a trailing-ToC classification of the final spine doc (a link-dense chapter list after the real back matter), and the fraction of unreadable docs. A trailing ToC is reported as an advisory, never a failure: the closing excerpt always comes from the real back matter, so a human can confirm the book ends where the author ended it.
172
+
133
173
  Audits can also be run on a directory of loose `.epub` files by passing the path as the second argument:
134
174
  ```sh
135
175
  bindery audit pagenumbers /path/to/loose/epubs
@@ -204,7 +244,7 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
204
244
  - `--json FILE` writes a machine-readable report of the whole run (per-book status, before/after counts, applied flag, summary totals). `--manual-list FILE` writes the paths of every book that was not auto-repaired, one per line, ready for manual follow-up.
205
245
  - `--apply` is required to write; the default is a dry run. `--backup DIR` mirrors originals before replacing; `--backup-inplace` writes `.epub.bak` beside each file.
206
246
  - `--install-to-calibre` resolves the Calibre book id from `metadata.db` via cquarry (one read-only path→id map per run), so a hand-renamed `Author/Title (id)/` directory can never send the repaired file to the wrong book. The directory-name regex is only a no-catalog fallback; with neither, the file is saved atomically in place.
207
- - `--install-to-calibre` installs the repaired EPUB through cquarry's write module: the file is placed atomically at the catalogued path (same filename, Calibre's layout never changes) and the format row is re-registered in one transaction, so the stored size stays truthful and the book lands in `metadata_dirtied` — Calibre regenerates its sidecar .opf on next start. No external `calibredb` call is involved. It falls back to atomic file replacement if the Calibre database ID cannot be resolved, and a database failure degrades to the in-place save with a warning. A directory-name-guessed id is trusted only when `metadata.db` corroborates it (the book exists and the file is that book's catalogued EPUB); an uncatalogued stray file inside a book directory is saved in place and the catalog row is left untouched.
247
+ - `--install-to-calibre` installs the repaired EPUB through cquarry's write module: the file is placed atomically at the catalogued path (same filename, Calibre's layout never changes) and the format row is re-registered in one transaction, so the stored size stays truthful and the book lands in `metadata_dirtied`; Calibre regenerates its sidecar .opf on next start. No external `calibredb` call is involved. It falls back to atomic file replacement if the Calibre database ID cannot be resolved, and a database failure degrades to the in-place save with a warning. A directory-name-guessed id is trusted only when `metadata.db` corroborates it (the book exists and the file is that book's catalogued EPUB); an uncatalogued stray file inside a book directory is saved in place and the catalog row is left untouched.
208
248
  - `--all` automatically turns on all opt-in non-fatal fixes and lossy strips (pagination, watermarks, bad attributes, unknown entities, image alt tags, etc.) in a single run.
209
249
  - Only the `.epub` is replaced. `metadata.opf`, `cover.jpg`, and `metadata.db` are left for Calibre's Quality Check sync to reconcile.
210
250
  - A per-book progress line goes to stderr (stdout stays a clean report); `--quiet` suppresses it. A corrupt or unreadable book is reported and skipped, never aborting the sweep.
@@ -35,8 +35,8 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
35
35
  - **`--unwrap-block-in-inline`**: safely unwraps `<span>` tags that illegally contain a block-level element (e.g. `<div>` or `<p>`), leaving the block element intact.
36
36
  - **`--strip-invalid-value`**: systematically strips invalid `value="..."` attributes from elements like `<div>`, `<span>`, `<p>`, etc.
37
37
  - **`--fix-page-map`**: normalizes legacy page-map markup — drops the non-standard `page-map="..."` attribute from the OPF `<spine>` and adds `class="pages"` to classless NCX `<pageList>` elements (epubcheck rejects both on older HarperCollins / Anna's Archive conversions).
38
- - **`--strip-epub3-attrs`**: scrubs the EPUB3-only attributes epubcheck rejects on an EPUB2 package — `page-progression-direction`, `epub:type`, `aria-label` (a fixed, documented set; rendering is unchanged, and lookalikes like `type` or the wider aria family survive). The edit is anchored to real start tags, so prose that merely mentions `epub:type="chapter"` is preserved.
39
- - **`--downgrade-epub3-tags`**: downgrades EPUB3/HTML5 semantic elements to their EPUB2 equivalents — `figure`/`section` to `div`, `figcaption` to `p` — keeping existing classes and appending the semantic name (`class="figure"`) as the styling hook. Tag names a stylesheet styles as an element selector are protected book-wide, so styled formatting is never destroyed. Both EPUB2-targeted fixes are gated on the package version declared in the OPF: on an EPUB 3 book they do nothing, because their target defects only exist below EPUB 3.
38
+ - **`--strip-epub3-attrs`**: scrubs the EPUB3-only attributes epubcheck rejects on an EPUB2 package: `page-progression-direction`, `epub:type`, `aria-label` (a fixed, documented set; rendering is unchanged, and lookalikes like `type` or the wider aria family survive). The edit is anchored to real start tags, so prose that merely mentions `epub:type="chapter"` is preserved.
39
+ - **`--downgrade-epub3-tags`**: downgrades EPUB3/HTML5 semantic elements to their EPUB2 equivalents (`figure`/`section` to `div`, `figcaption` to `p`), keeping existing classes and appending the semantic name (`class="figure"`) as the styling hook. Tag names a stylesheet styles as an element selector are protected book-wide, so styled formatting is never destroyed. Both EPUB2-targeted fixes are gated on the package version declared in the OPF: on an EPUB 3 book they do nothing, because their target defects only exist below EPUB 3.
40
40
  - **`--unwrap-illegal-tags`**: strips completely invalid or deprecated HTML tags that break EPUB3 validation (`<st>`, `<sentence>`, `<o>`, `<w>`, `<pagebreak>`) while retaining their inner text. Any of those names styled as an *element selector* by an EPUB stylesheet (`.css` entries and inline `<style>` blocks alike; class/id selectors like `.st`/`#w` don't count) is protected for the whole book, guaranteeing format preservation.
41
41
  - **`--prune-missing-resources`**: removes references to files the archive does not contain (RSC-007/PKG-010): dead `<link>` elements, anchors' `href` to absent files (anchor text preserved), absent `<img>` sources (replaced by their alt text when they carry one), and orphaned non-spine OPF manifest items. Spine documents are never pruned: a missing spine document is reported as a damaged fragment, not silently dropped.
42
42
  - **`--strip-broken-anchors`**: strips href attributes that cannot resolve, keeping the anchor text byte-for-byte: a `#fragment` the target document does not define (RSC-020/RSC-012; NCX navTargets fall back to the document target, so chapter navigation survives) and unresolvable URI schemes (`kindle:`, `file:`).
@@ -73,6 +73,41 @@ uv tool install bindery-cli # core tools
73
73
  uv tool install "bindery-cli[reserialize]" # incl. --reserialize
74
74
  ```
75
75
 
76
+ ## The Calibre plugin (Bindery Repair)
77
+
78
+ Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
79
+ repairs EPUBs as they are imported: it is attached to the GitHub release beside
80
+ the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
81
+ from file"). The plugin is the CLI's always-on core pass only: the five
82
+ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
83
+ strips stay CLI-only, because their acceptance is the epubcheck gate and
84
+ epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
85
+
86
+ Behavior and guarantees:
87
+
88
+ - `run()` never raises and never touches the original file or `metadata.db`: a
89
+ repaired copy is built in a persistent temp file and handed back for import;
90
+ any trouble returns the original path and logs one line.
91
+ - Byte-idempotent: an already-clean book yields zero fixes and the original
92
+ path, so re-adding a format re-runs the plugin harmlessly.
93
+ - The rewritten archive is `testzip()`-verified before it is handed back.
94
+ - One log line per book (fixed / no fixes / refused / errored), appended to
95
+ `bindery_repair.log` in Calibre's config directory by default.
96
+ - Files over the size cap are refused (default 150MB) rather than stalling an
97
+ import.
98
+
99
+ Configuration is the plugin's customization string, parsed as JSON:
100
+
101
+ ```json
102
+ {"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
103
+ "epubcheck_path": null}
104
+ ```
105
+
106
+ `epubcheck_path` opts into the experimental on-PATH validation mode: the
107
+ repaired copy is re-measured with your epubcheck binary and refused unless it
108
+ is no worse than the original. It defaults to off; the default pass is safe
109
+ without a gate, and the mode costs two epubcheck runs per imported book.
110
+
76
111
  ## Usage
77
112
 
78
113
  Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
@@ -104,10 +139,15 @@ bindery audit ocr ~/docs/Calibre\ Library
104
139
  # Flag books where a single content document is too large for readers to render
105
140
  bindery audit monolithic ~/docs/Calibre\ Library
106
141
 
142
+ # Spot-check completeness: prose coverage, first/middle/last excerpts, trailing ToC
143
+ bindery audit completeness ~/docs/Calibre\ Library
144
+
107
145
  # Run all audits and generate a comprehensive CSV
108
146
  cd ~/docs/Calibre\ Library && bindery audit all
109
147
  ```
110
148
 
149
+ **Completeness spot-check:** the `completeness` audit answers the pre-import question "does this book reach its end?" It reports, per book: the prose-document count against the spine (docs with 400+ visible characters), the opening and closing text of the first/middle/last prose doc, a trailing-ToC classification of the final spine doc (a link-dense chapter list after the real back matter), and the fraction of unreadable docs. A trailing ToC is reported as an advisory, never a failure: the closing excerpt always comes from the real back matter, so a human can confirm the book ends where the author ended it.
150
+
111
151
  Audits can also be run on a directory of loose `.epub` files by passing the path as the second argument:
112
152
  ```sh
113
153
  bindery audit pagenumbers /path/to/loose/epubs
@@ -182,7 +222,7 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
182
222
  - `--json FILE` writes a machine-readable report of the whole run (per-book status, before/after counts, applied flag, summary totals). `--manual-list FILE` writes the paths of every book that was not auto-repaired, one per line, ready for manual follow-up.
183
223
  - `--apply` is required to write; the default is a dry run. `--backup DIR` mirrors originals before replacing; `--backup-inplace` writes `.epub.bak` beside each file.
184
224
  - `--install-to-calibre` resolves the Calibre book id from `metadata.db` via cquarry (one read-only path→id map per run), so a hand-renamed `Author/Title (id)/` directory can never send the repaired file to the wrong book. The directory-name regex is only a no-catalog fallback; with neither, the file is saved atomically in place.
185
- - `--install-to-calibre` installs the repaired EPUB through cquarry's write module: the file is placed atomically at the catalogued path (same filename, Calibre's layout never changes) and the format row is re-registered in one transaction, so the stored size stays truthful and the book lands in `metadata_dirtied` — Calibre regenerates its sidecar .opf on next start. No external `calibredb` call is involved. It falls back to atomic file replacement if the Calibre database ID cannot be resolved, and a database failure degrades to the in-place save with a warning. A directory-name-guessed id is trusted only when `metadata.db` corroborates it (the book exists and the file is that book's catalogued EPUB); an uncatalogued stray file inside a book directory is saved in place and the catalog row is left untouched.
225
+ - `--install-to-calibre` installs the repaired EPUB through cquarry's write module: the file is placed atomically at the catalogued path (same filename, Calibre's layout never changes) and the format row is re-registered in one transaction, so the stored size stays truthful and the book lands in `metadata_dirtied`; Calibre regenerates its sidecar .opf on next start. No external `calibredb` call is involved. It falls back to atomic file replacement if the Calibre database ID cannot be resolved, and a database failure degrades to the in-place save with a warning. A directory-name-guessed id is trusted only when `metadata.db` corroborates it (the book exists and the file is that book's catalogued EPUB); an uncatalogued stray file inside a book directory is saved in place and the catalog row is left untouched.
186
226
  - `--all` automatically turns on all opt-in non-fatal fixes and lossy strips (pagination, watermarks, bad attributes, unknown entities, image alt tags, etc.) in a single run.
187
227
  - Only the `.epub` is replaced. `metadata.opf`, `cover.jpg`, and `metadata.db` are left for Calibre's Quality Check sync to reconcile.
188
228
  - A per-book progress line goes to stderr (stdout stays a clean report); `--quiet` suppresses it. A corrupt or unreadable book is reported and skipped, never aborting the sweep.
@@ -0,0 +1,119 @@
1
+ # REPORT-12-Sept: the repair-landscape research (bindery-cli vs real-world EPUB damage)
2
+
3
+ Research date: 2026-09-10/11. Method: a read-only research pass mapping
4
+ the full taxonomy of real-world EPUB damage — by producer (Calibre
5
+ conversions, Word/Office export, InDesign, legacy OCR, Adobe Digital
6
+ Editions, KindleUnpack, self-pub aggregators, Windows case-manglers) —
7
+ against this repo's shipped repair surface, the Phase 14/15 backlogs,
8
+ and the Bug Reports. References include the upstream clone's format
9
+ readers. Full evidence lives in the agent's findings; this report is
10
+ the distilled verdict and every actionable gap with its routing.
11
+
12
+ ## Verdict
13
+
14
+ Bindery handles roughly **60-65% of real-world damage classes
15
+ end-to-end** and is near-complete for its founding niche: markup
16
+ well-formedness (the conversion-fatals class), NCX basics,
17
+ zip/mimetype container hygiene, dead-reference pruning, watermark and
18
+ page-number furniture, and archive/spine diagnosis (corruption, DRM
19
+ skip, ToC-bloat classification). Phase 14's hardening and all five Bug
20
+ Reports are shipped through v0.35.0; Phase 15 (the completeness
21
+ analyzer) is approved.
22
+
23
+ The missing ~35% is not more markup surgery. It is **package-level
24
+ repairs** — container.xml, manifest media-types, spine attribute
25
+ cleanup, encryption.xml/obfuscation state, zip-level dedupe, href
26
+ case/IRI mismatches — and the finding that matters most: several of
27
+ those classes are **gateway defects**. Epubcheck stays fatal while
28
+ they persist, so no shipped repair can ever be gate-accepted on those
29
+ books. They are locked out of the pipeline entirely.
30
+
31
+ ## A. The damage-taxonomy matrix and the repair gaps
32
+
33
+ 1. **Missing/corrupt `META-INF/container.xml`** (any producer):
34
+ epubcheck fatal; the OPF is unfindable. The locator falls back to
35
+ the first `.opf` but never writes the file. Deterministic fix:
36
+ generate the standard container.xml at the located OPF (upstream
37
+ calibre's `initialize_container` is the model). Effort S; HIGH
38
+ value — a gateway repair that unlocks an entire class of books.
39
+ 2. **Stale font-obfuscation state** (publisher EPUB3 fonts; IDPF
40
+ `2008/embedding` and Adobe `pdf/enc#RC`): post-KindleUnpack/strip
41
+ tools decrypt fonts in place but leave `encryption.xml` declaring
42
+ obfuscation; epubcheck errors and the audit implies DRM where there
43
+ is none. Fix: detect via font magic bytes at offset 0 vs the
44
+ CipherReference; prune stale entries; drop encryption.xml when only
45
+ obfuscation entries remain and the fonts verify. Never
46
+ un-obfuscate. Also gives the audit a real OBFUSCATED-vs-DRM
47
+ distinction. Effort M; needs the magic-byte detection validated on
48
+ real samples first.
49
+ 3. **Wrong/missing manifest `media-type`** (aggregators, ADE): fonts,
50
+ images, and media declared with wrong types. Fix: sniff magic bytes
51
+ or extension map; attribute-only. Effort S.
52
+ 4. **href case/backslash/IRI mismatches** (KindleUnpack, Windows
53
+ producers): `Images/Cover.JPG` vs `images/cover.jpg`, backslash
54
+ separators, `%XX` variants — the file IS present, so
55
+ `--prune-missing-resources` cannot touch the RSC-007/PKG-010 it
56
+ causes. Fix: case-insensitive namemap when exactly one match;
57
+ backslash to slash. Effort M.
58
+ 5. **Dangling reference edges after pruning** (any producer):
59
+ `spine@toc`, `item@media-overlay`, `item@fallback`, and EPUB2 cover
60
+ meta pointing at items the prune pass deleted. Today's prune checks
61
+ only `spine_ids` — it can manufacture the regression the gate then
62
+ rejects. Fix: extend prune to rewrite those edges (the rewrite
63
+ machinery exists in fix_manifest_ids). Effort S/M.
64
+ 6. **Duplicate zip entries** (any producer): last-wins shadowing; the
65
+ audit counts `dup_entries`, the rewrite copies both. Fix: drop
66
+ shadowed duplicates on rewrite. Effort S.
67
+ 7. **Cover wiring** (EPUB2 producers): dangling `<meta name="cover">`,
68
+ missing EPUB3 `properties="cover-image"`, wrong cover media-type —
69
+ calibre and booksellers extract no cover. Narrow, finding-driven
70
+ repair when the cover is identifiable from the existing guide/meta;
71
+ audit-only otherwise. Effort M.
72
+ 8. **Broken CSS from converters**: `url()` to absent assets, malformed
73
+ selectors. Repair is fiddly; ship the audit-only detector first and
74
+ let prevalence data decide. Effort M (repair).
75
+ 9. **Human-facing metadata validity** (self-pub/Word): non-ISO
76
+ `dc:date`, invalid `dc:language`, missing EPUB3
77
+ `dcterms:modified`. Explicit non-goal today; `dcterms:modified`
78
+ also collides with byte-determinism. Needs a written charter
79
+ ruling: carve-out with a determinism-safe timestamp policy, or
80
+ route to cquarry/CalibreQuarry. Effort M + a decision.
81
+ 10. **EPUB3 dual-ToC drift** (nav vs NCX): detection is a
82
+ deterministic structural diff feeding `decisions_needed`; full
83
+ sync is ToC synthesis and stays out of charter permanently.
84
+ Effort L if repair is ever attempted; audit-only recommended.
85
+ 11. **No-repair classes, correctly routed**: OCR garbage, truncated
86
+ re-sources, absent font families (audit + re-source); real DRM
87
+ (clean ENCRYPTED skip — shipped); DB-level format rows
88
+ (cquarry's, done); catalog completeness and human-facing metadata
89
+ quality (CalibreQuarry's audits).
90
+
91
+ ## B. Research-before-boxing
92
+
93
+ 1. **FastSweep prevalence study** — run `fast_sweep.py --mode=extract`
94
+ aggregation over the full library targeting the new classes
95
+ (container.xml presence/validity, encryption.xml algorithms,
96
+ media-type warnings, case-mismatch RSC-007s). The harness exists;
97
+ it sizes every box above with real counts before any is built.
98
+ 2. **The metadata carve-out ruling** (gap 9) — Brandon decides;
99
+ the audit records both options.
100
+ 3. **The obfuscation magic-byte table** (gap 2) — validate IDPF and
101
+ Adobe font prefixes against real samples before the repair ships.
102
+
103
+ ## C. Routing notes
104
+
105
+ Package-structure repairs (items 1-6) fit the existing "structural
106
+ opt-ins + gate" model without reopening the semantics-preserving
107
+ contract. Items 7-10 each need an explicit scope decision first.
108
+ Cross-repo: DB-level size/format-row mismatches route to cquarry
109
+ (set_format, shipped); catalog completeness, duplicate detection, and
110
+ human-facing metadata quality route to CalibreQuarry's audits; OCR,
111
+ truncation, monolithic, and missing-font content stay audit-and-
112
+ re-source permanently.
113
+
114
+ ## D. Phase 16 routing
115
+
116
+ Items 1-6 (the ungated package-structure repairs) are boxed in
117
+ roadmap.md **Phase 16** with the FastSweep prevalence study leading.
118
+ Items 7-10 are recorded as decision-gated boxes. Phase 15 (approved)
119
+ runs first if its fixture work is not yet done.
@@ -1,4 +1,102 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.37.0 (2026-09-12)
3
+
4
+ ### The Bindery Repair Calibre plugin (Phase 3)
5
+
6
+ - **Books are repaired as they are imported.** Each release now attaches
7
+ BinderyRepair-v<VERSION>.zip to the GitHub release: a Calibre
8
+ FileTypePlugin (on_import) that runs the CLI's always-on core pass, the
9
+ five well-formedness fixes plus the NCX pipeline, on *.epub files as they
10
+ are added. run() never raises and never touches the original file or
11
+ metadata.db: a repaired copy is built in a persistent temp file,
12
+ testzip()-verified, and handed back for import; any trouble returns the
13
+ original path and logs one line. The plugin is byte-idempotent, so
14
+ re-adding a format re-runs it harmlessly.
15
+ - **The vendor slice is byte-identical to the repo.** The zip carries
16
+ transforms.py, epub.py, pagination.py, watermark.py, and reserialize.py
17
+ unchanged (the zip root is a package, so their relative imports resolve);
18
+ a suite drift test pins the equality, and publish.yml regenerates the zip
19
+ from the tag and attaches it to the release.
20
+ - **Nothing runs ungated, ever.** The structural repairs and the lossy
21
+ strips stay CLI-only because their acceptance is the epubcheck gate,
22
+ which cannot run inside Calibre. Identity: Bindery Repair /
23
+ bindery_repair. Config via the plugin's customization string as JSON:
24
+ log, log_path (default <config dir>/bindery_repair.log), max_size_mb
25
+ (recorded default 150MB), and epubcheck_path, the experimental on-PATH
26
+ validation mode, default OFF (the three open questions, answered
27
+ 2026-09-11).
28
+
29
+ ### Fixed: fix_ncx_playorder counted phantom fixes (found by the plugin)
30
+
31
+ - The already-correct comparison read the attribute value with its
32
+ quotes, so every sequential navPoint counted as a fix on every pass
33
+ while the rewritten bytes stayed identical. The Long Patrol reported
34
+ fix_ncx_playorder=59 on every run of a repaired book; a book whose
35
+ only fix was phantom was rewritten instead of reported as nochange;
36
+ and a single-quoted sequential attribute was rewritten to double
37
+ quotes, touching already-correct markup. Quotes are now stripped
38
+ before the comparison and an already-correct attribute is returned
39
+ untouched: honest counts, single quotes survive, and the plugin's
40
+ zero-fixes contract holds end to end.
41
+
42
+ ### Repo records
43
+
44
+ - Phase 16's FastSweep prevalence study (5,228 books) and the obfuscation
45
+ magic-byte validation are recorded in the roadmap with real counts for
46
+ every Phase 16 repair box.
47
+
48
+ ## v0.36.0 (2026-09-12)
49
+
50
+ ### The completeness spot-check: `audit completeness` (Phase 15)
51
+
52
+ - **The phase-1 judgment step finally has a tool owner.** The import
53
+ skill's completeness step asks a human question with no tool behind
54
+ it: "sample early, middle, and late pages ... read the LAST content
55
+ page to confirm it reaches real back matter rather than cutting off
56
+ mid-chapter". Two consecutive phase-1 runs answered it with a
57
+ hand-rolled zipfile sampler in /tmp. The new analyzer rides the
58
+ audit's existing single decompression pass (no second decode of any
59
+ book) and reports, per book: spine doc count, prose-doc count (spine
60
+ docs with at least 400 visible characters), the opening and closing
61
+ 120 characters of the first/middle/last prose doc, a trailing-ToC
62
+ classification of the final spine doc, and the fraction of unreadable
63
+ docs (corrupt entries plus unresolved itemrefs).
64
+ - **A trailing ToC never impersonates the ending.** The final spine doc
65
+ is classified as a chapter list when short link lines dominate it and
66
+ no block runs to paragraph length; such a doc is book furniture and
67
+ is excluded from the prose sampling, so the closing excerpt comes
68
+ from the real back matter. Verified against the real 2026-09-10
69
+ Redwall fixtures: Lord Brocktree's trailing ToC is caught and the
70
+ Epilogue is sampled as the last prose doc; The Long Patrol reads
71
+ exactly 55/57 prose docs through the percent-encoded hrefs that
72
+ defeated the hand-rolled sampler; Mattimeo's split doc (chapter
73
+ headings with real prose after every one) stays prose.
74
+ - **Advisory by contract.** The analyzer never flags a book and never
75
+ moves the exit code: the archive, spine, and emptytext verdicts keep
76
+ owning the flags. The verdict status is ADVISORY (not OK) when a
77
+ trailing ToC is present or the unreadable fraction reaches 10%.
78
+ `audit all` and `run phase1` include it through the battery, the CLI
79
+ grows the `completeness` mode, and `--json` records carry its
80
+ verdicts in the same shape as the other analyzers.
81
+
82
+ ### Dependencies
83
+
84
+ - Adopted cquarry 1.18.0 (floor + lock): the set_format repair lane now keeps
85
+ Calibre's FTS index and page counts honest automatically (the sidecar's
86
+ dirty queue and the pages rescan flag ride the write), and the search and
87
+ runtime fixes come along in the shared layer.
88
+ - Adopted cquarry 1.19.0 (floor + lock): the repair lane gains its undo
89
+ primitive -- save_original_format/restore_original_format -- so a format
90
+ repair can put the original bytes back if it goes wrong.
91
+
92
+ ### Repo records
93
+
94
+ - The release-tag backlog is settled forward-only from v0.35.0: the
95
+ nine untagged 0.18.0-0.26.0 entries are exempted by decision of
96
+ record (2026-09-11), and the v0.28.0 retag and the v0.33.0 em-dash
97
+ tag repair stay recorded-only force-pushes (roadmap.md, "Tag
98
+ policy").
99
+
2
100
  ## v0.35.0 (2026-09-10)
3
101
 
4
102
  ### The epubcheck daemon, reconciled: oracle-identical counts at ~15x the speed