bindery-cli 0.36.0__tar.gz → 0.38.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.36.0 → bindery_cli-0.38.0}/.github/workflows/publish.yml +25 -0
  2. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/CLAUDE.md +11 -8
  3. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/PKG-INFO +39 -2
  4. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/README.md +38 -1
  5. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/patchnotes.md +103 -0
  6. bindery_cli-0.38.0/plugin/__init__.py +239 -0
  7. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/pyproject.toml +1 -1
  8. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/roadmap.md +116 -9
  9. bindery_cli-0.38.0/scripts/build_plugin.py +89 -0
  10. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/spec.md +71 -4
  11. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/__init__.py +1 -1
  12. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/audit.py +133 -41
  13. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/cli.py +25 -0
  14. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/epub.py +195 -5
  15. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/transforms.py +9 -2
  16. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_audit.py +140 -0
  17. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_cli.py +9 -5
  18. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_epub.py +288 -0
  19. bindery_cli-0.38.0/tests/test_plugin.py +360 -0
  20. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_transforms.py +27 -0
  21. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/uv.lock +1 -1
  22. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/.github/workflows/ci.yml +0 -0
  23. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/.gitignore +0 -0
  24. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/AGENTS.md +0 -0
  25. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/LICENSE +0 -0
  26. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/REPORT-12-Sept.md +0 -0
  27. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/docs/screenshots/library-sweep.png +0 -0
  28. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/logo.svg +0 -0
  29. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/run_tests.sh +0 -0
  30. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/scripts/FastSweep.java +0 -0
  31. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/scripts/fast_sweep.py +0 -0
  32. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/__main__.py +0 -0
  33. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/library.py +0 -0
  34. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/pagination.py +0 -0
  35. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/reserialize.py +0 -0
  36. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/validate.py +0 -0
  37. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/watermark.py +0 -0
  38. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  39. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/testing_facility/top500candidates/REPORT.md +0 -0
  40. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_library.py +0 -0
  41. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_pagination.py +0 -0
  42. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_reserialize.py +0 -0
  43. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_validate.py +0 -0
  44. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_version.py +0 -0
  45. {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_watermark.py +0 -0
@@ -43,3 +43,28 @@ 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
+ permissions:
52
+ contents: write # create the release and attach the plugin zip
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+ - uses: actions/setup-python@v5
56
+ with:
57
+ python-version: "3.14"
58
+
59
+ - name: Run the suite
60
+ run: |
61
+ pip install .[reserialize]
62
+ PYTHONPATH=src python -m unittest discover -s tests
63
+
64
+ - name: Build the plugin zip from the tagged tree
65
+ run: python scripts/build_plugin.py
66
+
67
+ - name: Attach BinderyRepair zip to the release
68
+ uses: softprops/action-gh-release@v2
69
+ with:
70
+ files: dist/BinderyRepair-*.zip
@@ -29,9 +29,9 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
29
29
  * **Structural repairs** (`--fix-empty-body`, `--fix-missing-title`, `--fix-id-colons`,
30
30
  `--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
31
31
  `--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`,
32
- `--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`;
33
- transforms.py, threaded through epub.py): they alter markup structure or fabricate
34
- minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule;
32
+ `--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
33
+ `--fix-container`, `--fix-media-types`; transforms.py, threaded through epub.py):
34
+ they alter markup structure or fabricate minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule;
35
35
  v0.17.0 restored it. `--unwrap-illegal-tags` additionally protects any illegal-tag
36
36
  name that an EPUB stylesheet styles as an element selector (`css_protected_tags`,
37
37
  book-wide, inline `<style>` blocks included). The two EPUB2-targeted fixes
@@ -56,7 +56,9 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
56
56
  the safe opt-ins (`--fix-ids`, `--add-img-alt`, `--strip-bad-attrs`,
57
57
  `--escape-unknown-entities`) plus `--encode-url-spaces`, which repair without
58
58
  altering visible markup; the "twelve structural repairs and three lossy strips"
59
- phrasing elsewhere in these docs was never the complete inventory.
59
+ phrasing elsewhere in these docs was never the complete inventory, and the
60
+ Phase 16 package repairs (`--fix-container`, `--fix-media-types`) took the
61
+ structural count to fourteen (v0.38.0).
60
62
  - **The gate is the safety contract.** Never apply a repair epubcheck has not accepted.
61
63
  Respect the two-mode logic in `validate.gate` (fatal-fixing tolerates error unmasking;
62
64
  error-cleanup does not). The lossy strips (`--strip-pagination`, `--strip-broken-tags`,
@@ -73,16 +75,17 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
73
75
 
74
76
  ## Layout
75
77
 
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.
78
+ - `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
79
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
78
80
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
79
81
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
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
- - `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
+ - `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. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (`OBFUSCATION_ALGOS`: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED).
83
+ - `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan). Since v0.38.0 (Phase 16): `generate_container` + the `--fix-container` gateway (insert after mimetype when missing, replace in-loop when stale; constant-epoch timestamp keeps bytes deterministic), `prune_dangling_edges` (prune_missing_manifest_items returns the pruned ids and the edges that pointed at them — spine@toc, media-overlay, fallback, EPUB2 cover meta — are rewritten so pruning cannot manufacture the regression the gate rejects), and `fix_manifest_media_types` (extension map + magic-byte confirmation via a `peek` callable over the open source zip; jpg/png/gif only).
82
84
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
83
85
  - `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
86
  - `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/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, and the version pin.
87
+ - `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.
88
+ - `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
89
 
87
90
  ## Conventions
88
91
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.36.0
3
+ Version: 0.38.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
@@ -60,9 +60,11 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
60
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
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
- - **`--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.
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 (with every package edge that pointed at them rewritten: `spine@toc`, `item@media-overlay`, `item@fallback`, and the EPUB2 cover meta). 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:`).
65
65
  - **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file.
66
+ - **`--fix-container`**: generates `META-INF/container.xml` at the located OPF when the container is missing or names a file the archive does not contain. This is the gateway defect: epubcheck stays fatal while the OPF is unfindable, so no repair can be gate-accepted on such a book until the container exists.
67
+ - **`--fix-media-types`**: normalizes wrong manifest media-type declarations (OPF-029, e.g. a jpg stamped `image/png` by an aggregator). Attribute-only and quote-preserving; it fires only when the file's magic bytes confirm the extension, so an honestly-declared misnamed file is never made worse.
66
68
 
67
69
 
68
70
  Three opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
@@ -95,6 +97,41 @@ uv tool install bindery-cli # core tools
95
97
  uv tool install "bindery-cli[reserialize]" # incl. --reserialize
96
98
  ```
97
99
 
100
+ ## The Calibre plugin (Bindery Repair)
101
+
102
+ Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
103
+ repairs EPUBs as they are imported: it is attached to the GitHub release beside
104
+ the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
105
+ from file"). The plugin is the CLI's always-on core pass only: the five
106
+ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
107
+ strips stay CLI-only, because their acceptance is the epubcheck gate and
108
+ epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
109
+
110
+ Behavior and guarantees:
111
+
112
+ - `run()` never raises and never touches the original file or `metadata.db`: a
113
+ repaired copy is built in a persistent temp file and handed back for import;
114
+ any trouble returns the original path and logs one line.
115
+ - Byte-idempotent: an already-clean book yields zero fixes and the original
116
+ path, so re-adding a format re-runs the plugin harmlessly.
117
+ - The rewritten archive is `testzip()`-verified before it is handed back.
118
+ - One log line per book (fixed / no fixes / refused / errored), appended to
119
+ `bindery_repair.log` in Calibre's config directory by default.
120
+ - Files over the size cap are refused (default 150MB) rather than stalling an
121
+ import.
122
+
123
+ Configuration is the plugin's customization string, parsed as JSON:
124
+
125
+ ```json
126
+ {"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
127
+ "epubcheck_path": null}
128
+ ```
129
+
130
+ `epubcheck_path` opts into the experimental on-PATH validation mode: the
131
+ repaired copy is re-measured with your epubcheck binary and refused unless it
132
+ is no worse than the original. It defaults to off; the default pass is safe
133
+ without a gate, and the mode costs two epubcheck runs per imported book.
134
+
98
135
  ## Usage
99
136
 
100
137
  Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
@@ -38,9 +38,11 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
38
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
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
- - **`--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.
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 (with every package edge that pointed at them rewritten: `spine@toc`, `item@media-overlay`, `item@fallback`, and the EPUB2 cover meta). 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:`).
43
43
  - **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file.
44
+ - **`--fix-container`**: generates `META-INF/container.xml` at the located OPF when the container is missing or names a file the archive does not contain. This is the gateway defect: epubcheck stays fatal while the OPF is unfindable, so no repair can be gate-accepted on such a book until the container exists.
45
+ - **`--fix-media-types`**: normalizes wrong manifest media-type declarations (OPF-029, e.g. a jpg stamped `image/png` by an aggregator). Attribute-only and quote-preserving; it fires only when the file's magic bytes confirm the extension, so an honestly-declared misnamed file is never made worse.
44
46
 
45
47
 
46
48
  Three opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
@@ -73,6 +75,41 @@ uv tool install bindery-cli # core tools
73
75
  uv tool install "bindery-cli[reserialize]" # incl. --reserialize
74
76
  ```
75
77
 
78
+ ## The Calibre plugin (Bindery Repair)
79
+
80
+ Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
81
+ repairs EPUBs as they are imported: it is attached to the GitHub release beside
82
+ the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
83
+ from file"). The plugin is the CLI's always-on core pass only: the five
84
+ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
85
+ strips stay CLI-only, because their acceptance is the epubcheck gate and
86
+ epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
87
+
88
+ Behavior and guarantees:
89
+
90
+ - `run()` never raises and never touches the original file or `metadata.db`: a
91
+ repaired copy is built in a persistent temp file and handed back for import;
92
+ any trouble returns the original path and logs one line.
93
+ - Byte-idempotent: an already-clean book yields zero fixes and the original
94
+ path, so re-adding a format re-runs the plugin harmlessly.
95
+ - The rewritten archive is `testzip()`-verified before it is handed back.
96
+ - One log line per book (fixed / no fixes / refused / errored), appended to
97
+ `bindery_repair.log` in Calibre's config directory by default.
98
+ - Files over the size cap are refused (default 150MB) rather than stalling an
99
+ import.
100
+
101
+ Configuration is the plugin's customization string, parsed as JSON:
102
+
103
+ ```json
104
+ {"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
105
+ "epubcheck_path": null}
106
+ ```
107
+
108
+ `epubcheck_path` opts into the experimental on-PATH validation mode: the
109
+ repaired copy is re-measured with your epubcheck binary and refused unless it
110
+ is no worse than the original. It defaults to off; the default pass is safe
111
+ without a gate, and the mode costs two epubcheck runs per imported book.
112
+
76
113
  ## Usage
77
114
 
78
115
  Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
@@ -1,4 +1,107 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.38.0 (2026-09-12)
3
+
4
+ ### Phase 16: package-structure repairs
5
+
6
+ - **`--fix-container`, the gateway repair.** A book whose
7
+ META-INF/container.xml is missing, unparseable, or names a file the
8
+ archive does not contain is locked out of every repair: epubcheck
9
+ stays fatal while the OPF is unfindable. The new flag generates the
10
+ standard container at the OPF the locator already finds by fallback
11
+ (a byte-deterministic template with the constant epoch timestamp).
12
+ Oracle-verified on a real-shaped fixture: fatal before, gate-accepted
13
+ after. The Phase 16 prevalence study found 0 such books in-library
14
+ (5,228/5,228 have valid containers); the repair ships for the
15
+ acquisition phase, where the class actually lives.
16
+ - **`--prune-missing-resources` rewrites what it drops.** Removing a
17
+ manifest item whose file is absent used to leave every package edge
18
+ pointing at it dangling, manufacturing the regression the gate then
19
+ rejects. The prune now carries the pruned ids into a rewrite of the
20
+ four edge classes: `spine@toc`, `item@media-overlay`, `item@fallback`,
21
+ and the EPUB2 cover meta. Optional edges of dead targets are removed;
22
+ edges to live ids are untouched.
23
+ - **`--fix-media-types`.** Wrong manifest media-type declarations
24
+ (OPF-029; 275 occurrences in 15 library books, the class being a jpg
25
+ stamped `image/png` by an aggregator) are normalized attribute-only,
26
+ preserving quote style, and only when the file's magic bytes confirm
27
+ the extension: a PNG renamed .jpg keeps its wrong-but-honest
28
+ declaration instead of a worse one. On the real Arthur sample the run
29
+ goes from nochange to gate-accepted with 9 declarations fixed.
30
+ - **The audit distinguishes font obfuscation from DRM.** encryption.xml
31
+ entries under the obfuscation algorithms (the IDPF URI and both Adobe
32
+ forms, including the ns.adobe.com URI real-world files actually
33
+ carry, which the prevalence study caught) that read fine are the new
34
+ OBFUSCATED advisory: publisher font embedding, benign, problem-False,
35
+ exit-neutral. An unreadable obfuscation entry is CORRUPT (a broken
36
+ font, re-source), and only non-obfuscation algorithms give the
37
+ ENCRYPTED skip. 86 of the library's 5,228 books carry
38
+ obfuscation-only encryption.xml; the library holds no real DRM at all.
39
+ - **Boxed, not built, with real counts.** The obfuscation-aware
40
+ encryption repair (the magic-byte precondition validated on 424 real
41
+ fonts: all genuinely scrambled, zero stale entries in-library), href
42
+ case/backslash resolution (1 href in 1 book; 0 backslashes), and
43
+ duplicate zip-entry dedupe (0 books) are recorded in the roadmap with
44
+ their prevalence numbers and reopen conditions. The decision-gated
45
+ Phase 16 C boxes (cover wiring, the metadata carve-out, the NCX<->nav
46
+ drift detector, CSS url() pruning) carry recorded options and wait
47
+ for rulings.
48
+
49
+ ### The prevalence study (Phase 16 A)
50
+
51
+ - 5,228 library books through the FastSweep epubcheck harness plus a
52
+ direct zip-level pass: container.xml valid on every book; RSC-007 at
53
+ 14,156 occurrences in 600 books and PKG-010 in 280 (the prune
54
+ surface, 1,538 truly-absent manifest hrefs); OPF-029 at 275 in 15;
55
+ case-mismatch hrefs exactly 1; encryption.xml in 86 books, all of it
56
+ font obfuscation. Every Phase 16 repair box now carries its real
57
+ count.
58
+
59
+ ## v0.37.0 (2026-09-12)
60
+
61
+ ### The Bindery Repair Calibre plugin (Phase 3)
62
+
63
+ - **Books are repaired as they are imported.** Each release now attaches
64
+ BinderyRepair-v<VERSION>.zip to the GitHub release: a Calibre
65
+ FileTypePlugin (on_import) that runs the CLI's always-on core pass, the
66
+ five well-formedness fixes plus the NCX pipeline, on *.epub files as they
67
+ are added. run() never raises and never touches the original file or
68
+ metadata.db: a repaired copy is built in a persistent temp file,
69
+ testzip()-verified, and handed back for import; any trouble returns the
70
+ original path and logs one line. The plugin is byte-idempotent, so
71
+ re-adding a format re-runs it harmlessly.
72
+ - **The vendor slice is byte-identical to the repo.** The zip carries
73
+ transforms.py, epub.py, pagination.py, watermark.py, and reserialize.py
74
+ unchanged (the zip root is a package, so their relative imports resolve);
75
+ a suite drift test pins the equality, and publish.yml regenerates the zip
76
+ from the tag and attaches it to the release.
77
+ - **Nothing runs ungated, ever.** The structural repairs and the lossy
78
+ strips stay CLI-only because their acceptance is the epubcheck gate,
79
+ which cannot run inside Calibre. Identity: Bindery Repair /
80
+ bindery_repair. Config via the plugin's customization string as JSON:
81
+ log, log_path (default <config dir>/bindery_repair.log), max_size_mb
82
+ (recorded default 150MB), and epubcheck_path, the experimental on-PATH
83
+ validation mode, default OFF (the three open questions, answered
84
+ 2026-09-11).
85
+
86
+ ### Fixed: fix_ncx_playorder counted phantom fixes (found by the plugin)
87
+
88
+ - The already-correct comparison read the attribute value with its
89
+ quotes, so every sequential navPoint counted as a fix on every pass
90
+ while the rewritten bytes stayed identical. The Long Patrol reported
91
+ fix_ncx_playorder=59 on every run of a repaired book; a book whose
92
+ only fix was phantom was rewritten instead of reported as nochange;
93
+ and a single-quoted sequential attribute was rewritten to double
94
+ quotes, touching already-correct markup. Quotes are now stripped
95
+ before the comparison and an already-correct attribute is returned
96
+ untouched: honest counts, single quotes survive, and the plugin's
97
+ zero-fixes contract holds end to end.
98
+
99
+ ### Repo records
100
+
101
+ - Phase 16's FastSweep prevalence study (5,228 books) and the obfuscation
102
+ magic-byte validation are recorded in the roadmap with real counts for
103
+ every Phase 16 repair box.
104
+
2
105
  ## v0.36.0 (2026-09-12)
3
106
 
4
107
  ### The completeness spot-check: `audit completeness` (Phase 15)
@@ -0,0 +1,239 @@
1
+ """Bindery Repair: repair EPUBs on import with bindery-cli's gate-safe pass.
2
+
3
+ A Calibre FileTypePlugin running bindery-cli's always-on core pass (the five
4
+ well-formedness transforms plus the NCX pipeline) on *.epub files as they are
5
+ imported. The structural repairs and the lossy strips stay CLI-only: their
6
+ acceptance IS the epubcheck gate, which cannot run inside Calibre (no jar, no
7
+ JVM, and seconds-per-book latency), so their flags are never enabled here and
8
+ nothing runs ungated, ever.
9
+
10
+ The on_import contract: run(path) returns the path to a repaired copy built
11
+ with the plugin's own temporary_file(), and Calibre imports that instead. The
12
+ original on disk is never touched and nothing writes to metadata.db. No
13
+ exception ever escapes run(): a raising plugin cannot abort an import (Calibre
14
+ falls back to the original silently), so every failure returns the original
15
+ path and logs one line. The plugin is byte-idempotent: an already-clean book
16
+ yields zero fixes and the original path, which db.add_format's
17
+ run_hooks=True re-entry requires.
18
+
19
+ Configuration is the plain site_customization string, parsed as JSON::
20
+
21
+ {"log": true, "log_path": "/path/bindery_repair.log",
22
+ "max_size_mb": 150, "epubcheck_path": null}
23
+
24
+ log_path defaults to <calibre config dir>/bindery_repair.log. The size cap
25
+ refuses absurd files rather than stalling an import; 150MB is the recorded
26
+ default. epubcheck_path opts into the experimental on-PATH validation mode:
27
+ the repaired copy is re-measured with the epubcheck binary and refused unless
28
+ it is no worse than the original (the CLI's no_worse bar). It defaults to
29
+ off, keeping the import fast; the default pass is safe without a gate.
30
+
31
+ The vendored bindery modules ride in this zip as submodules of the plugin
32
+ package (their relative imports resolve unchanged), so the zip's module bytes
33
+ are identical to the repo's src/bindery/ sources; scripts/build_plugin.py
34
+ generates the zip at release and the suite's drift test pins the equality.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import subprocess
42
+ import zipfile
43
+ from collections import namedtuple
44
+ from pathlib import Path
45
+
46
+ __PLUGIN_VERSION__ = (0, 0, 0) # substituted by scripts/build_plugin.py
47
+
48
+ # The vendored bindery modules load as submodules of this plugin package
49
+ # (calibre_plugins.bindery_repair.epub and siblings), so their own relative
50
+ # imports resolve unchanged.
51
+ from calibre.customize import FileTypePlugin
52
+
53
+ from . import epub as _bindery_epub
54
+
55
+ DEFAULT_MAX_SIZE_MB = 150
56
+ _EPUB_SUFFIXES = (".epub",)
57
+ # the slice of validate.CheckResult the experimental mode needs
58
+ _EcCounts = namedtuple("_EcCounts", "fatals errors warnings")
59
+
60
+
61
+ class BinderyRepair(FileTypePlugin):
62
+ name = "Bindery Repair"
63
+ description = (
64
+ "Repair well-formedness defects in EPUB files as they are imported, "
65
+ "running bindery-cli's epubcheck-safe default pass"
66
+ )
67
+ author = "Brandon LaRocque"
68
+ version = __PLUGIN_VERSION__
69
+ minimum_calibre_version = (2, 0, 0)
70
+ supported_platforms = ["linux"]
71
+ type = "File type"
72
+ file_types = {"epub"}
73
+ on_import = True
74
+ priority = 1
75
+
76
+ # ---- run() never raises: the contract is the original path on trouble
77
+
78
+ def run(self, path_to_ebook):
79
+ try:
80
+ return self._run(path_to_ebook)
81
+ except Exception as e:
82
+ # the last-resort sink re-reads the config, whose parsing is
83
+ # itself exception-safe; run() must never raise
84
+ self._log(
85
+ f"errored: {path_to_ebook}: {type(e).__name__}: {e}",
86
+ self._resolve_log_path(self._config()),
87
+ )
88
+ return path_to_ebook
89
+
90
+ def _run(self, path_to_ebook: str) -> str:
91
+ src = Path(path_to_ebook)
92
+ if src.suffix.lower() not in _EPUB_SUFFIXES:
93
+ return path_to_ebook
94
+ cfg = self._config()
95
+ log_path = self._resolve_log_path(cfg)
96
+ try:
97
+ size_mb = src.stat().st_size / (1024 * 1024)
98
+ except OSError as e:
99
+ self._log(f"errored: {src}: {e}", log_path)
100
+ return path_to_ebook
101
+ cap = cfg.get("max_size_mb", DEFAULT_MAX_SIZE_MB)
102
+ if isinstance(cap, (int, float)) and cap > 0 and size_mb > cap:
103
+ self._log(
104
+ f"refused: {src.name}: {size_mb:.0f}MB over the {cap:g}MB cap",
105
+ log_path,
106
+ )
107
+ return path_to_ebook
108
+
109
+ with self.temporary_file(".epub") as tf:
110
+ dst = Path(tf.name)
111
+ try:
112
+ report = _bindery_epub.repair_epub(src, dst)
113
+ except Exception as e:
114
+ self._log(
115
+ f"errored: {src.name}: repair failed: {type(e).__name__}: {e}",
116
+ log_path,
117
+ )
118
+ return path_to_ebook
119
+ if not report:
120
+ # zero fixes: hand back the original byte-for-byte (the
121
+ # add_format(run_hooks=True) re-entry depends on this)
122
+ dst.unlink(missing_ok=True)
123
+ self._log(f"no fixes: {src.name}", log_path)
124
+ return path_to_ebook
125
+ with zipfile.ZipFile(dst) as z:
126
+ damaged = z.testzip()
127
+ if damaged is not None:
128
+ dst.unlink(missing_ok=True)
129
+ self._log(
130
+ f"refused: {src.name}: rewritten archive failed testzip() "
131
+ f"at {damaged}",
132
+ log_path,
133
+ )
134
+ return path_to_ebook
135
+ if "epubcheck_path" in cfg and cfg["epubcheck_path"]:
136
+ verdict = self._epubcheck_no_worse(src, dst, cfg["epubcheck_path"])
137
+ if verdict is False:
138
+ dst.unlink(missing_ok=True)
139
+ self._log(
140
+ f"refused: {src.name}: epubcheck measured the repair "
141
+ "no better or worse; keeping the original",
142
+ log_path,
143
+ )
144
+ return path_to_ebook
145
+ detail = ", ".join(f"{k}={v}" for k, v in sorted(report.fixes.items()))
146
+ if report.ncx_uid_synced:
147
+ detail = (detail + ", " if detail else "") + "ncx_uid_synced"
148
+ self._log(f"fixed: {src.name}: {detail}", log_path)
149
+ return str(dst)
150
+
151
+ # ---- configuration and logging
152
+
153
+ def _config(self) -> dict:
154
+ raw = getattr(self, "site_customization", None)
155
+ if not raw:
156
+ return {}
157
+ try:
158
+ cfg = json.loads(raw)
159
+ except ValueError:
160
+ # a broken config lands one line in the default log and the run
161
+ # proceeds on the defaults; an import must never be lost to config
162
+ self._log(
163
+ "config: site_customization is not valid JSON; using defaults",
164
+ self._default_log_path(),
165
+ )
166
+ return {}
167
+ return cfg if isinstance(cfg, dict) else {}
168
+
169
+ def _resolve_log_path(self, cfg: dict) -> str | None:
170
+ if not cfg.get("log", True):
171
+ return None
172
+ path = cfg.get("log_path") or self._default_log_path()
173
+ return path if isinstance(path, str) else None
174
+
175
+ def _default_log_path(self) -> str | None:
176
+ try:
177
+ from calibre.constants import config_dir
178
+
179
+ return os.path.join(config_dir, "bindery_repair.log")
180
+ except Exception:
181
+ return None
182
+
183
+ def _log(self, line: str, log_path: str | None) -> None:
184
+ """One line per book, appended; logging must never break an import."""
185
+ if not log_path:
186
+ return
187
+ try:
188
+ with open(log_path, "a", encoding="utf-8") as f:
189
+ f.write(line.rstrip() + "\n")
190
+ except OSError:
191
+ pass
192
+
193
+ # ---- experimental epubcheck-on-PATH validation (default off)
194
+
195
+ def _epubcheck_no_worse(self, src: Path, dst: Path, epubcheck: str) -> bool | None:
196
+ """Measure both files with the epubcheck binary; True when the repair
197
+ is no worse (no net-new fatals, no new errors unless fatals masked
198
+ them), False when it regressed, None when the binary cannot answer
199
+ (an unanswered check never refuses the repair; the pass is safe)."""
200
+ before = self._epubcheck_counts(src, epubcheck)
201
+ after = self._epubcheck_counts(dst, epubcheck)
202
+ if before is None or after is None:
203
+ return None
204
+ if after.fatals > before.fatals:
205
+ return False
206
+ if before.fatals == 0 and after.errors > before.errors:
207
+ return False
208
+ return True
209
+
210
+ def _epubcheck_counts(self, path: Path, epubcheck: str):
211
+ try:
212
+ proc = subprocess.run(
213
+ [epubcheck, "--json", "-", str(path)],
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=300,
217
+ )
218
+ except OSError, subprocess.TimeoutExpired:
219
+ return None
220
+ try:
221
+ data = json.loads(proc.stdout)
222
+ checker = data["checker"]
223
+ return _EcCounts(
224
+ int(checker["nFatal"]),
225
+ int(checker["nError"]),
226
+ int(checker["nWarning"]),
227
+ )
228
+ except json.JSONDecodeError, KeyError, TypeError, ValueError:
229
+ return None
230
+
231
+ # ---- plugin housekeeping
232
+
233
+ def customization_help(self, gui=False):
234
+ return (
235
+ 'Enter a JSON object: {"log": true, "log_path": ..., '
236
+ '"max_size_mb": 150, "epubcheck_path": null}. '
237
+ "epubcheck_path enables the experimental post-repair validation "
238
+ "with the epubcheck binary (slow; off by default)."
239
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.36.0"
7
+ version = "0.38.0"
8
8
  description = "Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library."
9
9
  readme = "README.md"
10
10
  classifiers = [