bindery-cli 0.50.0__tar.gz → 0.51.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.50.0 → bindery_cli-0.51.0}/AGENTS.md +18 -4
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/PKG-INFO +1 -1
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/patchnotes.md +42 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/pyproject.toml +1 -1
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/roadmap.md +1 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/spec.md +18 -4
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/cli.py +29 -1
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/epub.py +20 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/transforms.py +160 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_cli.py +42 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_epub.py +63 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_flags_real_core.py +22 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_flags_wiring.py +1 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_matcher_hardening.py +57 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_transforms.py +184 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/uv.lock +1 -1
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/.github/FUNDING.yml +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/.gitignore +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/LICENSE +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/README.md +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/logo.svg +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/plugin/__init__.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/project.done +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/run_tests.sh +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_comment_hyphens.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_contamination.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_cover_tocdrift.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_help.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_library.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_plugin.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_space_renames.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_version.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_watermark.py +0 -0
- {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_xml_hardening.py +0 -0
|
@@ -30,7 +30,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
30
30
|
`xmlns`, bare `&`, named entities, void self-closing), the NCX pipeline, and the
|
|
31
31
|
mimetype fix (added/normalized/first-stored, epub.py's archive rewrite); every core
|
|
32
32
|
fix must render identically to the author's intent: never add, remove, or reorder
|
|
33
|
-
visible content. The canonical opt-in inventory lives in spec.md: twenty-
|
|
33
|
+
visible content. The canonical opt-in inventory lives in spec.md: twenty-two structural
|
|
34
34
|
repairs, four lossy strips, four safe opt-ins, plus `--reserialize`.
|
|
35
35
|
* **Structural repairs** (`--fix-empty-body`, `--fix-missing-title`, `--fix-id-colons`,
|
|
36
36
|
`--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
|
|
@@ -38,7 +38,8 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
38
38
|
`--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
|
|
39
39
|
`--fix-container`, `--fix-media-types`, `--fix-cover`,
|
|
40
40
|
`--fix-comment-double-hyphen`, `--fix-svg-dup-ids`, `--fix-cdata-terminator`,
|
|
41
|
-
`--fix-misnested-inline`, `--fix-stray-close`, `--fix-unterminated-attr
|
|
41
|
+
`--fix-misnested-inline`, `--fix-stray-close`, `--fix-unterminated-attr`,
|
|
42
|
+
`--fix-unbound-prefix`;
|
|
42
43
|
transforms.py, threaded through epub.py). The v0.47.0 five (the 2026-10-06
|
|
43
44
|
issue batch): `--fix-svg-dup-ids` renames duplicate glyph ids inside
|
|
44
45
|
standalone `.svg` entries (first keeps its name, references resolve to the
|
|
@@ -60,7 +61,15 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
60
61
|
never percent-encoding (epubcheck decodes references before entry lookup,
|
|
61
62
|
verified against 5.3), ambiguous renames refused per entry (`space_rename_map`),
|
|
62
63
|
accepted under `no_worse` because PKG-010 sits on the warning axis the gate
|
|
63
|
-
does not measure.
|
|
64
|
+
does not measure. `--fix-unbound-prefix` (v0.51.0, issue #9, the Calibre
|
|
65
|
+
embed scar) binds an attribute prefix the OPF never declares inline on the
|
|
66
|
+
element that uses it (`<dc:identifier id="ID1" ns0:scheme="calibre">`
|
|
67
|
+
whose `xmlns:ns0` sits on a sibling, out of XML scope; two RSC-016 fatals
|
|
68
|
+
plus OPF-030): URI evidence is the same prefix declared elsewhere in the
|
|
69
|
+
document (all declarations must agree) or the OPF namespace for a DC
|
|
70
|
+
element under an OPF-namespaced package, anything else refuses the whole
|
|
71
|
+
document; scope-tracked over a comment/CDATA-aware tokenizer, OPF only
|
|
72
|
+
(content docs have the `--strip-bad-attrs` drop); normal `gate`.
|
|
64
73
|
They alter markup structure or fabricate minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule;
|
|
65
74
|
v0.17.0 restored it. `--unwrap-illegal-tags` additionally protects any illegal-tag
|
|
66
75
|
name that an EPUB stylesheet styles as an element selector (`css_protected_tags`,
|
|
@@ -101,7 +110,12 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
101
110
|
gain IS visible, so a run with no measurable improvement is a noop and nothing is
|
|
102
111
|
applied) except the two whose gains sit on axes epubcheck does not measure:
|
|
103
112
|
`--fix-cover` and `--encode-url-spaces`, accepted under the same `no_worse` bar with
|
|
104
|
-
the partial rule intact.
|
|
113
|
+
the partial rule intact. One masked-axis rule (v0.51.0, issue #9): when the gate
|
|
114
|
+
answers noop but `report.ncx_uid_synced` fired on a book that started with fatals,
|
|
115
|
+
`process_book` reclassifies the verdict to `partial` (a fatal package halts the
|
|
116
|
+
metadata checks, so the dtb:uid comparison never ran in either measurement and the
|
|
117
|
+
real sync measures as no gain); application behavior is unchanged, since still-fatal
|
|
118
|
+
books are never applied. Changing either bar means re-running the library dry run.
|
|
105
119
|
- **Library writes are sacred.** Replacement must stay atomic (temp in same dir, then
|
|
106
120
|
`os.replace`), touch only the `.epub`, preserve mode, and be dry-run by default.
|
|
107
121
|
Calibre format installation goes through cquarry's write module (`--install-to-calibre`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.51.0
|
|
4
4
|
Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
|
|
5
5
|
Project-URL: Homepage, https://github.com/VirInvictus/bindery-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/VirInvictus/bindery-cli
|
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
2
|
|
|
3
|
+
## v0.51.0 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
### The unbound-prefix repair: the Calibre embed scar comes off the OPF (issue #9)
|
|
6
|
+
|
|
7
|
+
Century Rain (library book #11104) carried an OPF that repeated
|
|
8
|
+
`calibredb embed_metadata` rounds had scarred: one `dc:identifier`, the package
|
|
9
|
+
unique-identifier itself, carried an `ns0:scheme` attribute whose `xmlns:ns0`
|
|
10
|
+
declaration sat on a sibling element, out of XML scope. epubcheck answered with
|
|
11
|
+
two RSC-016 fatals (unbound prefix) plus OPF-030 (unique-identifier not found),
|
|
12
|
+
and because a fatal package halts the metadata checks, the stale NCX `dtb:uid`
|
|
13
|
+
was invisible to the oracle on both sides of the gate. The old probe report said
|
|
14
|
+
it all: `1f/1e/0w -> 1f/1e/0w, ncx_uid_synced (no measurable gain)`, the ncx
|
|
15
|
+
fix refused with the OPF half.
|
|
16
|
+
|
|
17
|
+
- **`--fix-unbound-prefix`** (new structural repair, twenty-second) binds an
|
|
18
|
+
attribute prefix the OPF never declares inline on the element that uses it,
|
|
19
|
+
the exact shape the file's own healthy siblings already use. The URI is
|
|
20
|
+
evidence-based, never guessed: the same prefix declared elsewhere in the
|
|
21
|
+
document (every declaration must agree, or the document is refused), or the
|
|
22
|
+
OPF namespace for a Dublin Core element under an OPF-namespaced package (the
|
|
23
|
+
canonical EPUB 2 `opf:scheme` shape); anything else refuses the whole
|
|
24
|
+
document untouched. Scope tracking walks the document with a
|
|
25
|
+
comment/CDATA-aware tokenizer over a lenient element stack, so declarations
|
|
26
|
+
that genuinely cover a use satisfy it without an edit and clean documents
|
|
27
|
+
are byte-identical. OPF only: content documents already have the
|
|
28
|
+
invalid-attribute drop (`--strip-bad-attrs`) for this defect class. Normal
|
|
29
|
+
`gate` (clearing the fatal is the measurable gain). On the fixture:
|
|
30
|
+
`1f/1e/0w -> 0f/0e/0w`, epubcheck fully clean.
|
|
31
|
+
- **The masked ncx gain** (the issue's second half) stops lying in reports:
|
|
32
|
+
when the gate answers noop but the NCX uid sync actually fired on a book
|
|
33
|
+
that started with fatals, `process_book` reclassifies the verdict to
|
|
34
|
+
`partial` instead of `equal`/`no measurable gain`. The sync is real,
|
|
35
|
+
deterministic work the oracle could not see; the still-fatal rule is intact,
|
|
36
|
+
so the library sweep still refuses to apply such a book and the single-file
|
|
37
|
+
verb still writes it under the honest PARTIAL label when given an explicit
|
|
38
|
+
destination.
|
|
39
|
+
|
|
40
|
+
Twenty-seven tests joined the suite (691 total): the transform unit tests
|
|
41
|
+
(scope, evidence, refusal, idempotence), the archive-level e2e, the masked-gain
|
|
42
|
+
verdict rules, tokenizer spin guards beside the other matchers, and the argv
|
|
43
|
+
wiring rows.
|
|
44
|
+
|
|
3
45
|
## v0.50.0 (2026-10-14)
|
|
4
46
|
|
|
5
47
|
### The contamination analyzer: fan-bundle cosmetics get reported (issue #8)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.51.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 = [
|
|
@@ -44,6 +44,7 @@ The plugin vendors the core and never gates (the epubcheck latency ruling).
|
|
|
44
44
|
| 0.48 | The AI-probe batch: the enforced closed-Calibre gate on both metadata.db doors (`audit --tag`, `library --apply --install-to-calibre`), the stated contracts (exit codes, no-prompt, NCX-001, --id scoping, json envelope keys), and `--help-json` | v0.48.0 |
|
|
45
45
|
| 0.49 | The probe form (issue #7): single-file repair without an output path runs the gate and writes nothing; writing needs an explicit DST | v0.49.0 |
|
|
46
46
|
| 0.50 | The contamination analyzer (issue #8): foreign-title template stamps, fan-bundle ad signatures, review-paste front matter; advisory by contract, archive-wide corpus (`Book.extra_docs`, capped), and the `_dir_dispatch` map that fixes the v0.44.0 cover/tocdrift ocr misroute in directory/single mode | v0.50.0 |
|
|
47
|
+
| 0.51 | The unbound-prefix repair (issue #9): `--fix-unbound-prefix` binds the Calibre embed scar's undeclared attribute prefix inline (evidence-based URI, scope-tracked, refuse over guess), plus the masked-ncx-gain rule that stops a real dtb:uid sync reading as no measurable gain under a fatal package | v0.51.0 |
|
|
47
48
|
|
|
48
49
|
## Open work
|
|
49
50
|
|
|
@@ -13,14 +13,14 @@ restyle, re-compress, or restructure content, and it does not attempt to fix arb
|
|
|
13
13
|
schema (RSC-005) violations, which are usually harmless to readers and not safely
|
|
14
14
|
mechanizable.
|
|
15
15
|
|
|
16
|
-
The deliberate exceptions to "semantics-preserving" come in opt-in groups. The twenty-
|
|
16
|
+
The deliberate exceptions to "semantics-preserving" come in opt-in groups. The twenty-two
|
|
17
17
|
**structural repairs** (`--fix-empty-body`, `--fix-missing-title`, `--fix-id-colons`,
|
|
18
18
|
`--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
|
|
19
19
|
`--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`,
|
|
20
20
|
`--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
|
|
21
21
|
`--fix-container`, `--fix-media-types`, `--fix-cover`, `--fix-comment-double-hyphen`,
|
|
22
22
|
`--fix-svg-dup-ids`, `--fix-cdata-terminator`, `--fix-misnested-inline`,
|
|
23
|
-
`--fix-stray-close`, `--fix-unterminated-attr`) alter
|
|
23
|
+
`--fix-stray-close`, `--fix-unterminated-attr`, `--fix-unbound-prefix`) alter
|
|
24
24
|
markup structure or fabricate minimal content; the four
|
|
25
25
|
**lossy modes** (`--strip-pagination`, `--strip-broken-tags`, `--strip-watermarks`,
|
|
26
26
|
`--strip-stub-docs`) remove
|
|
@@ -280,8 +280,22 @@ use, with the `partial` rule intact.
|
|
|
280
280
|
quote before the next `<` (a real multi-line value closes quote-first), and only
|
|
281
281
|
double-quoted values are judged. It runs before the other tag-shape readers, which an
|
|
282
282
|
open value otherwise lies to.
|
|
283
|
-
|
|
284
|
-
|
|
283
|
+
- **`--fix-unbound-prefix`**: bind an attribute prefix the OPF never declares inline on
|
|
284
|
+
the element that uses it (the Calibre embed scar, issue #9: one dc: element carries
|
|
285
|
+
`ns0:scheme` while the `xmlns:ns0` declaration sits on a sibling, out of XML scope;
|
|
286
|
+
the RSC-016 "prefix is not bound" fatals make the whole package unparseable, which
|
|
287
|
+
also costs OPF-030 on the unique-identifier and masks the NCX-001 comparison). The
|
|
288
|
+
declaration is inserted on the user, the shape the file's own healthy siblings
|
|
289
|
+
already use. The URI is evidence-based, never guessed: the same prefix declared
|
|
290
|
+
elsewhere in the document with every declaration agreeing, or the OPF namespace for
|
|
291
|
+
a Dublin Core element under an OPF-namespaced package (the canonical EPUB 2
|
|
292
|
+
`opf:scheme` shape); anything else refuses the document untouched. Scope is tracked
|
|
293
|
+
with a lenient element stack over a comment/CDATA-aware tokenizer, so declarations
|
|
294
|
+
that genuinely cover a use satisfy it without an edit and clean documents are
|
|
295
|
+
byte-identical. Runs on the OPF only: content documents have the
|
|
296
|
+
invalid-attribute drop (`--strip-bad-attrs`) for the same defect class.
|
|
297
|
+
|
|
298
|
+
Twenty of the twenty-two are evaluated by the normal `gate`: unlike the lossy strips,
|
|
285
299
|
their benefit is visible to epubcheck (they clear errors), so a run with no measurable
|
|
286
300
|
improvement is a noop and nothing is applied. The two exceptions are `--fix-cover` and
|
|
287
301
|
`--encode-url-spaces`, whose gains sit on axes epubcheck does not measure (cover
|
|
@@ -11,7 +11,7 @@ Calibre plugin built from this core.
|
|
|
11
11
|
Single source of truth for the version. pyproject.toml must match VERSION below.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "0.
|
|
14
|
+
VERSION = "0.51.0"
|
|
15
15
|
__version__ = VERSION
|
|
16
16
|
|
|
17
17
|
from .epub import RepairReport, repair_epub # noqa: E402
|
|
@@ -102,6 +102,7 @@ def _flags_from_args(args: argparse.Namespace) -> RepairFlags:
|
|
|
102
102
|
misnested_inline=args.fix_misnested_inline or all_,
|
|
103
103
|
stray_close=args.fix_stray_close or all_,
|
|
104
104
|
unterminated_attr=args.fix_unterminated_attr or all_,
|
|
105
|
+
unbound_prefix=args.fix_unbound_prefix or all_,
|
|
105
106
|
)
|
|
106
107
|
|
|
107
108
|
|
|
@@ -159,6 +160,16 @@ def process_book(
|
|
|
159
160
|
watermark_refusals=report.watermark_refusals,
|
|
160
161
|
)
|
|
161
162
|
verdict = gate(before, after)
|
|
163
|
+
if verdict == "noop" and report.ncx_uid_synced and before.fatals > 0:
|
|
164
|
+
# The NCX-001 axis was masked in both measurements (issue #9): a fatal
|
|
165
|
+
# in the package halts the metadata checks, so epubcheck never compared
|
|
166
|
+
# dtb:uid to the unique-identifier, and the sync that really fixed it
|
|
167
|
+
# measures as no gain. The sync is semantics-preserving by construction
|
|
168
|
+
# (it writes only a differing dtb:uid content value), so the verdict
|
|
169
|
+
# stops calling real work gainless: partial, with the still-fatal rule
|
|
170
|
+
# intact (the library sweep still refuses to apply it; the single-file
|
|
171
|
+
# verb still writes it under the PARTIAL label when given a DST).
|
|
172
|
+
verdict = "partial"
|
|
162
173
|
if (
|
|
163
174
|
report.fixes.get("stripped_pagination")
|
|
164
175
|
or report.fixes.get("stripped_broken_tags")
|
|
@@ -1136,7 +1147,9 @@ def _phase1_decisions(books: list[dict], apply: bool) -> list[dict]:
|
|
|
1136
1147
|
f"{len(pending)} book(s) have gate-accepted repairs "
|
|
1137
1148
|
f"pending ({len(watermarked)} watermarked); this run "
|
|
1138
1149
|
"was read-only. Re-run with --apply-lossy to record "
|
|
1139
|
-
"the lossy-strip consent and apply."
|
|
1150
|
+
"the lossy-strip consent and apply. A book still "
|
|
1151
|
+
"carrying fatals stays unapplied until its fatal is "
|
|
1152
|
+
"fixed: the sweep never applies a still-fatal book."
|
|
1140
1153
|
),
|
|
1141
1154
|
"books": sorted(set(pending + watermarked)),
|
|
1142
1155
|
}
|
|
@@ -1777,6 +1790,21 @@ _REPAIR_FLAG_TABLE: dict[str, list[tuple[str, str, str]]] = {
|
|
|
1777
1790
|
"stays inside the comment bodies: text nodes and CDATA sections are "
|
|
1778
1791
|
"never touched, and each comment's terminator is left intact.",
|
|
1779
1792
|
),
|
|
1793
|
+
(
|
|
1794
|
+
"fix_unbound_prefix",
|
|
1795
|
+
"bind an undeclared attribute prefix inline (Calibre embed scar)",
|
|
1796
|
+
"bind an attribute prefix the OPF never declares inline on the "
|
|
1797
|
+
"element that uses it (RSC-016 'prefix is not bound'): calibre's "
|
|
1798
|
+
"embed_metadata writer can leave one dc: element carrying "
|
|
1799
|
+
"ns0:scheme while the xmlns declaration sits on a sibling, out of "
|
|
1800
|
+
"XML scope, which fatals the whole package (plus OPF-030 on the "
|
|
1801
|
+
"unique-identifier). The URI is evidence-based, never guessed: "
|
|
1802
|
+
"the same prefix declared elsewhere in the document, or the OPF "
|
|
1803
|
+
"namespace for a Dublin Core element under an OPF-namespaced "
|
|
1804
|
+
"package; anything else refuses the document untouched. Content "
|
|
1805
|
+
"documents are not touched (their unbound prefixes have the "
|
|
1806
|
+
"--strip-bad-attrs drop).",
|
|
1807
|
+
),
|
|
1780
1808
|
],
|
|
1781
1809
|
"lossy strips (these delete converter-injected content; accepted on a no-worse bar)": [
|
|
1782
1810
|
(
|
|
@@ -44,6 +44,7 @@ from .transforms import (
|
|
|
44
44
|
fix_ncx_src_fragments,
|
|
45
45
|
fix_stray_close,
|
|
46
46
|
fix_svg_dup_ids,
|
|
47
|
+
fix_unbound_prefixes,
|
|
47
48
|
fix_unterminated_attr,
|
|
48
49
|
outside_protected_map,
|
|
49
50
|
strip_attrs_in_start_tags,
|
|
@@ -284,6 +285,7 @@ class RepairFlags:
|
|
|
284
285
|
misnested_inline: bool = False
|
|
285
286
|
stray_close: bool = False
|
|
286
287
|
unterminated_attr: bool = False
|
|
288
|
+
unbound_prefix: bool = False
|
|
287
289
|
|
|
288
290
|
|
|
289
291
|
@dataclass
|
|
@@ -1478,6 +1480,15 @@ def repair_epub(
|
|
|
1478
1480
|
own `>` (`<p class="footnote>` -> `<p class="footnote">`; the RSC-016
|
|
1479
1481
|
'must not contain the < character' fatal). It runs before the tag-shape
|
|
1480
1482
|
readers, which an open value otherwise lies to.
|
|
1483
|
+
With `unbound_prefix`, bind an attribute prefix the OPF never declares
|
|
1484
|
+
inline on the element that uses it (the Calibre embed scar: one dc:
|
|
1485
|
+
element carries `ns0:scheme` while the xmlns declaration sits on a
|
|
1486
|
+
sibling, out of scope; two RSC-016 fatals plus OPF-030 on the
|
|
1487
|
+
unique-identifier). The URI is evidence-based: the same prefix declared
|
|
1488
|
+
elsewhere in the document, or the OPF namespace for a Dublin Core element
|
|
1489
|
+
under an OPF-namespaced package; anything else refuses the whole
|
|
1490
|
+
document. Content documents are not touched (their unbound prefixes have
|
|
1491
|
+
the --strip-bad-attrs drop).
|
|
1481
1492
|
With `strip_stub_docs` (lossy, the no_worse bar), drop spine documents whose
|
|
1482
1493
|
entire visible text is one identical short placeholder repeated across the
|
|
1483
1494
|
spine (the Bookmate/DRM-sample export whose chapters are all the same
|
|
@@ -1835,8 +1846,17 @@ def repair_epub(
|
|
|
1835
1846
|
or flags.fix_cover
|
|
1836
1847
|
or flags.comment_double_hyphens
|
|
1837
1848
|
or flags.strip_stub_docs
|
|
1849
|
+
or flags.unbound_prefix
|
|
1838
1850
|
):
|
|
1839
1851
|
opf_changed = False
|
|
1852
|
+
if flags.unbound_prefix:
|
|
1853
|
+
# First in the branch: the unbound prefix makes the whole
|
|
1854
|
+
# package unparseable, so the parseability scar comes off
|
|
1855
|
+
# before the readers that match against the text.
|
|
1856
|
+
text, n = fix_unbound_prefixes(text)
|
|
1857
|
+
if n:
|
|
1858
|
+
report.add({"unbound_prefixes_bound": n})
|
|
1859
|
+
opf_changed = True
|
|
1840
1860
|
if flags.fix_ids:
|
|
1841
1861
|
text, n = fix_manifest_ids(text)
|
|
1842
1862
|
if n:
|
|
@@ -991,6 +991,166 @@ def fix_unterminated_attr(s: str) -> tuple[str, int]:
|
|
|
991
991
|
return _UNTERMINATED_ATTR_RE.sub(repl, s), count
|
|
992
992
|
|
|
993
993
|
|
|
994
|
+
# --fix-unbound-prefix (issue #9). One tokenizer walks the whole document so
|
|
995
|
+
# prefix scope can be tracked: comments, CDATA sections, PIs, and doctypes are
|
|
996
|
+
# consumed as opaque tokens (markup-looking text inside them must never touch
|
|
997
|
+
# the element stack), end tags and start tags carry their names. The
|
|
998
|
+
# quote-aware loops are possessive per the house convention: the three
|
|
999
|
+
# branches are mutually exclusive, so a failing candidate fails linearly.
|
|
1000
|
+
_OPF_TOKEN_RE = re.compile(
|
|
1001
|
+
r"""<!--(?:(?!-->).)*+-->"""
|
|
1002
|
+
r"""|<!\[CDATA\[(?:(?!\]\]>).)*+\]\]>"""
|
|
1003
|
+
r"""|<\?(?:(?!\?>).)*+\?>"""
|
|
1004
|
+
r"""|<![^>]*+>"""
|
|
1005
|
+
r"""|</([A-Za-z_][\w.:-]*)((?:"[^"]*+"|'[^']*+'|[^>"'])*+)>"""
|
|
1006
|
+
r"""|<([A-Za-z_][\w.:-]*)((?:"[^"]*+"|'[^']*+'|[^>"'])*+)>"""
|
|
1007
|
+
)
|
|
1008
|
+
# Any quoted attribute (declarations and uses alike); group 3 is the value
|
|
1009
|
+
# exactly as spelled in the document, re-emitted verbatim when binding.
|
|
1010
|
+
_OPF_ATTR_RE = re.compile(r"""([A-Za-z_][\w.:-]*)\s*=\s*(["'])((?:(?!\2).)*+)\2""")
|
|
1011
|
+
_OPF_NS = "http://www.idpf.org/2007/opf"
|
|
1012
|
+
_DC_NS = "http://purl.org/dc/elements/1.1/"
|
|
1013
|
+
# Pre-bound in every XML document; never candidates for binding.
|
|
1014
|
+
_PREBOUND_PREFIXES = frozenset({"xml", "xmlns"})
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _unescape_attr(v: str) -> str:
|
|
1018
|
+
"""The five predefined XML entities, the only escapes a namespace URI
|
|
1019
|
+
could legitimately carry."""
|
|
1020
|
+
for ent, ch in (("<", "<"), (">", ">"), (""", '"'), ("'", "'")):
|
|
1021
|
+
v = v.replace(ent, ch)
|
|
1022
|
+
return v.replace("&", "&")
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def fix_unbound_prefixes(opf: str) -> tuple[str, int]:
|
|
1026
|
+
"""Bind an undeclared namespace prefix inline on each element that uses it.
|
|
1027
|
+
|
|
1028
|
+
Calibre's embed_metadata writer has been observed to leave one dc: element
|
|
1029
|
+
carrying an attribute prefix (``ns0:scheme``) whose xmlns declaration never
|
|
1030
|
+
made it into the document: the declaration sits on a sibling, and XML scope
|
|
1031
|
+
ends at the sibling's end tag. The unparseable package costs every
|
|
1032
|
+
downstream check (two RSC-016 fatals plus OPF-030 on the
|
|
1033
|
+
unique-identifier). The repair is the shape the file's own healthy
|
|
1034
|
+
siblings already use: declare the prefix on the element that uses it.
|
|
1035
|
+
|
|
1036
|
+
The URI is evidence-based, never guessed: (1) the same prefix declared
|
|
1037
|
+
elsewhere in the document, every declaration agreeing on one URI; (2) the
|
|
1038
|
+
prefix declared nowhere, but the carrying element is Dublin Core (its
|
|
1039
|
+
element prefix resolves to the DC namespace, or the literal ``dc:``
|
|
1040
|
+
spelling when that is itself unbound) and the package element declares the
|
|
1041
|
+
OPF namespace, the canonical EPUB 2 ``opf:scheme``/``opf:role`` shape
|
|
1042
|
+
calibre's writer emits; (3) anything else refuses the whole document (an
|
|
1043
|
+
invented URI would be a guess, and a half-bound package is still fatal, so
|
|
1044
|
+
a partial fix would buy nothing). Declarations scoped to an ancestor (or
|
|
1045
|
+
to the element itself) satisfy the check without an edit, so clean
|
|
1046
|
+
documents are byte-identical.
|
|
1047
|
+
|
|
1048
|
+
Opt-in (--fix-unbound-prefix). Runs on the OPF only: content documents
|
|
1049
|
+
have the invalid-attribute drop (--strip-bad-attrs) for the same defect
|
|
1050
|
+
class.
|
|
1051
|
+
"""
|
|
1052
|
+
# Document-wide declaration evidence, prefix -> the URIs it is bound to.
|
|
1053
|
+
# Scanned over start-tag tokens only: a raw scan would also read
|
|
1054
|
+
# commented-out or prose-mentioned xmlns spellings, and a junk URI that
|
|
1055
|
+
# exists only inside a comment must never become binding evidence (nor
|
|
1056
|
+
# poison the agreement check against a real declaration).
|
|
1057
|
+
decl_uris: dict[str, set[str]] = {}
|
|
1058
|
+
for m in _OPF_TOKEN_RE.finditer(opf):
|
|
1059
|
+
if m.group(3) is None:
|
|
1060
|
+
continue
|
|
1061
|
+
for am in _OPF_ATTR_RE.finditer(m.group(4)):
|
|
1062
|
+
name = am.group(1)
|
|
1063
|
+
if name.startswith("xmlns:"):
|
|
1064
|
+
decl_uris.setdefault(name[len("xmlns:") :], set()).add(am.group(3))
|
|
1065
|
+
|
|
1066
|
+
scope: list[tuple[str, dict[str, str]]] = []
|
|
1067
|
+
edits: list[tuple[int, str]] = []
|
|
1068
|
+
count = 0
|
|
1069
|
+
pkg_ns = False
|
|
1070
|
+
pkg_seen = False
|
|
1071
|
+
|
|
1072
|
+
for m in _OPF_TOKEN_RE.finditer(opf):
|
|
1073
|
+
if m.group(3) is None:
|
|
1074
|
+
if m.group(1) is None:
|
|
1075
|
+
continue # comment / CDATA / PI / doctype: opaque
|
|
1076
|
+
# End tag: pop to the matching start; a stray end tag with no
|
|
1077
|
+
# open element pops nothing. Mismatched nesting pops through
|
|
1078
|
+
# intermediate levels, which can only ever cause a redundant
|
|
1079
|
+
# inline re-declaration of a prefix that was still in scope,
|
|
1080
|
+
# never a semantic change: the evidence URI is document-wide,
|
|
1081
|
+
# so it equals whatever the dropped scope held.
|
|
1082
|
+
ename = m.group(1)
|
|
1083
|
+
for i in range(len(scope) - 1, -1, -1):
|
|
1084
|
+
if scope[i][0] == ename:
|
|
1085
|
+
del scope[i:]
|
|
1086
|
+
break
|
|
1087
|
+
continue
|
|
1088
|
+
|
|
1089
|
+
name, body = m.group(3), m.group(4)
|
|
1090
|
+
# The possessive body loop always swallows a self-closing slash (the
|
|
1091
|
+
# [^>"'] catch-all is disjoint from `>` but not from `/`), so
|
|
1092
|
+
# self-closing is read off the body, never a group.
|
|
1093
|
+
slash = body.rstrip().endswith("/")
|
|
1094
|
+
decls: dict[str, str] = {}
|
|
1095
|
+
attr_names: list[str] = []
|
|
1096
|
+
default_uri = None
|
|
1097
|
+
for am in _OPF_ATTR_RE.finditer(body):
|
|
1098
|
+
aname = am.group(1)
|
|
1099
|
+
if aname == "xmlns":
|
|
1100
|
+
default_uri = am.group(3)
|
|
1101
|
+
elif aname.startswith("xmlns:"):
|
|
1102
|
+
decls[aname[len("xmlns:") :]] = am.group(3)
|
|
1103
|
+
else:
|
|
1104
|
+
attr_names.append(aname)
|
|
1105
|
+
if not pkg_seen and name.split(":")[-1] == "package":
|
|
1106
|
+
pkg_seen = True
|
|
1107
|
+
pkg_ns = any(
|
|
1108
|
+
u == _OPF_NS or _unescape_attr(u) == _OPF_NS
|
|
1109
|
+
for u in (default_uri, *decls.values())
|
|
1110
|
+
if u
|
|
1111
|
+
)
|
|
1112
|
+
# Push before the attribute check: the element's own declarations
|
|
1113
|
+
# apply to its own attributes.
|
|
1114
|
+
scope.append((name, decls))
|
|
1115
|
+
binds: list[str] = []
|
|
1116
|
+
for aname in attr_names:
|
|
1117
|
+
prefix, sep, _ = aname.partition(":")
|
|
1118
|
+
if not sep or prefix in _PREBOUND_PREFIXES:
|
|
1119
|
+
continue
|
|
1120
|
+
if any(prefix in d for _, d in scope):
|
|
1121
|
+
continue
|
|
1122
|
+
uris = decl_uris.get(prefix)
|
|
1123
|
+
if uris is not None:
|
|
1124
|
+
if len(uris) != 1:
|
|
1125
|
+
return opf, 0 # one prefix, two URIs: intent unreadable
|
|
1126
|
+
uri = next(iter(uris))
|
|
1127
|
+
else:
|
|
1128
|
+
elem_prefix, _, _ = name.partition(":")
|
|
1129
|
+
elem_ns = None
|
|
1130
|
+
for _, d in reversed(scope):
|
|
1131
|
+
if elem_prefix in d:
|
|
1132
|
+
elem_ns = d[elem_prefix]
|
|
1133
|
+
break
|
|
1134
|
+
if pkg_ns and (
|
|
1135
|
+
elem_ns == _DC_NS or (elem_ns is None and elem_prefix == "dc")
|
|
1136
|
+
):
|
|
1137
|
+
uri = _OPF_NS
|
|
1138
|
+
else:
|
|
1139
|
+
return opf, 0 # no evidence: refuse the whole document
|
|
1140
|
+
binds.append(f' xmlns:{prefix}="{uri}"')
|
|
1141
|
+
if binds:
|
|
1142
|
+
# Insert directly after the element name, the position the
|
|
1143
|
+
# file's own healthy inline declarations occupy.
|
|
1144
|
+
edits.append((m.start(4), "".join(binds)))
|
|
1145
|
+
count += len(binds)
|
|
1146
|
+
if slash:
|
|
1147
|
+
scope.pop()
|
|
1148
|
+
|
|
1149
|
+
for at, text in reversed(edits):
|
|
1150
|
+
opf = opf[:at] + text + opf[at:]
|
|
1151
|
+
return opf, count
|
|
1152
|
+
|
|
1153
|
+
|
|
994
1154
|
# The always-on core for full (X)HTML content documents, in order: prolog and
|
|
995
1155
|
# root-tag fixes first, then ampersand/entity normalization, then void
|
|
996
1156
|
# self-closing. Exactly the five semantics-preserving well-formedness fixes the
|
|
@@ -1868,6 +1868,48 @@ class TestStripStubDocsGate(unittest.TestCase):
|
|
|
1868
1868
|
self.assertEqual(o.status, "reject")
|
|
1869
1869
|
|
|
1870
1870
|
|
|
1871
|
+
class TestMaskedNcxUidGain(unittest.TestCase):
|
|
1872
|
+
"""Issue #9, the gate's masked axis: a fatal package halts the metadata
|
|
1873
|
+
checks, so epubcheck never compares dtb:uid to the unique-identifier and
|
|
1874
|
+
a real NCX uid sync measures as no gain. A noop verdict that carries a
|
|
1875
|
+
sync on a book that started with fatals is reclassified partial: real
|
|
1876
|
+
work the oracle could not see, still-fatal rules intact."""
|
|
1877
|
+
|
|
1878
|
+
def _verdict(self, before, after, report):
|
|
1879
|
+
with (
|
|
1880
|
+
mock.patch("bindery.cli.repair_epub", return_value=report),
|
|
1881
|
+
mock.patch("bindery.cli.run_epubcheck", side_effect=[before, after]),
|
|
1882
|
+
):
|
|
1883
|
+
return process_book(Path("x.epub"), Path("."), validate=True)
|
|
1884
|
+
|
|
1885
|
+
def test_masked_sync_is_partial_not_equal(self):
|
|
1886
|
+
report = RepairReport(ncx_uid_synced=True)
|
|
1887
|
+
o = self._verdict(CheckResult(2, 1, 0), CheckResult(2, 1, 0), report)
|
|
1888
|
+
self.assertEqual(o.status, "partial")
|
|
1889
|
+
self.assertNotIn("no measurable gain", o.summary)
|
|
1890
|
+
self.assertIn("ncx_uid_synced", o.summary)
|
|
1891
|
+
|
|
1892
|
+
def test_sync_without_fatals_stays_gate_measured(self):
|
|
1893
|
+
# A parseable package lets epubcheck see the NCX axis; a noop there
|
|
1894
|
+
# means the sync bought nothing epubcheck measures.
|
|
1895
|
+
report = RepairReport(ncx_uid_synced=True)
|
|
1896
|
+
o = self._verdict(CheckResult(0, 0, 0), CheckResult(0, 0, 0), report)
|
|
1897
|
+
self.assertEqual(o.status, "equal")
|
|
1898
|
+
self.assertIn("no measurable gain", o.summary)
|
|
1899
|
+
|
|
1900
|
+
def test_masked_sync_fatals_reduced_stays_partial(self):
|
|
1901
|
+
# The gate itself answers partial when fatals merely dropped; the
|
|
1902
|
+
# reclassification must not downgrade a better verdict.
|
|
1903
|
+
report = RepairReport(ncx_uid_synced=True)
|
|
1904
|
+
o = self._verdict(CheckResult(2, 1, 0), CheckResult(1, 1, 0), report)
|
|
1905
|
+
self.assertEqual(o.status, "partial")
|
|
1906
|
+
|
|
1907
|
+
def test_reject_never_reclassified(self):
|
|
1908
|
+
report = RepairReport(ncx_uid_synced=True)
|
|
1909
|
+
o = self._verdict(CheckResult(1, 0, 0), CheckResult(2, 0, 0), report)
|
|
1910
|
+
self.assertEqual(o.status, "reject")
|
|
1911
|
+
|
|
1912
|
+
|
|
1871
1913
|
class TestHelpLayout(unittest.TestCase):
|
|
1872
1914
|
"""The two-level help contract (the 2026-10-06 restructure): the compact
|
|
1873
1915
|
--help is grouped with one-line flags and a short usage line, and
|
|
@@ -1970,3 +1970,66 @@ class TestStripStubDocs(unittest.TestCase):
|
|
|
1970
1970
|
dst = Path(td) / "out.epub"
|
|
1971
1971
|
report = repair_epub(src, dst, RepairFlags(strip_stub_docs=True))
|
|
1972
1972
|
self.assertNotIn("stub_docs_dropped", report.fixes)
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
SCARRED_OPF = (
|
|
1976
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
1977
|
+
'<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="ID1" '
|
|
1978
|
+
'version="2.0">'
|
|
1979
|
+
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
|
|
1980
|
+
"<dc:title>Century Rain</dc:title>"
|
|
1981
|
+
'<dc:creator xmlns:ns0="http://www.idpf.org/2007/opf" ns0:role="aut">'
|
|
1982
|
+
"Alastair Reynolds</dc:creator>"
|
|
1983
|
+
'<dc:identifier id="ID1" ns0:scheme="calibre">'
|
|
1984
|
+
"fccddb12-6dd3-4896-ab23-c676558ca98b</dc:identifier>"
|
|
1985
|
+
"</metadata>"
|
|
1986
|
+
'<manifest><item id="c1" href="c1.xhtml" '
|
|
1987
|
+
'media-type="application/xhtml+xml"/><item id="ncx" href="toc.ncx" '
|
|
1988
|
+
'media-type="application/x-dtbncx+xml"/></manifest>'
|
|
1989
|
+
'<spine toc="ncx"><itemref idref="c1"/></spine></package>'
|
|
1990
|
+
)
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
class TestUnboundPrefix(unittest.TestCase):
|
|
1994
|
+
"""Issue #9: the Calibre embed scar. One dc: element carries an
|
|
1995
|
+
ns0:-prefixed attribute whose declaration sits on a sibling, out of XML
|
|
1996
|
+
scope: two RSC-016 fatals plus OPF-030 on the unique-identifier, and the
|
|
1997
|
+
NCX uid sync measures as no gain while the package is unparseable."""
|
|
1998
|
+
|
|
1999
|
+
def _build(self, td: str) -> tuple[Path, Path]:
|
|
2000
|
+
src, dst = Path(td) / "in.epub", Path(td) / "out.epub"
|
|
2001
|
+
with zipfile.ZipFile(src, "w") as z:
|
|
2002
|
+
z.writestr("mimetype", "application/epub+zip")
|
|
2003
|
+
z.writestr("OEBPS/c1.xhtml", CONTENT)
|
|
2004
|
+
z.writestr("OEBPS/toc.ncx", NCX_BAD)
|
|
2005
|
+
z.writestr("OEBPS/content.opf", SCARRED_OPF)
|
|
2006
|
+
return src, dst
|
|
2007
|
+
|
|
2008
|
+
def test_off_by_default(self):
|
|
2009
|
+
with tempfile.TemporaryDirectory() as td:
|
|
2010
|
+
src, dst = self._build(td)
|
|
2011
|
+
report = repair_epub(src, dst)
|
|
2012
|
+
self.assertNotIn("unbound_prefixes_bound", report.fixes)
|
|
2013
|
+
|
|
2014
|
+
def test_scarred_opf_bound_and_ncx_synced(self):
|
|
2015
|
+
with tempfile.TemporaryDirectory() as td:
|
|
2016
|
+
src, dst = self._build(td)
|
|
2017
|
+
self.assertTrue(ncx_uid_mismatch(src))
|
|
2018
|
+
report = repair_epub(src, dst, RepairFlags(unbound_prefix=True))
|
|
2019
|
+
self.assertEqual(report.fixes.get("unbound_prefixes_bound"), 1)
|
|
2020
|
+
self.assertTrue(report.ncx_uid_synced)
|
|
2021
|
+
with zipfile.ZipFile(dst) as z:
|
|
2022
|
+
opf = z.read("OEBPS/content.opf").decode()
|
|
2023
|
+
self.assertIn(
|
|
2024
|
+
'<dc:identifier xmlns:ns0="http://www.idpf.org/2007/opf" '
|
|
2025
|
+
'id="ID1" ns0:scheme="calibre">',
|
|
2026
|
+
opf,
|
|
2027
|
+
)
|
|
2028
|
+
# the healthy sibling is untouched
|
|
2029
|
+
self.assertIn(
|
|
2030
|
+
'<dc:creator xmlns:ns0="http://www.idpf.org/2007/opf" ns0:role="aut">',
|
|
2031
|
+
opf,
|
|
2032
|
+
)
|
|
2033
|
+
# the bound package parses and its unique-identifier resolves
|
|
2034
|
+
ET.fromstring(opf)
|
|
2035
|
+
self.assertFalse(ncx_uid_mismatch(dst))
|
|
@@ -378,6 +378,23 @@ def _fixtures() -> dict:
|
|
|
378
378
|
},
|
|
379
379
|
"fix_unterminated_attr",
|
|
380
380
|
),
|
|
381
|
+
"--fix-unbound-prefix": (
|
|
382
|
+
{
|
|
383
|
+
"OEBPS/content.opf": _opf(
|
|
384
|
+
[c1],
|
|
385
|
+
[r1],
|
|
386
|
+
# The Calibre embed scar: ns0 declared on the sibling
|
|
387
|
+
# creator only, so the identifier's ns0:scheme is unbound.
|
|
388
|
+
meta=(
|
|
389
|
+
'<dc:creator xmlns:ns0="http://www.idpf.org/2007/opf" '
|
|
390
|
+
'ns0:role="aut">A</dc:creator>'
|
|
391
|
+
'<dc:identifier id="id" ns0:scheme="calibre">uuid-x</dc:identifier>'
|
|
392
|
+
),
|
|
393
|
+
),
|
|
394
|
+
"OEBPS/c1.xhtml": plain,
|
|
395
|
+
},
|
|
396
|
+
"unbound_prefixes_bound",
|
|
397
|
+
),
|
|
381
398
|
}
|
|
382
399
|
|
|
383
400
|
|
|
@@ -558,6 +575,11 @@ class FlagEndToEndTests(unittest.TestCase):
|
|
|
558
575
|
self._fixes_for("--fix-unterminated-attr")["fix_unterminated_attr"], 1
|
|
559
576
|
)
|
|
560
577
|
|
|
578
|
+
def test_fix_unbound_prefix(self):
|
|
579
|
+
self.assertGreaterEqual(
|
|
580
|
+
self._fixes_for("--fix-unbound-prefix")["unbound_prefixes_bound"], 1
|
|
581
|
+
)
|
|
582
|
+
|
|
561
583
|
|
|
562
584
|
if __name__ == "__main__":
|
|
563
585
|
unittest.main()
|
|
@@ -46,6 +46,7 @@ DEST_TO_FIELD = {
|
|
|
46
46
|
"fix_misnested_inline": "misnested_inline",
|
|
47
47
|
"fix_stray_close": "stray_close",
|
|
48
48
|
"fix_unterminated_attr": "unterminated_attr",
|
|
49
|
+
"fix_unbound_prefix": "unbound_prefix",
|
|
49
50
|
"strip_pagination": "strip_pagination",
|
|
50
51
|
"strip_broken_tags": "strip_brokentags",
|
|
51
52
|
"strip_watermarks": "strip_watermarks",
|
|
@@ -120,6 +120,63 @@ class OpfTagMatchersSpin(unittest.TestCase):
|
|
|
120
120
|
self.assertLess(elapsed, 5.0)
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
class UnboundPrefixTokenizerSpin(unittest.TestCase):
|
|
124
|
+
"""fix_unbound_prefixes' tokenizer (issue #9) walks every OPF it runs
|
|
125
|
+
on: a scarred document that ends inside an unterminated tag or comment
|
|
126
|
+
must fail linearly, and its success shapes must carve at real tag
|
|
127
|
+
boundaries."""
|
|
128
|
+
|
|
129
|
+
def test_unterminated_tag_spin_shape(self):
|
|
130
|
+
from bindery.transforms import fix_unbound_prefixes
|
|
131
|
+
|
|
132
|
+
opf = "<package><metadata><dc:identifier ns0:scheme='" + _apostrophe_run(500)
|
|
133
|
+
signal.alarm(20)
|
|
134
|
+
try:
|
|
135
|
+
t0 = time.perf_counter()
|
|
136
|
+
fix_unbound_prefixes(opf)
|
|
137
|
+
elapsed = time.perf_counter() - t0
|
|
138
|
+
finally:
|
|
139
|
+
signal.alarm(0)
|
|
140
|
+
self.assertLess(elapsed, 5.0)
|
|
141
|
+
|
|
142
|
+
def test_unterminated_comment_spin_shape(self):
|
|
143
|
+
from bindery.transforms import fix_unbound_prefixes
|
|
144
|
+
|
|
145
|
+
opf = "<package><metadata><!-- " + _apostrophe_run(500)
|
|
146
|
+
signal.alarm(20)
|
|
147
|
+
try:
|
|
148
|
+
t0 = time.perf_counter()
|
|
149
|
+
fix_unbound_prefixes(opf)
|
|
150
|
+
elapsed = time.perf_counter() - t0
|
|
151
|
+
finally:
|
|
152
|
+
signal.alarm(0)
|
|
153
|
+
self.assertLess(elapsed, 5.0)
|
|
154
|
+
|
|
155
|
+
def test_tokenizer_success_shapes(self):
|
|
156
|
+
tok = transforms._OPF_TOKEN_RE
|
|
157
|
+
m = tok.search('<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">')
|
|
158
|
+
self.assertIsNotNone(m)
|
|
159
|
+
self.assertEqual(m.group(3), "metadata")
|
|
160
|
+
# a bare attribute-less tag keeps its full name (a mandatory-one-char
|
|
161
|
+
# body loop would steal the final name character and orphan the
|
|
162
|
+
# end-tag match)
|
|
163
|
+
m = tok.search("<metadata>")
|
|
164
|
+
self.assertEqual((m.group(3), m.group(4)), ("metadata", ""))
|
|
165
|
+
m = tok.search("</dc:identifier>")
|
|
166
|
+
self.assertEqual(m.group(1), "dc:identifier")
|
|
167
|
+
# a `>` inside a quoted attribute stays attribute content; the
|
|
168
|
+
# self-closing slash lands in the body (the catch-all is disjoint
|
|
169
|
+
# from `>` but not from `/`), where the transform reads it
|
|
170
|
+
m = tok.search('<item href="a>b.xhtml"/>')
|
|
171
|
+
self.assertIsNotNone(m)
|
|
172
|
+
self.assertTrue(m.group(4).rstrip().endswith("/"))
|
|
173
|
+
# comments and CDATA are opaque tokens with no tag groups
|
|
174
|
+
m = tok.search("<!-- <dc:x ns1:y='z'></dc:x> -->")
|
|
175
|
+
self.assertIsNotNone(m)
|
|
176
|
+
self.assertIsNone(m.group(1))
|
|
177
|
+
self.assertIsNone(m.group(3))
|
|
178
|
+
|
|
179
|
+
|
|
123
180
|
class LazyMatcherDisjointBranch(unittest.TestCase):
|
|
124
181
|
"""The lazy matchers (_VOID_RE, the img/link/a tag matchers) now use a
|
|
125
182
|
disjoint branch 3 ([^>"']): an unterminated quote inside a tag can no
|
|
@@ -24,6 +24,7 @@ from bindery.transforms import (
|
|
|
24
24
|
fix_ncx_src_fragments,
|
|
25
25
|
fix_stray_close,
|
|
26
26
|
fix_svg_dup_ids,
|
|
27
|
+
fix_unbound_prefixes,
|
|
27
28
|
fix_unterminated_attr,
|
|
28
29
|
self_close_void,
|
|
29
30
|
strip_broken_tags,
|
|
@@ -834,6 +835,189 @@ class TestFixUnterminatedAttr(unittest.TestCase):
|
|
|
834
835
|
self.assertEqual((twice, n2), (once, 0))
|
|
835
836
|
|
|
836
837
|
|
|
838
|
+
def _scarred_opf(scar: str, healthy: str = "") -> str:
|
|
839
|
+
"""The Century Rain shape (issue #9): an EPUB2 package whose OPF
|
|
840
|
+
namespace is the DEFAULT namespace, a healthy sibling declaring ns0
|
|
841
|
+
inline, and one scarred element."""
|
|
842
|
+
return (
|
|
843
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
844
|
+
'<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="ID1" '
|
|
845
|
+
'version="2.0">\n'
|
|
846
|
+
' <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">\n'
|
|
847
|
+
f" {healthy}\n"
|
|
848
|
+
f" {scar}\n"
|
|
849
|
+
" </metadata>\n"
|
|
850
|
+
"</package>"
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
class TestFixUnboundPrefixes(unittest.TestCase):
|
|
855
|
+
SCAR = '<dc:identifier id="ID1" ns0:scheme="calibre">uuid-x</dc:identifier>'
|
|
856
|
+
HEALTHY = (
|
|
857
|
+
'<dc:creator xmlns:ns0="http://www.idpf.org/2007/opf" '
|
|
858
|
+
'ns0:role="aut">A</dc:creator>'
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
def test_sibling_declaration_bound_inline(self):
|
|
862
|
+
# The fixture shape: ns0 is declared on the sibling creator, out of
|
|
863
|
+
# scope for the identifier; the fix declares it on the user.
|
|
864
|
+
doc = _scarred_opf(self.SCAR, self.HEALTHY)
|
|
865
|
+
out, n = fix_unbound_prefixes(doc)
|
|
866
|
+
self.assertEqual(n, 1)
|
|
867
|
+
self.assertIn(
|
|
868
|
+
'<dc:identifier xmlns:ns0="http://www.idpf.org/2007/opf" id="ID1" '
|
|
869
|
+
'ns0:scheme="calibre">',
|
|
870
|
+
out,
|
|
871
|
+
)
|
|
872
|
+
ET.fromstring(out)
|
|
873
|
+
|
|
874
|
+
def test_clean_document_byte_identical(self):
|
|
875
|
+
doc = _scarred_opf(
|
|
876
|
+
'<dc:identifier id="ID1">uuid-x</dc:identifier>',
|
|
877
|
+
self.HEALTHY,
|
|
878
|
+
)
|
|
879
|
+
out, n = fix_unbound_prefixes(doc)
|
|
880
|
+
self.assertEqual((out, n), (doc, 0))
|
|
881
|
+
|
|
882
|
+
def test_own_declaration_satisfies(self):
|
|
883
|
+
# An element declaring and using the prefix on itself is legal XML.
|
|
884
|
+
doc = _scarred_opf(
|
|
885
|
+
'<dc:identifier id="ID1" xmlns:ns0="http://www.idpf.org/2007/opf" '
|
|
886
|
+
'ns0:scheme="calibre">u</dc:identifier>',
|
|
887
|
+
self.HEALTHY,
|
|
888
|
+
)
|
|
889
|
+
self.assertEqual(fix_unbound_prefixes(doc), (doc, 0))
|
|
890
|
+
|
|
891
|
+
def test_ancestor_declaration_satisfies(self):
|
|
892
|
+
doc = _scarred_opf(
|
|
893
|
+
'<dc:identifier id="ID1" opf:scheme="calibre">u</dc:identifier>'
|
|
894
|
+
).replace(
|
|
895
|
+
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">',
|
|
896
|
+
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
|
897
|
+
'xmlns:opf="http://www.idpf.org/2007/opf">',
|
|
898
|
+
)
|
|
899
|
+
self.assertEqual(fix_unbound_prefixes(doc), (doc, 0))
|
|
900
|
+
|
|
901
|
+
def test_child_of_declarator_in_scope(self):
|
|
902
|
+
doc = _scarred_opf(
|
|
903
|
+
'<wrapper xmlns:ns7="http://www.idpf.org/2007/opf">'
|
|
904
|
+
'<dc:identifier id="ID1" ns7:scheme="calibre">u</dc:identifier>'
|
|
905
|
+
"</wrapper>"
|
|
906
|
+
)
|
|
907
|
+
self.assertEqual(fix_unbound_prefixes(doc), (doc, 0))
|
|
908
|
+
|
|
909
|
+
def test_commented_declaration_is_not_evidence(self):
|
|
910
|
+
# A commented-out xmlns spelling must never become binding evidence
|
|
911
|
+
# (the code-reviewer bug): with the only ns0 inside a comment, the
|
|
912
|
+
# prefix is declared nowhere, so rule 2 binds the OPF namespace on
|
|
913
|
+
# this DC element, not the junk URI from the comment.
|
|
914
|
+
doc = _scarred_opf(
|
|
915
|
+
self.SCAR,
|
|
916
|
+
'<!-- <dc:creator xmlns:ns0="http://comment.example/ns">X</dc:creator> -->',
|
|
917
|
+
)
|
|
918
|
+
out, n = fix_unbound_prefixes(doc)
|
|
919
|
+
self.assertEqual(n, 1)
|
|
920
|
+
self.assertIn(
|
|
921
|
+
'<dc:identifier xmlns:ns0="http://www.idpf.org/2007/opf" id="ID1"',
|
|
922
|
+
out,
|
|
923
|
+
)
|
|
924
|
+
# the comment itself is untouched
|
|
925
|
+
self.assertIn("comment.example", out)
|
|
926
|
+
|
|
927
|
+
def test_comment_conflict_does_not_poison_agreement(self):
|
|
928
|
+
# The inverse: a comment's conflicting URI must not flip the
|
|
929
|
+
# agreement check into refusing a fixable document.
|
|
930
|
+
doc = _scarred_opf(
|
|
931
|
+
self.SCAR,
|
|
932
|
+
self.HEALTHY
|
|
933
|
+
+ '<!-- <dc:publisher xmlns:ns0="http://other.example/ns">P</dc:publisher> -->',
|
|
934
|
+
)
|
|
935
|
+
out, n = fix_unbound_prefixes(doc)
|
|
936
|
+
self.assertEqual(n, 1)
|
|
937
|
+
self.assertIn('xmlns:ns0="http://www.idpf.org/2007/opf"', out)
|
|
938
|
+
|
|
939
|
+
def test_foreign_dc_binding_refuses_rule2_fallback(self):
|
|
940
|
+
# The literal dc: fallback fires only when the dc prefix is itself
|
|
941
|
+
# unbound; a dc bound to a foreign namespace is not Dublin Core, and
|
|
942
|
+
# the document is refused untouched.
|
|
943
|
+
doc = _scarred_opf(
|
|
944
|
+
'<dc:identifier id="ID1" ns0:scheme="calibre">u</dc:identifier>'
|
|
945
|
+
).replace(
|
|
946
|
+
'xmlns:dc="http://purl.org/dc/elements/1.1/"',
|
|
947
|
+
'xmlns:dc="http://foreign.example/not-dc"',
|
|
948
|
+
)
|
|
949
|
+
self.assertEqual(fix_unbound_prefixes(doc), (doc, 0))
|
|
950
|
+
|
|
951
|
+
def test_ambiguous_prefix_refuses_whole_document(self):
|
|
952
|
+
doc = _scarred_opf(
|
|
953
|
+
'<dc:identifier id="ID1" ns0:scheme="calibre">u</dc:identifier>',
|
|
954
|
+
'<dc:creator xmlns:ns0="http://www.idpf.org/2007/opf">A</dc:creator>'
|
|
955
|
+
'<dc:publisher xmlns:ns0="http://other.example/ns">P</dc:publisher>',
|
|
956
|
+
)
|
|
957
|
+
self.assertEqual(fix_unbound_prefixes(doc), (doc, 0))
|
|
958
|
+
|
|
959
|
+
def test_no_evidence_not_dc_refuses(self):
|
|
960
|
+
# An unbound prefix on a non-DC element with no document evidence:
|
|
961
|
+
# no deterministic URI, so the document is refused untouched.
|
|
962
|
+
doc = _scarred_opf('<meta ns9:thing="x" name="cover" content="c"/>')
|
|
963
|
+
self.assertEqual(fix_unbound_prefixes(doc), (doc, 0))
|
|
964
|
+
|
|
965
|
+
def test_never_declared_dc_element_gets_opf_ns(self):
|
|
966
|
+
# Rule 2: the scar with no surviving declaration at all; a Dublin
|
|
967
|
+
# Core element under an OPF-namespaced package binds the OPF
|
|
968
|
+
# namespace, the canonical EPUB 2 opf:scheme shape.
|
|
969
|
+
doc = _scarred_opf(
|
|
970
|
+
'<dc:identifier id="ID1" ns0:scheme="calibre">u</dc:identifier>'
|
|
971
|
+
)
|
|
972
|
+
out, n = fix_unbound_prefixes(doc)
|
|
973
|
+
self.assertEqual(n, 1)
|
|
974
|
+
self.assertIn('<dc:identifier xmlns:ns0="http://www.idpf.org/2007/opf"', out)
|
|
975
|
+
ET.fromstring(out)
|
|
976
|
+
|
|
977
|
+
def test_never_declared_without_opf_package_refuses(self):
|
|
978
|
+
# Rule 2 needs the package itself to carry the OPF namespace.
|
|
979
|
+
doc = _scarred_opf(
|
|
980
|
+
'<dc:identifier id="ID1" ns0:scheme="calibre">u</dc:identifier>'
|
|
981
|
+
).replace('xmlns="http://www.idpf.org/2007/opf" ', "")
|
|
982
|
+
self.assertEqual(fix_unbound_prefixes(doc), (doc, 0))
|
|
983
|
+
|
|
984
|
+
def test_xml_prefix_never_flagged(self):
|
|
985
|
+
doc = _scarred_opf('<dc:identifier id="ID1" xml:lang="en">u</dc:identifier>')
|
|
986
|
+
self.assertEqual(fix_unbound_prefixes(doc), (doc, 0))
|
|
987
|
+
|
|
988
|
+
def test_self_closing_element_bound(self):
|
|
989
|
+
doc = _scarred_opf(f'<dc:rights ns0:r="x"/>{self.SCAR}', self.HEALTHY)
|
|
990
|
+
out, n = fix_unbound_prefixes(doc)
|
|
991
|
+
self.assertEqual(n, 2)
|
|
992
|
+
self.assertIn('xmlns:ns0="http://www.idpf.org/2007/opf"', out)
|
|
993
|
+
ET.fromstring(out)
|
|
994
|
+
|
|
995
|
+
def test_comment_markup_never_touched(self):
|
|
996
|
+
doc = _scarred_opf(
|
|
997
|
+
f"{self.SCAR}",
|
|
998
|
+
self.HEALTHY + '<!-- <dc:x ns1:scheme="nope"></dc:x> -->',
|
|
999
|
+
)
|
|
1000
|
+
out, n = fix_unbound_prefixes(doc)
|
|
1001
|
+
self.assertEqual(n, 1)
|
|
1002
|
+
self.assertNotIn('xmlns:ns1="http://www.idpf.org/2007/opf" ', out)
|
|
1003
|
+
ET.fromstring(out)
|
|
1004
|
+
|
|
1005
|
+
def test_single_quoted_evidence(self):
|
|
1006
|
+
doc = _scarred_opf(
|
|
1007
|
+
self.SCAR,
|
|
1008
|
+
"<dc:creator xmlns:ns0='http://www.idpf.org/2007/opf'>A</dc:creator>",
|
|
1009
|
+
)
|
|
1010
|
+
out, n = fix_unbound_prefixes(doc)
|
|
1011
|
+
self.assertEqual(n, 1)
|
|
1012
|
+
ET.fromstring(out)
|
|
1013
|
+
|
|
1014
|
+
def test_idempotent(self):
|
|
1015
|
+
doc = _scarred_opf(self.SCAR, self.HEALTHY)
|
|
1016
|
+
once, _ = fix_unbound_prefixes(doc)
|
|
1017
|
+
twice, n2 = fix_unbound_prefixes(once)
|
|
1018
|
+
self.assertEqual((twice, n2), (once, 0))
|
|
1019
|
+
|
|
1020
|
+
|
|
837
1021
|
class TestCssProtectedTags(unittest.TestCase):
|
|
838
1022
|
def test_element_selector_protects(self):
|
|
839
1023
|
self.assertIn("st", css_protected_tags("st { color: red }"))
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|