bindery-cli 0.47.0__tar.gz → 0.47.1__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 (51) hide show
  1. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/CLAUDE.md +1 -1
  2. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/PKG-INFO +1 -1
  3. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/patchnotes.md +34 -0
  4. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/pyproject.toml +1 -1
  5. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/__init__.py +1 -1
  6. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/cli.py +459 -273
  7. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_cli.py +82 -0
  8. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/uv.lock +1 -1
  9. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/.github/FUNDING.yml +0 -0
  10. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/.github/workflows/ci.yml +0 -0
  11. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/.github/workflows/publish.yml +0 -0
  12. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/.gitignore +0 -0
  13. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/AGENTS.md +0 -0
  14. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/LICENSE +0 -0
  15. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/README.md +0 -0
  16. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/docs/screenshots/library-sweep.png +0 -0
  17. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/logo.svg +0 -0
  18. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/plugin/__init__.py +0 -0
  19. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/project.done +0 -0
  20. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/roadmap.md +0 -0
  21. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/run_tests.sh +0 -0
  22. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/scripts/FastSweep.java +0 -0
  23. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/scripts/build_plugin.py +0 -0
  24. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/scripts/fast_sweep.py +0 -0
  25. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/spec.md +0 -0
  26. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/__main__.py +0 -0
  27. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/audit.py +0 -0
  28. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/epub.py +0 -0
  29. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/library.py +0 -0
  30. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/pagination.py +0 -0
  31. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/reserialize.py +0 -0
  32. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/transforms.py +0 -0
  33. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/validate.py +0 -0
  34. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/src/bindery/watermark.py +0 -0
  35. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_audit.py +0 -0
  36. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_comment_hyphens.py +0 -0
  37. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_cover_tocdrift.py +0 -0
  38. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_epub.py +0 -0
  39. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_flags_real_core.py +0 -0
  40. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_flags_wiring.py +0 -0
  41. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_library.py +0 -0
  42. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_matcher_hardening.py +0 -0
  43. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_pagination.py +0 -0
  44. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_plugin.py +0 -0
  45. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_reserialize.py +0 -0
  46. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_space_renames.py +0 -0
  47. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_transforms.py +0 -0
  48. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_validate.py +0 -0
  49. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_version.py +0 -0
  50. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_watermark.py +0 -0
  51. {bindery_cli-0.47.0 → bindery_cli-0.47.1}/tests/test_xml_hardening.py +0 -0
@@ -118,7 +118,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
118
118
  - `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan). Since v0.46.0 the opt-in selection is the `RepairFlags` dataclass (one field per flag, all off by default; the all-off instance IS the default pass): `repair_epub(src, dst, flags)` and `process_book(..., flags)` take one object instead of a ~25-kwarg signature, the EPUB2-targeted fixes' version gate swaps in a `dataclasses.replace` copy (never mutating the caller's instance), and the plugin's bare `repair_epub(src, dst)` call constructs exactly the all-off default pass. Since v0.38.0/v0.39.0 (Phase 16): `generate_container` + the `--fix-container` gateway (insert after mimetype when missing, replace in-loop when stale; constant-epoch timestamp keeps bytes deterministic), `prune_dangling_edges` (prune_missing_manifest_items returns the pruned ids and the edges that pointed at them (spine@toc, media-overlay, fallback, EPUB2 cover meta) are rewritten so pruning cannot manufacture the regression the gate rejects), and `fix_manifest_media_types` (extension map + magic-byte confirmation via a `peek` callable over the open source zip; jpg/png/gif only), and `fix_cover_meta` (the ruling's deterministic half: dangling EPUB2 cover meta re-pointed from the guide's cover reference when exactly one manifest item carries that file, removed otherwise; accepted under no_worse like the lossy strips because cover wiring is invisible to epubcheck).
119
119
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
120
120
  - `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched: never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book. Since 2026-09-15 `make_backup` takes the opt-in `--backup-keep N` ring: at most N backup files per book, the author original `.bak` never deleted, newest content at the highest name; default remains an unbounded rotation.
121
- - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; `repair --json` emits the library per-book record vocabulary (2026-09-15; since v0.45.0 every per-book record, the phase1 payload included, also carries the structured fix breakdown: the `fixes` dict plus `ncx_uid_synced`/`watermark_refusals` off the `Outcome`, and `_phase1_decisions` reads that data instead of substring-matching the rendered `summary`, which stays for human eyes; CalibreQuarry's lossy-consent mirror is the waiting consumer, floor `bindery>=0.45.0`); since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2; since 2026-09-15 the summary sums applied/equal states only and carries rejected projections as `rejected_projection`). Plus `bindery doctor` (2026-09-15): the environment self-check that imports none of the VirInvictus stack, never raises, and always exits 0. Both run verbs take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. Since v0.46.0 `cli._flags_from_args(args)` is the one place the argparse names map onto `RepairFlags` fields (each `or --all`), shared by `run_library` and `run_repair`; the old per-verb ~25-kwarg blocks are gone. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract). Since v0.40.0 `main()` guards the VirInvictus stack: a `ModuleNotFoundError` naming `vir_tui`/`cquarry` (marker-gated to 3.14+ on the 3.12 floor) becomes one stderr line and trouble exit 2 instead of a traceback; anything else missing still raises.
121
+ - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; `repair --json` emits the library per-book record vocabulary (2026-09-15; since v0.45.0 every per-book record, the phase1 payload included, also carries the structured fix breakdown: the `fixes` dict plus `ncx_uid_synced`/`watermark_refusals` off the `Outcome`, and `_phase1_decisions` reads that data instead of substring-matching the rendered `summary`, which stays for human eyes; CalibreQuarry's lossy-consent mirror is the waiting consumer, floor `bindery>=0.45.0`); since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2; since 2026-09-15 the summary sums applied/equal states only and carries rejected projections as `rejected_projection`). Plus `bindery doctor` (2026-09-15): the environment self-check that imports none of the VirInvictus stack, never raises, and always exits 0. Both run verbs take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. Since v0.46.0 `cli._flags_from_args(args)` is the one place the argparse names map onto `RepairFlags` fields (each `or --all`), shared by `run_library` and `run_repair`; the old per-verb ~25-kwarg blocks are gone. Since v0.47.1 the help is two-level and the repair-flag inventory is one table: `_REPAIR_FLAG_TABLE` (dest + one-line short help + long reference text) registers the compact grouped `--help` (argparse argument groups, hand-short usage line) and `--help-repairs` renders the long descriptions (a custom argparse Action, deliberately NOT store_true so the wiring test's selection count ignores it); adding a flag means adding a table row. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract). Since v0.40.0 `main()` guards the VirInvictus stack: a `ModuleNotFoundError` naming `vir_tui`/`cquarry` (marker-gated to 3.14+ on the 3.12 floor) becomes one stderr line and trouble exit 2 instead of a traceback; anything else missing still raises.
122
122
  - `plugin/__init__.py` + `scripts/build_plugin.py`: the Bindery Repair Calibre plugin (v0.37.0; identity `Bindery Repair` / `bindery_repair`, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendors `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, `reserialize.py` byte-identically into the zip (the zip root is a package, so their relative imports resolve unchanged; the suite's drift test pins the equality) with the version tuple substituted from the single-source VERSION; `publish.yml` attaches `BinderyRepair-v<VERSION>.zip` to each release. The plugin runs ONLY the default pass (epubcheck cannot gate inside Calibre), never raises, is byte-idempotent (zero fixes returns the original path), testzip()-verifies its output, logs one line per book, and takes JSON config via `site_customization` (`log`, `log_path`, `max_size_mb` default 150, `max_log_mb` rotation cap default 2, `epubcheck_path` experimental validation default OFF). Load it in tests through the `tests/test_plugin.py` stubs: the calibre namespace is installed in sys.modules and the package loads with a two-dir `__path__` (plugin dir first, then src/bindery) exactly like calibre's zipplugin loader shape. **Plugin/interpreter compatibility (v0.40.0):** v0.39.0 shipped PEP 758 unparenthesized except tuples, and the plugin died with a bare SyntaxError on every released Calibre (7.x and the whole 8.x series embed Python 3.11, 9.0+ embeds 3.14; from calibre's bypy sources.json, checked against the upstream clone). The core is now grammar-pinned: parenthesized except tuples everywhere, `ruff`'s `target-version = "py312"` pin (inference from requires-python would rewrite the parens back off), `tests/test_version.py`'s parse guard, and CI's `plugin-compat` job byte-compiling the built zip under 3.11/3.12/3.13/3.14. `minimum_calibre_version` is (7, 0, 0): the oldest series whose interpreter CI covers, replacing the hollow (2, 0, 0); `supported_platforms` stays `["linux"]` (developed and tested on Linux only). Any new syntax in the vendored modules must clear the guard before it ships.
123
123
  - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, the version pin and the syntax-floor grammar guard, and the Calibre plugin (build drift + run behavior under calibre stubs). Since the v0.46.0 follow-ups: `tests/test_flags_wiring.py` reflectively pins the RepairFlags inventory to the argparse dests and `_flags_from_args` mapping (a flag added without a field, a field without a flag, or a remapped line fails there), the all-off defaults, and the version gate's caller-immutability contract (test_epub's `TestVersionGateKeepsFlagsIntact`); `tests/test_comment_hyphens.py` carries the RSC-016 fix; `tests/test_flags_real_core.py` drives every repair flag from argv through the unmocked core (--no-validate; the gate itself stays pinned by the mocked-oracle tests and runs for real wherever epubcheck exists). All three ride CI's core-compat 3.12/3.13 leg; the hand-listed module list there must gain an entry for every new stack-free test module.
124
124
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.47.0
3
+ Version: 0.47.1
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,39 @@
1
1
  # bindery-cli Patch Notes
2
2
 
3
+ ## v0.47.1 (2026-10-06)
4
+
5
+ ### The two-level help: compact grouped --help, full --help-repairs reference
6
+
7
+ The per-verb help had grown into a wall: a 16-line usage line listing all 27
8
+ repair flags, then roughly 30 paragraph-length descriptions in one flat
9
+ options list with the safe, structural, and lossy classes visually
10
+ undifferentiated, and the shared set printed a third time in the top-level
11
+ help. Flag names, semantics, and the argparse surface are untouched
12
+ (CalibreQuarry's consent mirror drives the shipped parser); this release
13
+ reorganizes how they are presented.
14
+
15
+ * One inventory, two renderings: the repair flags now live in a single
16
+ table (dest, one-line short help, long reference text) that registers
17
+ the compact `--help` into titled argparse groups and renders
18
+ `--help-repairs` as the full reference with every flag's long
19
+ description. Structural repairs are grouped by defect class (markup and
20
+ nesting; references and resources; package wiring), and adding a flag
21
+ now means adding a table row.
22
+ * Short usage lines replace the flag wall (`bindery repair [options] path
23
+ [output]`), repair and library carry examples epilogs, and the
24
+ top-level help becomes a verb list plus a pointer to the two levels.
25
+ * The four lossy strips are consistently `LOSSY:`-prefixed (two of four
26
+ carried it before) and live in their own warning-titled section ("these
27
+ delete converter-injected content; accepted on a no-worse bar").
28
+ * library's own 13 flags are grouped (run control, candidate selection,
29
+ backups, output and library integration) instead of one flat list.
30
+ * `--help-repairs` is a custom argparse action, deliberately not
31
+ store_true, so the wiring test's repair-selection count ignores it;
32
+ TestHelpLayout (five tests) pins the usage lines, group titles, LOSSY
33
+ prefixes, reference content, and that guarantee.
34
+
35
+ Tests: 620 (up from 615).
36
+
3
37
  ## v0.47.0 (2026-10-06)
4
38
 
5
39
  ### The intake-wave batch: five repair surfaces from five open issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.47.0"
7
+ version = "0.47.1"
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 = [
@@ -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.47.0"
14
+ VERSION = "0.47.1"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -10,6 +10,7 @@ import shutil
10
10
  import subprocess
11
11
  import sys
12
12
  import tempfile
13
+ import textwrap
13
14
  import zipfile
14
15
  from dataclasses import dataclass, field
15
16
  from itertools import islice
@@ -1345,228 +1346,388 @@ def run_phase3(args) -> int:
1345
1346
  return 2 if repair_rc != 0 else 0
1346
1347
 
1347
1348
 
1349
+ # The repair-flag inventory, one table, two renderings: `_add_repair_flags`
1350
+ # registers the short one-liners into argparse argument groups (the compact
1351
+ # `--help`), and `--help-repairs` renders the long descriptions as the full
1352
+ # reference. Adding a flag means adding a row here; the dests below are the
1353
+ # contract tests/test_flags_wiring.py pins.
1354
+ # Row shape: (dest, short one-liner, long reference text).
1355
+ _REPAIR_FLAG_TABLE: dict[str, list[tuple[str, str, str]]] = {
1356
+ "safe opt-ins (markup-neutral)": [
1357
+ (
1358
+ "fix_ids",
1359
+ "rewrite invalid OPF/NCX ids (RSC-005), every reference in sync",
1360
+ "rewrite ids that are not valid XML names (digit-led, colon-bearing, "
1361
+ "or carrying any character outside the XML NameChar set, like the "
1362
+ "apostrophes calibre copies from filenames) in the OPF manifest and "
1363
+ "the NCX, updating every reference to them (spine idref, spine toc, "
1364
+ "item fallback, media-overlay, the EPUB 2 cover meta). The dc: "
1365
+ "metadata is never altered.",
1366
+ ),
1367
+ (
1368
+ "add_img_alt",
1369
+ 'add alt="" to <img> elements missing the required attribute',
1370
+ 'add alt="" to <img> elements missing the required attribute. '
1371
+ "Renders identically, but it adds markup the author never wrote, and "
1372
+ 'an empty alt asserts "decorative" to a screen reader where a '
1373
+ "missing alt did not; hence opt-in.",
1374
+ ),
1375
+ (
1376
+ "reserialize",
1377
+ "rebuild still-malformed documents via html5lib",
1378
+ "rebuild content documents that are still malformed by re-parsing "
1379
+ "them with html5lib and re-emitting XHTML, closing unclosed non-void "
1380
+ "elements the regex transforms cannot. Runs only on documents that "
1381
+ "are not already well-formed, so good files are untouched.",
1382
+ ),
1383
+ (
1384
+ "strip_bad_attrs",
1385
+ "drop invalid attributes (digit-led names, unbound prefixes)",
1386
+ "drop attributes that make the XML unparseable: a name starting "
1387
+ 'with a digit (a mangled 31="") or a namespaced name whose prefix '
1388
+ "is not declared anywhere in the document (Office VML v:shapes). "
1389
+ "Surgical and a no-op on well-formed files.",
1390
+ ),
1391
+ (
1392
+ "escape_unknown_entities",
1393
+ "&foo; -> &amp;foo; for names outside the HTML5 table",
1394
+ "escape entity names outside the HTML5 table, which renders exactly "
1395
+ "as browsers already render an unknown entity. Documents whose "
1396
+ "DOCTYPE carries an internal subset (which can declare custom "
1397
+ "entities) are skipped wholesale.",
1398
+ ),
1399
+ ],
1400
+ "structural repairs: markup and nesting": [
1401
+ (
1402
+ "fix_empty_body",
1403
+ "append &nbsp; to a strictly empty <body></body>",
1404
+ "append &nbsp; inside a strictly empty <body></body> (the 'body "
1405
+ "incomplete' error). Adds visible content the author never wrote; "
1406
+ "hence opt-in.",
1407
+ ),
1408
+ (
1409
+ "fix_missing_title",
1410
+ "inject <title>Unknown</title> when the head has none",
1411
+ "inject a <title>Unknown</title> fallback when the head has no "
1412
+ "usable title: an empty <title/> is filled in, an absent tag is "
1413
+ "added to the head.",
1414
+ ),
1415
+ (
1416
+ "fix_id_colons",
1417
+ 'id="X:Y" colons and matching #X:Y fragments become underscores',
1418
+ 'translate illegal colons in id="X:Y" and their matching internal '
1419
+ "#X:Y fragment references to underscores, so a ToC never dangles "
1420
+ "against the ids it references (the NCX's content src fragments "
1421
+ "follow the rename). Only the bare id attribute is in scope; the "
1422
+ "fragment of an external URL names a position in that other "
1423
+ "document and survives verbatim.",
1424
+ ),
1425
+ (
1426
+ "unwrap_block_in_inline",
1427
+ "unwrap a <span> illegally wrapping a block element",
1428
+ "unwrap a <span> that illegally wraps a block element "
1429
+ "(<div>/<p>/<blockquote>), keeping the block element and its text.",
1430
+ ),
1431
+ (
1432
+ "strip_invalid_value",
1433
+ "strip misplaced value= attributes from non-form elements",
1434
+ 'strip misplaced value="..." attributes from elements like <div>, '
1435
+ "<span>, <p> where they are schema violations.",
1436
+ ),
1437
+ (
1438
+ "unwrap_illegal_tags",
1439
+ "delete illegal tags (<st>, <w>...) keeping text; styled names safe",
1440
+ "delete illegal/deprecated tags (<st>, <sentence>, <o>, <w>, "
1441
+ "<pagebreak>) while keeping their inner text. Any of those names a "
1442
+ "stylesheet styles as an element selector (class/id selectors do "
1443
+ "not count) is protected book-wide, so styled formatting is never "
1444
+ "destroyed.",
1445
+ ),
1446
+ (
1447
+ "strip_epub3_attrs",
1448
+ "scrub EPUB3-only attributes an EPUB2 package rejects (fixed set)",
1449
+ "scrub the EPUB3-only attributes epubcheck rejects on an EPUB2 "
1450
+ "package: page-progression-direction, epub:type, aria-label (a "
1451
+ "fixed, documented set; rendering is unchanged). Gated on the "
1452
+ "package version: inert on EPUB 3 books.",
1453
+ ),
1454
+ (
1455
+ "downgrade_epub3_tags",
1456
+ "figure/section to div, figcaption to p; semantic name kept as class",
1457
+ "downgrade EPUB3/HTML5 semantic elements to EPUB2 equivalents "
1458
+ "(figure/section to div, figcaption to p), keeping existing classes "
1459
+ "and appending the semantic name as the styling hook. Names a "
1460
+ "stylesheet styles as an element selector are protected book-wide. "
1461
+ "Gated on the package version: inert on EPUB 3 books.",
1462
+ ),
1463
+ (
1464
+ "fix_misnested_inline",
1465
+ "rewrite the drop-cap mis-nest <i><b>X</i></b> to <i><b>X</b></i>",
1466
+ "rewrite the Mobipocket drop-cap mis-nest <i><b>X</i></b> to "
1467
+ "<i><b>X</b></i> (em/strong variants, either tag order): the unique "
1468
+ "well-formed form of the same two spans. A punctuation run caught "
1469
+ "between the reversed closers (<i><b>xile</i>,</b>) survives "
1470
+ "outside both spans; a letter run there is refused (which span "
1471
+ "keeps the letters has no deterministic answer).",
1472
+ ),
1473
+ (
1474
+ "fix_stray_close",
1475
+ "remove end tags whose element has no open start (stray </div>)",
1476
+ "remove an end tag whose element has no open start tag anywhere "
1477
+ "above it (the stray extra </div> on a shell page, which cascades "
1478
+ "into 'body must be terminated by the matching end-tag'). A merely "
1479
+ "mis-nested pair (<div><span></div></span>) is never touched: a "
1480
+ "close is removed only when nothing by that name is open.",
1481
+ ),
1482
+ (
1483
+ "fix_unterminated_attr",
1484
+ "close attribute values open to the tag's own '>' (RSC-016)",
1485
+ "close an attribute value left open to the tag's own '>' "
1486
+ '(<p class="footnote> -> <p class="footnote">): the parser '
1487
+ "swallows prose into the value until the next quote and dies on the "
1488
+ "first '<' it meets. Guards keep it off legal >-bearing values: "
1489
+ "bare-word values only, and no quote may close before the next '<'.",
1490
+ ),
1491
+ (
1492
+ "fix_cdata_terminator",
1493
+ "complete truncated /*]]> closers in inline <style> blocks (CSS-008)",
1494
+ "complete a truncated CDATA-terminator comment in an inline <style> "
1495
+ "block: /*]]> becomes /*]]>*/. A comment-wrapped CDATA block whose "
1496
+ "closing */ the converter dropped leaves the CSS parser inside an "
1497
+ "unterminated comment: one CSS-008 'Premature end of file' per "
1498
+ "affected document. Only the malformed-terminator token class; "
1499
+ "invalid CSS stays unfixed, and stylesheet files are not touched.",
1500
+ ),
1501
+ ],
1502
+ "structural repairs: references and resources": [
1503
+ (
1504
+ "fix_page_map",
1505
+ 'drop the OPF page-map attribute; class="pages" on NCX pageLists',
1506
+ "normalize legacy page-map markup: drop the non-standard page-map "
1507
+ 'attribute from the OPF <spine> and add class="pages" to classless '
1508
+ "NCX <pageList> elements (epubcheck rejects both on older "
1509
+ "HarperCollins / Anna's Archive conversions).",
1510
+ ),
1511
+ (
1512
+ "prune_missing_resources",
1513
+ "remove references to files the archive does not contain (RSC-007)",
1514
+ "remove references to files the archive does not contain "
1515
+ "(RSC-007/PKG-010): dead <link> elements, anchors' href to absent "
1516
+ "files (anchor text preserved), absent <img> sources (replaced by "
1517
+ "their alt text when they carry one), and orphaned non-spine OPF "
1518
+ "manifest items, with every package edge that pointed at them "
1519
+ "rewritten. Spine documents are never pruned: a missing spine "
1520
+ "document is reported as a damaged fragment, not silently dropped.",
1521
+ ),
1522
+ (
1523
+ "strip_broken_anchors",
1524
+ "strip hrefs that cannot resolve; anchor text always preserved",
1525
+ "strip href attributes that cannot resolve, keeping the anchor text "
1526
+ "byte-for-byte: a #fragment the target document does not define "
1527
+ "(RSC-020/RSC-012; NCX navTargets keep the document target, so "
1528
+ "chapter navigation survives) and unresolvable URI schemes "
1529
+ "(kindle:, file:).",
1530
+ ),
1531
+ (
1532
+ "encode_url_spaces",
1533
+ "encode raw spaces in URLs; rename space-bearing entries (PKG-010)",
1534
+ "repair raw-space URLs two ways: percent-encode raw spaces in "
1535
+ "src/href attribute values across the package (a literal space is "
1536
+ "not a valid URL, RSC-020), and rename the archive entries whose "
1537
+ "names carry raw spaces to their underscore spellings, rewriting "
1538
+ "every reference to them (underscore, never percent-encoding: "
1539
+ "epubcheck decodes references before entry lookup). Accepted under "
1540
+ "the no-worse bar: the rename half's gain sits on the warning axis.",
1541
+ ),
1542
+ (
1543
+ "fix_svg_dup_ids",
1544
+ "rename duplicate glyph ids in .svg entries (first keeps its name)",
1545
+ "rename duplicate id values inside standalone .svg entries (old "
1546
+ "calibre SVG page renders repeat glyph ids within one document; the "
1547
+ "intake wave that found this carried 445 to 1,339 'Duplicate "
1548
+ '"glNNNN"\' errors per book). The first occurrence keeps its name, '
1549
+ "later ones gain _2/_3, and internal references are deliberately "
1550
+ "untouched: they already resolve to the first definition under "
1551
+ "every reader's first-match lookup. Content-document ids are styled "
1552
+ "by CSS selectors and targeted by anchors; they stay out of scope.",
1553
+ ),
1554
+ ],
1555
+ "structural repairs: package wiring": [
1556
+ (
1557
+ "fix_container",
1558
+ "generate container.xml when missing or stale (the gateway defect)",
1559
+ "generate META-INF/container.xml at the located OPF when the "
1560
+ "container is missing or names a file the archive does not contain. "
1561
+ "This is the gateway defect: epubcheck stays fatal while the OPF is "
1562
+ "unfindable, so no repair can be gate-accepted on such a book until "
1563
+ "the container exists.",
1564
+ ),
1565
+ (
1566
+ "fix_media_types",
1567
+ "normalize wrong manifest media-types (magic-byte confirmed)",
1568
+ "normalize wrong manifest media-type declarations (OPF-029, e.g. a "
1569
+ "jpg stamped image/png by an aggregator). Attribute-only and "
1570
+ "quote-preserving; it fires only when the file's magic bytes "
1571
+ "confirm the extension, so a misnamed-but-consistent file is never "
1572
+ "made worse.",
1573
+ ),
1574
+ (
1575
+ "fix_cover",
1576
+ "re-point or remove a dangling EPUB2 cover meta (no-worse bar)",
1577
+ 'repair dangling EPUB2 cover wiring: a <meta name="cover"> whose '
1578
+ "content names no manifest item is re-pointed when the OPF guide's "
1579
+ "own cover reference names an existing item, and removed when "
1580
+ 'nothing identifies it. EPUB3 properties="cover-image" is '
1581
+ "deliberately audit-only (guessing which image is the cover is not "
1582
+ "deterministic). Cover wiring is invisible to epubcheck, so "
1583
+ "cover-only repairs are accepted under the same no-worse bar the "
1584
+ "lossy strips use.",
1585
+ ),
1586
+ (
1587
+ "fix_comment_double_hyphen",
1588
+ "replace -- inside XML comments with an en-dash (RSC-016)",
1589
+ "replace -- sequences inside XML comments with en-dashes. -- is "
1590
+ "illegal inside an XML comment (epubcheck fatal RSC-016: the book "
1591
+ "opens in lenient readers but never passes epubcheck). The edit "
1592
+ "stays inside the comment bodies: text nodes and CDATA sections are "
1593
+ "never touched, and each comment's terminator is left intact.",
1594
+ ),
1595
+ ],
1596
+ "lossy strips (these delete converter-injected content; accepted on a no-worse bar)": [
1597
+ (
1598
+ "strip_pagination",
1599
+ "LOSSY: page numbers/running heads baked into the body text",
1600
+ "remove print page numbers and running headers that a PDF/OCR "
1601
+ "conversion baked into the body text as literal paragraphs (so they "
1602
+ "reflow into the middle of a sentence). It removes only that "
1603
+ "injected furniture, never the author's prose: where a number split "
1604
+ "a sentence it rejoins the two paragraphs, and it preserves roman "
1605
+ "chapter numbers, page-list nav anchors, and years. Three safety "
1606
+ "nets guard every edit (character conservation, tag balance, and "
1607
+ "the epubcheck no-regression check); any failure leaves the "
1608
+ "document untouched.",
1609
+ ),
1610
+ (
1611
+ "strip_broken_tags",
1612
+ "LOSSY: leaked close tags rendered as text (</p> without <)",
1613
+ "remove leaked HTML closing tags missing their open brackets (e.g. "
1614
+ "</p> rendered as raw text in the reader).",
1615
+ ),
1616
+ (
1617
+ "strip_watermarks",
1618
+ "LOSSY: producer stamps (OceanofPDF ...) and marker files",
1619
+ "remove known producer and distributor watermarks and stray marker "
1620
+ "files. It locates the stamp and deletes the outermost wrapper "
1621
+ "whose entire visible text is the watermark, so prose that merely "
1622
+ "mentions the URL is preserved. An inline stamp link is deleted "
1623
+ "only when it holds nothing but the stamp; a match too large to be "
1624
+ "safe is refused and reported for manual repair rather than "
1625
+ "deleted.",
1626
+ ),
1627
+ (
1628
+ "strip_stub_docs",
1629
+ "LOSSY: repeated placeholder spine docs, full manifest/NCX cascade",
1630
+ "drop spine documents whose entire visible body text is one "
1631
+ "identical short placeholder repeated across the spine: the "
1632
+ "Bookmate-style export whose chapters are all the same 'content "
1633
+ "unavailable' notice. The drop cascades (archive entries, manifest "
1634
+ "items, spine order, NCX navPoints, nav toc entries), so the book "
1635
+ "opens straight into its real chapters. Deliberately conservative "
1636
+ "(the pool is the <body> span, image-carrier divider pages are "
1637
+ "exempt) and a book whose every spine doc is the stub is refused "
1638
+ "outright: that book is empty and needs a re-source, never a "
1639
+ "repair.",
1640
+ ),
1641
+ ],
1642
+ }
1643
+
1644
+
1645
+ class _HelpRepairsAction(argparse.Action):
1646
+ """The second help level: print the full repair-flag reference and exit.
1647
+
1648
+ Deliberately NOT a store_true action: tests/test_flags_wiring.py counts
1649
+ every store_true dest in _add_repair_flags as a repair selection, and
1650
+ this is a help affordance like -h, not a selection."""
1651
+
1652
+ def __init__(
1653
+ self,
1654
+ option_strings,
1655
+ dest=argparse.SUPPRESS,
1656
+ default=argparse.SUPPRESS,
1657
+ help=None,
1658
+ ):
1659
+ super().__init__(
1660
+ option_strings=option_strings,
1661
+ dest=dest,
1662
+ default=default,
1663
+ nargs=0,
1664
+ help=help,
1665
+ )
1666
+
1667
+ def __call__(self, parser, namespace, values, option_string=None):
1668
+ lines = [
1669
+ "bindery repair-flag reference (shared by `bindery repair` and "
1670
+ "`bindery library`)",
1671
+ "",
1672
+ "The always-on core pass is exactly five well-formedness fixes, the NCX",
1673
+ "pipeline, and the mimetype fix. Everything below is opt-in, and every",
1674
+ "repair is epubcheck-gated: applied only when the measured result improved",
1675
+ "(lossy strips: only when it did not get worse). Library mode is dry-run",
1676
+ "by default; `--all` enables every flag below at once. The README and",
1677
+ "spec.md carry the full rationale for each repair.",
1678
+ ]
1679
+ for title, rows in _REPAIR_FLAG_TABLE.items():
1680
+ lines.append("")
1681
+ lines.append(title)
1682
+ lines.append("")
1683
+ for dest, _short, long_help in rows:
1684
+ option = "--" + dest.replace("_", "-")
1685
+ lines.append(f" {option}")
1686
+ for para in long_help.split("\n"):
1687
+ lines.extend(
1688
+ textwrap.wrap(
1689
+ para,
1690
+ width=88,
1691
+ initial_indent=" ",
1692
+ subsequent_indent=" ",
1693
+ )
1694
+ or [" "]
1695
+ )
1696
+ print("\n".join(lines))
1697
+ parser.exit(0)
1698
+
1699
+
1348
1700
  def _add_repair_flags(p: argparse.ArgumentParser) -> None:
1349
- """The fix-selection and gate flags shared by both subcommands."""
1350
- p.add_argument(
1351
- "--fix-ids",
1352
- action="store_true",
1353
- help="also rewrite invalid ids in the OPF manifest and the NCX (RSC-005)",
1354
- )
1355
- p.add_argument(
1356
- "--add-img-alt",
1357
- action="store_true",
1358
- help='add alt="" to <img> elements missing the required attribute '
1359
- "(renders identically; asserts 'decorative' to screen readers)",
1360
- )
1361
- p.add_argument(
1362
- "--reserialize",
1363
- action="store_true",
1364
- help="rebuild still-malformed documents via html5lib (closes unclosed elements)",
1365
- )
1366
- p.add_argument(
1367
- "--strip-bad-attrs",
1368
- action="store_true",
1369
- help="drop invalid attributes (digit-led names, unbound namespace prefixes)",
1370
- )
1371
- p.add_argument(
1372
- "--escape-unknown-entities",
1373
- action="store_true",
1374
- help="escape entity names outside the HTML5 table (&foo; -> &amp;foo;), "
1375
- "rendering as browsers already render them; documents with a DOCTYPE "
1376
- "internal subset (which can declare custom entities) are skipped",
1377
- )
1378
- p.add_argument(
1379
- "--fix-empty-body",
1380
- action="store_true",
1381
- help="append &nbsp; to a strictly empty <body></body> (adds visible "
1382
- "content; hence opt-in)",
1383
- )
1384
- p.add_argument(
1385
- "--fix-missing-title",
1386
- action="store_true",
1387
- help="inject a <title>Unknown</title> fallback when the head has none",
1388
- )
1389
- p.add_argument(
1390
- "--fix-id-colons",
1391
- action="store_true",
1392
- help='translate illegal colons in id="X:Y" and their matching #X:Y '
1393
- "fragment references to underscores",
1394
- )
1395
- p.add_argument(
1396
- "--unwrap-block-in-inline",
1397
- action="store_true",
1398
- help="unwrap a <span> that illegally wraps a block element (<div>/<p>/"
1399
- "<blockquote>), keeping the block and its text",
1400
- )
1401
- p.add_argument(
1402
- "--strip-invalid-value",
1403
- action="store_true",
1404
- help='strip misplaced value="..." attributes from non-form elements',
1405
- )
1406
- p.add_argument(
1407
- "--unwrap-illegal-tags",
1408
- action="store_true",
1409
- help="delete illegal/deprecated tags (<st>, <sentence>, <o>, <w>, "
1410
- "<pagebreak>) keeping inner text; any tag a stylesheet styles as an "
1411
- "element selector is protected book-wide",
1412
- )
1413
- p.add_argument(
1414
- "--fix-page-map",
1415
- dest="fix_page_map",
1416
- action="store_true",
1417
- help="normalize legacy page-map markup: drop the non-standard page-map "
1418
- 'attribute from the OPF spine and add class="pages" to classless NCX '
1419
- "<pageList> elements (epubcheck rejects both)",
1420
- )
1421
- p.add_argument(
1422
- "--strip-epub3-attrs",
1423
- dest="strip_epub3_attrs",
1424
- action="store_true",
1425
- help="scrub the EPUB3-only attributes epubcheck rejects on an EPUB2 "
1426
- "package (page-progression-direction, epub:type, aria-label; fixed set)",
1427
- )
1428
- p.add_argument(
1429
- "--downgrade-epub3-tags",
1430
- dest="downgrade_epub3_tags",
1431
- action="store_true",
1432
- help="downgrade EPUB3/HTML5 semantic elements to EPUB2 equivalents "
1433
- "(figure/section to div, figcaption to p; semantic name kept as a "
1434
- "class; names a stylesheet styles as an element selector are "
1435
- "protected book-wide)",
1436
- )
1437
- p.add_argument(
1438
- "--prune-missing-resources",
1439
- dest="prune_missing_resources",
1440
- action="store_true",
1441
- help="remove references to files the archive does not contain "
1442
- "(RSC-007/PKG-010): dead <link> elements, anchors' href to absent "
1443
- "files, absent <img> sources (replaced by their alt text when they "
1444
- "carry one), and orphaned non-spine OPF manifest items; spine "
1445
- "documents are never pruned",
1446
- )
1447
- p.add_argument(
1448
- "--strip-broken-anchors",
1449
- dest="strip_broken_anchors",
1450
- action="store_true",
1451
- help="strip href attributes that cannot resolve: a #fragment the "
1452
- "target document does not define (RSC-020/RSC-012; NCX navTargets "
1453
- "keep the document target) and non-resolvable URI schemes (kindle:, "
1454
- "file:); anchor text is always preserved",
1455
- )
1456
- p.add_argument(
1457
- "--encode-url-spaces",
1458
- dest="encode_url_spaces",
1459
- action="store_true",
1460
- help="percent-encode raw spaces in src/href attribute values across "
1461
- "the package (OPF href, NCX src, content docs): a literal space is "
1462
- "not a valid URL (RSC-020)",
1463
- )
1464
- p.add_argument(
1465
- "--fix-container",
1466
- dest="fix_container",
1467
- action="store_true",
1468
- help="generate META-INF/container.xml at the located OPF when the "
1469
- "container is missing or names a file the archive does not contain "
1470
- "(the gateway defect: epubcheck stays fatal while the OPF is "
1471
- "unfindable)",
1472
- )
1473
- p.add_argument(
1474
- "--fix-media-types",
1475
- dest="fix_media_types",
1476
- action="store_true",
1477
- help="normalize wrong manifest media-type declarations (OPF-029): "
1478
- "attribute-only, fired only when the file's magic bytes confirm the "
1479
- "extension (jpg/png/gif)",
1480
- )
1481
- p.add_argument(
1482
- "--fix-cover",
1483
- dest="fix_cover",
1484
- action="store_true",
1485
- help='repair dangling EPUB2 cover wiring: re-point <meta name="cover"> '
1486
- "from the guide's cover reference when that names an existing manifest "
1487
- "item, remove the dead meta when nothing does; EPUB3 "
1488
- 'properties="cover-image" is audit-only by ruling',
1489
- )
1490
- p.add_argument(
1491
- "--fix-comment-double-hyphen",
1492
- dest="fix_comment_double_hyphen",
1493
- action="store_true",
1494
- help="replace `--` inside XML comments with an en-dash (RSC-016: "
1495
- "`--` is not permitted within comments); comment bodies only, "
1496
- "CDATA never touched",
1497
- )
1498
- p.add_argument(
1499
- "--fix-svg-dup-ids",
1500
- dest="fix_svg_dup_ids",
1501
- action="store_true",
1502
- help="rename duplicate id values inside standalone .svg entries "
1503
- "(old calibre SVG page renders, RSC-005 'Duplicate \"glNNNN\"'): "
1504
- "first occurrence keeps its name, later ones gain _2/_3; "
1505
- "references need no rewrite (they resolve to the first definition)",
1506
- )
1507
- p.add_argument(
1508
- "--fix-cdata-terminator",
1509
- dest="fix_cdata_terminator",
1510
- action="store_true",
1511
- help="complete a truncated CDATA-terminator comment in an inline "
1512
- "<style> block (/*]]> -> /*]]>*/; the CSS-008 'Premature end of "
1513
- "file' class, one error per affected document)",
1514
- )
1515
- p.add_argument(
1516
- "--fix-misnested-inline",
1517
- dest="fix_misnested_inline",
1518
- action="store_true",
1519
- help="rewrite the drop-cap mis-nest <i><b>X</i></b> to "
1520
- "<i><b>X</b></i> (em/strong variants, either tag order): the "
1521
- "unique well-formed form of the same two spans",
1522
- )
1523
- p.add_argument(
1524
- "--fix-stray-close",
1525
- dest="fix_stray_close",
1526
- action="store_true",
1527
- help="remove an end tag whose element has no open start tag (the "
1528
- "stray extra </div> that cascades into 'body must be terminated "
1529
- "by the matching end-tag'); mis-nested-but-open pairs never touched",
1530
- )
1531
- p.add_argument(
1532
- "--fix-unterminated-attr",
1533
- dest="fix_unterminated_attr",
1534
- action="store_true",
1535
- help="close an attribute value left open to the tag's own '>' "
1536
- '(<p class="footnote> -> <p class="footnote">; the RSC-016 '
1537
- "'must not contain the < character' fatal)",
1538
- )
1539
- p.add_argument(
1540
- "--strip-pagination",
1541
- action="store_true",
1542
- help="LOSSY: remove print page numbers/running headers baked into the body "
1543
- "text by a bad conversion, rejoining sentences they split (epubcheck-gated, "
1544
- "accepted when no worse)",
1545
- )
1546
- p.add_argument(
1547
- "--strip-broken-tags",
1548
- action="store_true",
1549
- help="LOSSY: remove leaked HTML closing tags missing their open bracket (e.g. </p> rendered as text) (epubcheck-gated)",
1550
- )
1551
- p.add_argument(
1552
- "--strip-watermarks",
1701
+ """The fix-selection and gate flags shared by both subcommands,
1702
+ registered from _REPAIR_FLAG_TABLE into titled groups (the compact
1703
+ --help); --help-repairs prints the same inventory with the long
1704
+ descriptions."""
1705
+ for title, rows in _REPAIR_FLAG_TABLE.items():
1706
+ g = p.add_argument_group(title)
1707
+ for dest, short, _long in rows:
1708
+ g.add_argument(
1709
+ "--" + dest.replace("_", "-"),
1710
+ dest=dest,
1711
+ action="store_true",
1712
+ help=short,
1713
+ )
1714
+ g = p.add_argument_group("gate and selection")
1715
+ g.add_argument(
1716
+ "--all",
1553
1717
  action="store_true",
1554
- help="remove known producer/distributor watermarks and stray marker files",
1718
+ help="enable every opt-in fix flag (safe, structural, and lossy)",
1555
1719
  )
1556
- p.add_argument(
1557
- "--strip-stub-docs",
1720
+ g.add_argument(
1721
+ "--no-validate",
1558
1722
  action="store_true",
1559
- help="drop spine docs whose entire text is one identical short "
1560
- "placeholder repeated across the spine (Bookmate/DRM-sample exports); "
1561
- "manifest/NCX/nav cascade; refuses a book whose whole spine is stubs",
1723
+ help="skip the epubcheck gate",
1562
1724
  )
1563
1725
  p.add_argument(
1564
- "--all",
1565
- action="store_true",
1566
- help="enable every opt-in fix flag (the safe structural repairs above "
1567
- "and all lossy strips)",
1726
+ "--help-repairs",
1727
+ action=_HelpRepairsAction,
1728
+ help="print the full repair-flag reference (every flag's long "
1729
+ "description, grouped) and exit",
1568
1730
  )
1569
- p.add_argument("--no-validate", action="store_true", help="skip the epubcheck gate")
1570
1731
 
1571
1732
 
1572
1733
  def run_audit_cmd(args: argparse.Namespace) -> int:
@@ -1632,38 +1793,42 @@ def run_audit_cmd(args: argparse.Namespace) -> int:
1632
1793
 
1633
1794
 
1634
1795
  def build_parser() -> argparse.ArgumentParser:
1635
- # Generate an attractive, perfectly aligned help block for the shared flags
1636
- dummy = argparse.ArgumentParser(add_help=False, usage=argparse.SUPPRESS)
1637
- group = dummy.add_argument_group(
1638
- "shared fix flags (can be passed to either repair or library)"
1639
- )
1640
- _add_repair_flags(group)
1641
- lib_group = dummy.add_argument_group("library-specific integration")
1642
- lib_group.add_argument(
1643
- "--install-to-calibre",
1644
- action="store_true",
1645
- help="with --apply, re-register the format natively in metadata.db through cquarry's write module (remove + add in one transaction) instead of a bare filesystem replace",
1646
- )
1647
- shared_help = dummy.format_help().strip()
1648
-
1649
1796
  ap = argparse.ArgumentParser(
1650
1797
  prog="bindery",
1651
1798
  description="Repair EPUBs, epubcheck-gated.",
1652
- epilog=shared_help,
1799
+ epilog="both `repair` and `library` accept the same opt-in repair-flag\n"
1800
+ "set (--all enables every one): `bindery repair --help` lists them\n"
1801
+ "grouped, and `bindery repair --help-repairs` prints the full\n"
1802
+ "reference.",
1653
1803
  formatter_class=argparse.RawDescriptionHelpFormatter,
1654
1804
  )
1655
1805
  ap.add_argument("--version", action="version", version=f"bindery {__version__}")
1656
1806
  sub = ap.add_subparsers(dest="cmd", required=True)
1657
1807
 
1658
- r = sub.add_parser("repair", help="repair a single EPUB to a new file")
1808
+ r = sub.add_parser(
1809
+ "repair",
1810
+ help="repair a single EPUB to a new file",
1811
+ usage="bindery repair [options] path [output]",
1812
+ description="Repair one EPUB into a new file with the epubcheck-gated "
1813
+ "core pass: the five well-formedness fixes, the NCX pipeline, and the "
1814
+ "mimetype fix run always; every other repair is opt-in (grouped "
1815
+ "below) and applied only when epubcheck confirms the result improved.",
1816
+ epilog="examples:\n"
1817
+ " bindery repair book.epub out.epub\n"
1818
+ " bindery repair book.epub out.epub --all\n"
1819
+ " bindery repair book.epub out.epub --fix-svg-dup-ids "
1820
+ "--fix-cdata-terminator\n",
1821
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1822
+ )
1659
1823
  r.add_argument("path")
1660
1824
  r.add_argument("output", nargs="?")
1661
- r.add_argument(
1825
+ rc = r.add_argument_group("run control")
1826
+ rc.add_argument(
1662
1827
  "--force",
1663
1828
  action="store_true",
1664
1829
  help="overwrite the output file if it already exists",
1665
1830
  )
1666
- r.add_argument(
1831
+ rc.add_argument(
1667
1832
  "--json",
1668
1833
  dest="json_path",
1669
1834
  metavar="FILE",
@@ -1748,58 +1913,76 @@ def build_parser() -> argparse.ArgumentParser:
1748
1913
  )
1749
1914
  audit.set_defaults(func=run_audit_cmd)
1750
1915
 
1751
- lib = sub.add_parser("library", help="scan/repair a Calibre library tree")
1916
+ lib = sub.add_parser(
1917
+ "library",
1918
+ help="scan/repair a Calibre library tree",
1919
+ usage="bindery library [options] path",
1920
+ description="Scan a Calibre library tree for repair candidates and "
1921
+ "repair them in place. Dry run by default; --apply atomically "
1922
+ "replaces accepted books. Shares the repair-flag set with `bindery "
1923
+ "repair` (--all enables every opt-in).",
1924
+ epilog="examples:\n"
1925
+ " bindery library ~/docs/Calibre\\ Library --sweep\n"
1926
+ " bindery library ~/docs/Calibre\\ Library --id 1234 --apply "
1927
+ "--backup /tmp/bindery-backups --install-to-calibre\n",
1928
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1929
+ )
1752
1930
  lib.add_argument("path")
1753
- lib.add_argument(
1931
+ rc = lib.add_argument_group("run control")
1932
+ rc.add_argument(
1754
1933
  "--apply",
1755
1934
  action="store_true",
1756
1935
  help="atomically replace accepted books in place (default: dry run)",
1757
1936
  )
1758
- lib.add_argument(
1937
+ rc.add_argument(
1759
1938
  "--only",
1760
1939
  choices=("fatals", "ncx", "all"),
1761
1940
  default="all",
1762
1941
  help="restrict to books with fatals, NCX-001 mismatch, or all (default)",
1763
1942
  )
1764
- lib.add_argument(
1765
- "--audit", help="audit CSV (fatals,errors,warnings,path) to filter candidates"
1766
- )
1767
- lib.add_argument(
1768
- "--sweep",
1769
- action="store_true",
1770
- help="select candidates via a live epubcheck sweep instead of an --audit CSV "
1771
- "(each sweep result doubles as that book's 'before' measurement)",
1943
+ rc.add_argument(
1944
+ "--limit", type=int, help="process at most N candidates (for sampling)"
1772
1945
  )
1773
- lib.add_argument(
1774
- "--json",
1775
- metavar="FILE",
1776
- help="write a machine-readable JSON report of the run to FILE",
1946
+ rc.add_argument(
1947
+ "--workers",
1948
+ type=int,
1949
+ default=1,
1950
+ metavar="N",
1951
+ help="concurrent epubcheck workers for the --sweep candidate pass "
1952
+ "(default 1: serial, unchanged). The repair phase stays serial",
1777
1953
  )
1778
- lib.add_argument(
1779
- "--manual-list",
1780
- metavar="FILE",
1781
- help="write the paths of books that were not auto-repaired "
1782
- "(nochange/equal/partial/reject/error/unreadable), one per line",
1954
+ rc.add_argument(
1955
+ "--quiet",
1956
+ action="store_true",
1957
+ help="suppress the per-book progress line on stderr",
1783
1958
  )
1784
- lib.add_argument(
1785
- "--install-to-calibre",
1959
+ cs = lib.add_argument_group("candidate selection")
1960
+ cs.add_argument(
1961
+ "--sweep",
1786
1962
  action="store_true",
1787
- help="with --apply, re-register the format natively in metadata.db through cquarry's write module (remove + add in one transaction) instead of a bare filesystem replace",
1963
+ help="select candidates via a live epubcheck sweep instead of an "
1964
+ "--audit CSV (each sweep result doubles as that book's 'before' "
1965
+ "measurement)",
1788
1966
  )
1789
- lib.add_argument(
1967
+ cs.add_argument(
1968
+ "--audit", help="audit CSV (fatals,errors,warnings,path) to filter candidates"
1969
+ )
1970
+ cs.add_argument(
1790
1971
  "--id",
1791
1972
  default="",
1792
1973
  metavar="IDS",
1793
1974
  help="comma-separated Calibre book ids to scope the sweep to "
1794
- "(resolved via cquarry's get_format_path; mutually exclusive with --audit)",
1975
+ "(resolved via cquarry's get_format_path; mutually exclusive with "
1976
+ "--audit)",
1795
1977
  )
1796
- lib.add_argument("--backup", help="directory to mirror backups into before --apply")
1797
- lib.add_argument(
1978
+ bk = lib.add_argument_group("backups")
1979
+ bk.add_argument("--backup", help="directory to mirror backups into before --apply")
1980
+ bk.add_argument(
1798
1981
  "--backup-inplace",
1799
1982
  action="store_true",
1800
1983
  help="with --apply, write a .epub.bak beside each replaced file",
1801
1984
  )
1802
- lib.add_argument(
1985
+ bk.add_argument(
1803
1986
  "--backup-keep",
1804
1987
  type=int,
1805
1988
  metavar="N",
@@ -1807,21 +1990,24 @@ def build_parser() -> argparse.ArgumentParser:
1807
1990
  "(minimum 2; the author original .bak is never deleted). Opt-in: "
1808
1991
  "without it the rotation grows unbounded",
1809
1992
  )
1810
- lib.add_argument(
1811
- "--limit", type=int, help="process at most N candidates (for sampling)"
1993
+ out = lib.add_argument_group("output and library integration")
1994
+ out.add_argument(
1995
+ "--json",
1996
+ metavar="FILE",
1997
+ help="write a machine-readable JSON report of the run to FILE",
1812
1998
  )
1813
- lib.add_argument(
1814
- "--workers",
1815
- type=int,
1816
- default=1,
1817
- metavar="N",
1818
- help="concurrent epubcheck workers for the --sweep candidate pass "
1819
- "(default 1: serial, unchanged). The repair phase stays serial",
1999
+ out.add_argument(
2000
+ "--manual-list",
2001
+ metavar="FILE",
2002
+ help="write the paths of books that were not auto-repaired "
2003
+ "(nochange/equal/partial/reject/error/unreadable), one per line",
1820
2004
  )
1821
- lib.add_argument(
1822
- "--quiet",
2005
+ out.add_argument(
2006
+ "--install-to-calibre",
1823
2007
  action="store_true",
1824
- help="suppress the per-book progress line on stderr",
2008
+ help="with --apply, re-register the format natively in metadata.db "
2009
+ "through cquarry's write module (remove + add in one transaction) "
2010
+ "instead of a bare filesystem replace",
1825
2011
  )
1826
2012
  _add_repair_flags(lib)
1827
2013
  lib.set_defaults(func=run_library)
@@ -4,6 +4,7 @@ the single-file repair must write the exact bytes the gate accepted (flags inclu
4
4
  and label partial output honestly, and candidate selection must refuse --only fatals
5
5
  without an audit."""
6
6
 
7
+ import argparse
7
8
  import contextlib
8
9
  import io
9
10
  import json
@@ -1792,3 +1793,84 @@ class TestStripStubDocsGate(unittest.TestCase):
1792
1793
  def test_regression_rejects(self) -> None:
1793
1794
  o = self._strip_verdict(CheckResult(0, 1, 0), CheckResult(0, 2, 0))
1794
1795
  self.assertEqual(o.status, "reject")
1796
+
1797
+
1798
+ class TestHelpLayout(unittest.TestCase):
1799
+ """The two-level help contract (the 2026-10-06 restructure): the compact
1800
+ --help is grouped with one-line flags and a short usage line, and
1801
+ --help-repairs prints the full reference carrying every flag's long
1802
+ description. The flag surface itself is pinned by test_flags_wiring; this
1803
+ module pins the presentation."""
1804
+
1805
+ def _subparser(self, name):
1806
+ parser = build_parser()
1807
+ for a in parser._actions:
1808
+ if isinstance(a, argparse._SubParsersAction):
1809
+ return a.choices[name]
1810
+ self.fail("no subparsers on build_parser()")
1811
+
1812
+ def _format(self, name):
1813
+ return self._subparser(name).format_help()
1814
+
1815
+ def test_usage_line_is_short(self):
1816
+ for verb, tail_ in (
1817
+ ("repair", "[options] path [output]"),
1818
+ ("library", "[options] path"),
1819
+ ):
1820
+ usage = self._format(verb).splitlines()[0]
1821
+ self.assertEqual(usage, f"usage: bindery {verb} {tail_}", verb)
1822
+
1823
+ def test_compact_help_groups_every_flag_once(self):
1824
+ from bindery.cli import _REPAIR_FLAG_TABLE
1825
+
1826
+ for verb in ("repair", "library"):
1827
+ text = self._format(verb)
1828
+ self.assertIn(
1829
+ "lossy strips (these delete converter-injected content; "
1830
+ "accepted on a no-worse bar)",
1831
+ text,
1832
+ verb,
1833
+ )
1834
+ for rows in _REPAIR_FLAG_TABLE.values():
1835
+ for dest, _short, _long in rows:
1836
+ option = "--" + dest.replace("_", "-")
1837
+ self.assertIn(option, text, f"{verb}: {option}")
1838
+
1839
+ def test_lossy_flags_carry_the_prefix(self):
1840
+ from bindery.cli import _REPAIR_FLAG_TABLE
1841
+
1842
+ text = self._format("repair")
1843
+ for dest, short, _long in _REPAIR_FLAG_TABLE[
1844
+ "lossy strips (these delete converter-injected content; "
1845
+ "accepted on a no-worse bar)"
1846
+ ]:
1847
+ self.assertTrue(short.startswith("LOSSY:"), dest)
1848
+ # the one-liner must survive into the rendered help intact
1849
+ self.assertIn(short.split(":")[0] + ":", text)
1850
+
1851
+ def test_help_repairs_prints_the_full_reference(self):
1852
+ from bindery.cli import _REPAIR_FLAG_TABLE
1853
+
1854
+ for verb in ("repair", "library"):
1855
+ buf = io.StringIO()
1856
+ with self.assertRaises(SystemExit) as cm, contextlib.redirect_stdout(buf):
1857
+ main([verb, "--help-repairs"])
1858
+ self.assertEqual(cm.exception.code, 0, verb)
1859
+ text = buf.getvalue()
1860
+ for rows in _REPAIR_FLAG_TABLE.values():
1861
+ for dest, _short, _long in rows:
1862
+ self.assertIn(
1863
+ "--" + dest.replace("_", "-"), text, f"{verb}: {dest}"
1864
+ )
1865
+ # long-text sentinels from two different groups
1866
+ self.assertIn("XML NameChar set", text, verb)
1867
+ self.assertIn("no-worse", text, verb)
1868
+
1869
+ def test_help_repairs_is_not_a_selection_flag(self):
1870
+ # _HelpRepairsAction is deliberately not a store_true: the wiring
1871
+ # test counts store_true dests as repair selections
1872
+ from argparse import _StoreTrueAction
1873
+
1874
+ p = self._subparser("repair")
1875
+ dests = {a.dest for a in p._actions if isinstance(a, _StoreTrueAction)}
1876
+ self.assertNotIn("help_repairs", dests)
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "bindery-cli"
11
- version = "0.47.0"
11
+ version = "0.47.1"
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
File without changes
File without changes
File without changes