bindery-cli 0.41.1__tar.gz → 0.42.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.41.1 → bindery_cli-0.42.0}/.github/workflows/publish.yml +5 -6
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/CLAUDE.md +1 -1
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/PKG-INFO +1 -1
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/patchnotes.md +7 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/project.done +7 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/pyproject.toml +1 -1
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/roadmap.md +31 -15
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/epub.py +10 -10
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/transforms.py +3 -3
- bindery_cli-0.42.0/tests/test_matcher_hardening.py +165 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/.gitignore +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/AGENTS.md +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/LICENSE +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/README.md +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/logo.svg +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/plugin/__init__.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/run_tests.sh +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/spec.md +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/cli.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/testing_facility/books_to_fix/.gitkeep +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_cli.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_epub.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_library.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_plugin.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_transforms.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_version.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/tests/test_watermark.py +0 -0
- {bindery_cli-0.41.1 → bindery_cli-0.42.0}/uv.lock +0 -0
|
@@ -73,13 +73,12 @@ jobs:
|
|
|
73
73
|
|
|
74
74
|
# The tag message IS the release's patchnotes entry (verbatim, per the
|
|
75
75
|
# tagging procedure), so the release body is extracted from the tag
|
|
76
|
-
# object rather than paraphrased here.
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
# back to the commit message).
|
|
76
|
+
# object rather than paraphrased here. The checkout is depth-1 and
|
|
77
|
+
# leaves a LIGHTWEIGHT local tag on the commit (whose %(contents) is
|
|
78
|
+
# the commit message, not the release entry), so the real annotated
|
|
79
|
+
# tag object is force-fetched from the origin before reading it.
|
|
81
80
|
- name: Fetch the tag object
|
|
82
|
-
run: git fetch --no-tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
|
81
|
+
run: git fetch --no-tags --force origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
|
83
82
|
|
|
84
83
|
- name: Extract the release body from the tag message
|
|
85
84
|
run: git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" > release-body.md
|
|
@@ -82,7 +82,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
82
82
|
|
|
83
83
|
## Layout
|
|
84
84
|
|
|
85
|
-
- `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. HAZARD (the 0.41.0 spin): the quote-aware tag-prefix alternation (double-quoted | single-quoted | [^>]) OVERLAPS ([^>] matches quotes too), so a plain `*` re-partitions exponentially on failing candidates; every such loop must carry the possessive `*+` (re 3.11+, the plugin's minimum Calibre). Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
|
|
85
|
+
- `src/bindery/transforms.py`: pure `str -> (str, int)` text transforms (including `strip_broken_tags`). Since v0.32.0: `strip_attrs_in_start_tags` anchors attribute edits on the quote-aware start-tag matcher, `_outside_protected` forwards arguments (context-carrying transforms can be decorated), `fix_id_colons` matches only the bare `id` attribute and only internal fragments (`fix_ncx_src_fragments` carries renames into the NCX), `fix_ncx_playorder` is anchored to `<navPoint>` start tags, and the css selector boundary covers namespaced and functional selector forms. HAZARD (the 0.41.0 spin): the quote-aware tag-prefix alternation (double-quoted | single-quoted | [^>]) OVERLAPS ([^>] matches quotes too), so a plain `*` re-partitions exponentially on failing candidates; every such greedy loop must carry the possessive `*+` (re 3.11+, the plugin's minimum Calibre). Since v0.42.0 the census is complete and pinned by tests/test_matcher_hardening.py: every greedy loop of this family is possessive (the three 0.41.1 rewrites plus _START_TAG_RE, _COVER_META_RE, _GUIDE_REF_RE, prune_dangling_edges' spine|item|itemref matcher, _ITEM_TAG_RE, _SPINE_ITEM_RE -- possessive is byte-identical on success because the loop is always followed by a literal `>` the [^>] branch cannot consume), and the five LAZY siblings (_VOID_RE, the transforms <img> matcher, and epub's link/a/img matchers) use the disjoint catch-all `[^>"']` instead: mutually exclusive branches cannot re-partition, and an unterminated quote inside a tag now fails to match rather than pairing across markup. NEW tag matchers: quote-aware possessive for greedy loops, the disjoint catch-all for lazy ones, and the tempered-quote idiom (["'])((?:(?!\1).)*)\1 for attribute values. Since v0.37.0: `fix_ncx_playorder` strips quotes before its already-correct comparison (regex groups carry quote characters; comparing quoted values against bare numbers counted phantom fixes on every sequential NCX and rewrote single-quoted correct attributes to double quotes).
|
|
86
86
|
- `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
|
|
87
87
|
- `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
|
|
88
88
|
- `src/bindery/reserialize.py`: structural repair via `html5lib`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.42.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,4 +1,11 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
|
+
## v0.42.0 (2026-09-16)
|
|
3
|
+
|
|
4
|
+
### The matcher audit executed: every quote-aware tag matcher hardened
|
|
5
|
+
The follow-up box the 0.41.1 hotfix opened is closed with a full per-matcher census, and the audit found the box's own enumeration incomplete. Six GREEDY patterns of the exponential overlap class were unhardened -- `_START_TAG_RE` (the quote-aware start-tag matcher anchoring `--strip-epub3-attrs` and `--strip-bad-attrs`, running under `repair --all` on every content doc; not even named in the box), `_COVER_META_RE`, `_GUIDE_REF_RE`, `prune_dangling_edges`' spine|item|itemref matcher, `_ITEM_TAG_RE`, and `_SPINE_ITEM_RE`. All six are possessive-bounded now: byte-identical on every successful match (the loop is always followed by a literal `>` that the `[^>]` branch cannot consume, so backtracking only ever happened on failure) and linear on failure. Five LAZY patterns of the quadratic class (`_VOID_RE`, the transforms `<img>` matcher, and epub's link/anchor/img matchers) took the disjoint catch-all `[^>"']` instead of a blind possessive swap: mutually exclusive branches cannot re-partition, so any quantifier is linear, and an unterminated quote inside a tag now fails to match instead of pairing with a distant quote and matching across markup. Every well-formed shape is byte-identical. The epub `_IMG_TAG_RE` is renamed `_PRUNE_IMG_TAG_RE` (it collided with the transforms matcher of the same name). Everything else in the package audited safe by construction: tempered-quote value matchers, two-branch quote alternations, `[^>]*` single-class prefixes (linear, not quote-aware -- accepted, documented), lazy paired-delimiter spans, and the analyzer tokenizers.
|
|
6
|
+
|
|
7
|
+
Pinned by `tests/test_matcher_hardening.py` (15 tests): alarm-guarded spin shapes per greedy site (OPF flavor, the truncated-OPF prune shape), unterminated-quote skips, and well-formed byte-identity. `CLAUDE.md`'s hazard note now carries the census and the rule for new matchers. The full suite is 491 tests.
|
|
8
|
+
|
|
2
9
|
## v0.41.1 (2026-09-15)
|
|
3
10
|
|
|
4
11
|
### The 0.41.0 CPU-spin regression: possessive bounds on the quote-aware tag matchers
|
|
@@ -4,6 +4,13 @@ Completed: 2026-09-15 (THE FINAL BLITZ, repo agent lane)
|
|
|
4
4
|
Range: 2b9f24e (analyzer trio) through f47fc8f (ci tag-fetch fix), on main,
|
|
5
5
|
pushed; CI green throughout; v0.41.0 tagged and published to PyPI.
|
|
6
6
|
|
|
7
|
+
POSTSCRIPT 2026-09-15 (same day, Brandon's regression report): v0.41.1
|
|
8
|
+
hotfixed the one real defect the blitz shipped, the catastrophic-backtracking
|
|
9
|
+
CPU spin in `repair --all`'s rewritten quote-aware span matcher (the Physics
|
|
10
|
+
fixture; Brandon bisected and root-caused it). Fix: possessive quantifiers
|
|
11
|
+
on all three rewritten tag-prefix patterns; verified on his fixture against
|
|
12
|
+
the 0.40.0 output shape; the follow-up matcher-audit box is in the roadmap.
|
|
13
|
+
|
|
7
14
|
## What shipped
|
|
8
15
|
|
|
9
16
|
- The analyzer-robustness trio: spineless-EPUB verdicts, per-book try
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.42.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 = [
|
|
@@ -1642,7 +1642,7 @@ four interpreter families.
|
|
|
1642
1642
|
(the oldest series whose embedded interpreter CI covers; the hollow
|
|
1643
1643
|
(2, 0, 0) is gone) and the README plugin section documents platforms
|
|
1644
1644
|
(Linux-tested) and the verification story.
|
|
1645
|
-
- [
|
|
1645
|
+
- [x] **Analyzer robustness on untrusted input:** *(SHIPPED in v0.41.0 -- the analyzer-robustness trio; verified 2026-09-16 by the post-blitz audit run: per-book verdicts over truncated/renamed-zip/zero-byte fixtures with no run abort.)* ** spine_integrity
|
|
1646
1646
|
IndexErrors on a spineless EPUB (nums[0] with an empty list) and
|
|
1647
1647
|
the call sites sit OUTSIDE the per-book try - one malformed book
|
|
1648
1648
|
aborts a whole run; load_book raises NameError (encrypted_names
|
|
@@ -1671,7 +1671,7 @@ four interpreter families.
|
|
|
1671
1671
|
--encode-url-spaces entry renames (the reference-rewrite half
|
|
1672
1672
|
shipped v0.28.0; the remainder is renaming archive entries whose
|
|
1673
1673
|
filenames carry raw spaces; PKG-010, 280 books); plugin log rotation.
|
|
1674
|
-
- [
|
|
1674
|
+
- [x] **GitHub presentation (workspace batch):** *(EXECUTED and verified at the ticked box below (line ~1745) during the blitz; ticked 2026-09-16 to stop it reading as open work.)* ** description rewrite
|
|
1675
1675
|
(leads with deterministic epubcheck-gated repair); 7 topics swapped
|
|
1676
1676
|
(add epub3/calibre-plugin/python-cli/ebook-audit); codex page still
|
|
1677
1677
|
says calibredb; wiki off.
|
|
@@ -1690,7 +1690,7 @@ four interpreter families.
|
|
|
1690
1690
|
real 3.12/3.13 (258 tests per leg, verified locally under
|
|
1691
1691
|
3.12.14/3.13.15). Suite 445, ruff 0.16.2 clean.
|
|
1692
1692
|
|
|
1693
|
-
- [
|
|
1693
|
+
- [x] **Candidate (lossy lane): strip duplicate stub spine docs.** *(SHIPPED as --strip-stub-docs (the ticked box at ~1796); verified 2026-09-16: the no_worse gate correctly refuses a fabricated regression-stub.)* ** Opened
|
|
1694
1694
|
2026-09-13 from the phase-1 Redwall-fleet successor run: The River
|
|
1695
1695
|
Has Roots (bookmate platform conversion) carries 14 of 37 spine
|
|
1696
1696
|
docs that are identical stubs (the same short boilerplate body
|
|
@@ -1708,7 +1708,7 @@ four interpreter families.
|
|
|
1708
1708
|
(kept in ~/Downloads pending Brandon's import-or-re-source call);
|
|
1709
1709
|
the 2026-09-13 phase-1 JSON has the analyzer verdicts.
|
|
1710
1710
|
|
|
1711
|
-
- [
|
|
1711
|
+
- [x] **`run phase3`'s aggregate before/after summary mixes a rejected
|
|
1712
1712
|
candidate's projected after-state into the totals** (observed
|
|
1713
1713
|
2026-09-13, OMW batch): Ghost Brigades measured 0f/47e -> 1f/24e
|
|
1714
1714
|
REGRESSION and was correctly rejected, untouched; the only applied
|
|
@@ -1870,14 +1870,30 @@ four interpreter families.
|
|
|
1870
1870
|
earlier transforms move where failing candidates sit; the regex
|
|
1871
1871
|
was input-sensitive, not the flags broken. Unpin 0.40.0 once
|
|
1872
1872
|
0.41.1 is installed.
|
|
1873
|
-
- [
|
|
1874
|
-
overlap ambiguity** (
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
loop
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1873
|
+
- [x] **Audit the remaining quote-aware tag matchers for the same
|
|
1874
|
+
overlap ambiguity** *(EXECUTED 2026-09-16, v0.42.0 -- the full
|
|
1875
|
+
per-matcher census, one ruling each: the audit found the box's own
|
|
1876
|
+
enumeration incomplete. Six GREEDY exponential-class sites were
|
|
1877
|
+
unhardened, possessive-bounded now (success is byte-identical:
|
|
1878
|
+
the loop is always followed by a literal `>` that `[^>]` cannot
|
|
1879
|
+
consume): transforms.py `_START_TAG_RE` -- the true gap, not even
|
|
1880
|
+
named in the box, running under repair --all on every content doc;
|
|
1881
|
+
`_COVER_META_RE`; `_GUIDE_REF_RE`; prune_dangling_edges'
|
|
1882
|
+
spine|item|itemref matcher; `_ITEM_TAG_RE`; `_SPINE_ITEM_RE`.
|
|
1883
|
+
Five LAZY quadratic-class sites took the disjoint catch-all
|
|
1884
|
+
`[^>"']` instead of a blind possessive swap (a lazy `*?` ->
|
|
1885
|
+
`*+` is NOT byte-identical with the trailing `\s*(/?)>`
|
|
1886
|
+
continuation, which peels whitespace and the self-close slash):
|
|
1887
|
+
`_VOID_RE`, the transforms `<img>` matcher, and epub's
|
|
1888
|
+
link/a/img matchers -- mutually exclusive branches make any
|
|
1889
|
+
quantifier linear and fix the far-match wart: an unterminated
|
|
1890
|
+
quote inside a tag now fails to match instead of pairing across
|
|
1891
|
+
markup. The epub `_IMG_TAG_RE` renamed `_PRUNE_IMG_TAG_RE` (name
|
|
1892
|
+
collision with the transforms one). Everything else audited safe
|
|
1893
|
+
by construction: tempered-quote value matchers, two-branch
|
|
1894
|
+
quote alternations, `[^>]*` single-class prefixes (linear,
|
|
1895
|
+
not quote-aware -- accepted), lazy paired-delimiter spans, and
|
|
1896
|
+
the analyzer tokenizers. Pinned by tests/test_matcher_hardening.py:
|
|
1897
|
+
15 tests (spin shapes alarm-guarded per greedy site, unterminated-
|
|
1898
|
+
quote skips, well-formed byte-identity, prune spin over the
|
|
1899
|
+
truncated-OPF shape). Never a third grammar copy.)*
|
|
@@ -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.42.0"
|
|
15
15
|
__version__ = VERSION
|
|
16
16
|
|
|
17
17
|
from .epub import RepairReport, repair_epub # noqa: E402
|
|
@@ -316,14 +316,14 @@ _EDGE_ATTR_RES = (
|
|
|
316
316
|
re.compile(r"""\s+fallback\s*=\s*(?:"[^"]*"|'[^']*')""", re.IGNORECASE),
|
|
317
317
|
)
|
|
318
318
|
_COVER_META_RE = re.compile(
|
|
319
|
-
r"""<meta\b(?=(?:[^>]*\bname=)["']cover["'])(?:(?:"[^"]*"|'[^']*'|[^>])
|
|
319
|
+
r"""<meta\b(?=(?:[^>]*\bname=)["']cover["'])(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""",
|
|
320
320
|
re.IGNORECASE,
|
|
321
321
|
)
|
|
322
322
|
_COVER_CONTENT_RE = re.compile(
|
|
323
323
|
r"""\s+content\s*=\s*(["'])((?:(?!\1).)+)\1""", re.IGNORECASE
|
|
324
324
|
)
|
|
325
325
|
_GUIDE_REF_RE = re.compile(
|
|
326
|
-
r"""<reference\b(?:(?:"[^"]*"|'[^']*'|[^>])
|
|
326
|
+
r"""<reference\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""", re.IGNORECASE
|
|
327
327
|
)
|
|
328
328
|
|
|
329
329
|
|
|
@@ -421,7 +421,7 @@ def prune_dangling_edges(opf_text: str, pruned_ids: set[str]) -> tuple[str, int]
|
|
|
421
421
|
return tag
|
|
422
422
|
|
|
423
423
|
out = re.sub(
|
|
424
|
-
r"""<(?:spine|item|itemref)\b(?:(?:"[^"]*"|'[^']*'|[^>])
|
|
424
|
+
r"""<(?:spine|item|itemref)\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""",
|
|
425
425
|
strip_attrs,
|
|
426
426
|
opf_text,
|
|
427
427
|
flags=re.IGNORECASE,
|
|
@@ -659,21 +659,21 @@ _DROP_HREF_ATTR_RE = re.compile(r"""\s+href\s*=\s*(?:"[^"]*"|'[^']*')""", re.IGN
|
|
|
659
659
|
# shape as transforms._VOID_RE, so a '>' inside an attribute value cannot end
|
|
660
660
|
# the tag early.
|
|
661
661
|
_LINK_TAG_RE = re.compile(
|
|
662
|
-
r"""<link(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
|
|
662
|
+
r"""<link(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
|
|
663
663
|
re.IGNORECASE | re.DOTALL,
|
|
664
664
|
)
|
|
665
665
|
_ANCHOR_TAG_RE = re.compile(
|
|
666
|
-
r"""<a(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""", re.IGNORECASE | re.DOTALL
|
|
666
|
+
r"""<a(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""", re.IGNORECASE | re.DOTALL
|
|
667
667
|
)
|
|
668
|
-
|
|
669
|
-
r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
|
|
668
|
+
_PRUNE_IMG_TAG_RE = re.compile(
|
|
669
|
+
r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
|
|
670
670
|
re.IGNORECASE | re.DOTALL,
|
|
671
671
|
)
|
|
672
|
-
_ITEM_TAG_RE = re.compile(r"""<item\b(?:(?:"[^"]*"|'[^']*'|[^>])
|
|
672
|
+
_ITEM_TAG_RE = re.compile(r"""<item\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""", re.IGNORECASE)
|
|
673
673
|
# `<itemref` must not be swallowed by the `<item\b` matcher above (\b has no
|
|
674
674
|
# boundary before the 'r'), and its own idref carries the spine order.
|
|
675
675
|
_SPINE_ITEM_RE = re.compile(
|
|
676
|
-
r"""<itemref\b(?:(?:"[^"]*"|'[^']*'|[^>])
|
|
676
|
+
r"""<itemref\b(?:(?:"[^"]*"|'[^']*'|[^>])*+)>""", re.IGNORECASE
|
|
677
677
|
)
|
|
678
678
|
# `\b` is what keeps `<item\b` from matching `<itemref`: "itemref" has a word
|
|
679
679
|
# character right after "item", so there is no boundary there.
|
|
@@ -999,7 +999,7 @@ def prune_missing_images(
|
|
|
999
999
|
counts[key] = counts.get(key, 0) + 1
|
|
1000
1000
|
return alt if key == "missing_imgs_unwrapped" else ""
|
|
1001
1001
|
|
|
1002
|
-
return
|
|
1002
|
+
return _PRUNE_IMG_TAG_RE.sub(repl, part), counts
|
|
1003
1003
|
|
|
1004
1004
|
total: dict[str, int] = {}
|
|
1005
1005
|
if "<!" in text:
|
|
@@ -32,14 +32,14 @@ XML_PREDEFINED = {"amp", "lt", "gt", "quot", "apos"}
|
|
|
32
32
|
# quote-aware so a `>` inside an attribute value does not end the tag early. Group 3
|
|
33
33
|
# captures an existing trailing slash so already-self-closed tags are left untouched.
|
|
34
34
|
_VOID_RE = re.compile(
|
|
35
|
-
rf"""<({VOID})(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
|
|
35
|
+
rf"""<({VOID})(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
|
|
36
36
|
re.IGNORECASE | re.DOTALL,
|
|
37
37
|
)
|
|
38
38
|
_VOID_END_RE = re.compile(rf"""</(?:{VOID})\s*>""", re.IGNORECASE)
|
|
39
39
|
_NAMED_ENTITY_RE = re.compile(r"&([a-zA-Z][a-zA-Z0-9]*);")
|
|
40
40
|
_BARE_AMP_RE = re.compile(r"&(?![a-zA-Z][a-zA-Z0-9]*;|#[0-9]+;|#[xX][0-9a-fA-F]+;)")
|
|
41
41
|
# A start tag (quote-aware) and an attribute within it, for invalid-attribute stripping.
|
|
42
|
-
_START_TAG_RE = re.compile(r"""<[a-zA-Z][\w:.-]*(?:"[^"]*"|'[^']*'|[^>])
|
|
42
|
+
_START_TAG_RE = re.compile(r"""<[a-zA-Z][\w:.-]*(?:"[^"]*"|'[^']*'|[^>])*+>""")
|
|
43
43
|
# The unquoted-value branch stops at whitespace or the end of the tag, and the `/?>`
|
|
44
44
|
# lookahead is what keeps it off the tag's own self-closing slash: a plain `[^\s>]+`
|
|
45
45
|
# swallowed it, so dropping the attribute in `<img 31=x/>` left `<img>` and turned a
|
|
@@ -235,7 +235,7 @@ def escape_unknown_entities(s: str) -> tuple[str, int]:
|
|
|
235
235
|
# An <img> start tag, quote-aware like _VOID_RE; group 1 is the attribute block and
|
|
236
236
|
# group 2 an existing self-closing slash, so the tag is rebuilt without reordering.
|
|
237
237
|
_IMG_TAG_RE = re.compile(
|
|
238
|
-
r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
|
|
238
|
+
r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(/?)>""",
|
|
239
239
|
re.IGNORECASE | re.DOTALL,
|
|
240
240
|
)
|
|
241
241
|
# Whitespace before `alt` keeps data-alt/xml:alt-style names from matching; a quoted
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Regression net for the 2026-09-16 per-matcher hardening (the roadmap's
|
|
2
|
+
matcher-audit follow-up box, executed).
|
|
3
|
+
|
|
4
|
+
The 0.41.1 hotfix made three rewritten patterns possessive; the audit that
|
|
5
|
+
followed found six more greedy patterns of the same exponential class
|
|
6
|
+
(the quote-aware loop whose branches overlap) and five lazy patterns of
|
|
7
|
+
the quadratic class. The greedy loops are now possessive `*+` (byte-
|
|
8
|
+
identical on success: the loop is always followed by a literal `>` that
|
|
9
|
+
the `[^>]` branch cannot consume, so backtracking only ever happened on
|
|
10
|
+
failure). The lazy loops got a disjoint branch 3 (`[^>"']`: the catch-all
|
|
11
|
+
no longer eats quote characters), which makes re-partitioning impossible
|
|
12
|
+
and stops an unterminated quote inside a tag from pairing with a distant
|
|
13
|
+
quote and matching across markup.
|
|
14
|
+
|
|
15
|
+
A1 behavioral pins (success cases unchanged) and A2 pins (unterminated-
|
|
16
|
+
quote tags are now skipped rather than mis-paired) live beside the spin
|
|
17
|
+
guards here. The spin guard is the same shape as the 0.41.0 test: a
|
|
18
|
+
failing candidate over an apostrophe-rich run must finish, not hang."""
|
|
19
|
+
|
|
20
|
+
import signal
|
|
21
|
+
import time
|
|
22
|
+
import unittest
|
|
23
|
+
|
|
24
|
+
from bindery import epub, transforms
|
|
25
|
+
from bindery.transforms import _START_TAG_RE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _apostrophe_run(times: int = 2000) -> str:
|
|
29
|
+
return "don't can't won't it's kind of prose, isn't it " * times
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GreedyStartTagSpin(unittest.TestCase):
|
|
33
|
+
"""_START_TAG_RE runs under repair --all on every content doc: the same
|
|
34
|
+
input class as the Physics fixture (an apostrophe-rich run with no `>`
|
|
35
|
+
after the tag opener) must fail linearly."""
|
|
36
|
+
|
|
37
|
+
def test_spin_shape_fails_fast(self):
|
|
38
|
+
doc = "<p class='broken " + _apostrophe_run() + "and still no closer in sight"
|
|
39
|
+
signal.alarm(20)
|
|
40
|
+
try:
|
|
41
|
+
t0 = time.perf_counter()
|
|
42
|
+
m = _START_TAG_RE.search(doc)
|
|
43
|
+
elapsed = time.perf_counter() - t0
|
|
44
|
+
finally:
|
|
45
|
+
signal.alarm(0)
|
|
46
|
+
self.assertIsNone(m)
|
|
47
|
+
self.assertLess(elapsed, 5.0)
|
|
48
|
+
|
|
49
|
+
def test_success_shape_is_unchanged(self):
|
|
50
|
+
text = "<p class='x' data-y=\"a>b\">body</p>"
|
|
51
|
+
m = _START_TAG_RE.search(text)
|
|
52
|
+
self.assertEqual(m.group(0), "<p class='x' data-y=\"a>b\">")
|
|
53
|
+
# the `>` inside the quoted attribute stays attribute content
|
|
54
|
+
self.assertNotIn("body", m.group(0))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OpfTagMatchersSpin(unittest.TestCase):
|
|
58
|
+
"""The OPF matchers iterate manifest/spine/guide tags: a truncated OPF
|
|
59
|
+
that ends inside an apostrophe-rich attribute run must fail linearly."""
|
|
60
|
+
|
|
61
|
+
SPIN_PREFIX = "<item id='id1' href='text/part1.xhtml' media-type='application/xhtml+xml' extra='"
|
|
62
|
+
|
|
63
|
+
def _assert_fast_no_match(self, regex, text):
|
|
64
|
+
signal.alarm(20)
|
|
65
|
+
try:
|
|
66
|
+
t0 = time.perf_counter()
|
|
67
|
+
m = regex.search(text)
|
|
68
|
+
elapsed = time.perf_counter() - t0
|
|
69
|
+
finally:
|
|
70
|
+
signal.alarm(0)
|
|
71
|
+
self.assertIsNone(m)
|
|
72
|
+
self.assertLess(elapsed, 5.0)
|
|
73
|
+
|
|
74
|
+
def test_item_tag_spin_shape(self):
|
|
75
|
+
self._assert_fast_no_match(
|
|
76
|
+
epub._ITEM_TAG_RE, self.SPIN_PREFIX + _apostrophe_run(500)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def test_itemref_tag_spin_shape(self):
|
|
80
|
+
text = "<itemref idref='id1' extra='" + _apostrophe_run(500)
|
|
81
|
+
self._assert_fast_no_match(epub._SPINE_ITEM_RE, text)
|
|
82
|
+
|
|
83
|
+
def test_cover_meta_spin_shape(self):
|
|
84
|
+
text = '<meta name="cover" content="' + _apostrophe_run(500)
|
|
85
|
+
self._assert_fast_no_match(epub._COVER_META_RE, text)
|
|
86
|
+
|
|
87
|
+
def test_guide_reference_spin_shape(self):
|
|
88
|
+
text = "<reference type='text' title='" + _apostrophe_run(500)
|
|
89
|
+
self._assert_fast_no_match(epub._GUIDE_REF_RE, text)
|
|
90
|
+
|
|
91
|
+
def test_success_shapes_unchanged(self):
|
|
92
|
+
opf = (
|
|
93
|
+
'<package><item id="id1" href="a.xhtml" media-type="application/xhtml+xml"/>'
|
|
94
|
+
"<itemref idref='id1'/>"
|
|
95
|
+
'<meta name="cover" content="cover-image"/>'
|
|
96
|
+
"<reference type='text' title='t' href='a.xhtml'/></package>"
|
|
97
|
+
)
|
|
98
|
+
item = epub._ITEM_TAG_RE.search(opf)
|
|
99
|
+
self.assertEqual(
|
|
100
|
+
item.group(0),
|
|
101
|
+
'<item id="id1" href="a.xhtml" media-type="application/xhtml+xml"/>',
|
|
102
|
+
)
|
|
103
|
+
self.assertIsNotNone(epub._SPINE_ITEM_RE.search(opf))
|
|
104
|
+
self.assertIsNotNone(epub._COVER_META_RE.search(opf))
|
|
105
|
+
self.assertIsNotNone(epub._GUIDE_REF_RE.search(opf))
|
|
106
|
+
|
|
107
|
+
def test_prune_dangling_edges_spin_shape(self):
|
|
108
|
+
# prune_dangling_edges' inline spine|item|itemref matcher over a
|
|
109
|
+
# truncated OPF: linear failure, no hang.
|
|
110
|
+
opf = '<package><spine toc="ncx"><itemref idref=\'broken ' + _apostrophe_run(
|
|
111
|
+
500
|
|
112
|
+
)
|
|
113
|
+
signal.alarm(20)
|
|
114
|
+
try:
|
|
115
|
+
t0 = time.perf_counter()
|
|
116
|
+
epub.prune_dangling_edges(opf, {"gone"})
|
|
117
|
+
elapsed = time.perf_counter() - t0
|
|
118
|
+
finally:
|
|
119
|
+
signal.alarm(0)
|
|
120
|
+
self.assertLess(elapsed, 5.0)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LazyMatcherDisjointBranch(unittest.TestCase):
|
|
124
|
+
"""The lazy matchers (_VOID_RE, the img/link/a tag matchers) now use a
|
|
125
|
+
disjoint branch 3 ([^>"']): an unterminated quote inside a tag can no
|
|
126
|
+
longer pair with a distant quote, so the mis-pairing match is gone.
|
|
127
|
+
Every well-formed shape is byte-identical."""
|
|
128
|
+
|
|
129
|
+
def test_void_unterminated_quote_skipped(self):
|
|
130
|
+
# OLD: this matched, pairing the quote with a distant one and
|
|
131
|
+
# mis-styling markup after the tag. NEW: no match, text untouched.
|
|
132
|
+
text = '<br x="a> and more prose follows here'
|
|
133
|
+
self.assertIsNone(transforms._VOID_RE.search(text))
|
|
134
|
+
|
|
135
|
+
def test_void_wellformed_shapes_still_match(self):
|
|
136
|
+
for tag in ("<br/>", "<br />", '<br class="x"/>', "<hr class='x' />"):
|
|
137
|
+
self.assertIsNotNone(transforms._VOID_RE.search(tag), tag)
|
|
138
|
+
|
|
139
|
+
def test_img_unterminated_quote_skipped(self):
|
|
140
|
+
text = '<img src="a> more prose'
|
|
141
|
+
self.assertIsNone(transforms._IMG_TAG_RE.search(text))
|
|
142
|
+
|
|
143
|
+
def test_img_wellformed_rebuild_identical(self):
|
|
144
|
+
tag = '<img src="pic.png" alt="a photo" />'
|
|
145
|
+
out, count = transforms.add_img_alt("<html><body>" + tag + "</body></html>")
|
|
146
|
+
self.assertEqual(count, 0) # alt already present; tag untouched
|
|
147
|
+
self.assertIn(tag, out)
|
|
148
|
+
|
|
149
|
+
def test_prune_img_unterminated_quote_skipped(self):
|
|
150
|
+
text = '<img src="a> more prose'
|
|
151
|
+
self.assertIsNone(epub._PRUNE_IMG_TAG_RE.search(text))
|
|
152
|
+
|
|
153
|
+
def test_link_and_anchor_unterminated_quote_skipped(self):
|
|
154
|
+
self.assertIsNone(epub._LINK_TAG_RE.search('<link href="a> prose'))
|
|
155
|
+
self.assertIsNone(epub._ANCHOR_TAG_RE.search("<a href='a> prose"))
|
|
156
|
+
|
|
157
|
+
def test_link_and_anchor_wellformed_match(self):
|
|
158
|
+
self.assertIsNotNone(
|
|
159
|
+
epub._LINK_TAG_RE.search('<link href="a.css" rel="stylesheet"/>')
|
|
160
|
+
)
|
|
161
|
+
self.assertIsNotNone(epub._ANCHOR_TAG_RE.search('<a href="b.xhtml">text</a>'))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
unittest.main()
|
|
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
|