bindery-cli 0.45.0__tar.gz → 0.46.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.
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/CLAUDE.md +12 -7
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/PKG-INFO +2 -1
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/README.md +1 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/patchnotes.md +7 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/pyproject.toml +1 -1
- bindery_cli-0.46.0/roadmap.md +206 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/spec.md +24 -9
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/cli.py +47 -116
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/epub.py +124 -86
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/transforms.py +43 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_cli.py +23 -9
- bindery_cli-0.46.0/tests/test_comment_hyphens.py +279 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_epub.py +58 -51
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_pagination.py +2 -2
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_space_renames.py +4 -3
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/uv.lock +1 -1
- bindery_cli-0.45.0/roadmap.md +0 -2004
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/.github/FUNDING.yml +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/.gitignore +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/AGENTS.md +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/LICENSE +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/logo.svg +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/plugin/__init__.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/project.done +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/run_tests.sh +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_cover_tocdrift.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_library.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_matcher_hardening.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_plugin.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_transforms.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_version.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_watermark.py +0 -0
- {bindery_cli-0.45.0 → bindery_cli-0.46.0}/tests/test_xml_hardening.py +0 -0
|
@@ -30,21 +30,26 @@ 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:
|
|
33
|
+
visible content. The canonical opt-in inventory lives in spec.md: sixteen structural
|
|
34
34
|
repairs, three 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
|
-
`--fix-container`, `--fix-media-types`, `--fix-cover
|
|
40
|
-
|
|
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)
|
|
42
|
+
replaces `--` inside XML comments with an en-dash: comment bodies only (text
|
|
43
|
+
nodes and CDATA never touched, the one deliberate exception to the
|
|
44
|
+
never-rewrite-comments invariant), in content documents, the NCX, and the OPF
|
|
45
|
+
alike; normal `gate` (clearing the fatal IS the measurable gain). Since v0.44.0 `--encode-url-spaces` also RENAMES the
|
|
41
46
|
archive entries whose names carry raw spaces to their underscore spellings
|
|
42
47
|
and rewrites every reference (OPF, NCX, content, CSS `url()`); underscore,
|
|
43
48
|
never percent-encoding (epubcheck decodes references before entry lookup,
|
|
44
49
|
verified against 5.3), ambiguous renames refused per entry (`space_rename_map`),
|
|
45
50
|
accepted under `no_worse` because PKG-010 sits on the warning axis the gate
|
|
46
|
-
does not measure
|
|
47
|
-
|
|
51
|
+
does not measure.
|
|
52
|
+
They alter markup structure or fabricate minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule;
|
|
48
53
|
v0.17.0 restored it. `--unwrap-illegal-tags` additionally protects any illegal-tag
|
|
49
54
|
name that an EPUB stylesheet styles as an element selector (`css_protected_tags`,
|
|
50
55
|
book-wide, inline `<style>` blocks included). The two EPUB2-targeted fixes
|
|
@@ -93,10 +98,10 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
93
98
|
- `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
|
|
94
99
|
- `src/bindery/reserialize.py`: structural repair via `html5lib`.
|
|
95
100
|
- `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.44.0 two more advisory analyzers ride the single pass: `cover` (the EPUB3 `properties~="cover-image"` slice of the ruled hybrid: dangling EPUB2 meta, EPUB3 declaration, cover-file presence) and `tocdrift` (the NCX navMap vs EPUB3 nav toc structural diff, fragments included; audit-only, ToC synthesis out of charter permanently); the loader now exposes the parsed OPF, the NCX text, and the nav HTML on `Book`. 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). Since v0.43.0 untrusted metadata XML is parsed through `_safe_xml_parse` (DOCTYPE/ENTITY declarations refused before xml.etree sees them; the stdlib shape of defusedxml's entity protection) and single entries above `_MAX_ENTRY_BYTES` (32 MB) are refused before decompression; a refused metadata file returns the shell book. Since v0.40.0 the vir-tui console renderer loads through the module-level `_LazyUI` proxy (a plain `import vir_tui as ui` at module level would make 3.12/3.13 installs unimportable now that the stack is marker-gated; LOAD_GLOBAL never consults module `__getattr__`, so a proxy object is the lazy shape that works).
|
|
96
|
-
- `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/v0.39.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), and `fix_cover_meta` (the ruling's deterministic half: dangling EPUB2 cover meta re-pointed from the guide's cover reference when exactly one manifest item carries that file, removed otherwise; accepted under no_worse like the lossy strips because cover wiring is invisible to epubcheck).
|
|
101
|
+
- `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.46.0 the opt-in selection is the `RepairFlags` dataclass (one field per flag, all off by default; the all-off instance IS the default pass): `repair_epub(src, dst, flags)` and `process_book(..., flags)` take one object instead of a ~25-kwarg signature, the EPUB2-targeted fixes' version gate swaps in a `dataclasses.replace` copy (never mutating the caller's instance), and the plugin's bare `repair_epub(src, dst)` call constructs exactly the all-off default pass. Since v0.38.0/v0.39.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), and `fix_cover_meta` (the ruling's deterministic half: dangling EPUB2 cover meta re-pointed from the guide's cover reference when exactly one manifest item carries that file, removed otherwise; accepted under no_worse like the lossy strips because cover wiring is invisible to epubcheck).
|
|
97
102
|
- `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
|
|
98
103
|
- `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.
|
|
99
|
-
- `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. `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.
|
|
104
|
+
- `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.
|
|
100
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.
|
|
101
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).
|
|
102
107
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.46.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
|
|
@@ -71,6 +71,7 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
|
|
|
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
|
+
- **`--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.
|
|
74
75
|
|
|
75
76
|
|
|
76
77
|
Four opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
|
|
@@ -44,6 +44,7 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
|
|
|
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
|
+
- **`--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.
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
Four opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
|
+
## v0.46.0 (2026-10-02)
|
|
3
|
+
|
|
4
|
+
### The last open repair class closes: RSC-016, plus the RepairFlags refactor
|
|
5
|
+
|
|
6
|
+
* **`--fix-comment-double-hyphen` (new structural repair, in the `--all` set)**: a `--` inside an XML comment is illegal XML (epubcheck fatal RSC-016, "The string \"--\" is not permitted within comments"), so such a book parses in lenient readers and never passes epubcheck. The fix replaces each `--` with an en-dash inside comment bodies only: text nodes are never touched (a `--` in character data is legal XML), CDATA sections stay protected, and every comment's `-->` terminator stays intact (the span ends there, so the replacement cannot reach it). It applies to content documents, the NCX, and the OPF alike, and each replaced sequence is counted under the `fixes` key `fix_comment_double_hyphen` (an additive key; CalibreQuarry's lossy-consent mirror reads the dict with `.get()` over its fixed lossy-marker tuple, so the new key is tolerated as structural, verified in `cquarry_cli/run.py`). It is the sixteenth structural repair, built exactly to the 2026-09-16 classics-wave box's recorded design: the Theaetetus hand-rescue's en-dash half, now native. The normal `gate` accepts it (clearing the fatal IS the measurable gain; verified against epubcheck 5.x on a synthetic book: 1f/1e -> 0f/1e), and the never-rewrite-comments invariant gains its one documented exception in spec.md.
|
|
7
|
+
* **`RepairFlags` (internal)**: the opt-in selection that used to be a ~25-kwarg signature triplicated across `repair_epub`, `process_book`, and the CLI's per-verb call sites is now one dataclass (`epub.RepairFlags`, one field per flag, all off by default; the all-off instance IS the default pass). `cli._flags_from_args` is the single argparse-to-fields mapping, shared by `library` and `repair`; the EPUB2-targeted fixes' package-version gate swaps in a `dataclasses.replace` copy, so a caller's instance is never mutated. No surface change: the CLI flags, the gate, and the JSON records are untouched (end-to-end and byte-determinism verified against real epubcheck). One API note for in-repo consumers: `bindery.epub.repair_epub` and `bindery.cli.process_book` now take `flags=RepairFlags(...)` instead of keyword flags; the plugin's bare `repair_epub(src, dst)` call constructs exactly the default pass.
|
|
8
|
+
* Suite: 524 -> 544 tests (`tests/test_comment_hyphens.py`: transform units, end-to-end over content/NCX/OPF, and the gate on both sides; the flag-wiring tuples pin the new flag).
|
|
2
9
|
## v0.45.0 (2026-09-21)
|
|
3
10
|
|
|
4
11
|
### The structured fix breakdown in the JSON records
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.46.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 = [
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# bindery-cli roadmap
|
|
2
|
+
|
|
3
|
+
*Phase numbers are historical identifiers, not an order: the campaign added phases
|
|
4
|
+
non-monotonically and two distinct efforts both carry "Phase 4". Cross-repo notes
|
|
5
|
+
cite them, so the ledger keeps the identifiers.*
|
|
6
|
+
|
|
7
|
+
**Minimized 2026-09-29 (the cquarry precedent).** Phases 1-16, the supplementary
|
|
8
|
+
audit section, the bug-report waves, and every audit block are shipped; their full
|
|
9
|
+
text is preserved in git at the tree of commit 157afc9 (the last full roadmap) and
|
|
10
|
+
the release-by-release record is `patchnotes.md`. This file keeps the ledger, the
|
|
11
|
+
open work, the records that live nowhere else, and the parity dispositions.
|
|
12
|
+
|
|
13
|
+
Standing rules: `spec.md` is the contract (deterministic, semantics-preserving,
|
|
14
|
+
epubcheck-gated repairs; opt-in lossy lane; dry-run default; atomic replacement).
|
|
15
|
+
The plugin vendors the core and never gates (the epubcheck latency ruling).
|
|
16
|
+
`tests/test_version.py` pins the version carriers.
|
|
17
|
+
|
|
18
|
+
## Completed phases (ledger)
|
|
19
|
+
|
|
20
|
+
| Phase | Scope | Shipped |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| 1 | Deterministic repair engine: transforms, NCX-001, mimetype repair, the two-mode epubcheck gate, `repair`/`library` modes, atomic in-place replacement with backups, dry-run default | v0.1.0 |
|
|
23
|
+
| 2 | The long tail: unclosed non-void elements (`--reserialize`), unbound namespaces (`--strip-bad-attrs`), digit-led/colon ids (`--fix-ids`), JSON reports + `--manual-list`, `--sweep` re-audit integration | v0.2.0-v0.7.0 |
|
|
24
|
+
| 4 (lossy) | `--strip-pagination`: the fenced lossy lane's first member (confident interrupts, character conservation, `no_worse`) | v0.5.0 |
|
|
25
|
+
| 5 | Audit fixes and hardening: the partial-classification bug, per-book exception isolation, id-anchor preservation, html5lib as an extra, the epubcheck locale/JSON hardening (5.2; cited by tests/test_validate.py), UX batch (progress, warnings, exit codes, `--limit`), mimetype/unknown-entity/NCX-ids/`--add-img-alt` additions, the void-end-tag spec gap | v0.6.0-v0.10.0 |
|
|
26
|
+
| 3 | Calibre plugin ("Bindery Repair", `FileTypePlugin` `on_import`): the decided shape (see Records kept), scoped vendor slice, byte-idempotence, per-release zip attachment | v0.37.0 |
|
|
27
|
+
| Suppl. | Library-audit schemas: `--fix-id-colons`, `--fix-empty-body`, `--fix-missing-title`, `--unwrap-block-in-inline`, `fix_ncx_playorder`, `--strip-invalid-value` (all opt-in since v0.17.0; `--add-img-alt` deliberately never defaulted) | v0.14.0 |
|
|
28
|
+
| 4 (cquarry) | cquarry integration: `get_format_path()` resolution, the `--tag` write path, `audit --id` single-book mode | v0.18.0-v0.19.0 |
|
|
29
|
+
| 6 | Code sweep: the content-loss fix, `--replace` crash, dead method, java pin removal, regex bounds, opt-in decoupling, CSS-aware unwrapping | v0.16.3-v0.19.0 |
|
|
30
|
+
| 7 | `monolithic` analyzer + the version-sync pin (`tests/test_version.py`) | v0.21.0 / v0.19.2 |
|
|
31
|
+
| 8 | `library --id` batch scoping, spine-integrity classification (convention vs fragment), `audit --id` comma lists; `--workers` made real in v0.30.0, the daemon pool in v0.35.0 | v0.23.0 |
|
|
32
|
+
| 9 | Archive integrity: the CRC sweep with its own CORRUPT verdict (never mislabeled EMPTY), the unreadable sub-reason split | v0.22.0 |
|
|
33
|
+
| 10 | OPF/NCX edges: `--fix-page-map`; the RSC-005 EPUB3 scrubs (`--strip-epub3-attrs`, `--downgrade-epub3-tags`), ruled Option B | v0.25.0-v0.27.0 |
|
|
34
|
+
| 11 | install-to-calibre on the native cquarry write module (`install_format`/`set_format`; the calibredb subprocess retired) | v0.24.0 |
|
|
35
|
+
| 12 | Top-error repairs: `--prune-missing-resources`, `--strip-broken-anchors`, the HTM-025 half, the FastSweep harness; bulk RSC-005 rejected (see Records kept) | v0.28.0 |
|
|
36
|
+
| 13 | Machine-readable audit (`audit --json`) + the `run phase1`/`run phase3` acquisition slices + the non-interactive contract | v0.29.0 |
|
|
37
|
+
| 14 | Hardening from the 2026-09-08 sweep: anchored/quote-aware regexes, `fix_id_colons` honesty + NCX follow, CDATA/comment protection everywhere, strict UTF-8, `--reserialize` html-root scoping, the watermark/roman/install/EPUB3-gate bug fixes, audit ToC accounting inside the open zip, the "unify on trouble" exit ruling, backup rotation + out-of-tree refusal, the daemon pool, docs reconciliation, the test-suite cleanups | v0.32.0-v0.35.0 |
|
|
38
|
+
| 15 | `audit completeness` spot-check analyzer (trailing-ToC, prose sampling), fixtures from the Redwall run | v0.36.0 |
|
|
39
|
+
| 16 | Package-structure repairs: the prevalence study, `--fix-container`, the OBFUSCATED vs DRM verdict split, prune edge completion, `--fix-media-types`, `--fix-cover` (the ruled hybrid), the boxed classes below | v0.38.0-v0.39.0 |
|
|
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
|
+
| 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
|
+
| 0.46 | RSC-016 `--fix-comment-double-hyphen` (the last open repair class) and the RepairFlags dataclass | v0.46.0 |
|
|
43
|
+
|
|
44
|
+
## Open work
|
|
45
|
+
|
|
46
|
+
- [ ] **Obfuscation-aware encryption.xml repair**: prune stale obfuscation entries
|
|
47
|
+
when font magic bytes prove the fonts are decrypted in place; drop
|
|
48
|
+
encryption.xml when only obfuscation entries remain and fonts verify. NEVER
|
|
49
|
+
un-obfuscate. *(BOXED with real counts, 2026-09-12: 424/424 readable
|
|
50
|
+
obfuscation fonts genuinely scrambled, zero stale entries in-library; the
|
|
51
|
+
precondition is validated and recognition carries both Adobe URI spellings.
|
|
52
|
+
The audit half SHIPPED v0.38.0 as the OBFUSCATED verdict. Opens only if
|
|
53
|
+
acquisition ever brings a decrypted-in-place book.)*
|
|
54
|
+
- [ ] **href case/backslash resolution**: case-insensitive namemap when exactly one
|
|
55
|
+
zip entry matches; backslash to slash. Kills the present-file
|
|
56
|
+
RSC-007/PKG-010 slice prune cannot touch. *(BOXED with real counts,
|
|
57
|
+
2026-09-12: exactly 1 case-mismatch href in 1 book and 0 backslash hrefs
|
|
58
|
+
across the library; reopen if acquisition changes the mix.)*
|
|
59
|
+
- [ ] **Duplicate zip-entry dedupe on rewrite**: drop shadowed duplicates (audit
|
|
60
|
+
already counts `dup_entries`). *(BOXED with real counts, 2026-09-12: 0 books
|
|
61
|
+
with duplicate entries in-library.)*
|
|
62
|
+
- [ ] **CSS url() pruning** as a `--prune-missing-resources` scope extension:
|
|
63
|
+
stays closed until a named epubcheck finding demands it (the 2026-09-12
|
|
64
|
+
prevalence read found selector damage, not url() candidates).
|
|
65
|
+
- [x] **Candidate transform: `--` inside XML comments (RSC-016 fatal)**. A
|
|
66
|
+
`fix_comment_double_hyphen` transform (comment nodes only, `--` -> en-dash,
|
|
67
|
+
byte-counted) would make the always-on core able to parse such books at all;
|
|
68
|
+
until then the book parses in lenient readers but never passes epubcheck.
|
|
69
|
+
Low frequency (one book in the classics wave); the fixture reference is in
|
|
70
|
+
git history. *(SHIPPED v0.46.0 as the opt-in `--fix-comment-double-hyphen`
|
|
71
|
+
structural repair: comment bodies only in content documents, the NCX, and
|
|
72
|
+
the OPF; the `-->` terminators stay intact; normal `gate`. Not a core
|
|
73
|
+
transform: it edits comment content, which everything else protects.)*
|
|
74
|
+
- [x] **RepairFlags dataclass** for the triplicated ~25-kwarg
|
|
75
|
+
process_book/repair_epub signature. Deferred with reason: pure churn on that
|
|
76
|
+
surface immediately after the v0.44 batch touched exactly those signatures;
|
|
77
|
+
do it as a standalone lane with the full suite and the plugin byte-compat
|
|
78
|
+
job green at every step, not as a rider. (The roman/arabic half of its parent
|
|
79
|
+
box resolved to UNIFY at pagination.py, v0.44.0.) *(SHIPPED v0.46.0 as its
|
|
80
|
+
own commit, exactly by that method: dataclass in epub.py, repair_epub, then
|
|
81
|
+
process_book, then `cli._flags_from_args` collapsing the per-verb kwarg
|
|
82
|
+
blocks, suite green at every step; the plugin's bare repair_epub call and
|
|
83
|
+
byte-compat unchanged.)*
|
|
84
|
+
- [ ] **MobileRead listing for the Bindery Repair plugin** (Brandon's manual
|
|
85
|
+
step): post the listing with name/identity, the release-attached zip,
|
|
86
|
+
minimum Calibre 7.0, Linux platform note. Posting is outward-facing under
|
|
87
|
+
his name.
|
|
88
|
+
- [ ] **README screenshot regeneration** (Brandon's call pending): rebuild
|
|
89
|
+
docs/screenshots/library-sweep.png against a synthetic testing_facility
|
|
90
|
+
library (invented titles, real dry-run output); the current PNG shows 16
|
|
91
|
+
real rows. Asked 2026-09-15, unanswered.
|
|
92
|
+
|
|
93
|
+
## The repair domain in the ecosystem parity program (recorded 2026-09-29)
|
|
94
|
+
|
|
95
|
+
Brandon opened the ecosystem Calibre-parity program on 2026-09-29 (cquarry roadmap.md,
|
|
96
|
+
"The parity program"; parity counts native coverage plus orchestration of Calibre's own
|
|
97
|
+
headless tools, and excludes process-bound and declined surface with recorded reasons).
|
|
98
|
+
bindery-cli's lane is the repair/acceptance domain, where this repo is already past
|
|
99
|
+
parity with Calibre's own tooling: ebook-polish has no acceptance oracle, no determinism
|
|
100
|
+
contract, and no atomic library replacement; bindery has all three, plus native
|
|
101
|
+
metadata.db registration and the Calibre plugin. Dispositions recorded under the program:
|
|
102
|
+
|
|
103
|
+
- **Conversion stays out, permanently** (spec.md:10-14): Calibre's conversion engine
|
|
104
|
+
(~45 input / ~20 output formats) is orchestration-lane territory (CalibreQuarry
|
|
105
|
+
`run convert`); bindery never grows one, and no parity claim depends on it.
|
|
106
|
+
- **The ebook-polish content-improving actions bindery lacks stay declined**:
|
|
107
|
+
embed/subset fonts, jacket, smarten punctuation, remove-unused-css, compress-images,
|
|
108
|
+
upgrade-book, download-external-resources, the --opf rewrite, --cover. They are
|
|
109
|
+
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,
|
|
111
|
+
not missing.
|
|
112
|
+
- **The `data`-row waiver note**: bindery has updated `data` rows through
|
|
113
|
+
`WritableCalibreDB.set_format` since v0.24.0 (the sanctioned install path);
|
|
114
|
+
cquarry's conditional write-back waiver was reworded 2026-09-29 to name what it
|
|
115
|
+
actually guards: a bindery-side book-metadata write-back convenience, still declined.
|
|
116
|
+
- **Parity-relevant accepted gaps**: RSC-016 (`--` inside XML comments) SHIPPED
|
|
117
|
+
v0.46.0 as `--fix-comment-double-hyphen` (the candidate box above), so no open
|
|
118
|
+
repair class blocks epubcheck acceptance on prevalence anymore; the
|
|
119
|
+
prevalence-gated classes (encryption.xml, href case/backslash,
|
|
120
|
+
zip dedupe, CSS url()) reopen only on acquisition mix. The RepairFlags refactor shipped
|
|
121
|
+
v0.46.0 (the ticked box above).
|
|
122
|
+
- **Brandon-parked items are unchanged**: the MobileRead plugin listing and the README
|
|
123
|
+
screenshot regeneration stay his; the program does not schedule them.
|
|
124
|
+
|
|
125
|
+
## Records kept (decisions and counts that live only here)
|
|
126
|
+
|
|
127
|
+
- **The plugin shape decision** (2026-08-09, researched against the Calibre source;
|
|
128
|
+
corrected 2026-09-05; shipped v0.37.0): `FileTypePlugin` with `on_import`:
|
|
129
|
+
`run(path)` returns a `temporary_file()` replacement before the file lands, so the
|
|
130
|
+
original on disk is never touched and metadata.db is never written. A raising plugin
|
|
131
|
+
cannot abort an import and cannot lose the file, but the failure is silent, so the
|
|
132
|
+
plugin never raises and keeps its own log under the Calibre config dir.
|
|
133
|
+
`supported_platforms = ['linux']` (the loader rejects an empty declaration). The hook
|
|
134
|
+
also fires on `add_format`, so the plugin is byte-idempotent. The vendor slice
|
|
135
|
+
(transforms/epub/pagination/watermark/reserialize + a plugin `__init__`) is
|
|
136
|
+
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
|
|
138
|
+
lossy strips stay CLI-only, because epubcheck cannot gate inside Calibre (no jar/JVM
|
|
139
|
+
and seconds-per-book latency). Config via `site_customization` JSON (`log`,
|
|
140
|
+
`log_path`, `max_size_mb`, recorded default 150MB); `publish.yml` attaches
|
|
141
|
+
`BinderyRepair-v<VERSION>.zip` to each release.
|
|
142
|
+
- **The metadata-validity carve-out ruling** (Brandon, 2026-09-12): route to
|
|
143
|
+
cquarry/CalibreQuarry; bindery's side is closed and dc: metadata stays a non-goal.
|
|
144
|
+
The routed half (51 OPF-085 invalid-UUID warnings plus the thin date/language tail)
|
|
145
|
+
is CalibreQuarry's lane (recorded in its roadmap; its 3.41/3.42 audit rows found the
|
|
146
|
+
classes clean at the DB level, the warnings file-side).
|
|
147
|
+
- **Prevalence baselines** (2026-09-12 study over 5,228 books; 2026-09-17 refresh):
|
|
148
|
+
RSC-005 164,302 -> 13,782 occurrences; RSC-007 14,156 in 600 books; RSC-020 9,953
|
|
149
|
+
in 337; PKG-010 8,237 in 284 (population flat, the rename class stayed live, hence
|
|
150
|
+
v0.44.0); container.xml valid 5,228/5,228; case-mismatch hrefs 1 in 1 book;
|
|
151
|
+
backslash hrefs 0; duplicate zip entries 0 books; encryption.xml in 86 books (426
|
|
152
|
+
Adobe + 3 IDPF obfuscation entries; 424/424 readable obfuscated fonts genuinely
|
|
153
|
+
scrambled at offset 0; the wild Adobe URI is `http://ns.adobe.com/pdf/enc#RC`, and
|
|
154
|
+
recognition carries both spellings); NO real-DRM algorithm anywhere in the library.
|
|
155
|
+
- **The exit-code contracts:** argparse-level misuse exits 2 before the tool's
|
|
156
|
+
validation; the tool's own usage errors exit 1; trouble (rejected + errors +
|
|
157
|
+
unreadable + partial books) exits 2 in all three layers by the 2026-09-10 "unify on
|
|
158
|
+
trouble" ruling (option A of the recorded pair).
|
|
159
|
+
- **The daemon decision** (2026-09-10): RECONCILE, not retire. The counting divergence
|
|
160
|
+
ran deeper than the daemon (the human summary counts message occurrences, epubcheck's
|
|
161
|
+
JSON counts aggregated messages, and the gate has always measured the aggregated
|
|
162
|
+
scale), so FastDaemon v2 reads the JSON epubcheck itself serializes and measures the
|
|
163
|
+
subprocess oracle's numbers exactly; the launch is Java's single-file source launcher
|
|
164
|
+
(no javac, no version skew); warm throughput ~0.27s/book vs ~4.2s/book subprocess.
|
|
165
|
+
Shipped v0.35.0.
|
|
166
|
+
- **requires-python ~3.12** (decision 63, executed v0.40.0): every cquarry/vir-tui on
|
|
167
|
+
PyPI declares >=3.14, so the VirInvictus pins carry
|
|
168
|
+
`; python_version >= '3.14'` markers (3.14 installs byte-identical); 3.12/3.13
|
|
169
|
+
installs run the stack-free repair core; the CLI dispatch guard turns a missing
|
|
170
|
+
stack into one stderr line + exit 2; CI byte-compiles the plugin zip under
|
|
171
|
+
3.11/3.12/3.13/3.14; `minimum_calibre_version` is (7, 0, 0).
|
|
172
|
+
- **Tag policy** (decided 2026-09-11, recorded 2026-09-12): v0.35.0 is the anchor;
|
|
173
|
+
every release from v0.36.0 on is tagged at its release commit under auto-tag
|
|
174
|
+
promotion. The nine untagged 0.18.0-0.26.0 entries are exempted by decision of
|
|
175
|
+
record (no backfill tags; patchnotes and PyPI carry them). Executed 2026-09-12: the
|
|
176
|
+
v0.28.0 tag re-pointed at its first green commit with its title line restored, the
|
|
177
|
+
v0.33.0 tag message corrected verbatim, and the 10 test_facility EPUBs stripped from
|
|
178
|
+
all history via git filter-repo (main + 14 tags force-pushed; PyPI untouched).
|
|
179
|
+
- **Privacy record:** top500candidates/REPORT.md is untracked forward-only (Brandon's
|
|
180
|
+
ruling, no history strip); the file stays on disk as the fast_sweep `--summary`
|
|
181
|
+
target. Reopen condition: the history-strip recipe (rewrite 16 release tags,
|
|
182
|
+
publish.yml disabled around the push) if the reachable copy is ever judged
|
|
183
|
+
unacceptable (also in project.done).
|
|
184
|
+
- **The matcher census** (v0.42.0; cited by tests/test_matcher_hardening.py): six
|
|
185
|
+
greedy exponential-class sites possessive-bounded (`_START_TAG_RE`, `_COVER_META_RE`,
|
|
186
|
+
`_GUIDE_REF_RE`, the prune spine matcher, `_ITEM_TAG_RE`, `_SPINE_ITEM_RE`); five
|
|
187
|
+
lazy quadratic-class sites took the disjoint `[^>"']` catch-all (`_VOID_RE`, the
|
|
188
|
+
transforms `<img>` matcher, epub's link/a/img matchers); everything else audited
|
|
189
|
+
safe by construction. Never a third grammar copy: pagination.py is the canonical
|
|
190
|
+
roman/number home (it vendors into the plugin; audit imports from it), and any
|
|
191
|
+
future divergence is a decision at pagination.
|
|
192
|
+
- **Non-goals kept with reasons:** ToC synthesis is out of the repair charter
|
|
193
|
+
permanently (tocdrift is detector-only); conversion is out permanently (the parity
|
|
194
|
+
section above); dc: metadata editing is out (spec); bulk RSC-005 rejected (164,302
|
|
195
|
+
instances; narrowly bounded sub-cases stay opt-in under their own flags); no
|
|
196
|
+
spine-item manifest pruning (missing spine documents mean corrupt archives or broken
|
|
197
|
+
fragments); no text deletion in anchor unwrapping (character conservation is
|
|
198
|
+
absolute); no auto-split of monolithic docs and no auto-trim of bloated ToCs (flag
|
|
199
|
+
and re-source); no repair of corrupt archive entries (re-source, never repair); no
|
|
200
|
+
PDF/DJVU work (never this repo's charter); `--add-img-alt` stays opt-in
|
|
201
|
+
(`alt=""` asserts "decorative" to screen readers); defusedxml stays declined (the
|
|
202
|
+
stdlib `_safe_xml_parse` delivers the entity protection and the vendored-plugin cost
|
|
203
|
+
was the real objection; the full-parser swap is Brandon-gated on a real 3.12/3.13
|
|
204
|
+
stranger audience); the plugin never gates on epubcheck (the latency ruling); no
|
|
205
|
+
manifest format ownership (CalibreQuarry owns `acquisition-manifest` and the
|
|
206
|
+
orchestration).
|
|
@@ -13,12 +13,13 @@ 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
|
|
16
|
+
The deliberate exceptions to "semantics-preserving" come in opt-in groups. The sixteen
|
|
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
|
|
21
|
+
`--fix-container`, `--fix-media-types`, `--fix-cover`,
|
|
22
|
+
`--fix-comment-double-hyphen`) alter
|
|
22
23
|
markup structure or fabricate minimal content; the three
|
|
23
24
|
**lossy modes** (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`) remove
|
|
24
25
|
content a converter injected rather than content the author wrote. A fourth group of
|
|
@@ -29,7 +30,10 @@ ONLY the five well-formedness transforms above, the NCX pipeline, and the mimety
|
|
|
29
30
|
fix: nothing else. (v0.14–v0.16 briefly
|
|
30
31
|
ran the structural repairs unconditionally, which broke this contract; v0.17.0 restored
|
|
31
32
|
it.) The `--all` flag enables every opt-in transform (safe, structural, and lossy) for a
|
|
32
|
-
comprehensive repair pass.
|
|
33
|
+
comprehensive repair pass. Internally the selection travels as one `RepairFlags`
|
|
34
|
+
object (`epub.RepairFlags`, v0.46.0) built once per verb from the parsed flags; that
|
|
35
|
+
is structure, not surface: the CLI flags above and the JSON records below are the
|
|
36
|
+
contract, and both are unchanged.
|
|
33
37
|
|
|
34
38
|
## Transforms
|
|
35
39
|
|
|
@@ -119,7 +123,7 @@ byte-for-byte. The normal gate applies.
|
|
|
119
123
|
|
|
120
124
|
### Opt-in: structural repairs
|
|
121
125
|
|
|
122
|
-
|
|
126
|
+
Sixteen repairs go past well-formedness and therefore require their own flag; none is ever
|
|
123
127
|
part of the default pipeline:
|
|
124
128
|
|
|
125
129
|
- **`--fix-empty-body`**: ` ` inside a strictly empty `<body></body>` ("body
|
|
@@ -221,13 +225,21 @@ file is absent: that class belongs to `--prune-missing-resources` and its edge c
|
|
|
221
225
|
The EPUB3 `properties="cover-image"` slice is audit-only by ruling. Cover wiring is invisible
|
|
222
226
|
to epubcheck, so cover-only repairs are accepted under the `no_worse` bar the lossy strips
|
|
223
227
|
use, with the `partial` rule intact.
|
|
224
|
-
|
|
225
|
-
|
|
228
|
+
- **`--fix-comment-double-hyphen`**: replace `--` inside XML comments with an en-dash
|
|
229
|
+
(RSC-016: "The string `--` is not permitted within comments", a fatal: the book parses in
|
|
230
|
+
lenient readers and never passes epubcheck). The edit is confined to comment bodies:
|
|
231
|
+
text nodes are never touched (a `--` in character data is legal XML), CDATA sections are
|
|
232
|
+
never touched, and each comment's `-->` terminator stays intact. An unclosed comment is a
|
|
233
|
+
different fatal class and is left alone. It applies to content documents, the NCX, and
|
|
234
|
+
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,
|
|
226
237
|
their benefit is visible to epubcheck (they clear errors), so a run with no measurable
|
|
227
238
|
improvement is a noop and nothing is applied. The exception is `--fix-cover`, whose
|
|
228
239
|
gain is invisible to epubcheck: it is accepted under the same `no_worse` bar the
|
|
229
|
-
lossy strips use, with the partial rule intact. CDATA sections
|
|
230
|
-
|
|
240
|
+
lossy strips use, with the partial rule intact. CDATA sections are never rewritten
|
|
241
|
+
anywhere; comment bodies only by the double-hyphen fix above, and nothing else inside
|
|
242
|
+
them.
|
|
231
243
|
|
|
232
244
|
### Transform invariants
|
|
233
245
|
|
|
@@ -236,7 +248,10 @@ rewritten, as everywhere else.
|
|
|
236
248
|
attribute, or element is added or removed beyond making the markup parseable.
|
|
237
249
|
- **CDATA sections and comments are never rewritten.** Their content is literal and
|
|
238
250
|
already legal XML; escaping a `&` or self-closing a `<br>` inside them would change
|
|
239
|
-
the content (e.g. corrupt CDATA-wrapped CSS/JS).
|
|
251
|
+
the content (e.g. corrupt CDATA-wrapped CSS/JS). The one deliberate exception is
|
|
252
|
+
`--fix-comment-double-hyphen` (above): a `--` inside a comment is precisely the case
|
|
253
|
+
where comment content is NOT already legal XML, and the fix is opt-in like every
|
|
254
|
+
other content edit.
|
|
240
255
|
- **Idempotent.** Re-running changes nothing once a document is well-formed.
|
|
241
256
|
- **Already-correct markup is untouched.** Self-closed void elements, predefined and
|
|
242
257
|
numeric entities, and single `xmlns` declarations are left exactly as they are.
|
|
@@ -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.
|
|
14
|
+
VERSION = "0.46.0"
|
|
15
15
|
__version__ = VERSION
|
|
16
16
|
|
|
17
17
|
from .epub import RepairReport, repair_epub # noqa: E402
|
|
@@ -30,7 +30,7 @@ from .audit import (
|
|
|
30
30
|
from .audit import (
|
|
31
31
|
run_library as run_audit_library,
|
|
32
32
|
)
|
|
33
|
-
from .epub import ncx_uid_mismatch, repair_epub
|
|
33
|
+
from .epub import RepairFlags, ncx_uid_mismatch, repair_epub
|
|
34
34
|
from .library import (
|
|
35
35
|
CalibreIdResolver,
|
|
36
36
|
atomic_replace,
|
|
@@ -65,34 +65,45 @@ class Outcome:
|
|
|
65
65
|
watermark_refusals: int = 0
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def _flags_from_args(args: argparse.Namespace) -> RepairFlags:
|
|
69
|
+
"""The repair selection from parsed args: each flag OR'd with --all, so
|
|
70
|
+
--all enables every opt-in. The one place the argparse-to-RepairFlags
|
|
71
|
+
name mapping is spelled out; both verbs share it."""
|
|
72
|
+
all_ = getattr(args, "all", False)
|
|
73
|
+
return RepairFlags(
|
|
74
|
+
fix_ids=args.fix_ids or all_,
|
|
75
|
+
reserialize=args.reserialize or all_,
|
|
76
|
+
strip_attrs=args.strip_bad_attrs or all_,
|
|
77
|
+
strip_pagination=args.strip_pagination or all_,
|
|
78
|
+
strip_brokentags=args.strip_broken_tags or all_,
|
|
79
|
+
strip_watermarks=args.strip_watermarks or all_,
|
|
80
|
+
strip_stub_docs=args.strip_stub_docs or all_,
|
|
81
|
+
escape_entities=args.escape_unknown_entities or all_,
|
|
82
|
+
img_alt=args.add_img_alt or all_,
|
|
83
|
+
empty_body=args.fix_empty_body or all_,
|
|
84
|
+
missing_title=args.fix_missing_title or all_,
|
|
85
|
+
id_colons=args.fix_id_colons or all_,
|
|
86
|
+
block_in_inline=args.unwrap_block_in_inline or all_,
|
|
87
|
+
invalid_value=args.strip_invalid_value or all_,
|
|
88
|
+
illegal_tags=args.unwrap_illegal_tags or all_,
|
|
89
|
+
page_map=args.fix_page_map or all_,
|
|
90
|
+
strip_epub3_attrs=args.strip_epub3_attrs or all_,
|
|
91
|
+
downgrade_epub3=args.downgrade_epub3_tags or all_,
|
|
92
|
+
prune_missing=args.prune_missing_resources or all_,
|
|
93
|
+
strip_anchors=args.strip_broken_anchors or all_,
|
|
94
|
+
url_spaces=args.encode_url_spaces or all_,
|
|
95
|
+
fix_container=args.fix_container or all_,
|
|
96
|
+
fix_media_types=args.fix_media_types or all_,
|
|
97
|
+
fix_cover=args.fix_cover or all_,
|
|
98
|
+
comment_double_hyphens=args.fix_comment_double_hyphen or all_,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
68
102
|
def process_book(
|
|
69
103
|
epub: Path,
|
|
70
104
|
workdir: Path,
|
|
71
105
|
validate: bool,
|
|
72
|
-
|
|
73
|
-
reserialize: bool = False,
|
|
74
|
-
strip_attrs: bool = False,
|
|
75
|
-
strip_pagination: bool = False,
|
|
76
|
-
strip_brokentags: bool = False,
|
|
77
|
-
strip_watermarks: bool = False,
|
|
78
|
-
strip_stub_docs: bool = False,
|
|
79
|
-
escape_entities: bool = False,
|
|
80
|
-
img_alt: bool = False,
|
|
81
|
-
empty_body: bool = False,
|
|
82
|
-
missing_title: bool = False,
|
|
83
|
-
id_colons: bool = False,
|
|
84
|
-
block_in_inline: bool = False,
|
|
85
|
-
invalid_value: bool = False,
|
|
86
|
-
illegal_tags: bool = False,
|
|
87
|
-
page_map: bool = False,
|
|
88
|
-
strip_epub3_attrs: bool = False,
|
|
89
|
-
downgrade_epub3: bool = False,
|
|
90
|
-
prune_missing: bool = False,
|
|
91
|
-
strip_anchors: bool = False,
|
|
92
|
-
url_spaces: bool = False,
|
|
93
|
-
fix_container: bool = False,
|
|
94
|
-
fix_media_types: bool = False,
|
|
95
|
-
fix_cover: bool = False,
|
|
106
|
+
flags: RepairFlags | None = None,
|
|
96
107
|
before: CheckResult | None = None,
|
|
97
108
|
) -> Outcome:
|
|
98
109
|
"""Repair `epub` into a temp file and decide whether the result is acceptable.
|
|
@@ -100,34 +111,7 @@ def process_book(
|
|
|
100
111
|
`before` is a pre-measured epubcheck result for `epub` (from a --sweep pass),
|
|
101
112
|
saving a second multi-second run; when None it is measured here."""
|
|
102
113
|
repaired = workdir / "repaired.epub"
|
|
103
|
-
report = repair_epub(
|
|
104
|
-
epub,
|
|
105
|
-
repaired,
|
|
106
|
-
fix_ids=fix_ids,
|
|
107
|
-
reserialize=reserialize,
|
|
108
|
-
strip_attrs=strip_attrs,
|
|
109
|
-
strip_pagination=strip_pagination,
|
|
110
|
-
strip_brokentags=strip_brokentags,
|
|
111
|
-
strip_watermarks=strip_watermarks,
|
|
112
|
-
strip_stub_docs=strip_stub_docs,
|
|
113
|
-
escape_entities=escape_entities,
|
|
114
|
-
img_alt=img_alt,
|
|
115
|
-
empty_body=empty_body,
|
|
116
|
-
missing_title=missing_title,
|
|
117
|
-
id_colons=id_colons,
|
|
118
|
-
block_in_inline=block_in_inline,
|
|
119
|
-
invalid_value=invalid_value,
|
|
120
|
-
illegal_tags=illegal_tags,
|
|
121
|
-
page_map=page_map,
|
|
122
|
-
strip_epub3_attrs=strip_epub3_attrs,
|
|
123
|
-
downgrade_epub3=downgrade_epub3,
|
|
124
|
-
prune_missing=prune_missing,
|
|
125
|
-
strip_anchors=strip_anchors,
|
|
126
|
-
url_spaces=url_spaces,
|
|
127
|
-
fix_container=fix_container,
|
|
128
|
-
fix_media_types=fix_media_types,
|
|
129
|
-
fix_cover=fix_cover,
|
|
130
|
-
)
|
|
114
|
+
report = repair_epub(epub, repaired, flags=flags)
|
|
131
115
|
if not report:
|
|
132
116
|
return Outcome(epub, "nochange", None, None, "no applicable fixes")
|
|
133
117
|
|
|
@@ -589,41 +573,7 @@ def run_library(args) -> int:
|
|
|
589
573
|
epub,
|
|
590
574
|
work,
|
|
591
575
|
validate,
|
|
592
|
-
|
|
593
|
-
reserialize=args.reserialize or getattr(args, "all", False),
|
|
594
|
-
strip_attrs=args.strip_bad_attrs or getattr(args, "all", False),
|
|
595
|
-
strip_pagination=args.strip_pagination
|
|
596
|
-
or getattr(args, "all", False),
|
|
597
|
-
strip_brokentags=args.strip_broken_tags
|
|
598
|
-
or getattr(args, "all", False),
|
|
599
|
-
strip_watermarks=args.strip_watermarks
|
|
600
|
-
or getattr(args, "all", False),
|
|
601
|
-
strip_stub_docs=args.strip_stub_docs or getattr(args, "all", False),
|
|
602
|
-
escape_entities=args.escape_unknown_entities
|
|
603
|
-
or getattr(args, "all", False),
|
|
604
|
-
img_alt=args.add_img_alt or getattr(args, "all", False),
|
|
605
|
-
empty_body=args.fix_empty_body or getattr(args, "all", False),
|
|
606
|
-
missing_title=args.fix_missing_title or getattr(args, "all", False),
|
|
607
|
-
id_colons=args.fix_id_colons or getattr(args, "all", False),
|
|
608
|
-
block_in_inline=args.unwrap_block_in_inline
|
|
609
|
-
or getattr(args, "all", False),
|
|
610
|
-
invalid_value=args.strip_invalid_value
|
|
611
|
-
or getattr(args, "all", False),
|
|
612
|
-
illegal_tags=args.unwrap_illegal_tags
|
|
613
|
-
or getattr(args, "all", False),
|
|
614
|
-
page_map=args.fix_page_map or getattr(args, "all", False),
|
|
615
|
-
strip_epub3_attrs=args.strip_epub3_attrs
|
|
616
|
-
or getattr(args, "all", False),
|
|
617
|
-
downgrade_epub3=args.downgrade_epub3_tags
|
|
618
|
-
or getattr(args, "all", False),
|
|
619
|
-
prune_missing=args.prune_missing_resources
|
|
620
|
-
or getattr(args, "all", False),
|
|
621
|
-
strip_anchors=args.strip_broken_anchors
|
|
622
|
-
or getattr(args, "all", False),
|
|
623
|
-
url_spaces=args.encode_url_spaces or getattr(args, "all", False),
|
|
624
|
-
fix_container=args.fix_container or getattr(args, "all", False),
|
|
625
|
-
fix_media_types=args.fix_media_types or getattr(args, "all", False),
|
|
626
|
-
fix_cover=args.fix_cover or getattr(args, "all", False),
|
|
576
|
+
flags=_flags_from_args(args),
|
|
627
577
|
before=checks.get(epub),
|
|
628
578
|
)
|
|
629
579
|
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
|
@@ -842,34 +792,7 @@ def run_repair(args) -> int:
|
|
|
842
792
|
src,
|
|
843
793
|
work,
|
|
844
794
|
validate=not args.no_validate,
|
|
845
|
-
|
|
846
|
-
reserialize=args.reserialize or getattr(args, "all", False),
|
|
847
|
-
strip_attrs=args.strip_bad_attrs or getattr(args, "all", False),
|
|
848
|
-
strip_pagination=args.strip_pagination or getattr(args, "all", False),
|
|
849
|
-
strip_brokentags=args.strip_broken_tags or getattr(args, "all", False),
|
|
850
|
-
strip_watermarks=args.strip_watermarks or getattr(args, "all", False),
|
|
851
|
-
strip_stub_docs=args.strip_stub_docs or getattr(args, "all", False),
|
|
852
|
-
escape_entities=args.escape_unknown_entities
|
|
853
|
-
or getattr(args, "all", False),
|
|
854
|
-
img_alt=args.add_img_alt or getattr(args, "all", False),
|
|
855
|
-
empty_body=args.fix_empty_body or getattr(args, "all", False),
|
|
856
|
-
missing_title=args.fix_missing_title or getattr(args, "all", False),
|
|
857
|
-
id_colons=args.fix_id_colons or getattr(args, "all", False),
|
|
858
|
-
block_in_inline=args.unwrap_block_in_inline
|
|
859
|
-
or getattr(args, "all", False),
|
|
860
|
-
invalid_value=args.strip_invalid_value or getattr(args, "all", False),
|
|
861
|
-
illegal_tags=args.unwrap_illegal_tags or getattr(args, "all", False),
|
|
862
|
-
page_map=args.fix_page_map or getattr(args, "all", False),
|
|
863
|
-
strip_epub3_attrs=args.strip_epub3_attrs or getattr(args, "all", False),
|
|
864
|
-
downgrade_epub3=args.downgrade_epub3_tags
|
|
865
|
-
or getattr(args, "all", False),
|
|
866
|
-
prune_missing=args.prune_missing_resources
|
|
867
|
-
or getattr(args, "all", False),
|
|
868
|
-
strip_anchors=args.strip_broken_anchors or getattr(args, "all", False),
|
|
869
|
-
url_spaces=args.encode_url_spaces or getattr(args, "all", False),
|
|
870
|
-
fix_container=args.fix_container or getattr(args, "all", False),
|
|
871
|
-
fix_media_types=args.fix_media_types or getattr(args, "all", False),
|
|
872
|
-
fix_cover=args.fix_cover or getattr(args, "all", False),
|
|
795
|
+
flags=_flags_from_args(args),
|
|
873
796
|
)
|
|
874
797
|
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
|
875
798
|
print(f"error: cannot read {src}: {e}", file=sys.stderr)
|
|
@@ -1559,6 +1482,14 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
|
|
|
1559
1482
|
"item, remove the dead meta when nothing does; EPUB3 "
|
|
1560
1483
|
'properties="cover-image" is audit-only by ruling',
|
|
1561
1484
|
)
|
|
1485
|
+
p.add_argument(
|
|
1486
|
+
"--fix-comment-double-hyphen",
|
|
1487
|
+
dest="fix_comment_double_hyphen",
|
|
1488
|
+
action="store_true",
|
|
1489
|
+
help="replace `--` inside XML comments with an en-dash (RSC-016: "
|
|
1490
|
+
"`--` is not permitted within comments); comment bodies only, "
|
|
1491
|
+
"text and CDATA never touched",
|
|
1492
|
+
)
|
|
1562
1493
|
p.add_argument(
|
|
1563
1494
|
"--strip-pagination",
|
|
1564
1495
|
action="store_true",
|