bindery-cli 0.36.0__tar.gz → 0.37.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/.github/workflows/publish.yml +23 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/CLAUDE.md +3 -2
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/PKG-INFO +36 -1
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/README.md +35 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/patchnotes.md +46 -0
- bindery_cli-0.37.0/plugin/__init__.py +239 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/pyproject.toml +1 -1
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/roadmap.md +58 -4
- bindery_cli-0.37.0/scripts/build_plugin.py +89 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/spec.md +46 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/transforms.py +9 -2
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_cli.py +9 -5
- bindery_cli-0.37.0/tests/test_plugin.py +360 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_transforms.py +27 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/uv.lock +1 -1
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/.gitignore +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/AGENTS.md +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/LICENSE +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/REPORT-12-Sept.md +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/logo.svg +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/run_tests.sh +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/cli.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/epub.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/testing_facility/books_to_fix/.gitkeep +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/testing_facility/top500candidates/REPORT.md +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_epub.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_library.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_version.py +0 -0
- {bindery_cli-0.36.0 → bindery_cli-0.37.0}/tests/test_watermark.py +0 -0
|
@@ -43,3 +43,26 @@ jobs:
|
|
|
43
43
|
|
|
44
44
|
- name: Publish to PyPI
|
|
45
45
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
46
|
+
|
|
47
|
+
plugin-zip:
|
|
48
|
+
name: Build the Bindery Repair Calibre plugin zip
|
|
49
|
+
needs: test
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v4
|
|
53
|
+
- uses: actions/setup-python@v5
|
|
54
|
+
with:
|
|
55
|
+
python-version: "3.14"
|
|
56
|
+
|
|
57
|
+
- name: Run the suite
|
|
58
|
+
run: |
|
|
59
|
+
pip install .[reserialize]
|
|
60
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
61
|
+
|
|
62
|
+
- name: Build the plugin zip from the tagged tree
|
|
63
|
+
run: python scripts/build_plugin.py
|
|
64
|
+
|
|
65
|
+
- name: Attach BinderyRepair zip to the release
|
|
66
|
+
uses: softprops/action-gh-release@v2
|
|
67
|
+
with:
|
|
68
|
+
files: dist/BinderyRepair-*.zip
|
|
@@ -73,7 +73,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
73
73
|
|
|
74
74
|
## Layout
|
|
75
75
|
|
|
76
|
-
- `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms.
|
|
76
|
+
- `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
|
|
77
77
|
- `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
|
|
78
78
|
- `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
|
|
79
79
|
- `src/bindery/reserialize.py`: structural repair via `html5lib`.
|
|
@@ -82,7 +82,8 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
82
82
|
- `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
|
|
83
83
|
- `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched — never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book.
|
|
84
84
|
- `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2). Both take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract).
|
|
85
|
-
- `
|
|
85
|
+
- `plugin/__init__.py` + `scripts/build_plugin.py`: the Bindery Repair Calibre plugin (v0.37.0; identity `Bindery Repair` / `bindery_repair`, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendors `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, `reserialize.py` byte-identically into the zip (the zip root is a package, so their relative imports resolve unchanged; the suite's drift test pins the equality) with the version tuple substituted from the single-source VERSION; `publish.yml` attaches `BinderyRepair-v<VERSION>.zip` to each release. The plugin runs ONLY the default pass (epubcheck cannot gate inside Calibre), never raises, is byte-idempotent (zero fixes returns the original path), testzip()-verifies its output, logs one line per book, and takes JSON config via `site_customization` (`log`, `log_path`, `max_size_mb` default 150, `epubcheck_path` experimental validation default OFF). Load it in tests through the `tests/test_plugin.py` stubs: the calibre namespace is installed in sys.modules and the package loads with a two-dir `__path__` (plugin dir first, then src/bindery) exactly like calibre's zipplugin loader shape.
|
|
86
|
+
- `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, the version pin, and the Calibre plugin (build drift + run behavior under calibre stubs).
|
|
86
87
|
|
|
87
88
|
## Conventions
|
|
88
89
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.37.0
|
|
4
4
|
Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
|
|
5
5
|
Author: vrnvctss
|
|
6
6
|
License: MIT
|
|
@@ -95,6 +95,41 @@ uv tool install bindery-cli # core tools
|
|
|
95
95
|
uv tool install "bindery-cli[reserialize]" # incl. --reserialize
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
## The Calibre plugin (Bindery Repair)
|
|
99
|
+
|
|
100
|
+
Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
|
|
101
|
+
repairs EPUBs as they are imported: it is attached to the GitHub release beside
|
|
102
|
+
the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
|
|
103
|
+
from file"). The plugin is the CLI's always-on core pass only: the five
|
|
104
|
+
well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
|
|
105
|
+
strips stay CLI-only, because their acceptance is the epubcheck gate and
|
|
106
|
+
epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
|
|
107
|
+
|
|
108
|
+
Behavior and guarantees:
|
|
109
|
+
|
|
110
|
+
- `run()` never raises and never touches the original file or `metadata.db`: a
|
|
111
|
+
repaired copy is built in a persistent temp file and handed back for import;
|
|
112
|
+
any trouble returns the original path and logs one line.
|
|
113
|
+
- Byte-idempotent: an already-clean book yields zero fixes and the original
|
|
114
|
+
path, so re-adding a format re-runs the plugin harmlessly.
|
|
115
|
+
- The rewritten archive is `testzip()`-verified before it is handed back.
|
|
116
|
+
- One log line per book (fixed / no fixes / refused / errored), appended to
|
|
117
|
+
`bindery_repair.log` in Calibre's config directory by default.
|
|
118
|
+
- Files over the size cap are refused (default 150MB) rather than stalling an
|
|
119
|
+
import.
|
|
120
|
+
|
|
121
|
+
Configuration is the plugin's customization string, parsed as JSON:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
|
|
125
|
+
"epubcheck_path": null}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`epubcheck_path` opts into the experimental on-PATH validation mode: the
|
|
129
|
+
repaired copy is re-measured with your epubcheck binary and refused unless it
|
|
130
|
+
is no worse than the original. It defaults to off; the default pass is safe
|
|
131
|
+
without a gate, and the mode costs two epubcheck runs per imported book.
|
|
132
|
+
|
|
98
133
|
## Usage
|
|
99
134
|
|
|
100
135
|
Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
|
|
@@ -73,6 +73,41 @@ uv tool install bindery-cli # core tools
|
|
|
73
73
|
uv tool install "bindery-cli[reserialize]" # incl. --reserialize
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
## The Calibre plugin (Bindery Repair)
|
|
77
|
+
|
|
78
|
+
Each release also ships **BinderyRepair-v<VERSION>.zip**, a Calibre plugin that
|
|
79
|
+
repairs EPUBs as they are imported: it is attached to the GitHub release beside
|
|
80
|
+
the PyPI wheel (install it via Calibre's Preferences, Plugins, "Load plugin
|
|
81
|
+
from file"). The plugin is the CLI's always-on core pass only: the five
|
|
82
|
+
well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
|
|
83
|
+
strips stay CLI-only, because their acceptance is the epubcheck gate and
|
|
84
|
+
epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
|
|
85
|
+
|
|
86
|
+
Behavior and guarantees:
|
|
87
|
+
|
|
88
|
+
- `run()` never raises and never touches the original file or `metadata.db`: a
|
|
89
|
+
repaired copy is built in a persistent temp file and handed back for import;
|
|
90
|
+
any trouble returns the original path and logs one line.
|
|
91
|
+
- Byte-idempotent: an already-clean book yields zero fixes and the original
|
|
92
|
+
path, so re-adding a format re-runs the plugin harmlessly.
|
|
93
|
+
- The rewritten archive is `testzip()`-verified before it is handed back.
|
|
94
|
+
- One log line per book (fixed / no fixes / refused / errored), appended to
|
|
95
|
+
`bindery_repair.log` in Calibre's config directory by default.
|
|
96
|
+
- Files over the size cap are refused (default 150MB) rather than stalling an
|
|
97
|
+
import.
|
|
98
|
+
|
|
99
|
+
Configuration is the plugin's customization string, parsed as JSON:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{"log": true, "log_path": "/path/bindery_repair.log", "max_size_mb": 150,
|
|
103
|
+
"epubcheck_path": null}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`epubcheck_path` opts into the experimental on-PATH validation mode: the
|
|
107
|
+
repaired copy is re-measured with your epubcheck binary and refused unless it
|
|
108
|
+
is no worse than the original. It defaults to off; the default pass is safe
|
|
109
|
+
without a gate, and the mode costs two epubcheck runs per imported book.
|
|
110
|
+
|
|
76
111
|
## Usage
|
|
77
112
|
|
|
78
113
|
Four verbs: `bindery repair` fixes a single EPUB epubcheck-gated, `bindery audit`
|
|
@@ -1,4 +1,50 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
|
+
## v0.37.0 (2026-09-12)
|
|
3
|
+
|
|
4
|
+
### The Bindery Repair Calibre plugin (Phase 3)
|
|
5
|
+
|
|
6
|
+
- **Books are repaired as they are imported.** Each release now attaches
|
|
7
|
+
BinderyRepair-v<VERSION>.zip to the GitHub release: a Calibre
|
|
8
|
+
FileTypePlugin (on_import) that runs the CLI's always-on core pass, the
|
|
9
|
+
five well-formedness fixes plus the NCX pipeline, on *.epub files as they
|
|
10
|
+
are added. run() never raises and never touches the original file or
|
|
11
|
+
metadata.db: a repaired copy is built in a persistent temp file,
|
|
12
|
+
testzip()-verified, and handed back for import; any trouble returns the
|
|
13
|
+
original path and logs one line. The plugin is byte-idempotent, so
|
|
14
|
+
re-adding a format re-runs it harmlessly.
|
|
15
|
+
- **The vendor slice is byte-identical to the repo.** The zip carries
|
|
16
|
+
transforms.py, epub.py, pagination.py, watermark.py, and reserialize.py
|
|
17
|
+
unchanged (the zip root is a package, so their relative imports resolve);
|
|
18
|
+
a suite drift test pins the equality, and publish.yml regenerates the zip
|
|
19
|
+
from the tag and attaches it to the release.
|
|
20
|
+
- **Nothing runs ungated, ever.** The structural repairs and the lossy
|
|
21
|
+
strips stay CLI-only because their acceptance is the epubcheck gate,
|
|
22
|
+
which cannot run inside Calibre. Identity: Bindery Repair /
|
|
23
|
+
bindery_repair. Config via the plugin's customization string as JSON:
|
|
24
|
+
log, log_path (default <config dir>/bindery_repair.log), max_size_mb
|
|
25
|
+
(recorded default 150MB), and epubcheck_path, the experimental on-PATH
|
|
26
|
+
validation mode, default OFF (the three open questions, answered
|
|
27
|
+
2026-09-11).
|
|
28
|
+
|
|
29
|
+
### Fixed: fix_ncx_playorder counted phantom fixes (found by the plugin)
|
|
30
|
+
|
|
31
|
+
- The already-correct comparison read the attribute value with its
|
|
32
|
+
quotes, so every sequential navPoint counted as a fix on every pass
|
|
33
|
+
while the rewritten bytes stayed identical. The Long Patrol reported
|
|
34
|
+
fix_ncx_playorder=59 on every run of a repaired book; a book whose
|
|
35
|
+
only fix was phantom was rewritten instead of reported as nochange;
|
|
36
|
+
and a single-quoted sequential attribute was rewritten to double
|
|
37
|
+
quotes, touching already-correct markup. Quotes are now stripped
|
|
38
|
+
before the comparison and an already-correct attribute is returned
|
|
39
|
+
untouched: honest counts, single quotes survive, and the plugin's
|
|
40
|
+
zero-fixes contract holds end to end.
|
|
41
|
+
|
|
42
|
+
### Repo records
|
|
43
|
+
|
|
44
|
+
- Phase 16's FastSweep prevalence study (5,228 books) and the obfuscation
|
|
45
|
+
magic-byte validation are recorded in the roadmap with real counts for
|
|
46
|
+
every Phase 16 repair box.
|
|
47
|
+
|
|
2
48
|
## v0.36.0 (2026-09-12)
|
|
3
49
|
|
|
4
50
|
### 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.37.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 = [
|
|
@@ -278,8 +278,16 @@ locale hardening (5.2), in v0.10.0. Phase 5 is closed.*
|
|
|
278
278
|
|
|
279
279
|
## Phase 3: integration (maybe)
|
|
280
280
|
|
|
281
|
-
- [
|
|
282
|
-
`on_import = True`.
|
|
281
|
+
- [x] Calibre plugin so books are repaired on add, as a `FileTypePlugin` with
|
|
282
|
+
`on_import = True`. *(SHIPPED 2026-09-12 as v0.37.0: "Bindery Repair" /
|
|
283
|
+
`bindery_repair`, built to the scoped spec below. Vendor slice byte-
|
|
284
|
+
identical with a suite drift test; default pass only; testzip()
|
|
285
|
+
verification; byte-idempotent; one log line per book; site_customization
|
|
286
|
+
JSON config with the size cap at the recorded default 150MB and the
|
|
287
|
+
epubcheck-on-PATH experimental mode default OFF; publish.yml attaches
|
|
288
|
+
BinderyRepair-v<VERSION>.zip to each release. The plugin's smoke test
|
|
289
|
+
found the fix_ncx_playorder phantom-count bug (Bug Reports), which the
|
|
290
|
+
same release fixes.)*
|
|
283
291
|
- [x] ~~Optional metadata.db nudge so Calibre notices the new file size~~
|
|
284
292
|
**Dropped 2026-08-09: unnecessary under `on_import`.** See the decision below.
|
|
285
293
|
|
|
@@ -370,6 +378,12 @@ Open questions (Brandon): epubcheck-on-PATH experimental mode (default no, the
|
|
|
370
378
|
latency ruling stands); plugin identity `Bindery Repair` / `bindery_repair`;
|
|
371
379
|
size-cap default.
|
|
372
380
|
|
|
381
|
+
*(ANSWERED 2026-09-11 and SHIPPED 2026-09-12 as v0.37.0: epubcheck-on-PATH
|
|
382
|
+
mode exists behind the `epubcheck_path` config key and defaults OFF; identity
|
|
383
|
+
is `Bindery Repair` / `bindery_repair`; the size cap keeps the documented
|
|
384
|
+
default, now recorded as 150MB in README and spec. See the plugin box above
|
|
385
|
+
and the spec's "The Calibre plugin" section.)*
|
|
386
|
+
|
|
373
387
|
|
|
374
388
|
## Supplementary Phase (Based on Library Audit)
|
|
375
389
|
Following a comprehensive epubcheck sweep of a 5,043-book Calibre library on 2026-08-22 (~3,228 candidates for repair), several recurring error schemas were identified; all have since shipped. (This section absorbs the one-off `BINDERY_REPORT.md`, retired in v0.17.0 — its performance notes live in the v0.13.0 patchnotes, and its findings are the checked items below.)
|
|
@@ -696,6 +710,26 @@ driven alternative was rejected because it would still silently mutate legal
|
|
|
696
710
|
markup whenever an unrelated finding existed; the gate keeps EPUB3 books
|
|
697
711
|
byte-identical.)*
|
|
698
712
|
|
|
713
|
+
### fix_ncx_playorder counts phantom fixes on already-sequential NCXs (2026-09-12, plugin work)
|
|
714
|
+
**Bug in the default pass's `fix_ncx_playorder`:** the already-correct
|
|
715
|
+
comparison read the captured attribute value WITH its quotes
|
|
716
|
+
(`m.group(2) != str(playorder)`), so every navPoint counted as a fix on
|
|
717
|
+
every pass. The rewritten bytes stayed identical (which is why the
|
|
718
|
+
deterministic-output tests and every gate run never caught it), but the
|
|
719
|
+
fix counts were phantom: The Long Patrol reported `fix_ncx_playorder=59`
|
|
720
|
+
on every run of an already-repaired book, and a book whose only "fix"
|
|
721
|
+
was phantom was rewritten instead of being reported as nochange. A
|
|
722
|
+
single-quoted sequential attribute was also rewritten to double quotes,
|
|
723
|
+
touching already-correct markup. Found by the Calibre plugin's smoke
|
|
724
|
+
test: the plugin's zero-fixes contract (return the original path) broke
|
|
725
|
+
on real books. `transforms.py` `fix_ncx_playorder`.
|
|
726
|
+
**Fix:** strip the quotes before comparing; an already-correct attribute
|
|
727
|
+
is returned untouched (its quote style preserved) and not counted.
|
|
728
|
+
*(Fixed in v0.37.0: honest counts on every NCX, single quotes survive,
|
|
729
|
+
and the plugin's byte-idempotence holds end to end. The sweep's
|
|
730
|
+
apply-path behavior changes only in that a phantom-only repair is now
|
|
731
|
+
correctly a noop.)*
|
|
732
|
+
|
|
699
733
|
## Phase 11: Migrate install-to-calibre to native cquarry API (proposed 2026-08-31)
|
|
700
734
|
|
|
701
735
|
*Context: `bindery library --install-to-calibre` currently shells out to the external `calibredb add_format` CLI binary. This is fragile (it crashed on 2026-08-31 due to a non-existent `--replace` flag) and bypasses the transaction and trigger-safety guarantees built into the `cquarry` library. Since bindery-cli already imports `cquarry` for path resolution, it should adopt the native write module.*
|
|
@@ -1418,14 +1452,34 @@ an opt-in structural repair under the existing gate contract.
|
|
|
1418
1452
|
|
|
1419
1453
|
### A. Research first (boxes cannot be honestly sized without these)
|
|
1420
1454
|
|
|
1421
|
-
- [
|
|
1455
|
+
- [x] **FastSweep prevalence study**: run `fast_sweep.py
|
|
1422
1456
|
--mode=extract` aggregation over the full library for the new
|
|
1423
1457
|
classes — container.xml presence/validity, encryption.xml
|
|
1424
1458
|
algorithms, manifest media-type warnings, case-mismatch RSC-007s.
|
|
1425
1459
|
Each Phase 16 repair box gets its real count before it opens.
|
|
1426
|
-
-
|
|
1460
|
+
*(done 2026-09-12: 5,228 books through epubcheck extract (664s) plus
|
|
1461
|
+
a direct zip-level pass. Real counts: container.xml valid on
|
|
1462
|
+
5,228/5,228 (zero gateway-defect books in-library); OPF-029
|
|
1463
|
+
media-type mismatches 275 occurrences in 15 books; RSC-007 14,156
|
|
1464
|
+
occurrences in 600 books and PKG-010 in 280 (the prune surface,
|
|
1465
|
+
1,538 truly-absent manifest hrefs); case-mismatch hrefs exactly 1 in
|
|
1466
|
+
1 book; backslash hrefs 0; duplicate zip entries 0 books; encryption.xml
|
|
1467
|
+
in 86 books carrying 429 entries (426 Adobe `ns.adobe.com/pdf/enc#RC`
|
|
1468
|
+
obfuscation + 3 IDPF `2008/embedding`), and NO real-DRM algorithm
|
|
1469
|
+
anywhere in the library.)*
|
|
1470
|
+
- [x] **Obfuscation magic-byte validation**: confirm the IDPF
|
|
1427
1471
|
(`2008/embedding`) and Adobe (`pdf/enc#RC`) font prefixes at offset 0
|
|
1428
1472
|
against real samples before the encryption-repair box opens.
|
|
1473
|
+
*(done 2026-09-12, same pass: all 424 readable obfuscation-declared
|
|
1474
|
+
fonts carry scrambled, non-font bytes at offset 0 (no OTTO/ttf/ttc/
|
|
1475
|
+
woff magic anywhere) — every in-library obfuscated font is genuinely
|
|
1476
|
+
obfuscated, so the "decrypted in place" stale-entry class has ZERO
|
|
1477
|
+
prevalence here; the valid-magic-at-offset-0 test is validated as the
|
|
1478
|
+
right discriminator for the repair's precondition. Two CipherReference
|
|
1479
|
+
targets are absent from their archives. Real-sample correction: the
|
|
1480
|
+
Adobe algorithm URI in the wild is `http://ns.adobe.com/pdf/enc#RC`,
|
|
1481
|
+
not the spec-text `http://www.adobe.com/2005/pdf/enc#RC`; recognition
|
|
1482
|
+
must carry both.)*
|
|
1429
1483
|
|
|
1430
1484
|
### B. The repairs (each: opt-in flag, dry-run default, gate-accepted)
|
|
1431
1485
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build the Bindery Repair Calibre plugin zip from the repo tree.
|
|
3
|
+
|
|
4
|
+
The plugin vendors bindery-cli's gate-safe repair core: transforms.py,
|
|
5
|
+
epub.py, pagination.py, watermark.py, reserialize.py (byte-identical copies;
|
|
6
|
+
the zip root is a package, so their relative imports resolve unchanged) plus
|
|
7
|
+
plugin/__init__.py with the version tuple substituted from
|
|
8
|
+
src/bindery/__init__.py. A `plugin-import-name-bindery_repair.txt` marker
|
|
9
|
+
names the import package calibre_plugins.bindery_repair.
|
|
10
|
+
|
|
11
|
+
stdlib only; a release-time tool (publish.yml runs it against the tag), not
|
|
12
|
+
part of the repair contract.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
python scripts/build_plugin.py --out dist/BinderyRepair-v0.36.0.zip
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import zipfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
27
|
+
PLUGIN_ENTRY = REPO / "plugin" / "__init__.py"
|
|
28
|
+
VENDOR_MODULES = (
|
|
29
|
+
"transforms.py",
|
|
30
|
+
"epub.py",
|
|
31
|
+
"pagination.py",
|
|
32
|
+
"watermark.py",
|
|
33
|
+
"reserialize.py",
|
|
34
|
+
)
|
|
35
|
+
IMPORT_NAME_MARKER = "plugin-import-name-bindery_repair.txt"
|
|
36
|
+
|
|
37
|
+
# Keep in sync with __PLUGIN_VERSION__ in plugin/__init__.py.
|
|
38
|
+
_VERSION_LINE = re.compile(r"^__PLUGIN_VERSION__ = .*$", re.MULTILINE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def repo_version() -> tuple[int, int, int]:
|
|
42
|
+
"""The single source of truth: src/bindery/__init__.py's VERSION."""
|
|
43
|
+
text = (REPO / "src" / "bindery" / "__init__.py").read_text(encoding="utf-8")
|
|
44
|
+
match = re.search(r'^VERSION = "(\d+)\.(\d+)\.(\d+)"$', text, re.MULTILINE)
|
|
45
|
+
if not match:
|
|
46
|
+
sys.exit('error: no VERSION = "X.Y.Z" line in src/bindery/__init__.py')
|
|
47
|
+
return tuple(int(part) for part in match.groups())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def plugin_entry_bytes(version: tuple[int, int, int]) -> bytes:
|
|
51
|
+
text = PLUGIN_ENTRY.read_text(encoding="utf-8")
|
|
52
|
+
if "__PLUGIN_VERSION__ = " not in text:
|
|
53
|
+
sys.exit("error: plugin/__init__.py carries no __PLUGIN_VERSION__ line")
|
|
54
|
+
substituted = _VERSION_LINE.sub(f"__PLUGIN_VERSION__ = {version!r}", text, count=1)
|
|
55
|
+
return substituted.encode("utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build(out: Path) -> Path:
|
|
59
|
+
version = repo_version()
|
|
60
|
+
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
|
61
|
+
z.writestr(IMPORT_NAME_MARKER, "bindery_repair\n")
|
|
62
|
+
z.writestr("__init__.py", plugin_entry_bytes(version))
|
|
63
|
+
for name in VENDOR_MODULES:
|
|
64
|
+
src = REPO / "src" / "bindery" / name
|
|
65
|
+
z.writestr(name, src.read_bytes())
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> int:
|
|
70
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
71
|
+
ap.add_argument(
|
|
72
|
+
"--out",
|
|
73
|
+
type=Path,
|
|
74
|
+
default=None,
|
|
75
|
+
help="output zip path (default: dist/BinderyRepair-v<VERSION>.zip)",
|
|
76
|
+
)
|
|
77
|
+
args = ap.parse_args()
|
|
78
|
+
version = repo_version()
|
|
79
|
+
out = (
|
|
80
|
+
args.out or REPO / "dist" / f"BinderyRepair-v{'.'.join(map(str, version))}.zip"
|
|
81
|
+
)
|
|
82
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
build(out)
|
|
84
|
+
print(f"wrote {out} (Bindery Repair v{'.'.join(map(str, version))})")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
sys.exit(main())
|
|
@@ -405,6 +405,52 @@ replacement contract is untouched. The scope refusal is mechanical: no `--ids`,
|
|
|
405
405
|
no sweep, exit 2 (a library-wide sweep is a dedicated hours-long task, never a
|
|
406
406
|
verb call). Books left partial or unreadable surface as `decisions_needed`.
|
|
407
407
|
|
|
408
|
+
## The Calibre plugin (Bindery Repair)
|
|
409
|
+
|
|
410
|
+
Each release vendors the repair core into a Calibre plugin zip,
|
|
411
|
+
`BinderyRepair-v<VERSION>.zip`, generated from the tagged tree by
|
|
412
|
+
`scripts/build_plugin.py` and attached to the GitHub release. The zip carries
|
|
413
|
+
`transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, and
|
|
414
|
+
`reserialize.py` byte-identical to `src/bindery/` (the zip root is a package,
|
|
415
|
+
so their relative imports resolve unchanged; a suite drift test pins the
|
|
416
|
+
equality) plus the plugin entry `plugin/__init__.py` with the version tuple
|
|
417
|
+
substituted from the single-source `VERSION`.
|
|
418
|
+
|
|
419
|
+
Identity and shape: the plugin is `Bindery Repair` (import name
|
|
420
|
+
`bindery_repair`, via the `plugin-import-name-` marker), a
|
|
421
|
+
`FileTypePlugin` with `on_import = True`, `supported_platforms = ['linux']`
|
|
422
|
+
(the loader rejects an empty platform list, which also makes other OSes an
|
|
423
|
+
enforced non-goal).
|
|
424
|
+
|
|
425
|
+
The active fix set is exactly the CLI's default pass: the five
|
|
426
|
+
well-formedness transforms plus the NCX pipeline. All structural repairs and
|
|
427
|
+
the three lossy strips stay CLI-only: their acceptance IS the epubcheck gate,
|
|
428
|
+
which cannot run inside Calibre. Opt-in flags are never enabled by the
|
|
429
|
+
plugin; nothing runs ungated.
|
|
430
|
+
|
|
431
|
+
Behavior contract:
|
|
432
|
+
|
|
433
|
+
- `run(path)` never raises. It returns the path of a repaired copy built with
|
|
434
|
+
the plugin's `temporary_file()` (Calibre imports that instead; the original
|
|
435
|
+
on disk is untouched, `metadata.db` is never written, there is no
|
|
436
|
+
`postimport` hook), or the original path on any trouble: non-`.epub`
|
|
437
|
+
suffix, over the size cap, zero fixes, a failed `testzip()` verification of
|
|
438
|
+
the rewritten archive, or an exception (each logged, never raised).
|
|
439
|
+
- Byte-idempotence: an already-clean book yields zero fixes and the original
|
|
440
|
+
path, because the format-add path (`db.add_format(..., run_hooks=True)`)
|
|
441
|
+
re-enters the plugin on repaired files.
|
|
442
|
+
- One log line per book (fixed / no fixes / refused / errored), appended
|
|
443
|
+
under a lock-free best-effort policy: logging failures never break an
|
|
444
|
+
import.
|
|
445
|
+
- Configuration is the `site_customization` string parsed as JSON: `log`
|
|
446
|
+
(default true), `log_path` (default `<config_dir>/bindery_repair.log`),
|
|
447
|
+
`max_size_mb` (default 150: refuse absurd files rather than stall an
|
|
448
|
+
import), and `epubcheck_path` (default null). `epubcheck_path` enables the
|
|
449
|
+
experimental on-PATH validation mode: the repaired copy is re-measured with
|
|
450
|
+
the user's epubcheck binary under the `no_worse` bar and refused on a
|
|
451
|
+
regression; an unanswered measurement never refuses. The mode is off by
|
|
452
|
+
default (the latency ruling stands; the default pass is safe ungated).
|
|
453
|
+
|
|
408
454
|
## Out of scope (non-goals)
|
|
409
455
|
|
|
410
456
|
- Fixing RSC-005 schema/content-model violations *in bulk*: the scoped, opt-in
|
|
@@ -333,9 +333,16 @@ def fix_ncx_playorder(s: str) -> tuple[str, int]:
|
|
|
333
333
|
|
|
334
334
|
def repl(m: re.Match) -> str:
|
|
335
335
|
nonlocal count, playorder
|
|
336
|
-
|
|
336
|
+
# the captured value carries its quotes; strip them before comparing,
|
|
337
|
+
# or every already-correct navPoint counts as a fix on every pass
|
|
338
|
+
# (identical output bytes, phantom counts; found by the plugin work,
|
|
339
|
+
# 2026-09-12). A correct value is returned untouched, so a
|
|
340
|
+
# single-quoted correct attribute keeps its quote style.
|
|
341
|
+
if m.group(2).strip("\"'") == str(playorder):
|
|
342
|
+
res = m.group(0)
|
|
343
|
+
else:
|
|
337
344
|
count += 1
|
|
338
|
-
|
|
345
|
+
res = f'{m.group(1)} playOrder="{playorder}"'
|
|
339
346
|
playorder += 1
|
|
340
347
|
return res
|
|
341
348
|
|
|
@@ -536,8 +536,8 @@ class TestApplyFailureIsolation(unittest.TestCase):
|
|
|
536
536
|
def test_apply_oserror_is_recorded_and_run_continues(self):
|
|
537
537
|
with tempfile.TemporaryDirectory() as td:
|
|
538
538
|
root = Path(td)
|
|
539
|
-
_phase1_book(root / "a.epub", broken=False)
|
|
540
|
-
_phase1_book(root / "b.epub", broken=False)
|
|
539
|
+
_phase1_book(root / "a.epub", broken=False, real_fix=True)
|
|
540
|
+
_phase1_book(root / "b.epub", broken=False, real_fix=True)
|
|
541
541
|
jout = root / "lib.json"
|
|
542
542
|
out, err = io.StringIO(), io.StringIO()
|
|
543
543
|
with (
|
|
@@ -902,10 +902,13 @@ class TestAuditJsonWiring(unittest.TestCase):
|
|
|
902
902
|
run.assert_not_called()
|
|
903
903
|
|
|
904
904
|
|
|
905
|
-
def _phase1_book(path: Path, broken: bool) -> None:
|
|
905
|
+
def _phase1_book(path: Path, broken: bool, real_fix: bool = False) -> None:
|
|
906
906
|
"""A realistic loose EPUB: proper container/OPF/NCX, >2000 chars of body
|
|
907
907
|
text so the audit battery stays silent on it. `broken` leaves an unclosed
|
|
908
|
-
<p> that only --reserialize can repair (epubcheck fatal, audit-clean).
|
|
908
|
+
<p> that only --reserialize can repair (epubcheck fatal, audit-clean).
|
|
909
|
+
`real_fix` writes a genuinely unordered NCX playOrder so the default pass
|
|
910
|
+
reports a real fix (the 2026-09-12 playOrder counter fix made a clean
|
|
911
|
+
book a true noop, so tests exercising the apply path need this)."""
|
|
909
912
|
container = (
|
|
910
913
|
'<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
|
911
914
|
'<rootfiles><rootfile full-path="content.opf" '
|
|
@@ -921,11 +924,12 @@ def _phase1_book(path: Path, broken: bool) -> None:
|
|
|
921
924
|
'<item id="ncx" href="t.ncx" media-type="application/x-dtbncx+xml"/>'
|
|
922
925
|
'</manifest><spine toc="ncx"><itemref idref="c1"/></spine></package>'
|
|
923
926
|
)
|
|
927
|
+
playorder_attr = 'playOrder="7"' if real_fix else 'playOrder="1"'
|
|
924
928
|
ncx = (
|
|
925
929
|
'<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">'
|
|
926
930
|
'<head><meta name="dtb:uid" content="x"/></head>'
|
|
927
931
|
"<docTitle><text>T</text></docTitle><navMap>"
|
|
928
|
-
'<navPoint id="n1"
|
|
932
|
+
f'<navPoint id="n1" {playorder_attr}><navLabel><text>C1</text></navLabel>'
|
|
929
933
|
'<content src="t.xhtml"/></navPoint></navMap></ncx>'
|
|
930
934
|
)
|
|
931
935
|
if broken:
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""The Bindery Repair Calibre plugin: zip build drift, import shape, and
|
|
2
|
+
run() behavior under a stubbed calibre namespace.
|
|
3
|
+
|
|
4
|
+
The plugin vendors the repair core's five modules byte-identically; the
|
|
5
|
+
loader (calibre/customize/zipplugin.py) imports every zip-root .py as a
|
|
6
|
+
submodule of the plugin package, which is exactly the import shape these
|
|
7
|
+
tests install, so behavior tested here is behavior that ships."""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import types
|
|
15
|
+
import unittest
|
|
16
|
+
import zipfile
|
|
17
|
+
from collections import namedtuple
|
|
18
|
+
|
|
19
|
+
REPO = pathlib.Path(__file__).resolve().parents[1]
|
|
20
|
+
PLUGIN_DIR = REPO / "plugin"
|
|
21
|
+
_Counts = namedtuple("_Counts", "fatals errors warnings")
|
|
22
|
+
|
|
23
|
+
VENDOR_MODULES = (
|
|
24
|
+
"transforms.py",
|
|
25
|
+
"epub.py",
|
|
26
|
+
"pagination.py",
|
|
27
|
+
"watermark.py",
|
|
28
|
+
"reserialize.py",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _repo_version() -> tuple[int, int, int]:
|
|
33
|
+
text = (REPO / "src" / "bindery" / "__init__.py").read_text(encoding="utf-8")
|
|
34
|
+
prefix = 'VERSION = "'
|
|
35
|
+
start = text.index(prefix) + len(prefix)
|
|
36
|
+
end = text.index('"', start)
|
|
37
|
+
return tuple(int(part) for part in text[start:end].split("."))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_PTF_DIR: tempfile.TemporaryDirectory | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _StubPersistentTemporaryFile:
|
|
44
|
+
"""The slice of calibre.ptempfile.PersistentTemporaryFile the plugin
|
|
45
|
+
uses: a context manager handing out a real path that survives close()
|
|
46
|
+
and the with-block (run() returns the path for Calibre to import; only
|
|
47
|
+
interpreter shutdown cleans it)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, suffix):
|
|
50
|
+
fd = tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir=_ptf_dir())
|
|
51
|
+
self.name = fd.name
|
|
52
|
+
fd.close()
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *args):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ptf_dir() -> str:
|
|
62
|
+
return _PTF_DIR.name if _PTF_DIR is not None else tempfile.gettempdir()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _install_calibre_stubs(config_dir: str) -> None:
|
|
66
|
+
cal = types.ModuleType("calibre")
|
|
67
|
+
customize = types.ModuleType("calibre.customize")
|
|
68
|
+
constants = types.ModuleType("calibre.constants")
|
|
69
|
+
ptemp = types.ModuleType("calibre.ptempfile")
|
|
70
|
+
|
|
71
|
+
class FileTypePlugin:
|
|
72
|
+
def __init__(self, plugin_path):
|
|
73
|
+
self.plugin_path = plugin_path
|
|
74
|
+
self.site_customization = None
|
|
75
|
+
|
|
76
|
+
def temporary_file(self, suffix):
|
|
77
|
+
return _StubPersistentTemporaryFile(suffix)
|
|
78
|
+
|
|
79
|
+
customize.FileTypePlugin = FileTypePlugin
|
|
80
|
+
constants.config_dir = config_dir
|
|
81
|
+
ptemp.PersistentTemporaryFile = _StubPersistentTemporaryFile
|
|
82
|
+
cal.customize = customize
|
|
83
|
+
cal.constants = constants
|
|
84
|
+
cal.ptempfile = ptemp
|
|
85
|
+
for name, mod in (
|
|
86
|
+
("calibre", cal),
|
|
87
|
+
("calibre.customize", customize),
|
|
88
|
+
("calibre.constants", constants),
|
|
89
|
+
("calibre.ptempfile", ptemp),
|
|
90
|
+
):
|
|
91
|
+
sys.modules.setdefault(name, mod)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_plugin_package():
|
|
95
|
+
"""Import plugin/__init__.py exactly the way calibre's loader does: as
|
|
96
|
+
the package calibre_plugins.bindery_repair, with the vendored modules
|
|
97
|
+
resolvable as its submodules (plugin dir first, then src/bindery)."""
|
|
98
|
+
_install_calibre_stubs(tempfile.gettempdir())
|
|
99
|
+
pkg = types.ModuleType("calibre_plugins")
|
|
100
|
+
sys.modules.setdefault("calibre_plugins", pkg)
|
|
101
|
+
spec = importlib.util.spec_from_file_location(
|
|
102
|
+
"calibre_plugins.bindery_repair",
|
|
103
|
+
PLUGIN_DIR / "__init__.py",
|
|
104
|
+
submodule_search_locations=[str(PLUGIN_DIR), str(REPO / "src" / "bindery")],
|
|
105
|
+
)
|
|
106
|
+
module = importlib.util.module_from_spec(spec)
|
|
107
|
+
sys.modules["calibre_plugins.bindery_repair"] = module
|
|
108
|
+
spec.loader.exec_module(module)
|
|
109
|
+
return module
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestPluginBuild(unittest.TestCase):
|
|
113
|
+
"""The zip the release attaches is a pure function of the repo tree."""
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def setUpClass(cls):
|
|
117
|
+
sys.path.insert(0, str(REPO / "scripts"))
|
|
118
|
+
import build_plugin
|
|
119
|
+
|
|
120
|
+
cls.build_plugin = build_plugin
|
|
121
|
+
cls.tmp = tempfile.TemporaryDirectory(prefix="bindery_plugin_")
|
|
122
|
+
cls.zip_path = pathlib.Path(cls.tmp.name) / "test-plugin.zip"
|
|
123
|
+
build_plugin.build(cls.zip_path)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def tearDownClass(cls):
|
|
127
|
+
cls.tmp.cleanup()
|
|
128
|
+
sys.path.remove(str(REPO / "scripts"))
|
|
129
|
+
|
|
130
|
+
def _entry(self, name: str) -> bytes:
|
|
131
|
+
with zipfile.ZipFile(self.zip_path) as z:
|
|
132
|
+
return z.read(name)
|
|
133
|
+
|
|
134
|
+
def test_vendor_modules_are_byte_identical(self):
|
|
135
|
+
for name in VENDOR_MODULES:
|
|
136
|
+
self.assertEqual(
|
|
137
|
+
self._entry(name),
|
|
138
|
+
(REPO / "src" / "bindery" / name).read_bytes(),
|
|
139
|
+
name,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def test_entry_is_the_plugin_source_with_the_repo_version(self):
|
|
143
|
+
raw = self._entry("__init__.py").decode("utf-8")
|
|
144
|
+
version = _repo_version()
|
|
145
|
+
self.assertIn(f"__PLUGIN_VERSION__ = {version!r}", raw)
|
|
146
|
+
committed = (PLUGIN_DIR / "__init__.py").read_text(encoding="utf-8")
|
|
147
|
+
# the only permitted difference is the substituted version line
|
|
148
|
+
self.assertEqual(
|
|
149
|
+
raw,
|
|
150
|
+
self.build_plugin._VERSION_LINE.sub(
|
|
151
|
+
f"__PLUGIN_VERSION__ = {version!r}", committed, count=1
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def test_import_name_marker_and_zip_layout(self):
|
|
156
|
+
self.assertEqual(
|
|
157
|
+
self._entry("plugin-import-name-bindery_repair.txt"),
|
|
158
|
+
b"bindery_repair\n",
|
|
159
|
+
)
|
|
160
|
+
with zipfile.ZipFile(self.zip_path) as z:
|
|
161
|
+
names = set(z.namelist())
|
|
162
|
+
self.assertIn("__init__.py", names)
|
|
163
|
+
self.assertTrue(set(VENDOR_MODULES) <= names)
|
|
164
|
+
|
|
165
|
+
def test_default_output_name_carries_the_version(self):
|
|
166
|
+
version = ".".join(str(part) for part in _repo_version())
|
|
167
|
+
self.assertEqual(self.build_plugin.repo_version(), _repo_version())
|
|
168
|
+
out = self.build_plugin.REPO / "dist" / f"BinderyRepair-v{version}.zip"
|
|
169
|
+
self.assertEqual(out.parent, self.build_plugin.REPO / "dist")
|
|
170
|
+
self.assertTrue(out.name.startswith("BinderyRepair-v"))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TestPluginRun(unittest.TestCase):
|
|
174
|
+
"""run() behavior against synthetic EPUBs, under the calibre stubs."""
|
|
175
|
+
|
|
176
|
+
CONTAINER = (
|
|
177
|
+
'<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
|
178
|
+
'<rootfiles><rootfile full-path="content.opf" '
|
|
179
|
+
'media-type="application/oebps-package+xml"/></rootfiles></container>'
|
|
180
|
+
)
|
|
181
|
+
OPF = (
|
|
182
|
+
'<package xmlns="http://www.idpf.org/2007/opf">'
|
|
183
|
+
"<manifest>"
|
|
184
|
+
'<item id="c1" href="text.xhtml" media-type="application/xhtml+xml"/>'
|
|
185
|
+
"</manifest>"
|
|
186
|
+
'<spine><itemref idref="c1"/></spine></package>'
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def setUpClass(cls):
|
|
191
|
+
global _PTF_DIR
|
|
192
|
+
_PTF_DIR = tempfile.TemporaryDirectory(prefix="bindery_plugin_tmp_")
|
|
193
|
+
cls._ptf_dir = _PTF_DIR
|
|
194
|
+
cls.plugin_module = _load_plugin_package()
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def tearDownClass(cls):
|
|
198
|
+
cls._ptf_dir.cleanup()
|
|
199
|
+
|
|
200
|
+
def setUp(self):
|
|
201
|
+
self.tmp = tempfile.TemporaryDirectory(prefix="bindery_plugin_run_")
|
|
202
|
+
self.addCleanup(self.tmp.cleanup)
|
|
203
|
+
self.plugin = self.plugin_module.BinderyRepair("/tmp/not-a-real-plugin.zip")
|
|
204
|
+
self.plugin.site_customization = json.dumps(
|
|
205
|
+
{"log_path": str(pathlib.Path(self.tmp.name) / "plugin.log")}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _epub(self, name="t.epub", doc=None, pad=0):
|
|
209
|
+
# doc is the FULL content-document text; None builds a clean doc
|
|
210
|
+
if doc is None:
|
|
211
|
+
doc = "<html><body><p>ordinary prose</p></body></html>"
|
|
212
|
+
p = pathlib.Path(self.tmp.name) / name
|
|
213
|
+
with zipfile.ZipFile(p, "w") as z:
|
|
214
|
+
z.writestr("mimetype", "application/epub+zip")
|
|
215
|
+
z.writestr("META-INF/container.xml", self.CONTAINER)
|
|
216
|
+
z.writestr("content.opf", self.OPF)
|
|
217
|
+
z.writestr("text.xhtml", doc)
|
|
218
|
+
if pad:
|
|
219
|
+
z.writestr("filler.bin", b"\0" * pad)
|
|
220
|
+
return p
|
|
221
|
+
|
|
222
|
+
def _log(self) -> str:
|
|
223
|
+
f = pathlib.Path(self.tmp.name) / "plugin.log"
|
|
224
|
+
return f.read_text() if f.exists() else ""
|
|
225
|
+
|
|
226
|
+
def test_damaged_book_is_repaired_and_original_untouched(self):
|
|
227
|
+
# a BOM before the first < is prolog junk: one default-pass fix
|
|
228
|
+
p = pathlib.Path(self.tmp.name) / "d.epub"
|
|
229
|
+
with zipfile.ZipFile(p, "w") as z:
|
|
230
|
+
z.writestr("mimetype", "application/epub+zip")
|
|
231
|
+
z.writestr("META-INF/container.xml", self.CONTAINER)
|
|
232
|
+
z.writestr("content.opf", self.OPF)
|
|
233
|
+
z.writestr(
|
|
234
|
+
"text.xhtml",
|
|
235
|
+
"\ufeff<html><body><p>prolog junk here</p></body></html>",
|
|
236
|
+
)
|
|
237
|
+
before = p.read_bytes()
|
|
238
|
+
out = self.plugin.run(str(p))
|
|
239
|
+
self.assertNotEqual(out, str(p))
|
|
240
|
+
self.assertTrue(pathlib.Path(out).exists())
|
|
241
|
+
self.assertEqual(p.read_bytes(), before) # the original is never touched
|
|
242
|
+
with zipfile.ZipFile(out) as z:
|
|
243
|
+
self.assertIsNone(z.testzip())
|
|
244
|
+
self.assertIn("prolog junk here", z.read("text.xhtml").decode("utf-8"))
|
|
245
|
+
self.assertIn("fixed:", self._log())
|
|
246
|
+
|
|
247
|
+
def test_clean_book_returns_original_byte_for_byte(self):
|
|
248
|
+
p = self._epub()
|
|
249
|
+
out = self.plugin.run(str(p))
|
|
250
|
+
self.assertEqual(out, str(p))
|
|
251
|
+
self.assertIn("no fixes:", self._log())
|
|
252
|
+
|
|
253
|
+
def test_idempotent_second_run_reports_no_fixes(self):
|
|
254
|
+
p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
|
|
255
|
+
first = self.plugin.run(str(p))
|
|
256
|
+
repaired = pathlib.Path(first).read_bytes()
|
|
257
|
+
second = self.plugin.run(first)
|
|
258
|
+
self.assertEqual(second, first)
|
|
259
|
+
self.assertIn("no fixes:", self._log())
|
|
260
|
+
# and the repaired file the first run produced is stable
|
|
261
|
+
self.assertEqual(pathlib.Path(first).read_bytes(), repaired)
|
|
262
|
+
|
|
263
|
+
def test_size_cap_refuses_and_returns_original(self):
|
|
264
|
+
self.plugin.site_customization = json.dumps(
|
|
265
|
+
{
|
|
266
|
+
"log_path": str(pathlib.Path(self.tmp.name) / "plugin.log"),
|
|
267
|
+
"max_size_mb": 0.01, # ~10KB: the padded book exceeds it
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
p = self._epub(pad=64 * 1024)
|
|
271
|
+
out = self.plugin.run(str(p))
|
|
272
|
+
self.assertEqual(out, str(p))
|
|
273
|
+
self.assertIn("refused:", self._log())
|
|
274
|
+
self.assertIn("cap", self._log())
|
|
275
|
+
|
|
276
|
+
def test_broken_json_config_falls_back_to_defaults(self):
|
|
277
|
+
self.plugin.site_customization = "{not json"
|
|
278
|
+
p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
|
|
279
|
+
out = self.plugin.run(str(p))
|
|
280
|
+
# defaults still repair (log goes to the calibre config dir, which
|
|
281
|
+
# the stub points into a read-safe scratch dir)
|
|
282
|
+
self.assertNotEqual(out, str(p))
|
|
283
|
+
|
|
284
|
+
def test_non_epub_suffix_passes_through(self):
|
|
285
|
+
p = pathlib.Path(self.tmp.name) / "cover.jpg"
|
|
286
|
+
p.write_bytes(b"not an epub at all")
|
|
287
|
+
out = self.plugin.run(str(p))
|
|
288
|
+
self.assertEqual(out, str(p))
|
|
289
|
+
self.assertEqual(self._log(), "")
|
|
290
|
+
|
|
291
|
+
def test_unreadable_zip_errors_and_returns_original(self):
|
|
292
|
+
p = pathlib.Path(self.tmp.name) / "broken.epub"
|
|
293
|
+
p.write_bytes(b"PK\x03\x04 this is not really a zip")
|
|
294
|
+
out = self.plugin.run(str(p))
|
|
295
|
+
self.assertEqual(out, str(p))
|
|
296
|
+
self.assertIn("errored:", self._log())
|
|
297
|
+
|
|
298
|
+
def test_experimental_epubcheck_mode_refuses_a_regression(self):
|
|
299
|
+
p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
|
|
300
|
+
self.plugin.site_customization = json.dumps(
|
|
301
|
+
{
|
|
302
|
+
"log_path": str(pathlib.Path(self.tmp.name) / "plugin.log"),
|
|
303
|
+
"epubcheck_path": "/usr/bin/false",
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def fake_counts(plugin, path, cmd):
|
|
308
|
+
# the original measures clean, the repaired copy "regresses"
|
|
309
|
+
return _Counts(0, 0, 0) if path == p else _Counts(1, 0, 0)
|
|
310
|
+
|
|
311
|
+
self.plugin_module.BinderyRepair._epubcheck_counts = fake_counts
|
|
312
|
+
self.addCleanup(
|
|
313
|
+
delattr,
|
|
314
|
+
self.plugin_module.BinderyRepair,
|
|
315
|
+
"_epubcheck_counts",
|
|
316
|
+
)
|
|
317
|
+
out = self.plugin.run(str(p))
|
|
318
|
+
self.assertEqual(out, str(p))
|
|
319
|
+
self.assertIn("refused:", self._log())
|
|
320
|
+
|
|
321
|
+
def test_experimental_epubcheck_mode_accepts_no_worse(self):
|
|
322
|
+
p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
|
|
323
|
+
self.plugin.site_customization = json.dumps(
|
|
324
|
+
{
|
|
325
|
+
"log_path": str(pathlib.Path(self.tmp.name) / "plugin.log"),
|
|
326
|
+
"epubcheck_path": "/usr/bin/false",
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
self.plugin_module.BinderyRepair._epubcheck_counts = lambda plugin, path, cmd: (
|
|
330
|
+
_Counts(1, 0, 0)
|
|
331
|
+
)
|
|
332
|
+
self.addCleanup(
|
|
333
|
+
delattr,
|
|
334
|
+
self.plugin_module.BinderyRepair,
|
|
335
|
+
"_epubcheck_counts",
|
|
336
|
+
)
|
|
337
|
+
out = self.plugin.run(str(p))
|
|
338
|
+
self.assertNotEqual(out, str(p))
|
|
339
|
+
self.assertIn("fixed:", self._log())
|
|
340
|
+
|
|
341
|
+
def test_experimental_epubcheck_mode_unanswered_check_accepts(self):
|
|
342
|
+
p = self._epub(doc="\ufeff<html><body><p>junk</p></body></html>")
|
|
343
|
+
self.plugin.site_customization = json.dumps(
|
|
344
|
+
{
|
|
345
|
+
"log_path": str(pathlib.Path(self.tmp.name) / "plugin.log"),
|
|
346
|
+
"epubcheck_path": "/usr/bin/false",
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
self.plugin_module.BinderyRepair._epubcheck_counts = lambda plugin, path, cmd: (
|
|
350
|
+
None
|
|
351
|
+
)
|
|
352
|
+
self.addCleanup(
|
|
353
|
+
delattr,
|
|
354
|
+
self.plugin_module.BinderyRepair,
|
|
355
|
+
"_epubcheck_counts",
|
|
356
|
+
)
|
|
357
|
+
out = self.plugin.run(str(p))
|
|
358
|
+
# an unanswered measurement never refuses the (safe) default pass
|
|
359
|
+
self.assertNotEqual(out, str(p))
|
|
360
|
+
self.assertIn("fixed:", self._log())
|
|
@@ -462,6 +462,33 @@ class TestFixNcxPlayorder(unittest.TestCase):
|
|
|
462
462
|
self.assertEqual(out.count('playOrder="1"'), 1)
|
|
463
463
|
self.assertIn("Chapter playOrder=", out)
|
|
464
464
|
|
|
465
|
+
def test_already_sequential_playorder_is_not_counted_as_fixes(self):
|
|
466
|
+
# found by the plugin work (2026-09-12): the already-correct
|
|
467
|
+
# comparison read the attribute value WITH its quotes, so every
|
|
468
|
+
# sequential navPoint counted as a fix on every pass. The rewritten
|
|
469
|
+
# bytes were identical, which is why the deterministic-output tests
|
|
470
|
+
# never caught it; only the fix counts were phantom (59 on a real
|
|
471
|
+
# book, every run), and the plugin's zero-fixes contract broke.
|
|
472
|
+
ncx = "".join(f'<navPoint id="p{i}" playOrder="{i}"/>' for i in range(1, 5))
|
|
473
|
+
out, n = fix_ncx_playorder(ncx)
|
|
474
|
+
self.assertEqual(n, 0)
|
|
475
|
+
self.assertEqual(out, ncx)
|
|
476
|
+
|
|
477
|
+
def test_single_quoted_sequential_playorder_is_untouched(self):
|
|
478
|
+
# the same bug's second face: already-correct markup must stay
|
|
479
|
+
# untouched (single quotes preserved), not be rewritten to doubles
|
|
480
|
+
ncx = "".join(f"<navPoint id='p{i}' playOrder='{i}'/>" for i in range(1, 5))
|
|
481
|
+
out, n = fix_ncx_playorder(ncx)
|
|
482
|
+
self.assertEqual(n, 0)
|
|
483
|
+
self.assertEqual(out, ncx)
|
|
484
|
+
|
|
485
|
+
def test_resequence_count_still_counts_real_changes(self):
|
|
486
|
+
ncx = '<navPoint playOrder="2"/><navPoint playOrder="2"/>'
|
|
487
|
+
out, n = fix_ncx_playorder(ncx)
|
|
488
|
+
self.assertEqual(n, 1) # the first moves 2->1; the second is right
|
|
489
|
+
self.assertIn('playOrder="1"', out)
|
|
490
|
+
self.assertIn('playOrder="2"', out)
|
|
491
|
+
|
|
465
492
|
|
|
466
493
|
class TestStripInvalidValue(unittest.TestCase):
|
|
467
494
|
def test_misplaced_value_is_stripped(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|