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.
Files changed (52) hide show
  1. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/AGENTS.md +18 -4
  2. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/PKG-INFO +1 -1
  3. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/patchnotes.md +42 -0
  4. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/pyproject.toml +1 -1
  5. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/roadmap.md +1 -0
  6. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/spec.md +18 -4
  7. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/__init__.py +1 -1
  8. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/cli.py +29 -1
  9. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/epub.py +20 -0
  10. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/transforms.py +160 -0
  11. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_cli.py +42 -0
  12. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_epub.py +63 -0
  13. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_flags_real_core.py +22 -0
  14. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_flags_wiring.py +1 -0
  15. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_matcher_hardening.py +57 -0
  16. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_transforms.py +184 -0
  17. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/uv.lock +1 -1
  18. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/.github/FUNDING.yml +0 -0
  19. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/.github/workflows/ci.yml +0 -0
  20. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/.github/workflows/publish.yml +0 -0
  21. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/.gitignore +0 -0
  22. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/LICENSE +0 -0
  23. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/README.md +0 -0
  24. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/docs/screenshots/library-sweep.png +0 -0
  25. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/logo.svg +0 -0
  26. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/plugin/__init__.py +0 -0
  27. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/project.done +0 -0
  28. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/run_tests.sh +0 -0
  29. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/scripts/FastSweep.java +0 -0
  30. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/scripts/build_plugin.py +0 -0
  31. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/scripts/fast_sweep.py +0 -0
  32. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/__main__.py +0 -0
  33. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/audit.py +0 -0
  34. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/library.py +0 -0
  35. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/pagination.py +0 -0
  36. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/reserialize.py +0 -0
  37. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/validate.py +0 -0
  38. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/src/bindery/watermark.py +0 -0
  39. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_audit.py +0 -0
  40. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_comment_hyphens.py +0 -0
  41. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_contamination.py +0 -0
  42. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_cover_tocdrift.py +0 -0
  43. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_help.py +0 -0
  44. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_library.py +0 -0
  45. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_pagination.py +0 -0
  46. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_plugin.py +0 -0
  47. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_reserialize.py +0 -0
  48. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_space_renames.py +0 -0
  49. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_validate.py +0 -0
  50. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_version.py +0 -0
  51. {bindery_cli-0.50.0 → bindery_cli-0.51.0}/tests/test_watermark.py +0 -0
  52. {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-one structural
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. Changing either bar means re-running the library dry run.
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.50.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.50.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-one
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
- Nineteen of the twenty-one are evaluated by the normal `gate`: unlike the lossy strips,
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.50.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 (("&lt;", "<"), ("&gt;", ">"), ("&quot;", '"'), ("&apos;", "'")):
1021
+ v = v.replace(ent, ch)
1022
+ return v.replace("&amp;", "&")
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 }"))
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "bindery-cli"
11
- version = "0.50.0"
11
+ version = "0.51.0"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "cquarry", marker = "python_full_version >= '3.14'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes