bindery-cli 0.36.0__tar.gz → 0.38.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/.github/workflows/publish.yml +25 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/CLAUDE.md +11 -8
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/PKG-INFO +39 -2
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/README.md +38 -1
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/patchnotes.md +103 -0
- bindery_cli-0.38.0/plugin/__init__.py +239 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/pyproject.toml +1 -1
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/roadmap.md +116 -9
- bindery_cli-0.38.0/scripts/build_plugin.py +89 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/spec.md +71 -4
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/audit.py +133 -41
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/cli.py +25 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/epub.py +195 -5
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/transforms.py +9 -2
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_audit.py +140 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_cli.py +9 -5
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_epub.py +288 -0
- bindery_cli-0.38.0/tests/test_plugin.py +360 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_transforms.py +27 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/uv.lock +1 -1
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/.gitignore +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/AGENTS.md +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/LICENSE +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/REPORT-12-Sept.md +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/logo.svg +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/run_tests.sh +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/testing_facility/books_to_fix/.gitkeep +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/testing_facility/top500candidates/REPORT.md +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_library.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_version.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.38.0}/tests/test_watermark.py +0 -0
|
@@ -43,3 +43,28 @@ jobs:
|
|
|
43
43
|
|
|
44
44
|
- name: Publish to PyPI
|
|
45
45
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
46
|
+
|
|
47
|
+
plugin-zip:
|
|
48
|
+
name: Build the Bindery Repair Calibre plugin zip
|
|
49
|
+
needs: test
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
permissions:
|
|
52
|
+
contents: write # create the release and attach the plugin zip
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/checkout@v4
|
|
55
|
+
- uses: actions/setup-python@v5
|
|
56
|
+
with:
|
|
57
|
+
python-version: "3.14"
|
|
58
|
+
|
|
59
|
+
- name: Run the suite
|
|
60
|
+
run: |
|
|
61
|
+
pip install .[reserialize]
|
|
62
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
63
|
+
|
|
64
|
+
- name: Build the plugin zip from the tagged tree
|
|
65
|
+
run: python scripts/build_plugin.py
|
|
66
|
+
|
|
67
|
+
- name: Attach BinderyRepair zip to the release
|
|
68
|
+
uses: softprops/action-gh-release@v2
|
|
69
|
+
with:
|
|
70
|
+
files: dist/BinderyRepair-*.zip
|
|
@@ -29,9 +29,9 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
29
29
|
* **Structural repairs** (`--fix-empty-body`, `--fix-missing-title`, `--fix-id-colons`,
|
|
30
30
|
`--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
|
|
31
31
|
`--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`,
|
|
32
|
-
`--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces
|
|
33
|
-
transforms.py, threaded through epub.py):
|
|
34
|
-
minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule;
|
|
32
|
+
`--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
|
|
33
|
+
`--fix-container`, `--fix-media-types`; transforms.py, threaded through epub.py):
|
|
34
|
+
they alter markup structure or fabricate minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule;
|
|
35
35
|
v0.17.0 restored it. `--unwrap-illegal-tags` additionally protects any illegal-tag
|
|
36
36
|
name that an EPUB stylesheet styles as an element selector (`css_protected_tags`,
|
|
37
37
|
book-wide, inline `<style>` blocks included). The two EPUB2-targeted fixes
|
|
@@ -56,7 +56,9 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
56
56
|
the safe opt-ins (`--fix-ids`, `--add-img-alt`, `--strip-bad-attrs`,
|
|
57
57
|
`--escape-unknown-entities`) plus `--encode-url-spaces`, which repair without
|
|
58
58
|
altering visible markup; the "twelve structural repairs and three lossy strips"
|
|
59
|
-
phrasing elsewhere in these docs was never the complete inventory
|
|
59
|
+
phrasing elsewhere in these docs was never the complete inventory, and the
|
|
60
|
+
Phase 16 package repairs (`--fix-container`, `--fix-media-types`) took the
|
|
61
|
+
structural count to fourteen (v0.38.0).
|
|
60
62
|
- **The gate is the safety contract.** Never apply a repair epubcheck has not accepted.
|
|
61
63
|
Respect the two-mode logic in `validate.gate` (fatal-fixing tolerates error unmasking;
|
|
62
64
|
error-cleanup does not). The lossy strips (`--strip-pagination`, `--strip-broken-tags`,
|
|
@@ -73,16 +75,17 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
73
75
|
|
|
74
76
|
## Layout
|
|
75
77
|
|
|
76
|
-
- `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms.
|
|
78
|
+
- `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
|
|
77
79
|
- `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
|
|
78
80
|
- `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
|
|
79
81
|
- `src/bindery/reserialize.py`: structural repair via `html5lib`.
|
|
80
|
-
- `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis — a damaged entry reports its own CORRUPT verdict instead of feeding emptytext — and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry — do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book.
|
|
81
|
-
- `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan).
|
|
82
|
+
- `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis — a damaged entry reports its own CORRUPT verdict instead of feeding emptytext — and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry — do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (`OBFUSCATION_ALGOS`: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED).
|
|
83
|
+
- `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan). Since v0.38.0 (Phase 16): `generate_container` + the `--fix-container` gateway (insert after mimetype when missing, replace in-loop when stale; constant-epoch timestamp keeps bytes deterministic), `prune_dangling_edges` (prune_missing_manifest_items returns the pruned ids and the edges that pointed at them — spine@toc, media-overlay, fallback, EPUB2 cover meta — are rewritten so pruning cannot manufacture the regression the gate rejects), and `fix_manifest_media_types` (extension map + magic-byte confirmation via a `peek` callable over the open source zip; jpg/png/gif only).
|
|
82
84
|
- `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
|
|
83
85
|
- `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched — never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book.
|
|
84
86
|
- `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2). Both take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract).
|
|
85
|
-
- `
|
|
87
|
+
- `plugin/__init__.py` + `scripts/build_plugin.py`: the Bindery Repair Calibre plugin (v0.37.0; identity `Bindery Repair` / `bindery_repair`, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendors `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, `reserialize.py` byte-identically into the zip (the zip root is a package, so their relative imports resolve unchanged; the suite's drift test pins the equality) with the version tuple substituted from the single-source VERSION; `publish.yml` attaches `BinderyRepair-v<VERSION>.zip` to each release. The plugin runs ONLY the default pass (epubcheck cannot gate inside Calibre), never raises, is byte-idempotent (zero fixes returns the original path), testzip()-verifies its output, logs one line per book, and takes JSON config via `site_customization` (`log`, `log_path`, `max_size_mb` default 150, `epubcheck_path` experimental validation default OFF). Load it in tests through the `tests/test_plugin.py` stubs: the calibre namespace is installed in sys.modules and the package loads with a two-dir `__path__` (plugin dir first, then src/bindery) exactly like calibre's zipplugin loader shape.
|
|
88
|
+
- `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, the version pin, and the Calibre plugin (build drift + run behavior under calibre stubs).
|
|
86
89
|
|
|
87
90
|
## Conventions
|
|
88
91
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.38.0
|
|
4
4
|
Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
|
|
5
5
|
Author: vrnvctss
|
|
6
6
|
License: MIT
|
|
@@ -60,9 +60,11 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
|
|
|
60
60
|
- **`--strip-epub3-attrs`**: scrubs the EPUB3-only attributes epubcheck rejects on an EPUB2 package: `page-progression-direction`, `epub:type`, `aria-label` (a fixed, documented set; rendering is unchanged, and lookalikes like `type` or the wider aria family survive). The edit is anchored to real start tags, so prose that merely mentions `epub:type="chapter"` is preserved.
|
|
61
61
|
- **`--downgrade-epub3-tags`**: downgrades EPUB3/HTML5 semantic elements to their EPUB2 equivalents (`figure`/`section` to `div`, `figcaption` to `p`), keeping existing classes and appending the semantic name (`class="figure"`) as the styling hook. Tag names a stylesheet styles as an element selector are protected book-wide, so styled formatting is never destroyed. Both EPUB2-targeted fixes are gated on the package version declared in the OPF: on an EPUB 3 book they do nothing, because their target defects only exist below EPUB 3.
|
|
62
62
|
- **`--unwrap-illegal-tags`**: strips completely invalid or deprecated HTML tags that break EPUB3 validation (`<st>`, `<sentence>`, `<o>`, `<w>`, `<pagebreak>`) while retaining their inner text. Any of those names styled as an *element selector* by an EPUB stylesheet (`.css` entries and inline `<style>` blocks alike; class/id selectors like `.st`/`#w` don't count) is protected for the whole book, guaranteeing format preservation.
|
|
63
|
-
- **`--prune-missing-resources`**: removes references to files the archive does not contain (RSC-007/PKG-010): dead `<link>` elements, anchors' `href` to absent files (anchor text preserved), absent `<img>` sources (replaced by their alt text when they carry one), and orphaned non-spine OPF manifest items. Spine documents are never pruned: a missing spine document is reported as a damaged fragment, not silently dropped.
|
|
63
|
+
- **`--prune-missing-resources`**: removes references to files the archive does not contain (RSC-007/PKG-010): dead `<link>` elements, anchors' `href` to absent files (anchor text preserved), absent `<img>` sources (replaced by their alt text when they carry one), and orphaned non-spine OPF manifest items (with every package edge that pointed at them rewritten: `spine@toc`, `item@media-overlay`, `item@fallback`, and the EPUB2 cover meta). Spine documents are never pruned: a missing spine document is reported as a damaged fragment, not silently dropped.
|
|
64
64
|
- **`--strip-broken-anchors`**: strips href attributes that cannot resolve, keeping the anchor text byte-for-byte: a `#fragment` the target document does not define (RSC-020/RSC-012; NCX navTargets fall back to the document target, so chapter navigation survives) and unresolvable URI schemes (`kindle:`, `file:`).
|
|
65
65
|
- **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file.
|
|
66
|
+
- **`--fix-container`**: generates `META-INF/container.xml` at the located OPF when the container is missing or names a file the archive does not contain. This is the gateway defect: epubcheck stays fatal while the OPF is unfindable, so no repair can be gate-accepted on such a book until the container exists.
|
|
67
|
+
- **`--fix-media-types`**: normalizes wrong manifest media-type declarations (OPF-029, e.g. a jpg stamped `image/png` by an aggregator). Attribute-only and quote-preserving; it fires only when the file's magic bytes confirm the extension, so an honestly-declared misnamed file is never made worse.
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
Three opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
|
|
@@ -95,6 +97,41 @@ uv tool install bindery-cli # core tools
|
|
|
95
97
|
uv tool install "bindery-cli[reserialize]" # incl. --reserialize
|
|
96
98
|
```
|
|
97
99
|
|
|
100
|
+
## The Calibre plugin (Bindery Repair)
|
|
101
|
+
|
|
102
|
+
Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
|
|
103
|
+
repairs EPUBs as they are imported: it is attached to the GitHub release beside
|
|
104
|
+
the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
|
|
105
|
+
from file"). The plugin is the CLI's always-on core pass only: the five
|
|
106
|
+
well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
|
|
107
|
+
strips stay CLI-only, because their acceptance is the epubcheck gate and
|
|
108
|
+
epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
|
|
109
|
+
|
|
110
|
+
Behavior and guarantees:
|
|
111
|
+
|
|
112
|
+
- `run()` never raises and never touches the original file or `metadata.db`: a
|
|
113
|
+
repaired copy is built in a persistent temp file and handed back for import;
|
|
114
|
+
any trouble returns the original path and logs one line.
|
|
115
|
+
- Byte-idempotent: an already-clean book yields zero fixes and the original
|
|
116
|
+
path, so re-adding a format re-runs the plugin harmlessly.
|
|
117
|
+
- The rewritten archive is `testzip()`-verified before it is handed back.
|
|
118
|
+
- One log line per book (fixed / no fixes / refused / errored), appended to
|
|
119
|
+
`bindery_repair.log` in Calibre's config directory by default.
|
|
120
|
+
- Files over the size cap are refused (default 150MB) rather than stalling an
|
|
121
|
+
import.
|
|
122
|
+
|
|
123
|
+
Configuration is the plugin's customization string, parsed as JSON:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
|
|
127
|
+
"epubcheck_path": null}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`epubcheck_path` opts into the experimental on-PATH validation mode: the
|
|
131
|
+
repaired copy is re-measured with your epubcheck binary and refused unless it
|
|
132
|
+
is no worse than the original. It defaults to off; the default pass is safe
|
|
133
|
+
without a gate, and the mode costs two epubcheck runs per imported book.
|
|
134
|
+
|
|
98
135
|
## Usage
|
|
99
136
|
|
|
100
137
|
Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
|
|
@@ -38,9 +38,11 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
|
|
|
38
38
|
- **`--strip-epub3-attrs`**: scrubs the EPUB3-only attributes epubcheck rejects on an EPUB2 package: `page-progression-direction`, `epub:type`, `aria-label` (a fixed, documented set; rendering is unchanged, and lookalikes like `type` or the wider aria family survive). The edit is anchored to real start tags, so prose that merely mentions `epub:type="chapter"` is preserved.
|
|
39
39
|
- **`--downgrade-epub3-tags`**: downgrades EPUB3/HTML5 semantic elements to their EPUB2 equivalents (`figure`/`section` to `div`, `figcaption` to `p`), keeping existing classes and appending the semantic name (`class="figure"`) as the styling hook. Tag names a stylesheet styles as an element selector are protected book-wide, so styled formatting is never destroyed. Both EPUB2-targeted fixes are gated on the package version declared in the OPF: on an EPUB 3 book they do nothing, because their target defects only exist below EPUB 3.
|
|
40
40
|
- **`--unwrap-illegal-tags`**: strips completely invalid or deprecated HTML tags that break EPUB3 validation (`<st>`, `<sentence>`, `<o>`, `<w>`, `<pagebreak>`) while retaining their inner text. Any of those names styled as an *element selector* by an EPUB stylesheet (`.css` entries and inline `<style>` blocks alike; class/id selectors like `.st`/`#w` don't count) is protected for the whole book, guaranteeing format preservation.
|
|
41
|
-
- **`--prune-missing-resources`**: removes references to files the archive does not contain (RSC-007/PKG-010): dead `<link>` elements, anchors' `href` to absent files (anchor text preserved), absent `<img>` sources (replaced by their alt text when they carry one), and orphaned non-spine OPF manifest items. Spine documents are never pruned: a missing spine document is reported as a damaged fragment, not silently dropped.
|
|
41
|
+
- **`--prune-missing-resources`**: removes references to files the archive does not contain (RSC-007/PKG-010): dead `<link>` elements, anchors' `href` to absent files (anchor text preserved), absent `<img>` sources (replaced by their alt text when they carry one), and orphaned non-spine OPF manifest items (with every package edge that pointed at them rewritten: `spine@toc`, `item@media-overlay`, `item@fallback`, and the EPUB2 cover meta). Spine documents are never pruned: a missing spine document is reported as a damaged fragment, not silently dropped.
|
|
42
42
|
- **`--strip-broken-anchors`**: strips href attributes that cannot resolve, keeping the anchor text byte-for-byte: a `#fragment` the target document does not define (RSC-020/RSC-012; NCX navTargets fall back to the document target, so chapter navigation survives) and unresolvable URI schemes (`kindle:`, `file:`).
|
|
43
43
|
- **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file.
|
|
44
|
+
- **`--fix-container`**: generates `META-INF/container.xml` at the located OPF when the container is missing or names a file the archive does not contain. This is the gateway defect: epubcheck stays fatal while the OPF is unfindable, so no repair can be gate-accepted on such a book until the container exists.
|
|
45
|
+
- **`--fix-media-types`**: normalizes wrong manifest media-type declarations (OPF-029, e.g. a jpg stamped `image/png` by an aggregator). Attribute-only and quote-preserving; it fires only when the file's magic bytes confirm the extension, so an honestly-declared misnamed file is never made worse.
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
Three opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
|
|
@@ -73,6 +75,41 @@ uv tool install bindery-cli # core tools
|
|
|
73
75
|
uv tool install "bindery-cli[reserialize]" # incl. --reserialize
|
|
74
76
|
```
|
|
75
77
|
|
|
78
|
+
## The Calibre plugin (Bindery Repair)
|
|
79
|
+
|
|
80
|
+
Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
|
|
81
|
+
repairs EPUBs as they are imported: it is attached to the GitHub release beside
|
|
82
|
+
the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
|
|
83
|
+
from file"). The plugin is the CLI's always-on core pass only: the five
|
|
84
|
+
well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
|
|
85
|
+
strips stay CLI-only, because their acceptance is the epubcheck gate and
|
|
86
|
+
epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
|
|
87
|
+
|
|
88
|
+
Behavior and guarantees:
|
|
89
|
+
|
|
90
|
+
- `run()` never raises and never touches the original file or `metadata.db`: a
|
|
91
|
+
repaired copy is built in a persistent temp file and handed back for import;
|
|
92
|
+
any trouble returns the original path and logs one line.
|
|
93
|
+
- Byte-idempotent: an already-clean book yields zero fixes and the original
|
|
94
|
+
path, so re-adding a format re-runs the plugin harmlessly.
|
|
95
|
+
- The rewritten archive is `testzip()`-verified before it is handed back.
|
|
96
|
+
- One log line per book (fixed / no fixes / refused / errored), appended to
|
|
97
|
+
`bindery_repair.log` in Calibre's config directory by default.
|
|
98
|
+
- Files over the size cap are refused (default 150MB) rather than stalling an
|
|
99
|
+
import.
|
|
100
|
+
|
|
101
|
+
Configuration is the plugin's customization string, parsed as JSON:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
|
|
105
|
+
"epubcheck_path": null}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`epubcheck_path` opts into the experimental on-PATH validation mode: the
|
|
109
|
+
repaired copy is re-measured with your epubcheck binary and refused unless it
|
|
110
|
+
is no worse than the original. It defaults to off; the default pass is safe
|
|
111
|
+
without a gate, and the mode costs two epubcheck runs per imported book.
|
|
112
|
+
|
|
76
113
|
## Usage
|
|
77
114
|
|
|
78
115
|
Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
|
|
@@ -1,4 +1,107 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
|
+
## v0.38.0 (2026-09-12)
|
|
3
|
+
|
|
4
|
+
### Phase 16: package-structure repairs
|
|
5
|
+
|
|
6
|
+
- **`--fix-container`, the gateway repair.** A book whose
|
|
7
|
+
META-INF/container.xml is missing, unparseable, or names a file the
|
|
8
|
+
archive does not contain is locked out of every repair: epubcheck
|
|
9
|
+
stays fatal while the OPF is unfindable. The new flag generates the
|
|
10
|
+
standard container at the OPF the locator already finds by fallback
|
|
11
|
+
(a byte-deterministic template with the constant epoch timestamp).
|
|
12
|
+
Oracle-verified on a real-shaped fixture: fatal before, gate-accepted
|
|
13
|
+
after. The Phase 16 prevalence study found 0 such books in-library
|
|
14
|
+
(5,228/5,228 have valid containers); the repair ships for the
|
|
15
|
+
acquisition phase, where the class actually lives.
|
|
16
|
+
- **`--prune-missing-resources` rewrites what it drops.** Removing a
|
|
17
|
+
manifest item whose file is absent used to leave every package edge
|
|
18
|
+
pointing at it dangling, manufacturing the regression the gate then
|
|
19
|
+
rejects. The prune now carries the pruned ids into a rewrite of the
|
|
20
|
+
four edge classes: `spine@toc`, `item@media-overlay`, `item@fallback`,
|
|
21
|
+
and the EPUB2 cover meta. Optional edges of dead targets are removed;
|
|
22
|
+
edges to live ids are untouched.
|
|
23
|
+
- **`--fix-media-types`.** Wrong manifest media-type declarations
|
|
24
|
+
(OPF-029; 275 occurrences in 15 library books, the class being a jpg
|
|
25
|
+
stamped `image/png` by an aggregator) are normalized attribute-only,
|
|
26
|
+
preserving quote style, and only when the file's magic bytes confirm
|
|
27
|
+
the extension: a PNG renamed .jpg keeps its wrong-but-honest
|
|
28
|
+
declaration instead of a worse one. On the real Arthur sample the run
|
|
29
|
+
goes from nochange to gate-accepted with 9 declarations fixed.
|
|
30
|
+
- **The audit distinguishes font obfuscation from DRM.** encryption.xml
|
|
31
|
+
entries under the obfuscation algorithms (the IDPF URI and both Adobe
|
|
32
|
+
forms, including the ns.adobe.com URI real-world files actually
|
|
33
|
+
carry, which the prevalence study caught) that read fine are the new
|
|
34
|
+
OBFUSCATED advisory: publisher font embedding, benign, problem-False,
|
|
35
|
+
exit-neutral. An unreadable obfuscation entry is CORRUPT (a broken
|
|
36
|
+
font, re-source), and only non-obfuscation algorithms give the
|
|
37
|
+
ENCRYPTED skip. 86 of the library's 5,228 books carry
|
|
38
|
+
obfuscation-only encryption.xml; the library holds no real DRM at all.
|
|
39
|
+
- **Boxed, not built, with real counts.** The obfuscation-aware
|
|
40
|
+
encryption repair (the magic-byte precondition validated on 424 real
|
|
41
|
+
fonts: all genuinely scrambled, zero stale entries in-library), href
|
|
42
|
+
case/backslash resolution (1 href in 1 book; 0 backslashes), and
|
|
43
|
+
duplicate zip-entry dedupe (0 books) are recorded in the roadmap with
|
|
44
|
+
their prevalence numbers and reopen conditions. The decision-gated
|
|
45
|
+
Phase 16 C boxes (cover wiring, the metadata carve-out, the NCX<->nav
|
|
46
|
+
drift detector, CSS url() pruning) carry recorded options and wait
|
|
47
|
+
for rulings.
|
|
48
|
+
|
|
49
|
+
### The prevalence study (Phase 16 A)
|
|
50
|
+
|
|
51
|
+
- 5,228 library books through the FastSweep epubcheck harness plus a
|
|
52
|
+
direct zip-level pass: container.xml valid on every book; RSC-007 at
|
|
53
|
+
14,156 occurrences in 600 books and PKG-010 in 280 (the prune
|
|
54
|
+
surface, 1,538 truly-absent manifest hrefs); OPF-029 at 275 in 15;
|
|
55
|
+
case-mismatch hrefs exactly 1; encryption.xml in 86 books, all of it
|
|
56
|
+
font obfuscation. Every Phase 16 repair box now carries its real
|
|
57
|
+
count.
|
|
58
|
+
|
|
59
|
+
## v0.37.0 (2026-09-12)
|
|
60
|
+
|
|
61
|
+
### The Bindery Repair Calibre plugin (Phase 3)
|
|
62
|
+
|
|
63
|
+
- **Books are repaired as they are imported.** Each release now attaches
|
|
64
|
+
BinderyRepair-v<VERSION>.zip to the GitHub release: a Calibre
|
|
65
|
+
FileTypePlugin (on_import) that runs the CLI's always-on core pass, the
|
|
66
|
+
five well-formedness fixes plus the NCX pipeline, on *.epub files as they
|
|
67
|
+
are added. run() never raises and never touches the original file or
|
|
68
|
+
metadata.db: a repaired copy is built in a persistent temp file,
|
|
69
|
+
testzip()-verified, and handed back for import; any trouble returns the
|
|
70
|
+
original path and logs one line. The plugin is byte-idempotent, so
|
|
71
|
+
re-adding a format re-runs it harmlessly.
|
|
72
|
+
- **The vendor slice is byte-identical to the repo.** The zip carries
|
|
73
|
+
transforms.py, epub.py, pagination.py, watermark.py, and reserialize.py
|
|
74
|
+
unchanged (the zip root is a package, so their relative imports resolve);
|
|
75
|
+
a suite drift test pins the equality, and publish.yml regenerates the zip
|
|
76
|
+
from the tag and attaches it to the release.
|
|
77
|
+
- **Nothing runs ungated, ever.** The structural repairs and the lossy
|
|
78
|
+
strips stay CLI-only because their acceptance is the epubcheck gate,
|
|
79
|
+
which cannot run inside Calibre. Identity: Bindery Repair /
|
|
80
|
+
bindery_repair. Config via the plugin's customization string as JSON:
|
|
81
|
+
log, log_path (default <config dir>/bindery_repair.log), max_size_mb
|
|
82
|
+
(recorded default 150MB), and epubcheck_path, the experimental on-PATH
|
|
83
|
+
validation mode, default OFF (the three open questions, answered
|
|
84
|
+
2026-09-11).
|
|
85
|
+
|
|
86
|
+
### Fixed: fix_ncx_playorder counted phantom fixes (found by the plugin)
|
|
87
|
+
|
|
88
|
+
- The already-correct comparison read the attribute value with its
|
|
89
|
+
quotes, so every sequential navPoint counted as a fix on every pass
|
|
90
|
+
while the rewritten bytes stayed identical. The Long Patrol reported
|
|
91
|
+
fix_ncx_playorder=59 on every run of a repaired book; a book whose
|
|
92
|
+
only fix was phantom was rewritten instead of reported as nochange;
|
|
93
|
+
and a single-quoted sequential attribute was rewritten to double
|
|
94
|
+
quotes, touching already-correct markup. Quotes are now stripped
|
|
95
|
+
before the comparison and an already-correct attribute is returned
|
|
96
|
+
untouched: honest counts, single quotes survive, and the plugin's
|
|
97
|
+
zero-fixes contract holds end to end.
|
|
98
|
+
|
|
99
|
+
### Repo records
|
|
100
|
+
|
|
101
|
+
- Phase 16's FastSweep prevalence study (5,228 books) and the obfuscation
|
|
102
|
+
magic-byte validation are recorded in the roadmap with real counts for
|
|
103
|
+
every Phase 16 repair box.
|
|
104
|
+
|
|
2
105
|
## v0.36.0 (2026-09-12)
|
|
3
106
|
|
|
4
107
|
### The completeness spot-check: `audit completeness` (Phase 15)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Bindery Repair: repair EPUBs on import with bindery-cli's gate-safe pass.
|
|
2
|
+
|
|
3
|
+
A Calibre FileTypePlugin running bindery-cli's always-on core pass (the five
|
|
4
|
+
well-formedness transforms plus the NCX pipeline) on *.epub files as they are
|
|
5
|
+
imported. The structural repairs and the lossy strips stay CLI-only: their
|
|
6
|
+
acceptance IS the epubcheck gate, which cannot run inside Calibre (no jar, no
|
|
7
|
+
JVM, and seconds-per-book latency), so their flags are never enabled here and
|
|
8
|
+
nothing runs ungated, ever.
|
|
9
|
+
|
|
10
|
+
The on_import contract: run(path) returns the path to a repaired copy built
|
|
11
|
+
with the plugin's own temporary_file(), and Calibre imports that instead. The
|
|
12
|
+
original on disk is never touched and nothing writes to metadata.db. No
|
|
13
|
+
exception ever escapes run(): a raising plugin cannot abort an import (Calibre
|
|
14
|
+
falls back to the original silently), so every failure returns the original
|
|
15
|
+
path and logs one line. The plugin is byte-idempotent: an already-clean book
|
|
16
|
+
yields zero fixes and the original path, which db.add_format's
|
|
17
|
+
run_hooks=True re-entry requires.
|
|
18
|
+
|
|
19
|
+
Configuration is the plain site_customization string, parsed as JSON::
|
|
20
|
+
|
|
21
|
+
{"log": true, "log_path": "/path/bindery_repair.log",
|
|
22
|
+
"max_size_mb": 150, "epubcheck_path": null}
|
|
23
|
+
|
|
24
|
+
log_path defaults to <calibre config dir>/bindery_repair.log. The size cap
|
|
25
|
+
refuses absurd files rather than stalling an import; 150MB is the recorded
|
|
26
|
+
default. epubcheck_path opts into the experimental on-PATH validation mode:
|
|
27
|
+
the repaired copy is re-measured with the epubcheck binary and refused unless
|
|
28
|
+
it is no worse than the original (the CLI's no_worse bar). It defaults to
|
|
29
|
+
off, keeping the import fast; the default pass is safe without a gate.
|
|
30
|
+
|
|
31
|
+
The vendored bindery modules ride in this zip as submodules of the plugin
|
|
32
|
+
package (their relative imports resolve unchanged), so the zip's module bytes
|
|
33
|
+
are identical to the repo's src/bindery/ sources; scripts/build_plugin.py
|
|
34
|
+
generates the zip at release and the suite's drift test pins the equality.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import subprocess
|
|
42
|
+
import zipfile
|
|
43
|
+
from collections import namedtuple
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
__PLUGIN_VERSION__ = (0, 0, 0) # substituted by scripts/build_plugin.py
|
|
47
|
+
|
|
48
|
+
# The vendored bindery modules load as submodules of this plugin package
|
|
49
|
+
# (calibre_plugins.bindery_repair.epub and siblings), so their own relative
|
|
50
|
+
# imports resolve unchanged.
|
|
51
|
+
from calibre.customize import FileTypePlugin
|
|
52
|
+
|
|
53
|
+
from . import epub as _bindery_epub
|
|
54
|
+
|
|
55
|
+
DEFAULT_MAX_SIZE_MB = 150
|
|
56
|
+
_EPUB_SUFFIXES = (".epub",)
|
|
57
|
+
# the slice of validate.CheckResult the experimental mode needs
|
|
58
|
+
_EcCounts = namedtuple("_EcCounts", "fatals errors warnings")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BinderyRepair(FileTypePlugin):
|
|
62
|
+
name = "Bindery Repair"
|
|
63
|
+
description = (
|
|
64
|
+
"Repair well-formedness defects in EPUB files as they are imported, "
|
|
65
|
+
"running bindery-cli's epubcheck-safe default pass"
|
|
66
|
+
)
|
|
67
|
+
author = "Brandon LaRocque"
|
|
68
|
+
version = __PLUGIN_VERSION__
|
|
69
|
+
minimum_calibre_version = (2, 0, 0)
|
|
70
|
+
supported_platforms = ["linux"]
|
|
71
|
+
type = "File type"
|
|
72
|
+
file_types = {"epub"}
|
|
73
|
+
on_import = True
|
|
74
|
+
priority = 1
|
|
75
|
+
|
|
76
|
+
# ---- run() never raises: the contract is the original path on trouble
|
|
77
|
+
|
|
78
|
+
def run(self, path_to_ebook):
|
|
79
|
+
try:
|
|
80
|
+
return self._run(path_to_ebook)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
# the last-resort sink re-reads the config, whose parsing is
|
|
83
|
+
# itself exception-safe; run() must never raise
|
|
84
|
+
self._log(
|
|
85
|
+
f"errored: {path_to_ebook}: {type(e).__name__}: {e}",
|
|
86
|
+
self._resolve_log_path(self._config()),
|
|
87
|
+
)
|
|
88
|
+
return path_to_ebook
|
|
89
|
+
|
|
90
|
+
def _run(self, path_to_ebook: str) -> str:
|
|
91
|
+
src = Path(path_to_ebook)
|
|
92
|
+
if src.suffix.lower() not in _EPUB_SUFFIXES:
|
|
93
|
+
return path_to_ebook
|
|
94
|
+
cfg = self._config()
|
|
95
|
+
log_path = self._resolve_log_path(cfg)
|
|
96
|
+
try:
|
|
97
|
+
size_mb = src.stat().st_size / (1024 * 1024)
|
|
98
|
+
except OSError as e:
|
|
99
|
+
self._log(f"errored: {src}: {e}", log_path)
|
|
100
|
+
return path_to_ebook
|
|
101
|
+
cap = cfg.get("max_size_mb", DEFAULT_MAX_SIZE_MB)
|
|
102
|
+
if isinstance(cap, (int, float)) and cap > 0 and size_mb > cap:
|
|
103
|
+
self._log(
|
|
104
|
+
f"refused: {src.name}: {size_mb:.0f}MB over the {cap:g}MB cap",
|
|
105
|
+
log_path,
|
|
106
|
+
)
|
|
107
|
+
return path_to_ebook
|
|
108
|
+
|
|
109
|
+
with self.temporary_file(".epub") as tf:
|
|
110
|
+
dst = Path(tf.name)
|
|
111
|
+
try:
|
|
112
|
+
report = _bindery_epub.repair_epub(src, dst)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
self._log(
|
|
115
|
+
f"errored: {src.name}: repair failed: {type(e).__name__}: {e}",
|
|
116
|
+
log_path,
|
|
117
|
+
)
|
|
118
|
+
return path_to_ebook
|
|
119
|
+
if not report:
|
|
120
|
+
# zero fixes: hand back the original byte-for-byte (the
|
|
121
|
+
# add_format(run_hooks=True) re-entry depends on this)
|
|
122
|
+
dst.unlink(missing_ok=True)
|
|
123
|
+
self._log(f"no fixes: {src.name}", log_path)
|
|
124
|
+
return path_to_ebook
|
|
125
|
+
with zipfile.ZipFile(dst) as z:
|
|
126
|
+
damaged = z.testzip()
|
|
127
|
+
if damaged is not None:
|
|
128
|
+
dst.unlink(missing_ok=True)
|
|
129
|
+
self._log(
|
|
130
|
+
f"refused: {src.name}: rewritten archive failed testzip() "
|
|
131
|
+
f"at {damaged}",
|
|
132
|
+
log_path,
|
|
133
|
+
)
|
|
134
|
+
return path_to_ebook
|
|
135
|
+
if "epubcheck_path" in cfg and cfg["epubcheck_path"]:
|
|
136
|
+
verdict = self._epubcheck_no_worse(src, dst, cfg["epubcheck_path"])
|
|
137
|
+
if verdict is False:
|
|
138
|
+
dst.unlink(missing_ok=True)
|
|
139
|
+
self._log(
|
|
140
|
+
f"refused: {src.name}: epubcheck measured the repair "
|
|
141
|
+
"no better or worse; keeping the original",
|
|
142
|
+
log_path,
|
|
143
|
+
)
|
|
144
|
+
return path_to_ebook
|
|
145
|
+
detail = ", ".join(f"{k}={v}" for k, v in sorted(report.fixes.items()))
|
|
146
|
+
if report.ncx_uid_synced:
|
|
147
|
+
detail = (detail + ", " if detail else "") + "ncx_uid_synced"
|
|
148
|
+
self._log(f"fixed: {src.name}: {detail}", log_path)
|
|
149
|
+
return str(dst)
|
|
150
|
+
|
|
151
|
+
# ---- configuration and logging
|
|
152
|
+
|
|
153
|
+
def _config(self) -> dict:
|
|
154
|
+
raw = getattr(self, "site_customization", None)
|
|
155
|
+
if not raw:
|
|
156
|
+
return {}
|
|
157
|
+
try:
|
|
158
|
+
cfg = json.loads(raw)
|
|
159
|
+
except ValueError:
|
|
160
|
+
# a broken config lands one line in the default log and the run
|
|
161
|
+
# proceeds on the defaults; an import must never be lost to config
|
|
162
|
+
self._log(
|
|
163
|
+
"config: site_customization is not valid JSON; using defaults",
|
|
164
|
+
self._default_log_path(),
|
|
165
|
+
)
|
|
166
|
+
return {}
|
|
167
|
+
return cfg if isinstance(cfg, dict) else {}
|
|
168
|
+
|
|
169
|
+
def _resolve_log_path(self, cfg: dict) -> str | None:
|
|
170
|
+
if not cfg.get("log", True):
|
|
171
|
+
return None
|
|
172
|
+
path = cfg.get("log_path") or self._default_log_path()
|
|
173
|
+
return path if isinstance(path, str) else None
|
|
174
|
+
|
|
175
|
+
def _default_log_path(self) -> str | None:
|
|
176
|
+
try:
|
|
177
|
+
from calibre.constants import config_dir
|
|
178
|
+
|
|
179
|
+
return os.path.join(config_dir, "bindery_repair.log")
|
|
180
|
+
except Exception:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def _log(self, line: str, log_path: str | None) -> None:
|
|
184
|
+
"""One line per book, appended; logging must never break an import."""
|
|
185
|
+
if not log_path:
|
|
186
|
+
return
|
|
187
|
+
try:
|
|
188
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
189
|
+
f.write(line.rstrip() + "\n")
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
# ---- experimental epubcheck-on-PATH validation (default off)
|
|
194
|
+
|
|
195
|
+
def _epubcheck_no_worse(self, src: Path, dst: Path, epubcheck: str) -> bool | None:
|
|
196
|
+
"""Measure both files with the epubcheck binary; True when the repair
|
|
197
|
+
is no worse (no net-new fatals, no new errors unless fatals masked
|
|
198
|
+
them), False when it regressed, None when the binary cannot answer
|
|
199
|
+
(an unanswered check never refuses the repair; the pass is safe)."""
|
|
200
|
+
before = self._epubcheck_counts(src, epubcheck)
|
|
201
|
+
after = self._epubcheck_counts(dst, epubcheck)
|
|
202
|
+
if before is None or after is None:
|
|
203
|
+
return None
|
|
204
|
+
if after.fatals > before.fatals:
|
|
205
|
+
return False
|
|
206
|
+
if before.fatals == 0 and after.errors > before.errors:
|
|
207
|
+
return False
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
def _epubcheck_counts(self, path: Path, epubcheck: str):
|
|
211
|
+
try:
|
|
212
|
+
proc = subprocess.run(
|
|
213
|
+
[epubcheck, "--json", "-", str(path)],
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
timeout=300,
|
|
217
|
+
)
|
|
218
|
+
except OSError, subprocess.TimeoutExpired:
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
data = json.loads(proc.stdout)
|
|
222
|
+
checker = data["checker"]
|
|
223
|
+
return _EcCounts(
|
|
224
|
+
int(checker["nFatal"]),
|
|
225
|
+
int(checker["nError"]),
|
|
226
|
+
int(checker["nWarning"]),
|
|
227
|
+
)
|
|
228
|
+
except json.JSONDecodeError, KeyError, TypeError, ValueError:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
# ---- plugin housekeeping
|
|
232
|
+
|
|
233
|
+
def customization_help(self, gui=False):
|
|
234
|
+
return (
|
|
235
|
+
'Enter a JSON object: {"log": true, "log_path": ..., '
|
|
236
|
+
'"max_size_mb": 150, "epubcheck_path": null}. '
|
|
237
|
+
"epubcheck_path enables the experimental post-repair validation "
|
|
238
|
+
"with the epubcheck binary (slow; off by default)."
|
|
239
|
+
)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.38.0"
|
|
8
8
|
description = "Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
classifiers = [
|