Per-project guidance. Overrides the global file where they conflict.
A focused EPUB repair and diagnostic tool: deterministic well-formedness fixes, gated by epubcheck,
with atomic in-place replacement in a Calibre library. Absorbed the retired oceanstrip at
v0.12.0 (2026-08-22) as the lossy --strip-watermarks flag; the standalone repo is gone from
the workspace (2026-08-26), and any instruction that runs python -m oceanstrip is dead.
Born from the 2026 library audit (see the user memory calibre-library-epubcheck-audit).
- Minimal Dependencies. Runtime deps are exactly:
tqdm(progress/output),vir-tui(shared TUI rendering) andcquarry(read-only Calibremetadata.dbaccess, adopted in v0.16.0), pinned as PyPI ranges in pyproject.toml (vir-tui>=2.5.0,cquarry>=1.19.0);uv.lockrecords the resolved versions. Since v0.40.0 both VirInvictus pins carry; python_version >= '3.14'markers (every release they ship declares requires-python >=3.14) and the package floor is 3.12: a 3.12/3.13 install runs the stack-free repair core, and the audit/library surfaces degrade through cli.py's dispatch guard instead of failing resolution.html5libremains the one approved heavy-parsing exception (used only for the--reserializefix, imported lazily so every other mode runs without it). Tests use the standardunittestframework. epubcheck is an external CLI dependency expected on PATH;--install-to-calibreneeds no external binary, it re-registers the format through cquarry's write module (the calibredb subprocess was retired in v0.24.0). Before adding any further Python package, stop and ask. - Semantics-preserving transforms by default, everything else fenced behind a flag.
The always-on core is exactly five well-formedness fixes (prolog junk, duplicate
xmlns, bare&, named entities, void self-closing), the NCX pipeline, and the mimetype fix (added/normalized/first-stored, epub.py's archive rewrite); every core fix must render identically to the author's intent: never add, remove, or reorder visible content. The canonical opt-in inventory lives in spec.md: sixteen structural repairs, four lossy strips, four safe opt-ins, plus--reserialize.- Structural repairs (
--fix-empty-body,--fix-missing-title,--fix-id-colons,--fix-page-map,--strip-epub3-attrs,--downgrade-epub3-tags,--unwrap-block-in-inline,--strip-invalid-value,--unwrap-illegal-tags,--prune-missing-resources,--strip-broken-anchors,--encode-url-spaces,--fix-container,--fix-media-types,--fix-cover,--fix-comment-double-hyphen; transforms.py, threaded through epub.py).--fix-comment-double-hyphen(v0.46.0, the RSC-016 fatal) replaces--inside XML comments with an en-dash: comment bodies only (text nodes and CDATA never touched, the one deliberate exception to the never-rewrite-comments invariant), in content documents, the NCX, and the OPF alike; normalgate(clearing the fatal IS the measurable gain). Since v0.44.0--encode-url-spacesalso RENAMES the archive entries whose names carry raw spaces to their underscore spellings and rewrites every reference (OPF, NCX, content, CSSurl()); underscore, never percent-encoding (epubcheck decodes references before entry lookup, verified against 5.3), ambiguous renames refused per entry (space_rename_map), accepted underno_worsebecause PKG-010 sits on the warning axis the gate does not measure. They alter markup structure or fabricate minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule; v0.17.0 restored it.--unwrap-illegal-tagsadditionally protects any illegal-tag name that an EPUB stylesheet styles as an element selector (css_protected_tags, book-wide, inline<style>blocks included). The two EPUB2-targeted fixes (--strip-epub3-attrs,--downgrade-epub3-tags) are additionally gated on the package version in the OPF (package_version()): inert on EPUB 3 books and when no version can be read, since their target defects only exist below EPUB 3; the attribute scrub is anchored to real start tags viatransforms.strip_attrs_in_start_tags(prose mentioning the attributes and CDATA/comment content are preserved), andstrip_invalid_valuematches the attribute name with a(?<![\w:.-])valuelookbehind sodata-valuesurvives. - Lossy strips (
--strip-pagination,--strip-broken-tags,--strip-watermarks; pagination.py, watermark.py;--strip-stub-docs, epub.py'sdetect_stub_docs): remove only what a converter injected (page numbers, running headers, leaked tags, watermarks, repeated placeholder chapters), fenced behind character conservation, tag balance, and the epubcheck no-regression bar. The watermark anchored pass's whole-match fallback only fires when the match holds nothing but the stamp; a larger match (an unclosed stamp anchor that swallowed prose) is refused, counted inRepairReport.watermark_refusals, and surfaced as amanual_watermark_repairdecision byrun phase1on both the read-only and apply paths.--strip-stub-docs(2026-09-15) mirrors emptytext's placeholder signals: identical short text across= 3 spine docs and >= 30% of the spine; the whole-spine-stubs book is refused (EMPTY, re-source), and the drop cascades to archive entries, manifest, spine, NCX navPoints, and nav toc entries. Do not let any NEW fix touch content without its own flag; if a candidate repair cannot be made deterministically safe, it does not belong here: report it for manual repair instead.
- Structural repairs (
- The gate is the safety contract. Never apply a repair epubcheck has not accepted.
Respect the two-mode logic in
validate.gate(fatal-fixing tolerates error unmasking; error-cleanup does not). The lossy strips (--strip-pagination,--strip-broken-tags,--strip-watermarks,--strip-stub-docs) are accepted byvalidate.no_worseinstead (their gain is invisible to epubcheck, so it only forbids a regression, never demands a measured improvement). The structural opt-ins go through the normalgate(their gain IS visible, so a run with no measurable improvement is a noop and nothing is applied) except the two whose gains sit on axes epubcheck does not measure:--fix-coverand--encode-url-spaces, accepted under the sameno_worsebar with the partial rule intact. Changing either bar means re-running the library dry run. - Library writes are sacred. Replacement must stay atomic (temp in same dir, then
os.replace), touch only the.epub, preserve mode, and be dry-run by default. Calibre format installation goes through cquarry's write module (--install-to-calibre; the calibredb subprocess was retired in v0.24.0). Never write to the library without--apply. Test every change on/tmpcopies first.
src/bindery/transforms.py: purestr -> (str, int)text transforms (includingstrip_broken_tags). Since v0.32.0:strip_attrs_in_start_tagsanchors attribute edits on the quote-aware start-tag matcher,_outside_protectedforwards arguments (context-carrying transforms can be decorated),fix_id_colonsmatches only the bareidattribute and only internal fragments (fix_ncx_src_fragmentscarries renames into the NCX),fix_ncx_playorderis 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 transformsmatcher, 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_playorderstrips 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).src/bindery/pagination.py: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).src/bindery/watermark.py: the opt-in lossy watermark strip (anchored and anchorless signature removal).src/bindery/reserialize.py: structural repair viahtml5lib.src/bindery/audit.py: read-only body-text audits (content,pagenumbers,emptytext,ocr,monolithicsince v0.21.0 with--max-doc-chars N, andcompletenesssince v0.36.0) behind theauditsubcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths throughcquarry.get_format_path()and can apply a tag to flagged books viacquarry.write.WritableCalibreDB(only with explicit--tag; the only sanctioned write path). Since v0.19.0audit --id BOOK_IDaudits a single library book through cquarry's single-entityget_book()fetch (no library-wide cache; supports--tag; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis (a damaged entry reports its own CORRUPT verdict instead of feeding emptytext) and manifest/NCX references to absent files classify asconvention(bloated ToC, consecutive span) orfragment(broken span). Hazard: the scan loop reusestagas its per-book display column, so the--tagargument is captured asaudit_tagat function entry; do not collapse them again. Since v0.29.0audit --json FILEwrites per-file analyzer verdicts in thelibrary --jsonshape (directory, library, and single-book modes; with--id, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.44.0 two more advisory analyzers ride the single pass:cover(the EPUB3properties~="cover-image"slice of the ruled hybrid: dangling EPUB2 meta, EPUB3 declaration, cover-file presence) andtocdrift(the NCX navMap vs EPUB3 nav toc structural diff, fragments included; audit-only, ToC synthesis out of charter permanently); the loader now exposes the parsed OPF, the NCX text, and the nav HTML onBook. Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (_trailing_toc: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (OBFUSCATION_ALGOS: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED). Since v0.43.0 untrusted metadata XML is parsed through_safe_xml_parse(DOCTYPE/ENTITY declarations refused before xml.etree sees them; the stdlib shape of defusedxml's entity protection) and single entries above_MAX_ENTRY_BYTES(32 MB) are refused before decompression; a refused metadata file returns the shell book. Since v0.40.0 the vir-tui console renderer loads through the module-level_LazyUIproxy (a plainimport vir_tui as uiat module level would make 3.12/3.13 installs unimportable now that the stack is marker-gated; LOAD_GLOBAL never consults module__getattr__, so a proxy object is the lazy shape that works).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 theRepairFlagsdataclass (one field per flag, all off by default; the all-off instance IS the default pass):repair_epub(src, dst, flags)andprocess_book(..., flags)take one object instead of a ~25-kwarg signature, the EPUB2-targeted fixes' version gate swaps in adataclasses.replacecopy (never mutating the caller's instance), and the plugin's barerepair_epub(src, dst)call constructs exactly the all-off default pass. Since v0.38.0/v0.39.0 (Phase 16):generate_container+ the--fix-containergateway (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), andfix_manifest_media_types(extension map + magic-byte confirmation via apeekcallable over the open source zip; jpg/png/gif only), andfix_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).src/bindery/validate.py: epubcheck wrapper, thegate(improvement) andno_worse(no-regression, for the lossy strips) acceptance bars.src/bindery/library.py: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0CalibreIdResolverresolves the book id frommetadata.dbthrough cquarry (one lazy path→id map per run; since v0.20.0 the map comes fromCalibreDB.format_path_index(), re-normalized withresolve().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.0install_format()places the repaired file atomically and updates thedatarow throughcquarry.write.WritableCalibreDB(since v0.31.0 viaset_format, cquarry 1.17's sanctioned remove+add in one transaction); the externalcalibredbCLI 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, catalogueddata.namewhen 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-15make_backuptakes the opt-in--backup-keep Nring: at most N backup files per book, the author original.baknever deleted, newest content at the highest name; default remains an unbounded rotation.src/bindery/cli.py:repairandlibrarysubcommands, including--alland--install-to-calibre;repair --jsonemits 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: thefixesdict plusncx_uid_synced/watermark_refusalsoff theOutcome, and_phase1_decisionsreads that data instead of substring-matching the renderedsummary, which stays for human eyes; CalibreQuarry's lossy-consent mirror is the waiting consumer, floorbindery>=0.45.0); since v0.29.0 also therunverbs: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) andphase3(the scoped post-import apply step:library --id --sweep --only all --apply --all --install-to-calibreplus 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 asrejected_projection). Plusbindery 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-interactiveand surface open questions asdecisions_neededin their JSON, never prompts. They drive the shippedrun_librarythrough the real parser, so no flag can drift between the verb and the subcommand it wraps. Since v0.46.0cli._flags_from_args(args)is the one place the argparse names map ontoRepairFlagsfields (eachor --all), shared byrun_libraryandrun_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--limitstays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract). Since v0.40.0main()guards the VirInvictus stack: aModuleNotFoundErrornamingvir_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.plugin/__init__.py+scripts/build_plugin.py: the Bindery Repair Calibre plugin (v0.37.0; identityBindery Repair/bindery_repair, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendorstransforms.py,epub.py,pagination.py,watermark.py,reserialize.pybyte-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.ymlattachesBinderyRepair-v<VERSION>.zipto 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 viasite_customization(log,log_path,max_size_mbdefault 150,max_log_mbrotation cap default 2,epubcheck_pathexperimental validation default OFF). Load it in tests through thetests/test_plugin.pystubs: 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'starget-version = "py312"pin (inference from requires-python would rewrite the parens back off),tests/test_version.py's parse guard, and CI'splugin-compatjob byte-compiling the built zip under 3.11/3.12/3.13/3.14.minimum_calibre_versionis (7, 0, 0): the oldest series whose interpreter CI covers, replacing the hollow (2, 0, 0);supported_platformsstays["linux"](developed and tested on Linux only). Any new syntax in the vendored modules must clear the guard before it ships.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 andCalibreIdResolver, 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.pyreflectively pins the RepairFlags inventory to the argparse dests and_flags_from_argsmapping (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'sTestVersionGateKeepsFlagsIntact);tests/test_comment_hyphens.pycarries the RSC-016 fix;tests/test_flags_real_core.pydrives 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.
- Type hints,
from __future__ import annotations, ruff for lint and format. ./run_tests.shruns the suite in the project venv. Since the 3.12 floor,uv run --python XREBUILDS.venvfor interpreter X, and thepython_version >= '3.14'markers then gate vir-tui/cquarry OUT of it below 3.14 (the suite goes red with ModuleNotFoundError: cquarry). After interpreter experiments, rebuild withuv run --python 3.14before running the suite again.VERSIONlives insrc/bindery/__init__.py, mirrored inpyproject.toml. Bump both.- Run tests with
./run_tests.sh.
The library is real data. The loop is always: dry run on /tmp copies, inspect the
report, then apply with backups. epubcheck is the oracle; a repaired book that still has
fatals is partial and must be left for manual work, never auto-applied.