bindery-cli 0.46.0__tar.gz → 0.47.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 (51) hide show
  1. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/.github/workflows/ci.yml +6 -3
  2. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/CLAUDE.md +30 -13
  3. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/PKG-INFO +10 -5
  4. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/README.md +9 -4
  5. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/patchnotes.md +98 -3
  6. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/plugin/__init__.py +5 -3
  7. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/pyproject.toml +1 -1
  8. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/roadmap.md +9 -2
  9. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/spec.md +63 -13
  10. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/__init__.py +1 -1
  11. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/audit.py +37 -2
  12. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/cli.py +47 -1
  13. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/epub.py +130 -10
  14. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/transforms.py +251 -2
  15. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_audit.py +97 -0
  16. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_epub.py +107 -0
  17. bindery_cli-0.47.0/tests/test_flags_real_core.py +563 -0
  18. bindery_cli-0.47.0/tests/test_flags_wiring.py +118 -0
  19. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_transforms.py +234 -0
  20. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_version.py +35 -0
  21. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/uv.lock +2 -2
  22. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/.github/FUNDING.yml +0 -0
  23. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/.github/workflows/publish.yml +0 -0
  24. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/.gitignore +0 -0
  25. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/AGENTS.md +0 -0
  26. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/LICENSE +0 -0
  27. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/docs/screenshots/library-sweep.png +0 -0
  28. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/logo.svg +0 -0
  29. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/project.done +0 -0
  30. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/run_tests.sh +0 -0
  31. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/scripts/FastSweep.java +0 -0
  32. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/scripts/build_plugin.py +0 -0
  33. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/scripts/fast_sweep.py +0 -0
  34. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/__main__.py +0 -0
  35. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/library.py +0 -0
  36. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/pagination.py +0 -0
  37. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/reserialize.py +0 -0
  38. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/validate.py +0 -0
  39. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/src/bindery/watermark.py +0 -0
  40. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_cli.py +0 -0
  41. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_comment_hyphens.py +0 -0
  42. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_cover_tocdrift.py +0 -0
  43. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_library.py +0 -0
  44. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_matcher_hardening.py +0 -0
  45. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_pagination.py +0 -0
  46. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_plugin.py +0 -0
  47. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_reserialize.py +0 -0
  48. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_space_renames.py +0 -0
  49. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_validate.py +0 -0
  50. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_watermark.py +0 -0
  51. {bindery_cli-0.46.0 → bindery_cli-0.47.0}/tests/test_xml_hardening.py +0 -0
@@ -44,9 +44,10 @@ jobs:
44
44
  # The install floor (decision 63): vir-tui/cquarry are marker-gated
45
45
  # to 3.14 there, so these legs install the core (tqdm only) and run
46
46
  # exactly the test modules that never touch the gated stack: the
47
- # repair core, the plugin under calibre stubs, validate, and the
48
- # version/grammar pins. audit/library/CLI-stack tests stay on the
49
- # 3.14 leg above.
47
+ # repair core, the plugin under calibre stubs, validate, the
48
+ # version/grammar pins, the RSC-016 comment-hyphen fix, the
49
+ # RepairFlags wiring pins, and the flags-driven real-core run.
50
+ # audit/library/CLI-stack tests stay on the 3.14 leg above.
50
51
  python-version: ["3.12", "3.13"]
51
52
  steps:
52
53
  - uses: actions/checkout@v5
@@ -59,6 +60,8 @@ jobs:
59
60
  tests.test_transforms tests.test_epub tests.test_pagination
60
61
  tests.test_watermark tests.test_reserialize tests.test_version
61
62
  tests.test_plugin tests.test_validate
63
+ tests.test_comment_hyphens tests.test_flags_wiring
64
+ tests.test_flags_real_core
62
65
  plugin-compat:
63
66
  name: Parse-check the plugin zip under the interpreters Calibre embeds
64
67
  runs-on: ubuntu-latest
@@ -30,15 +30,27 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
30
30
  `xmlns`, bare `&`, named entities, void self-closing), the NCX pipeline, and the
31
31
  mimetype fix (added/normalized/first-stored, epub.py's archive rewrite); every core
32
32
  fix must render identically to the author's intent: never add, remove, or reorder
33
- visible content. The canonical opt-in inventory lives in spec.md: sixteen structural
34
- repairs, three lossy strips, four safe opt-ins, plus `--reserialize`.
33
+ visible content. The canonical opt-in inventory lives in spec.md: twenty-one structural
34
+ repairs, four lossy strips, four safe opt-ins, plus `--reserialize`.
35
35
  * **Structural repairs** (`--fix-empty-body`, `--fix-missing-title`, `--fix-id-colons`,
36
36
  `--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
37
37
  `--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`,
38
38
  `--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
39
39
  `--fix-container`, `--fix-media-types`, `--fix-cover`,
40
- `--fix-comment-double-hyphen`; transforms.py, threaded
41
- through epub.py). `--fix-comment-double-hyphen` (v0.46.0, the RSC-016 fatal)
40
+ `--fix-comment-double-hyphen`, `--fix-svg-dup-ids`, `--fix-cdata-terminator`,
41
+ `--fix-misnested-inline`, `--fix-stray-close`, `--fix-unterminated-attr`;
42
+ transforms.py, threaded through epub.py). The v0.47.0 five (the 2026-10-06
43
+ issue batch): `--fix-svg-dup-ids` renames duplicate glyph ids inside
44
+ standalone `.svg` entries (first keeps its name, references resolve to the
45
+ first definition so they are never rewritten); `--fix-cdata-terminator`
46
+ completes a truncated `/*]]>` CDATA-terminator comment inside inline
47
+ `<style>` blocks (CSS-008); `--fix-misnested-inline` normalizes the
48
+ Mobipocket drop-cap mis-nest `<i><b>X</i></b>` (punctuation between the
49
+ reversed closers survives outside both spans; a letter run is refused);
50
+ `--fix-stray-close` removes an end tag with no open start frame (a merely
51
+ mis-nested pair is never touched); `--fix-unterminated-attr` closes an
52
+ attribute value open to the tag's own `>` (bare-word values, no quote
53
+ before the next `<`); all normal `gate`. `--fix-comment-double-hyphen` (v0.46.0, the RSC-016 fatal)
42
54
  replaces `--` inside XML comments with an en-dash: comment bodies only (text
43
55
  nodes and CDATA never touched, the one deliberate exception to the
44
56
  never-rewrite-comments invariant), in content documents, the NCX, and the OPF
@@ -71,7 +83,10 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
71
83
  in `RepairReport.watermark_refusals`, and surfaced as a `manual_watermark_repair`
72
84
  decision by `run phase1` on both the read-only and apply paths. `--strip-stub-docs`
73
85
  (2026-09-15) mirrors emptytext's placeholder signals: identical short text across
74
- >= 3 spine docs and >= 30% of the spine; the whole-spine-stubs book is refused
86
+ >= 3 spine docs and >= 30% of the spine; since v0.47.0 the pool is the `<body>`
87
+ span and image-carrier pages (empty body text, an `<img>` present) are exempt, so
88
+ identical head `<title>`s can never weld illustrated divider pages into a fake
89
+ stub class (the Great Change misfire); the whole-spine-stubs book is refused
75
90
  (EMPTY, re-source), and the drop cascades to archive entries, manifest, spine,
76
91
  NCX navPoints, and nav toc entries.
77
92
  Do not let any NEW fix touch content without its own flag; if a candidate repair cannot
@@ -80,11 +95,13 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
80
95
  - **The gate is the safety contract.** Never apply a repair epubcheck has not accepted.
81
96
  Respect the two-mode logic in `validate.gate` (fatal-fixing tolerates error unmasking;
82
97
  error-cleanup does not). The lossy strips (`--strip-pagination`, `--strip-broken-tags`,
83
- `--strip-watermarks`) are accepted by `validate.no_worse` instead (their gain is
84
- invisible to epubcheck, so it only forbids a regression, never demands a measured
85
- improvement). The structural opt-ins go through the normal `gate`: their gain IS
86
- visible (they clear errors), so a run with no measurable improvement is a noop and
87
- nothing is applied. Changing either bar means re-running the library dry run.
98
+ `--strip-watermarks`, `--strip-stub-docs`) are accepted by `validate.no_worse` instead
99
+ (their gain is invisible to epubcheck, so it only forbids a regression, never demands
100
+ a measured improvement). The structural opt-ins go through the normal `gate` (their
101
+ gain IS visible, so a run with no measurable improvement is a noop and nothing is
102
+ applied) except the two whose gains sit on axes epubcheck does not measure:
103
+ `--fix-cover` and `--encode-url-spaces`, accepted under the same `no_worse` bar with
104
+ the partial rule intact. Changing either bar means re-running the library dry run.
88
105
  - **Library writes are sacred.** Replacement must stay atomic (temp in same dir, then
89
106
  `os.replace`), touch only the `.epub`, preserve mode, and be dry-run by default.
90
107
  Calibre format installation goes through cquarry's write module (`--install-to-calibre`;
@@ -93,7 +110,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
93
110
 
94
111
  ## Layout
95
112
 
96
- - `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. HAZARD (the 0.41.0 spin): the quote-aware tag-prefix alternation (double-quoted | single-quoted | [^>]) OVERLAPS ([^>] matches quotes too), so a plain `*` re-partitions exponentially on failing candidates; every such greedy loop must carry the possessive `*+` (re 3.11+, the plugin's minimum Calibre). Since v0.42.0 the census is complete and pinned by tests/test_matcher_hardening.py: every greedy loop of this family is possessive (the three 0.41.1 rewrites plus _START_TAG_RE, _COVER_META_RE, _GUIDE_REF_RE, prune_dangling_edges' spine|item|itemref matcher, _ITEM_TAG_RE, _SPINE_ITEM_RE -- possessive is byte-identical on success because the loop is always followed by a literal `>` the [^>] branch cannot consume), and the five LAZY siblings (_VOID_RE, the transforms <img> matcher, and epub's link/a/img matchers) use the disjoint catch-all `[^>"']` instead: mutually exclusive branches cannot re-partition, and an unterminated quote inside a tag now fails to match rather than pairing across markup. NEW tag matchers: quote-aware possessive for greedy loops, the disjoint catch-all for lazy ones, and the tempered-quote idiom (["'])((?:(?!\1).)*)\1 for attribute values. 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).
113
+ - `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. HAZARD (the 0.41.0 spin): the quote-aware tag-prefix alternation (double-quoted | single-quoted | [^>]) OVERLAPS ([^>] matches quotes too), so a plain `*` re-partitions exponentially on failing candidates; every such greedy loop must carry the possessive `*+` (re 3.11+, the plugin's minimum Calibre). Since v0.42.0 the census is complete and pinned by tests/test_matcher_hardening.py: every greedy loop of this family is possessive (the three 0.41.1 rewrites plus _START_TAG_RE, _COVER_META_RE, _GUIDE_REF_RE, prune_dangling_edges' spine|item|itemref matcher, _ITEM_TAG_RE, _SPINE_ITEM_RE; possessive is byte-identical on success because the loop is always followed by a literal `>` the [^>] branch cannot consume), and the five LAZY siblings (_VOID_RE, the transforms <img> matcher, and epub's link/a/img matchers) use the disjoint catch-all `[^>"']` instead: mutually exclusive branches cannot re-partition, and an unterminated quote inside a tag now fails to match rather than pairing across markup. NEW tag matchers: quote-aware possessive for greedy loops, the disjoint catch-all for lazy ones, and the tempered-quote idiom (["'])((?:(?!\1).)*)\1 for attribute values. 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).
97
114
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
98
115
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
99
116
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
@@ -102,8 +119,8 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
102
119
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
103
120
  - `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. Since 2026-09-15 `make_backup` takes the opt-in `--backup-keep N` ring: at most N backup files per book, the author original `.bak` never deleted, newest content at the highest name; default remains an unbounded rotation.
104
121
  - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; `repair --json` emits the library per-book record vocabulary (2026-09-15; since v0.45.0 every per-book record, the phase1 payload included, also carries the structured fix breakdown: the `fixes` dict plus `ncx_uid_synced`/`watermark_refusals` off the `Outcome`, and `_phase1_decisions` reads that data instead of substring-matching the rendered `summary`, which stays for human eyes; CalibreQuarry's lossy-consent mirror is the waiting consumer, floor `bindery>=0.45.0`); 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; since 2026-09-15 the summary sums applied/equal states only and carries rejected projections as `rejected_projection`). Plus `bindery doctor` (2026-09-15): the environment self-check that imports none of the VirInvictus stack, never raises, and always exits 0. Both run verbs 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. Since v0.46.0 `cli._flags_from_args(args)` is the one place the argparse names map onto `RepairFlags` fields (each `or --all`), shared by `run_library` and `run_repair`; the old per-verb ~25-kwarg blocks are gone. `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). Since v0.40.0 `main()` guards the VirInvictus stack: a `ModuleNotFoundError` naming `vir_tui`/`cquarry` (marker-gated to 3.14+ on the 3.12 floor) becomes one stderr line and trouble exit 2 instead of a traceback; anything else missing still raises.
105
- - `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. **Plugin/interpreter compatibility (v0.40.0):** v0.39.0 shipped PEP 758 unparenthesized except tuples, and the plugin died with a bare SyntaxError on every released Calibre (7.x and the whole 8.x series embed Python 3.11, 9.0+ embeds 3.14; from calibre's bypy sources.json, checked against the upstream clone). The core is now grammar-pinned: parenthesized except tuples everywhere, `ruff`'s `target-version = "py312"` pin (inference from requires-python would rewrite the parens back off), `tests/test_version.py`'s parse guard, and CI's `plugin-compat` job byte-compiling the built zip under 3.11/3.12/3.13/3.14. `minimum_calibre_version` is (7, 0, 0): the oldest series whose interpreter CI covers, replacing the hollow (2, 0, 0); `supported_platforms` stays `["linux"]` (developed and tested on Linux only). Any new syntax in the vendored modules must clear the guard before it ships.
106
- - `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 syntax-floor grammar guard, and the Calibre plugin (build drift + run behavior under calibre stubs).
122
+ - `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, `max_log_mb` rotation cap default 2, `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. **Plugin/interpreter compatibility (v0.40.0):** v0.39.0 shipped PEP 758 unparenthesized except tuples, and the plugin died with a bare SyntaxError on every released Calibre (7.x and the whole 8.x series embed Python 3.11, 9.0+ embeds 3.14; from calibre's bypy sources.json, checked against the upstream clone). The core is now grammar-pinned: parenthesized except tuples everywhere, `ruff`'s `target-version = "py312"` pin (inference from requires-python would rewrite the parens back off), `tests/test_version.py`'s parse guard, and CI's `plugin-compat` job byte-compiling the built zip under 3.11/3.12/3.13/3.14. `minimum_calibre_version` is (7, 0, 0): the oldest series whose interpreter CI covers, replacing the hollow (2, 0, 0); `supported_platforms` stays `["linux"]` (developed and tested on Linux only). Any new syntax in the vendored modules must clear the guard before it ships.
123
+ - `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 syntax-floor grammar guard, and the Calibre plugin (build drift + run behavior under calibre stubs). Since the v0.46.0 follow-ups: `tests/test_flags_wiring.py` reflectively pins the RepairFlags inventory to the argparse dests and `_flags_from_args` mapping (a flag added without a field, a field without a flag, or a remapped line fails there), the all-off defaults, and the version gate's caller-immutability contract (test_epub's `TestVersionGateKeepsFlagsIntact`); `tests/test_comment_hyphens.py` carries the RSC-016 fix; `tests/test_flags_real_core.py` drives every repair flag from argv through the unmocked core (--no-validate; the gate itself stays pinned by the mocked-oracle tests and runs for real wherever epubcheck exists). All three ride CI's core-compat 3.12/3.13 leg; the hand-listed module list there must gain an entry for every new stack-free test module.
107
124
 
108
125
  ## Conventions
109
126
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.46.0
3
+ Version: 0.47.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
  Project-URL: Homepage, https://github.com/VirInvictus/bindery-cli
6
6
  Project-URL: Repository, https://github.com/VirInvictus/bindery-cli
@@ -51,7 +51,7 @@ bindery-cli makes accidentally broken markup well-formed again. It does not rewr
51
51
 
52
52
  Everything below is off until its flag is passed (or all at once via `--all`). They go further than well-formedness (altering structure or adding minimal content), so they never run by default:
53
53
 
54
- - **`--fix-ids`**: rewrite ids that are not valid XML names (start with a digit, contain a colon) in the OPF manifest, updating every reference to them (spine, fallback, media-overlay, the EPUB 2 cover meta), and in the NCX (where old conversions stamp navPoint ids from UUIDs). Touches the OPF, so it is off by default; the dc: metadata is never altered.
54
+ - **`--fix-ids`**: rewrite ids that are not valid XML names (start with a digit, contain a colon, or carry any character outside the XML NameChar set, like the apostrophes calibre copies from filenames) in the OPF manifest, updating every reference to them (spine, fallback, media-overlay, the EPUB 2 cover meta), and in the NCX (where old conversions stamp navPoint ids from UUIDs). Touches the OPF, so it is off by default; the dc: metadata is never altered.
55
55
  - **`--add-img-alt`**: add `alt=""` to `<img>` elements missing the required attribute. Renders identically, but it adds markup the author never wrote, and an empty alt tells a screen reader the image is decorative; hence opt-in.
56
56
  - **`--reserialize`**: rebuild content documents that are still malformed by re-parsing them with html5lib and re-emitting XHTML, closing unclosed `<p>`/`<div>`/`<span>`/`<blockquote>` that the regex transforms cannot. Runs only on documents that are not already well-formed, so good files are untouched.
57
57
  - **`--strip-bad-attrs`**: drop attributes that are invalid XML (a name starting with a digit, or a namespaced name whose prefix is never declared, like Office VML `v:shapes`). Surgical and a no-op on well-formed files.
@@ -67,11 +67,16 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
67
67
  - **`--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.
68
68
  - **`--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.
69
69
  - **`--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:`).
70
- - **`--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.
70
+ - **`--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. Since v0.44.0 the flag also renames archive entries whose names carry raw spaces to their underscore spellings and rewrites every reference to them (OPF, NCX, content, CSS `url()`), clearing the PKG-010 space-in-filename advisories: underscore, never percent-encoding, because epubcheck decodes references before entry lookup.
71
71
  - **`--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.
72
72
  - **`--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 a misnamed-but-consistent file (a PNG renamed .jpg) is never made worse.
73
73
  - **`--fix-cover`**: repairs dangling EPUB2 cover wiring. A `<meta name="cover">` whose content names no manifest item is re-pointed when the OPF guide's own cover reference names an existing item, and removed when nothing identifies it; EPUB3 `properties="cover-image"` is deliberately audit-only (guessing which image is the cover is not deterministic). Cover wiring is invisible to epubcheck, so cover-only repairs are accepted under the same no-worse bar the lossy strips use.
74
74
  - **`--fix-comment-double-hyphen`**: replaces `--` sequences inside XML comments with en-dashes. `--` is illegal inside an XML comment (epubcheck fatal RSC-016: the book opens in lenient readers but never passes epubcheck). The edit stays inside the comment bodies: text nodes and CDATA sections are never touched, and each comment's terminator is left intact.
75
+ - **`--fix-svg-dup-ids`**: renames duplicate `id` values inside standalone `.svg` entries (old calibre SVG page renders repeat glyph ids within one document; the intake wave that found this carried 445 to 1,339 'Duplicate "glNNNN"' errors per book). The first occurrence keeps its name, later ones gain `_2`/`_3`, and internal references are deliberately untouched: they already resolve to the first definition under every reader's first-match lookup.
76
+ - **`--fix-cdata-terminator`**: completes a truncated CDATA-terminator comment in an inline `<style>` block (`/*]]>` becomes `/*]]>*/`). A comment-wrapped CDATA block whose closing `*/` the converter dropped leaves the CSS parser inside an unterminated comment: one CSS-008 'Premature end of file' per affected document. Only the malformed-terminator token class is rewritten; invalid CSS stays unfixed, and stylesheet files are not touched.
77
+ - **`--fix-misnested-inline`**: rewrites the Mobipocket drop-cap mis-nest `<i><b>X</i></b>` to `<i><b>X</b></i>` (em/strong variants and either tag order). Only the both-open-then-reversed-close shape is touched; a punctuation run caught between the reversed closers (`<i><b>xile</i>,</b>`) survives outside both spans, and a letter run there is refused (which span keeps the letters has no deterministic answer).
78
+ - **`--fix-stray-close`**: removes an end tag whose element has no open start tag anywhere above it (the stray extra `</div>` on a shell page, which cascades into 'body must be terminated by the matching end-tag'). Merely mis-nested pairs are never touched: a close is removed only when nothing by that name is open.
79
+ - **`--fix-unterminated-attr`**: closes an attribute value left open to the tag's own `>` (`<p class="footnote>` becomes `<p class="footnote">`). The parser swallows prose into the value until the next quote and dies on the first `<` it meets. Guards keep it off legal `>`-bearing values: bare-word values only, and no quote may close before the next `<`.
75
80
 
76
81
 
77
82
  Four opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
@@ -79,7 +84,7 @@ Four opt-in fixes are **lossy** and stand apart from the semantics-preserving re
79
84
  - **`--strip-pagination`**: remove print page numbers and running headers that a PDF/OCR conversion baked into the body text as literal paragraphs (so they reflow into the middle of a sentence: "where the hay cart **16** was taking him"). It removes only that injected furniture, never the author's prose: where a number split a sentence it rejoins the two paragraphs (closing up a word split like `compli-` / `mentary`), and it preserves roman chapter numbers, page-list nav anchors, and years. A roman numeral counts as a page number only when it is well-formed and under 100, so ordinary words (`mid`, `mix`, `civil`) are never mistaken for pages. A book is only treated as paginated when it has both a dense run of arabic numbers and several confident mid-sentence interrupts, so a merely chapter-numbered book is left alone. Three safety nets guard every edit (character conservation, tag balance, and an epubcheck no-regression check); any failure leaves the document untouched.
80
85
  - **`--strip-broken-tags`**: remove leaked HTML closing tags missing their open brackets (e.g. `</p>`) that render as raw text.
81
86
  - **`--strip-watermarks`**: remove known producer and distributor watermarks (e.g. OceanofPDF, ABC Amber LIT Converter) and stray marker files. It locates the stamp and deletes the outermost wrapper whose entire visible text is the watermark, ensuring prose that merely mentions the URL is preserved. An inline stamp link is deleted only when it holds nothing but the stamp; a match too large to be safe (an unclosed stamp anchor that swallowed prose) is refused and reported for manual repair rather than deleted.
82
- - **`--strip-stub-docs`**: drop spine documents whose entire visible text is one identical short placeholder repeated across the spine: the Bookmate-style export whose chapters are all the same "content unavailable" notice, passing epubcheck because the placeholder is valid XHTML. The drop cascades (archive entries, manifest items, spine order, NCX navPoints, nav toc entries), so the book opens straight into its real chapters. The identity rule is deliberately conservative (same text, at least 3 docs, at least 30% of the spine, each doc short but not blank) and a book whose *every* spine doc is the stub is refused outright: that book is empty and needs a re-source, never a repair.
87
+ - **`--strip-stub-docs`**: drop spine documents whose entire visible body text is one identical short placeholder repeated across the spine: the Bookmate-style export whose chapters are all the same "content unavailable" notice, passing epubcheck because the placeholder is valid XHTML. The drop cascades (archive entries, manifest items, spine order, NCX navPoints, nav toc entries), so the book opens straight into its real chapters. The identity rule is deliberately conservative (same body text, at least 3 docs, at least 30% of the spine, each doc short but not blank; the pool is the `<body>` span, and image-carrier pages with no body text are exempt, so illustrated part-divider pages are never candidates) and a book whose *every* spine doc is the stub is refused outright: that book is empty and needs a re-source, never a repair.
83
88
 
84
89
  All lossy edits are invisible to epubcheck, so they are accepted when the result is *no worse* rather than measurably better.
85
90
 
@@ -94,7 +99,7 @@ Introducing a net-new fatal is always rejected. If epubcheck itself fails to run
94
99
 
95
100
  Under the hood, the oracle can run two ways: the usual `epubcheck --json -` subprocess, or a persistent daemon that writes `FastDaemon.java` into a temp directory at runtime, compiles it in memory with the running JVM's own compiler (Java's single-file source launcher: no javac, no javac/java version skew), and drives the warm JVM over a pipe. The daemon reports the counts it reads from the JSON document that epubcheck's own `CheckingReport` serializes, so it measures exactly what the subprocess measures, just ~15x faster. It is a pool bounded by `--workers` (serial runs use one), every roundtrip is bounded by the caller's timeout, and any failure tears it down and falls back to the subprocess for good.
96
101
 
97
- The lossy modes (`--strip-pagination`, `--strip-broken-tags`, and `--strip-watermarks`) are the exception to the "must improve" rule. Since they remove visible markup rather than correcting XML schema violations, epubcheck counts often remain unchanged. They are accepted when the result is **no worse** (no net-new fatals or errors), relying on strict programmatic safety nets instead.
102
+ The lossy modes (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`, and `--strip-stub-docs`) are the exception to the "must improve" rule. Since they remove converter-injected content rather than correcting XML schema violations, epubcheck counts often remain unchanged. They are accepted when the result is **no worse** (no net-new fatals or errors), relying on strict programmatic safety nets instead.
98
103
 
99
104
  ## Install
100
105
 
@@ -24,7 +24,7 @@ bindery-cli makes accidentally broken markup well-formed again. It does not rewr
24
24
 
25
25
  Everything below is off until its flag is passed (or all at once via `--all`). They go further than well-formedness (altering structure or adding minimal content), so they never run by default:
26
26
 
27
- - **`--fix-ids`**: rewrite ids that are not valid XML names (start with a digit, contain a colon) in the OPF manifest, updating every reference to them (spine, fallback, media-overlay, the EPUB 2 cover meta), and in the NCX (where old conversions stamp navPoint ids from UUIDs). Touches the OPF, so it is off by default; the dc: metadata is never altered.
27
+ - **`--fix-ids`**: rewrite ids that are not valid XML names (start with a digit, contain a colon, or carry any character outside the XML NameChar set, like the apostrophes calibre copies from filenames) in the OPF manifest, updating every reference to them (spine, fallback, media-overlay, the EPUB 2 cover meta), and in the NCX (where old conversions stamp navPoint ids from UUIDs). Touches the OPF, so it is off by default; the dc: metadata is never altered.
28
28
  - **`--add-img-alt`**: add `alt=""` to `<img>` elements missing the required attribute. Renders identically, but it adds markup the author never wrote, and an empty alt tells a screen reader the image is decorative; hence opt-in.
29
29
  - **`--reserialize`**: rebuild content documents that are still malformed by re-parsing them with html5lib and re-emitting XHTML, closing unclosed `<p>`/`<div>`/`<span>`/`<blockquote>` that the regex transforms cannot. Runs only on documents that are not already well-formed, so good files are untouched.
30
30
  - **`--strip-bad-attrs`**: drop attributes that are invalid XML (a name starting with a digit, or a namespaced name whose prefix is never declared, like Office VML `v:shapes`). Surgical and a no-op on well-formed files.
@@ -40,11 +40,16 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
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 (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
- - **`--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.
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. Since v0.44.0 the flag also renames archive entries whose names carry raw spaces to their underscore spellings and rewrites every reference to them (OPF, NCX, content, CSS `url()`), clearing the PKG-010 space-in-filename advisories: underscore, never percent-encoding, because epubcheck decodes references before entry lookup.
44
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
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 a misnamed-but-consistent file (a PNG renamed .jpg) is never made worse.
46
46
  - **`--fix-cover`**: repairs dangling EPUB2 cover wiring. A `<meta name="cover">` whose content names no manifest item is re-pointed when the OPF guide's own cover reference names an existing item, and removed when nothing identifies it; EPUB3 `properties="cover-image"` is deliberately audit-only (guessing which image is the cover is not deterministic). Cover wiring is invisible to epubcheck, so cover-only repairs are accepted under the same no-worse bar the lossy strips use.
47
47
  - **`--fix-comment-double-hyphen`**: replaces `--` sequences inside XML comments with en-dashes. `--` is illegal inside an XML comment (epubcheck fatal RSC-016: the book opens in lenient readers but never passes epubcheck). The edit stays inside the comment bodies: text nodes and CDATA sections are never touched, and each comment's terminator is left intact.
48
+ - **`--fix-svg-dup-ids`**: renames duplicate `id` values inside standalone `.svg` entries (old calibre SVG page renders repeat glyph ids within one document; the intake wave that found this carried 445 to 1,339 'Duplicate "glNNNN"' errors per book). The first occurrence keeps its name, later ones gain `_2`/`_3`, and internal references are deliberately untouched: they already resolve to the first definition under every reader's first-match lookup.
49
+ - **`--fix-cdata-terminator`**: completes a truncated CDATA-terminator comment in an inline `<style>` block (`/*]]>` becomes `/*]]>*/`). A comment-wrapped CDATA block whose closing `*/` the converter dropped leaves the CSS parser inside an unterminated comment: one CSS-008 'Premature end of file' per affected document. Only the malformed-terminator token class is rewritten; invalid CSS stays unfixed, and stylesheet files are not touched.
50
+ - **`--fix-misnested-inline`**: rewrites the Mobipocket drop-cap mis-nest `<i><b>X</i></b>` to `<i><b>X</b></i>` (em/strong variants and either tag order). Only the both-open-then-reversed-close shape is touched; a punctuation run caught between the reversed closers (`<i><b>xile</i>,</b>`) survives outside both spans, and a letter run there is refused (which span keeps the letters has no deterministic answer).
51
+ - **`--fix-stray-close`**: removes an end tag whose element has no open start tag anywhere above it (the stray extra `</div>` on a shell page, which cascades into 'body must be terminated by the matching end-tag'). Merely mis-nested pairs are never touched: a close is removed only when nothing by that name is open.
52
+ - **`--fix-unterminated-attr`**: closes an attribute value left open to the tag's own `>` (`<p class="footnote>` becomes `<p class="footnote">`). The parser swallows prose into the value until the next quote and dies on the first `<` it meets. Guards keep it off legal `>`-bearing values: bare-word values only, and no quote may close before the next `<`.
48
53
 
49
54
 
50
55
  Four opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
@@ -52,7 +57,7 @@ Four opt-in fixes are **lossy** and stand apart from the semantics-preserving re
52
57
  - **`--strip-pagination`**: remove print page numbers and running headers that a PDF/OCR conversion baked into the body text as literal paragraphs (so they reflow into the middle of a sentence: "where the hay cart **16** was taking him"). It removes only that injected furniture, never the author's prose: where a number split a sentence it rejoins the two paragraphs (closing up a word split like `compli-` / `mentary`), and it preserves roman chapter numbers, page-list nav anchors, and years. A roman numeral counts as a page number only when it is well-formed and under 100, so ordinary words (`mid`, `mix`, `civil`) are never mistaken for pages. A book is only treated as paginated when it has both a dense run of arabic numbers and several confident mid-sentence interrupts, so a merely chapter-numbered book is left alone. Three safety nets guard every edit (character conservation, tag balance, and an epubcheck no-regression check); any failure leaves the document untouched.
53
58
  - **`--strip-broken-tags`**: remove leaked HTML closing tags missing their open brackets (e.g. `</p>`) that render as raw text.
54
59
  - **`--strip-watermarks`**: remove known producer and distributor watermarks (e.g. OceanofPDF, ABC Amber LIT Converter) and stray marker files. It locates the stamp and deletes the outermost wrapper whose entire visible text is the watermark, ensuring prose that merely mentions the URL is preserved. An inline stamp link is deleted only when it holds nothing but the stamp; a match too large to be safe (an unclosed stamp anchor that swallowed prose) is refused and reported for manual repair rather than deleted.
55
- - **`--strip-stub-docs`**: drop spine documents whose entire visible text is one identical short placeholder repeated across the spine: the Bookmate-style export whose chapters are all the same "content unavailable" notice, passing epubcheck because the placeholder is valid XHTML. The drop cascades (archive entries, manifest items, spine order, NCX navPoints, nav toc entries), so the book opens straight into its real chapters. The identity rule is deliberately conservative (same text, at least 3 docs, at least 30% of the spine, each doc short but not blank) and a book whose *every* spine doc is the stub is refused outright: that book is empty and needs a re-source, never a repair.
60
+ - **`--strip-stub-docs`**: drop spine documents whose entire visible body text is one identical short placeholder repeated across the spine: the Bookmate-style export whose chapters are all the same "content unavailable" notice, passing epubcheck because the placeholder is valid XHTML. The drop cascades (archive entries, manifest items, spine order, NCX navPoints, nav toc entries), so the book opens straight into its real chapters. The identity rule is deliberately conservative (same body text, at least 3 docs, at least 30% of the spine, each doc short but not blank; the pool is the `<body>` span, and image-carrier pages with no body text are exempt, so illustrated part-divider pages are never candidates) and a book whose *every* spine doc is the stub is refused outright: that book is empty and needs a re-source, never a repair.
56
61
 
57
62
  All lossy edits are invisible to epubcheck, so they are accepted when the result is *no worse* rather than measurably better.
58
63
 
@@ -67,7 +72,7 @@ Introducing a net-new fatal is always rejected. If epubcheck itself fails to run
67
72
 
68
73
  Under the hood, the oracle can run two ways: the usual `epubcheck --json -` subprocess, or a persistent daemon that writes `FastDaemon.java` into a temp directory at runtime, compiles it in memory with the running JVM's own compiler (Java's single-file source launcher: no javac, no javac/java version skew), and drives the warm JVM over a pipe. The daemon reports the counts it reads from the JSON document that epubcheck's own `CheckingReport` serializes, so it measures exactly what the subprocess measures, just ~15x faster. It is a pool bounded by `--workers` (serial runs use one), every roundtrip is bounded by the caller's timeout, and any failure tears it down and falls back to the subprocess for good.
69
74
 
70
- The lossy modes (`--strip-pagination`, `--strip-broken-tags`, and `--strip-watermarks`) are the exception to the "must improve" rule. Since they remove visible markup rather than correcting XML schema violations, epubcheck counts often remain unchanged. They are accepted when the result is **no worse** (no net-new fatals or errors), relying on strict programmatic safety nets instead.
75
+ The lossy modes (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`, and `--strip-stub-docs`) are the exception to the "must improve" rule. Since they remove converter-injected content rather than correcting XML schema violations, epubcheck counts often remain unchanged. They are accepted when the result is **no worse** (no net-new fatals or errors), relying on strict programmatic safety nets instead.
71
76
 
72
77
  ## Install
73
78
 
@@ -1,4 +1,99 @@
1
1
  # bindery-cli Patch Notes
2
+
3
+ ## v0.47.0 (2026-10-06)
4
+
5
+ ### The intake-wave batch: five repair surfaces from five open issues
6
+
7
+ The 2026-10-05/06 acquisition wave filed five issues against real books; all
8
+ five land here. Two are classifier and validity corrections to existing
9
+ surfaces, three are new opt-in structural repairs, and each was verified
10
+ epubcheck-gated against its own fixture before shipping.
11
+
12
+ * The stub classifier pools BODY text and exempts image carriers (issue #1).
13
+ The four Kobo part-divider pages of the Great Change carry only a
14
+ full-bleed `<img>` each, and their identical head `<title>` welded them,
15
+ with the title page, into a fake five-doc stub class: emptytext answered
16
+ PARTIAL and `--strip-stub-docs` offered a strip that deleted real
17
+ illustrations (the apply-time gate caught the deletion, but the offer was
18
+ wrong). Both the analyzer and the repair twin now pool the `<body>` span,
19
+ and a doc with no body text that carries an image is furniture, never a
20
+ candidate; identical titles alone are never a stub class.
21
+ * `--fix-ids` sees every invalid character, not just colons and digit leads
22
+ (issue #3). The Lisey's Story OPF carries ids like
23
+ `Stephen_King_-_Lisey's_story22` (apostrophes copied from filenames),
24
+ which passed the original empty/colon/leading-char NCName check
25
+ wholesale, so a full `--all` sweep left the RSC-005 id errors standing
26
+ (epubcheck's message says "without colons"; the defect was never colons).
27
+ The check now enforces the XML NameChar set at every position, and the
28
+ rename sanitizes every invalid character to `_`, references in sync as
29
+ before. Fixture: 22 ids renamed, the OPF id error class cleared; the
30
+ errors that remain on that book are unrelated classes (deprecated body
31
+ attributes, stray GeoXML elements).
32
+ * `--fix-svg-dup-ids` (issue #2): old calibre 0.8.x SVG page renders repeat
33
+ glyph ids within one document. The first occurrence keeps its name, later
34
+ ones gain `_2`/`_3`, and references are deliberately untouched: they
35
+ resolve to the first definition under every reader's first-match lookup,
36
+ so first-keeps-id preserves exactly what rendered. Standalone `.svg`
37
+ entries only; content-document ids are styled and anchor-targeted and
38
+ stay out of scope. Fixture: 346 ids rewritten, 80 aggregated errors to
39
+ zero.
40
+ * `--fix-cdata-terminator` (issue #4): completes the truncated `/*]]>`
41
+ CDATA-terminator comment inside inline `<style>` blocks (the 2002-era
42
+ conversion lineage whose 19 content docs each died with CSS-008
43
+ "Premature end of file"; the aggregated gate saw one message). Only the
44
+ malformed-terminator token class: invalid CSS stays unfixed, stylesheet
45
+ files are not touched, and an intact closer (even space-separated) is
46
+ left alone.
47
+ * The markup-fatal trio (issue #5): `--fix-misnested-inline` normalizes the
48
+ Mobipocket drop-cap mis-nest `<i><b>X</i></b>` (em/strong variants and
49
+ either order; punctuation between the reversed closers survives outside
50
+ both spans; a letter run is refused as ambiguous),
51
+ `--fix-stray-close` removes an end tag whose element has no open start
52
+ frame (a merely mis-nested pair is never touched; script/style text is
53
+ skipped to its end tag and an unclosed comment stops the walk), and
54
+ `--fix-unterminated-attr` closes an
55
+ attribute value open to the tag's own `>` (`<p class="footnote>`;
56
+ bare-word values only, and only when no quote closes before the next
57
+ `<`). Life (Keith Richards): 3 fatals to zero, gate-accepted with the
58
+ unterminated-attr repair running before the tag-shape readers an open
59
+ value would otherwise lie to.
60
+
61
+ Tests: 615 (up from 575), including a real-core row per new flag, the
62
+ wiring-table pins for the five new dests, classifier regressions for both
63
+ sides of the stub fix, and a version-sync pin for uv.lock's root entry
64
+ (the pre-release audit found v0.46.1 shipped with the lock one patch
65
+ stale, a class the pyproject pin cannot see). The plugin ships the default pass only; it
66
+ vendors the changed modules byte-identically and CI byte-compiles it under
67
+ 3.11 through 3.14.
68
+
69
+ ## v0.46.1 (2026-10-02)
70
+
71
+ ### The closing sweep: documentation truth after the 0.46 line
72
+
73
+ The 2026-10-02 verification of the four-lane run left four documentation
74
+ findings against the v0.46.0 tree; all four land here as text plus one
75
+ plugin string. No surface change beyond the plugin's help text.
76
+
77
+ * The safety contract's lossy enumeration names all four modes: the
78
+ paragraph listed three and omitted `--strip-stub-docs` (shipped
79
+ v0.41.0), while the mode list twenty lines up, spec.md, and CLAUDE.md
80
+ all carry four. Its rationale sentence now says converter-injected
81
+ content rather than visible markup, true of the document-dropping stub
82
+ mode too.
83
+ * CLAUDE.md's tests bullet loses its em-dash: the one straggler in the
84
+ v0.45.0..HEAD markdown delta, recast with a semicolon.
85
+ * ci.yml's core-compat comment describes every module its list runs: the
86
+ v0.46.0 follow-ups added comment-hyphens, flags-wiring, and
87
+ flags-real-core to the module list without touching the enumeration
88
+ above it, so a reader reconciling comment against list found three
89
+ orphans.
90
+ * The plugin's `customization_help` names `max_log_mb`: the rotation cap
91
+ is documented in the README and honored by the log writer, but the
92
+ in-Calibre customization dialog never mentioned the key. Rides the
93
+ release-attached plugin zip.
94
+ * Suite: 544 -> 575 tests since v0.46.0 (the two follow-up wiring-test
95
+ commits, unpinned in that entry's count), all green on this text-only
96
+ sweep.
2
97
  ## v0.46.0 (2026-10-02)
3
98
 
4
99
  ### The last open repair class closes: RSC-016, plus the RepairFlags refactor
@@ -26,7 +121,7 @@
26
121
 
27
122
  * **`--encode-url-spaces` gained its rename half**, the biggest real-prevalence repair class left (PKG-010: 8,237 occurrences in 284 books at the 2026-09-17 refresh). Archive entries whose names carry raw spaces are renamed to their underscore spellings and every reference is rewritten (OPF, NCX, content `src`/`href`, stylesheet `url()`). The design was decided by experiment, not by the roadmap box's plan: epubcheck 5.3 DECODES references before entry lookup, so percent-encoded entries are unfindable by the encoded references pointing at them (all four raw/percent/underscore combinations built and checked); underscore on both sides resolves and clears PKG-010. Ambiguous renames are refused per entry (a target that already exists, two names converging, a duplicated archive name), a book with any non-UTF-8 document refuses all of its renames, a renamed OPF regenerates a stale container, and the acceptance rides `no_worse` because the gain sits on the warning axis the improvement gate does not measure (same bar as `--fix-cover`).
28
123
  * **The matcher fix that explains Theaetetus** (the 2026-09-16 hand-rescue): the encode pass's value class `[^"']` stopped at apostrophes inside double-quoted hrefs, so the 270 raw-space OPF hrefs never matched and the fatal survived. The matcher now takes the tempered-quote idiom (possessive, house style), and an apostrophe in a double-quoted filename is ordinary.
29
- * **`bindery audit cover`** (advisory, the EPUB3 half of the ruled hybrid): dangling EPUB2 `<meta name="cover">` (the `--fix-cover` class), the EPUB3 `properties~="cover-image"` declaration, and whether the named cover file exists. **`bindery audit tocdrift`**: the structural diff between the NCX navMap and the EPUB3 nav toc, keyed on the full target including fragments -- entries each side is missing plus label mismatches. Both never flag a book and never move the exit code; ToC synthesis stays out of the repair charter permanently. The loader exposes the parsed OPF, the NCX text, and the nav HTML on `Book`.
124
+ * **`bindery audit cover`** (advisory, the EPUB3 half of the ruled hybrid): dangling EPUB2 `<meta name="cover">` (the `--fix-cover` class), the EPUB3 `properties~="cover-image"` declaration, and whether the named cover file exists. **`bindery audit tocdrift`**: the structural diff between the NCX navMap and the EPUB3 nav toc, keyed on the full target including fragments: entries each side is missing plus label mismatches. Both never flag a book and never move the exit code; ToC synthesis stays out of the repair charter permanently. The loader exposes the parsed OPF, the NCX text, and the nav HTML on `Book`.
30
125
  * **The audit-refresh deep-dive ran** (read-only FastSweep extract over the full 5,228-book library; raw report in the facility): RSC-005 164,302 -> 13,782 occurrences, RSC-007 3,219 -> 14,162 in 604 books (the classics-wave imports), RSC-020 9,953 in 337 books, PKG-010 8,237 in 284 books (population flat: the rename class was still live, hence this ship).
31
126
  * **The roman grammar is one copy again**: pagination.py is the canonical home (it vendors into the plugin; audit does not), audit.py imports `ROMAN_RE`/`ROMAN_MAX`/`roman_value`, and the two byte-identical copies cannot drift. The RepairFlags dataclass refactor stays boxed with its reason (pure churn on the signatures this release touched).
32
127
  * Suite: 497 -> 521 tests (tests/test_space_renames.py, tests/test_cover_tocdrift.py, and the updated `--encode-url-spaces` end-to-end pins).
@@ -35,13 +130,13 @@
35
130
 
36
131
  ### The stdlib XML hardening: untrusted metadata is bounded and entity-refused, no dependency
37
132
 
38
- * **`--audit` and every `load_book` caller no longer decompress blind.** Two protections, both stdlib-only (the defusedxml dependency stays declined): single entries whose declared size exceeds `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression -- a bomb entry in a tiny zip cannot be downloaded into memory -- and the untrusted metadata files (container.xml, OPF, encryption.xml) parse through `_safe_xml_parse`, which refuses DOCTYPE/ENTITY declarations before xml.etree sees them. A refused or unparseable metadata file returns the shell book (the archive verdict is the book's whole story), never a hang or a traceback.
133
+ * **`--audit` and every `load_book` caller no longer decompress blind.** Two protections, both stdlib-only (the defusedxml dependency stays declined): single entries whose declared size exceeds `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression (a bomb entry in a tiny zip cannot be downloaded into memory), and the untrusted metadata files (container.xml, OPF, encryption.xml) parse through `_safe_xml_parse`, which refuses DOCTYPE/ENTITY declarations before xml.etree sees them. A refused or unparseable metadata file returns the shell book (the archive verdict is the book's whole story), never a hang or a traceback.
39
134
  * Pinned by `tests/test_xml_hardening.py` (entity-bomb container returns the shell book; clean XML parses; the entry cap is sane). The defusedxml dependency ask is closed as declined with the stdlib shape delivered.
40
135
 
41
136
  ## v0.42.0 (2026-09-16)
42
137
 
43
138
  ### The matcher audit executed: every quote-aware tag matcher hardened
44
- The follow-up box the 0.41.1 hotfix opened is closed with a full per-matcher census, and the audit found the box's own enumeration incomplete. Six GREEDY patterns of the exponential overlap class were unhardened -- `_START_TAG_RE` (the quote-aware start-tag matcher anchoring `--strip-epub3-attrs` and `--strip-bad-attrs`, running under `repair --all` on every content doc; not even named in the box), `_COVER_META_RE`, `_GUIDE_REF_RE`, `prune_dangling_edges`' spine|item|itemref matcher, `_ITEM_TAG_RE`, and `_SPINE_ITEM_RE`. All six are possessive-bounded now: byte-identical on every successful match (the loop is always followed by a literal `>` that the `[^>]` branch cannot consume, so backtracking only ever happened on failure) and linear on failure. Five LAZY patterns of the quadratic class (`_VOID_RE`, the transforms `<img>` matcher, and epub's link/anchor/img matchers) took the disjoint catch-all `[^>"']` instead of a blind possessive swap: mutually exclusive branches cannot re-partition, so any quantifier is linear, and an unterminated quote inside a tag now fails to match instead of pairing with a distant quote and matching across markup. Every well-formed shape is byte-identical. The epub `_IMG_TAG_RE` is renamed `_PRUNE_IMG_TAG_RE` (it collided with the transforms matcher of the same name). Everything else in the package audited safe by construction: tempered-quote value matchers, two-branch quote alternations, `[^>]*` single-class prefixes (linear, not quote-aware -- accepted, documented), lazy paired-delimiter spans, and the analyzer tokenizers.
139
+ The follow-up box the 0.41.1 hotfix opened is closed with a full per-matcher census, and the audit found the box's own enumeration incomplete. Six GREEDY patterns of the exponential overlap class were unhardened: `_START_TAG_RE` (the quote-aware start-tag matcher anchoring `--strip-epub3-attrs` and `--strip-bad-attrs`, running under `repair --all` on every content doc; not even named in the box), `_COVER_META_RE`, `_GUIDE_REF_RE`, `prune_dangling_edges`' spine|item|itemref matcher, `_ITEM_TAG_RE`, and `_SPINE_ITEM_RE`. All six are possessive-bounded now: byte-identical on every successful match (the loop is always followed by a literal `>` that the `[^>]` branch cannot consume, so backtracking only ever happened on failure) and linear on failure. Five LAZY patterns of the quadratic class (`_VOID_RE`, the transforms `<img>` matcher, and epub's link/anchor/img matchers) took the disjoint catch-all `[^>"']` instead of a blind possessive swap: mutually exclusive branches cannot re-partition, so any quantifier is linear, and an unterminated quote inside a tag now fails to match instead of pairing with a distant quote and matching across markup. Every well-formed shape is byte-identical. The epub `_IMG_TAG_RE` is renamed `_PRUNE_IMG_TAG_RE` (it collided with the transforms matcher of the same name). Everything else in the package audited safe by construction: tempered-quote value matchers, two-branch quote alternations, `[^>]*` single-class prefixes (linear, not quote-aware; accepted, documented), lazy paired-delimiter spans, and the analyzer tokenizers.
45
140
 
46
141
  Pinned by `tests/test_matcher_hardening.py` (15 tests): alarm-guarded spin shapes per greedy site (OPF flavor, the truncated-OPF prune shape), unterminated-quote skips, and well-formed byte-identity. `CLAUDE.md`'s hazard note now carries the census and the rule for new matchers. The full suite is 491 tests.
47
142
 
@@ -270,7 +270,9 @@ class BinderyRepair(FileTypePlugin):
270
270
  def customization_help(self, gui=False):
271
271
  return (
272
272
  'Enter a JSON object: {"log": true, "log_path": ..., '
273
- '"max_size_mb": 150, "epubcheck_path": null}. '
274
- "epubcheck_path enables the experimental post-repair validation "
275
- "with the epubcheck binary (slow; off by default)."
273
+ '"max_size_mb": 150, "max_log_mb": 2, "epubcheck_path": null}. '
274
+ "max_log_mb is the log rotation cap in MB (default 2, one .old "
275
+ "generation kept, 0 disables). epubcheck_path enables the "
276
+ "experimental post-repair validation with the epubcheck binary "
277
+ "(slow; off by default)."
276
278
  )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.46.0"
7
+ version = "0.47.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 = [
@@ -40,6 +40,7 @@ The plugin vendors the core and never gates (the epubcheck latency ruling).
40
40
  | Waves 15 + blitz | The plugin PEP-758 HIGH + the 3.11-3.14 interpreter matrix, analyzer robustness, the per-matcher census (possessive bounding; cited by tests/test_matcher_hardening.py), stdlib XML hardening, FastDaemon v2 (reconcile-not-retire), `--strip-stub-docs`, `bindery doctor` + `repair --json`, `--encode-url-spaces` entry renames, the cover + tocdrift analyzers, the privacy/GitHub/docs/comment/hygiene batches, requires-python 3.12 with markers, the em-dash sweep | v0.40.0-v0.44.0 |
41
41
  | 0.45 | Structured fix records (`fixes`, `ncx_uid_synced`, `watermark_refusals`) in every JSON payload; CalibreQuarry consumes them with the 0.45.0 floor | v0.45.0 |
42
42
  | 0.46 | RSC-016 `--fix-comment-double-hyphen` (the last open repair class) and the RepairFlags dataclass | v0.46.0 |
43
+ | 0.47 | The intake-wave batch (five open issues): the stub classifier's body-pool + image-carrier exemption, the NCName-complete `--fix-ids`, and `--fix-svg-dup-ids`, `--fix-cdata-terminator`, `--fix-misnested-inline`, `--fix-stray-close`, `--fix-unterminated-attr` | v0.47.0 |
43
44
 
44
45
  ## Open work
45
46
 
@@ -89,6 +90,12 @@ The plugin vendors the core and never gates (the epubcheck latency ruling).
89
90
  docs/screenshots/library-sweep.png against a synthetic testing_facility
90
91
  library (invented titles, real dry-run output); the current PNG shows 16
91
92
  real rows. Asked 2026-09-15, unanswered.
93
+ - [x] **The 2026-10-02 closing sweep** (the four-lane verification's doc
94
+ findings against the v0.46.0 tree): the safety contract's lossy
95
+ enumeration names all four modes, the core-compat em-dash is gone,
96
+ ci.yml's module comment covers all eleven stack-free modules, and the
97
+ plugin's `customization_help` names `max_log_mb`. *(SHIPPED v0.46.1:
98
+ text plus the one plugin string; no surface change.)*
92
99
 
93
100
  ## The repair domain in the ecosystem parity program (recorded 2026-09-29)
94
101
 
@@ -107,7 +114,7 @@ metadata.db registration and the Calibre plugin. Dispositions recorded under the
107
114
  embed/subset fonts, jacket, smarten punctuation, remove-unused-css, compress-images,
108
115
  upgrade-book, download-external-resources, the --opf rewrite, --cover. They are
109
116
  content-improving rather than repair, and dc: metadata editing is out of charter
110
- (spec.md:583). Recorded here so the parity ledger's bindery row names them declined,
117
+ (spec.md:597). Recorded here so the parity ledger's bindery row names them declined,
111
118
  not missing.
112
119
  - **The `data`-row waiver note**: bindery has updated `data` rows through
113
120
  `WritableCalibreDB.set_format` since v0.24.0 (the sanctioned install path);
@@ -134,7 +141,7 @@ metadata.db registration and the Calibre plugin. Dispositions recorded under the
134
141
  also fires on `add_format`, so the plugin is byte-idempotent. The vendor slice
135
142
  (transforms/epub/pagination/watermark/reserialize + a plugin `__init__`) is
136
143
  generated at release from the tagged tree with a byte-equality drift test. Active
137
- set = the gate-safe default pass only: every structural repair and the three
144
+ set = the gate-safe default pass only: every structural repair and the four
138
145
  lossy strips stay CLI-only, because epubcheck cannot gate inside Calibre (no jar/JVM
139
146
  and seconds-per-book latency). Config via `site_customization` JSON (`log`,
140
147
  `log_path`, `max_size_mb`, recorded default 150MB); `publish.yml` attaches
@@ -13,15 +13,17 @@ restyle, re-compress, or restructure content, and it does not attempt to fix arb
13
13
  schema (RSC-005) violations, which are usually harmless to readers and not safely
14
14
  mechanizable.
15
15
 
16
- The deliberate exceptions to "semantics-preserving" come in opt-in groups. The sixteen
16
+ The deliberate exceptions to "semantics-preserving" come in opt-in groups. The twenty-one
17
17
  **structural repairs** (`--fix-empty-body`, `--fix-missing-title`, `--fix-id-colons`,
18
18
  `--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
19
19
  `--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`,
20
20
  `--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
21
- `--fix-container`, `--fix-media-types`, `--fix-cover`,
22
- `--fix-comment-double-hyphen`) alter
23
- markup structure or fabricate minimal content; the three
24
- **lossy modes** (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`) remove
21
+ `--fix-container`, `--fix-media-types`, `--fix-cover`, `--fix-comment-double-hyphen`,
22
+ `--fix-svg-dup-ids`, `--fix-cdata-terminator`, `--fix-misnested-inline`,
23
+ `--fix-stray-close`, `--fix-unterminated-attr`) alter
24
+ markup structure or fabricate minimal content; the four
25
+ **lossy modes** (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`,
26
+ `--strip-stub-docs`) remove
25
27
  content a converter injected rather than content the author wrote. A fourth group of
26
28
  **safe opt-ins** (`--fix-ids`, `--add-img-alt`, `--strip-bad-attrs`,
27
29
  `--escape-unknown-entities`) repairs without altering visible markup, and
@@ -123,7 +125,7 @@ byte-for-byte. The normal gate applies.
123
125
 
124
126
  ### Opt-in: structural repairs
125
127
 
126
- Sixteen repairs go past well-formedness and therefore require their own flag; none is ever
128
+ Twenty-one repairs go past well-formedness and therefore require their own flag; none is ever
127
129
  part of the default pipeline:
128
130
 
129
131
  - **`--fix-empty-body`**: `&nbsp;` inside a strictly empty `<body></body>` ("body
@@ -232,12 +234,60 @@ use, with the `partial` rule intact.
232
234
  never touched, and each comment's `-->` terminator stays intact. An unclosed comment is a
233
235
  different fatal class and is left alone. It applies to content documents, the NCX, and
234
236
  the OPF alike, and every replaced sequence is counted.
235
-
236
- Fifteen of the sixteen are evaluated by the normal `gate`: unlike the lossy strips,
237
+ - **`--fix-svg-dup-ids`**: rename duplicate `id` values inside standalone `.svg`
238
+ entries (old calibre 0.8.x SVG page renders repeat glyph ids within one document;
239
+ four books from one intake wave carried 445 to 1,339 'Duplicate "glNNNN"' RSC-005s
240
+ each). The first occurrence keeps its name; every later one gains `_2`, `_3`, ... in
241
+ document order (prefixed with `_` until the name is free). Internal
242
+ `xlink:href="#x"` / `url(#x)` references are deliberately never rewritten: they
243
+ already resolve to the first definition under every reader's first-match lookup, so
244
+ first-keeps-id preserves exactly what rendered. Scope is standalone `.svg` entries
245
+ only: content-document ids are styled by CSS selectors and targeted by anchors, and
246
+ renaming those needs a reference graph this repair does not build.
247
+ - **`--fix-cdata-terminator`**: complete a truncated CDATA-terminator comment in an
248
+ inline `<style>` block: `/*]]>` becomes `/*]]>*/` (a comment-wrapped CDATA block
249
+ whose closing `*/` the converter dropped; epubcheck's CSS parser reads the dangling
250
+ `/*` as an unterminated comment and answers CSS-008 'Premature end of file' once per
251
+ affected document, 19 identical errors on the 2002-era fixture). Deliberately narrow:
252
+ only the malformed-terminator token class, inside `<style>` blocks only; invalid CSS
253
+ properties and selectors stay unfixed (renderers degrade gracefully), stylesheet FILE
254
+ entries are not touched, and a closer that already sits intact (possibly after
255
+ whitespace) is left alone.
256
+ - **`--fix-misnested-inline`**: rewrite the Mobipocket drop-cap mis-nest
257
+ `<i><b>X</i></b>` to `<i><b>X</b></i>` (em/strong variants and either tag order
258
+ included). Only the both-open-then-reversed-close shape is rewritten: any match is
259
+ XML fatal, and swapping the closers is the unique well-formed form carrying the same
260
+ two spans over the same text. The Exile variant, where a short punctuation run sits
261
+ between the reversed closers (`<i><b>xile</i>,</b>`), is covered: the punctuation
262
+ survives outside both spans, byte-preserved. A letter run between the closers
263
+ (`<i><b>S</i>ome</b>`, where `<b>` spans `Some` and `<i>` only `S`) is refused: which
264
+ span keeps the letters has no deterministic answer. Same-tag pairs, attribute-bearing
265
+ starts, and markup crossing the run are untouched.
266
+ - **`--fix-stray-close`**: remove an end tag whose element has no open start tag
267
+ anywhere above it (the stray extra `</div>` on a shell page, which cascades into
268
+ 'body must be terminated by the matching end-tag'). The rule is deliberately narrower
269
+ than a nesting repair: a close is removed only when NOTHING by that name is open, so
270
+ a merely mis-nested pair (`<div><span></div></span>`) is left exactly as written
271
+ (its `</span>` still has an open frame and is never misread as stray). Self-closed
272
+ and void starts open no frame; raw-text elements (`script`, `style`) are skipped to
273
+ their end tag so JS/CSS text is never read as markup; the walk stops at an unclosed
274
+ comment or CDATA opener, where the parser stops reading markup too.
275
+ - **`--fix-unterminated-attr`**: close an attribute value left open to the tag's own
276
+ `>` (`<p class="footnote>` -> `<p class="footnote">`; the parser swallows prose into
277
+ the value until the next quote, meets a `<`, and dies with the RSC-016 fatal "the
278
+ value of attribute ... must not contain the '<' character"). Three guards keep it off
279
+ legal `>`-bearing values: the value is one bare word, the `>` must be followed by no
280
+ quote before the next `<` (a real multi-line value closes quote-first), and only
281
+ double-quoted values are judged. It runs before the other tag-shape readers, which an
282
+ open value otherwise lies to.
283
+
284
+ Nineteen of the twenty-one are evaluated by the normal `gate`: unlike the lossy strips,
237
285
  their benefit is visible to epubcheck (they clear errors), so a run with no measurable
238
- improvement is a noop and nothing is applied. The exception is `--fix-cover`, whose
239
- gain is invisible to epubcheck: it is accepted under the same `no_worse` bar the
240
- lossy strips use, with the partial rule intact. CDATA sections are never rewritten
286
+ improvement is a noop and nothing is applied. The two exceptions are `--fix-cover` and
287
+ `--encode-url-spaces`, whose gains sit on axes epubcheck does not measure (cover
288
+ wiring is not a schema finding; the rename half's PKG-010 gain sits on the warning
289
+ axis): both are accepted under the same `no_worse` bar the lossy strips use, with
290
+ the partial rule intact. CDATA sections are never rewritten
241
291
  anywhere; comment bodies only by the double-hyphen fix above, and nothing else inside
242
292
  them.
243
293
 
@@ -264,7 +314,7 @@ Since this removes visible text from the reading experience, it is lossy by desi
264
314
  Strips known producer and redistributor watermarks out of EPUBs (e.g. OceanofPDF.com, ABC Amber LIT Converter). The removal is a balanced-element surgery rather than regex slicing: it locates the stamp and deletes the outermost wrapper whose *entire visible text* is the watermark, ensuring prose that merely mentions the URL is preserved. Also drops known zero-byte marker files. Like other lossy operations, this is verified via `no_worse`.
265
315
 
266
316
  ### Opt-in, lossy: stub-document strip (`--strip-stub-docs`)
267
- Drops spine documents whose entire visible text is one identical short placeholder repeated across the spine: the Bookmate/DRM-sample export whose chapters are all the same "content unavailable" notice, valid XHTML that epubcheck passes. The identity rule mirrors the emptytext analyzer's placeholder signals and is deliberately conservative: a candidate doc's visible text is 12-600 characters (short, not blank) and IDENTICAL to the text of at least two other spine docs, and the class covers at least 30% of the spine; only the single most common repeated class qualifies.
317
+ Drops spine documents whose entire visible BODY text is one identical short placeholder repeated across the spine: the Bookmate/DRM-sample export whose chapters are all the same "content unavailable" notice, valid XHTML that epubcheck passes. The identity rule mirrors the emptytext analyzer's placeholder signals and is deliberately conservative: a candidate doc's visible body text is 12-600 characters (short, not blank) and IDENTICAL to the text of at least two other spine docs, and the class covers at least 30% of the spine; only the single most common repeated class qualifies. The pool is the `<body>` span, not the whole document (whole document when a doc has none; a converter stamps the same head `<title>` on every shell page, which used to weld unrelated docs into a fake stub class), and an image-carrier page (empty body text, an `<img>`/`<image>` present) is exempt outright: those are illustrated part-divider pages, furniture the strip must never delete (the Great Change misfire, 2026-10-05).
268
318
 
269
319
  The drop cascades fully: the placeholder archive entries, their manifest items and spine itemrefs (with every package edge rewritten), their NCX navPoints (the always-on playOrder resequencing heals the sequence afterward), and their nav toc `<li>` entries. Refusals, each reported as a no-op: no repeated class, the class under the fraction bar, and the class covering the whole spine (a book whose every spine doc is the same stub is EMPTY; it needs a re-source, never a repair). Accepted under the `no_worse` bar with the partial rule intact.
270
320
 
@@ -445,7 +495,7 @@ Two more advisory analyzers ship in v0.44.0. `cover` reads the package's own cov
445
495
  wiring (the EPUB3 half of the ruled hybrid): a dangling EPUB2 `<meta name="cover">`
446
496
  (the `--fix-cover` repair class), an EPUB3 `properties~="cover-image"` declaration,
447
497
  and whether the named cover file exists. `tocdrift` diffs the two tables of contents
448
- an EPUB 3 book may carry -- the NCX navMap against the nav document -- reporting
498
+ an EPUB 3 book may carry (the NCX navMap against the nav document), reporting
449
499
  entries each side is missing and labels that disagree; it feeds decisions, never a
450
500
  rewrite (ToC synthesis is out of the repair charter permanently). Both never flag a
451
501
  book and never move the exit code.
@@ -11,7 +11,7 @@ Calibre plugin built from this core.
11
11
  Single source of truth for the version. pyproject.toml must match VERSION below.
12
12
  """
13
13
 
14
- VERSION = "0.46.0"
14
+ VERSION = "0.47.0"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402