bindery-cli 0.27.0__py3-none-any.whl

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/library.py ADDED
@@ -0,0 +1,244 @@
1
+ """Calibre-library-aware helpers: find books and install a repaired format.
2
+
3
+ File placement is atomic and surgical: the repaired EPUB is written to a temporary
4
+ file in the target's directory, fsynced, then os.replace()d over the original so the
5
+ path and filename Calibre expects never change and no half-written file is ever
6
+ visible. With a resolved book id the ``data`` row follows through cquarry's write
7
+ module (OPF-resync queue included); without one, only the .epub is touched and
8
+ metadata.opf, cover.jpg, and metadata.db are left alone for Calibre's Quality Check
9
+ sync to reconcile. An optional backup is taken first.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import shutil
17
+ import sqlite3
18
+ from pathlib import Path
19
+
20
+
21
+ def iter_epubs(root: Path):
22
+ """Yield every .epub under a Calibre library tree, sorted. Case-insensitive on the
23
+ suffix: Calibre emits lowercase, but a hand-added Book.EPUB should not be invisible."""
24
+ yield from sorted(
25
+ p for p in root.rglob("*.epub", case_sensitive=False) if p.is_file()
26
+ )
27
+
28
+
29
+ def backup_path(epub: Path, backup_dir: Path | None) -> Path:
30
+ """Where the backup of `epub` should go."""
31
+ if backup_dir is None:
32
+ return epub.with_suffix(epub.suffix + ".bak")
33
+ # Mirror Author/Title (id)/file.epub under backup_dir to avoid name collisions.
34
+ return backup_dir / epub.parent.name / epub.name
35
+
36
+
37
+ def make_backup(epub: Path, backup_dir: Path | None) -> Path:
38
+ dst = backup_path(epub, backup_dir)
39
+ dst.parent.mkdir(parents=True, exist_ok=True)
40
+ shutil.copy2(epub, dst)
41
+ return dst
42
+
43
+
44
+ def atomic_replace(target: Path, new_file: Path) -> None:
45
+ """Replace `target` with the contents of `new_file`, atomically and in place.
46
+
47
+ `new_file` is copied into the target's directory first so the final os.replace is a
48
+ same-filesystem rename (atomic). File mode of the original is preserved. On any
49
+ failure the temp file is removed, so a half-written .bindery.tmp never lingers in
50
+ the library.
51
+ """
52
+ # A fresh format's destination does not exist yet; a replacement
53
+ # inherits the original's mode.
54
+ mode = target.stat().st_mode if target.exists() else 0o644
55
+ tmp = target.with_name(target.name + ".bindery.tmp")
56
+ try:
57
+ shutil.copyfile(new_file, tmp)
58
+ os.chmod(tmp, mode)
59
+ with open(tmp, "rb") as fh:
60
+ os.fsync(fh.fileno())
61
+ os.replace(tmp, target)
62
+ except BaseException:
63
+ tmp.unlink(missing_ok=True)
64
+ raise
65
+ # fsync the directory too, so the rename itself survives a crash (either the old
66
+ # or the new file is durable; never a missing or partial one).
67
+ dfd = os.open(target.parent, os.O_RDONLY)
68
+ try:
69
+ os.fsync(dfd)
70
+ finally:
71
+ os.close(dfd)
72
+
73
+
74
+ class CalibreIdResolver:
75
+ """Resolve Calibre book ids from ``metadata.db`` via cquarry — no guessing.
76
+
77
+ Builds a lazy, one-shot map of EPUB path -> book id using cquarry's own
78
+ layout logic (:meth:`CalibreDB.get_format_path`), so the path truth lives
79
+ in exactly one place and a hand-renamed ``Author/Title (id)/`` directory
80
+ can never cause the wrong book to be replaced. Returns ``None`` (caller
81
+ falls back to the directory-name heuristic) when metadata.db is missing,
82
+ unreadable, or the file is not a catalogued EPUB.
83
+ """
84
+
85
+ def __init__(self, library_root: Path) -> None:
86
+ self._root = Path(library_root)
87
+ self._paths: dict[str, int] | None = None
88
+
89
+ def _load(self) -> None:
90
+ if self._paths is not None:
91
+ return
92
+ self._paths = {}
93
+ db_path = self._root / "metadata.db"
94
+ if not db_path.is_file():
95
+ return
96
+ try:
97
+ from cquarry.db import CalibreDB
98
+
99
+ db = CalibreDB(str(db_path))
100
+ except Exception:
101
+ return
102
+ try:
103
+ # cquarry 1.8's format_path_index() is this map, built canonically
104
+ # in one data⋈books query (the per-book get_format_path loop this
105
+ # used to run is the same construction, N queries over). Keys are
106
+ # re-normalized the resolver's historical way — resolve() for
107
+ # symlinked library dirs, .lower() for case-insensitive matching.
108
+ index = db.format_path_index()
109
+ for path, bid in index.items():
110
+ if not path.upper().endswith(".EPUB"):
111
+ continue # the resolver maps EPUBs only
112
+ self._paths[str(Path(path).resolve()).lower()] = bid
113
+ finally:
114
+ db.close()
115
+
116
+ def id_for(self, epub: Path) -> int | None:
117
+ """The catalogued book id for `epub`, or None if not in metadata.db."""
118
+ self._load()
119
+ return self._paths.get(str(Path(epub).resolve()).lower())
120
+
121
+ @property
122
+ def db_path(self) -> Path:
123
+ """The metadata.db this resolver reads its id map from."""
124
+ return self._root / "metadata.db"
125
+
126
+
127
+ def guess_calibre_id(epub: Path) -> str | None:
128
+ """Legacy fallback: pull the id out of the ``(123)`` directory fragment.
129
+
130
+ Only used when cquarry cannot resolve the id (no metadata.db, or the file
131
+ is not catalogued) — the heuristic breaks on renamed directories, which is
132
+ exactly why the resolver above is preferred.
133
+ """
134
+ match = re.search(r"\((\d+)\)/[^/]+\.epub$", str(epub.absolute()))
135
+ return match.group(1) if match else None
136
+
137
+
138
+ def install_format(
139
+ target: Path, new_file: Path, resolver: CalibreIdResolver | None = None
140
+ ) -> None:
141
+ """Install a repaired EPUB as a book's format through cquarry's write module.
142
+
143
+ The book id comes from cquarry's metadata.db view when a resolver is given
144
+ (accurate even for hand-renamed directories); the legacy ``(id)``
145
+ directory-name guess is the fallback, and without any id the repaired
146
+ file is saved atomically in place instead.
147
+
148
+ With a book id, the repaired file is placed in the book's directory —
149
+ an atomic replace over the catalogued file when one exists (same path,
150
+ same ``data.name``; Calibre's layout never changes) — and the ``data``
151
+ row follows through ``WritableCalibreDB``: ``remove_format`` +
152
+ ``add_format`` in one ``batch()`` when the format exists (``add_format``
153
+ refuses duplicates by design), a fresh ``add_format`` otherwise. The
154
+ row's size stays truthful and the book lands in ``metadata_dirtied``, so
155
+ Calibre regenerates its sidecar .opf. Files are the caller's
156
+ responsibility in cquarry; they are placed here, atomically, before the
157
+ row is written, and a failed row update degrades to the in-place save
158
+ with a warning rather than losing the repair.
159
+
160
+ This used to shell out to ``calibredb add_format``; the native path drops
161
+ the external CLI dependency and the flag-shape crash class with it (the
162
+ 2026-08-31 ``--replace`` incident).
163
+ """
164
+ calibre_id: str | None = None
165
+ if resolver is not None:
166
+ bid = resolver.id_for(target)
167
+ if bid is not None:
168
+ calibre_id = str(bid)
169
+ if calibre_id is None:
170
+ calibre_id = guess_calibre_id(target)
171
+ if calibre_id is None:
172
+ import sys
173
+
174
+ print(
175
+ f"WARNING: Could not resolve the Calibre id for {target.name} "
176
+ "(not in metadata.db, no (id) directory). Saving in place instead.",
177
+ file=sys.stderr,
178
+ )
179
+ atomic_replace(target, new_file)
180
+ return
181
+
182
+ if resolver is not None:
183
+ db_path = resolver.db_path
184
+ else:
185
+ # The legacy guess comes from a path inside a library tree whose root
186
+ # only calibredb knew (its default library / CALIBRE_DBPATH). Mirror
187
+ # that contract; with neither, do not guess where to write.
188
+ env = os.environ.get("CALIBRE_DBPATH")
189
+ db_path = Path(env) / "metadata.db" if env else None
190
+ if db_path is None or not db_path.is_file():
191
+ import sys
192
+
193
+ print(
194
+ f"WARNING: No metadata.db found for book {calibre_id} (no resolver "
195
+ "library, CALIBRE_DBPATH unset or missing). Saving in place instead.",
196
+ file=sys.stderr,
197
+ )
198
+ atomic_replace(target, new_file)
199
+ return
200
+
201
+ from cquarry.write import WritableCalibreDB
202
+
203
+ placed = False
204
+ try:
205
+ with WritableCalibreDB(str(db_path)) as wdb:
206
+ row = wdb.conn.execute(
207
+ "SELECT name FROM data WHERE book = ? AND upper(format) = 'EPUB'",
208
+ (int(calibre_id),),
209
+ ).fetchone()
210
+ if row is not None:
211
+ atomic_replace(target, new_file)
212
+ placed = True
213
+ with wdb.batch():
214
+ wdb.remove_format(int(calibre_id), "EPUB")
215
+ wdb.add_format(
216
+ int(calibre_id), "EPUB", row["name"], new_file.stat().st_size
217
+ )
218
+ else:
219
+ book = wdb.conn.execute(
220
+ "SELECT path FROM books WHERE id = ?", (int(calibre_id),)
221
+ ).fetchone()
222
+ dest_dir = Path(db_path).parent / book["path"]
223
+ name = new_file.stem
224
+ dest_dir.mkdir(parents=True, exist_ok=True)
225
+ atomic_replace(dest_dir / f"{name}.epub", new_file)
226
+ placed = True
227
+ with wdb.batch():
228
+ wdb.add_format(
229
+ int(calibre_id), "EPUB", name, new_file.stat().st_size
230
+ )
231
+ except (ValueError, sqlite3.Error) as e:
232
+ import sys
233
+
234
+ if not placed:
235
+ # The repair must never be lost to a database problem: save it in
236
+ # place (same path the library already knows) and say what happened.
237
+ atomic_replace(target, new_file)
238
+ placed = True
239
+ print(
240
+ f"WARNING: the repaired file for book {calibre_id} was saved, but the "
241
+ f"database row could not be updated ({e}); Calibre may show a stale "
242
+ "size until its next metadata refresh.",
243
+ file=sys.stderr,
244
+ )
bindery/pagination.py ADDED
@@ -0,0 +1,359 @@
1
+ """Opt-in lossy repair: strip print page numbers (and running headers) baked into
2
+ the body text by a PDF/OCR conversion.
3
+
4
+ This is deliberately NOT one of the core transforms. Bindery's other fixes are
5
+ semantics-preserving (they render identically to the author's intent); this one
6
+ removes visible content the author never wrote (a converter's page numbers and
7
+ running headers) and, where such a number split a sentence, rejoins the two
8
+ paragraphs. It is therefore gated differently (epubcheck must be no worse, since
9
+ the gain is invisible to epubcheck) and is off unless --strip-pagination is given.
10
+
11
+ The detection mirrors CalibreQuarry's bindery audit_pagenumbers.py: a standalone
12
+ <p> whose whole text is a bare number is only treated as baked when it genuinely
13
+ interrupts prose. Merging happens only on the two confident interrupt signals (a
14
+ lowercase continuation after the number, or a word split across it); otherwise the
15
+ number is deleted and the paragraph break is left as-is. Running headers/footers
16
+ are detected as short blocks repeated across the whole book.
17
+
18
+ Three independent safety nets, any failure aborts the edit and returns the
19
+ document unchanged:
20
+ 1. character conservation: every visible character of real prose is preserved
21
+ (only the removed numbers/headers disappear, and word-split hyphens close up);
22
+ 2. tag balance: <p> and <a> stay balanced after splicing;
23
+ 3. the caller's epubcheck gate (no net-new fatals or errors) is the final oracle.
24
+
25
+ Scope is intentionally `<p>` elements only, which is where the defect is carried
26
+ in every observed case; this keeps the splicing well understood.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import html as _html
32
+ import re
33
+ from collections import Counter
34
+
35
+ PROSE_MIN = 120 # a neighbour this long counts as a real prose paragraph
36
+ RUNHEAD_MIN_REPEAT = 8 # a short block repeated this often is a running header
37
+ RUNHEAD_MAX_LEN = 60 # running headers are short
38
+
39
+ # <p> cannot nest <p> in valid HTML, so a non-greedy match to the next </p> is exact.
40
+ _P_RE = re.compile(r"<p\b[^>]*>.*?</p>", re.IGNORECASE | re.DOTALL)
41
+ _TAG_RE = re.compile(r"<[^>]+>")
42
+ _WS_RE = re.compile(r"\s+")
43
+ _INT_RE = re.compile(r"\d{1,4}\Z")
44
+ _ROMAN_RE = re.compile(r"[ivxlcdm]{2,7}\Z", re.IGNORECASE)
45
+ # An element carrying an id is a navigation target (page-list, internal link); it must
46
+ # survive even when its visible number is removed, or the nav breaks. That covers both
47
+ # <a id=...> anchors inside a removed block and an id on the removed <p> itself.
48
+ _ID_ANCHOR_RE = re.compile(
49
+ r"<a\b[^>]*\bid=(?:\"[^\"]*\"|'[^']*')[^>]*>.*?</a>"
50
+ r"|<a\b[^>]*\bid=(?:\"[^\"]*\"|'[^']*')[^>]*/>",
51
+ re.IGNORECASE | re.DOTALL,
52
+ )
53
+ _OPEN_ID_RE = re.compile(r"\bid=(\"[^\"]*\"|'[^']*')", re.IGNORECASE)
54
+ # strip a trailing hyphen that ends the visible text, even if closing tags follow it
55
+ _TRAIL_HYPHEN_RE = re.compile(r"-(\s*(?:</[a-zA-Z][^>]*>\s*)*)\Z")
56
+
57
+
58
+ def roman_value(s: str) -> int | None:
59
+ vals = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000}
60
+ total = 0
61
+ s = s.lower()
62
+ for i, c in enumerate(s):
63
+ if c not in vals:
64
+ return None
65
+ v = vals[c]
66
+ total += -v if (i + 1 < len(s) and vals[s[i + 1]] > v) else v
67
+ return total or None
68
+
69
+
70
+ def number_value(text: str) -> int | None:
71
+ """A bare page-number-ish value (1-9999 arabic, or a roman numeral), else None.
72
+ Year-range values (1500-2099) are excluded: those are chronologies, not pages."""
73
+ if _INT_RE.fullmatch(text):
74
+ v = int(text)
75
+ return None if 1500 <= v <= 2099 else v
76
+ if _ROMAN_RE.fullmatch(text):
77
+ return roman_value(text)
78
+ return None
79
+
80
+
81
+ def _visible(inner_html: str) -> str:
82
+ """The collapsed, entity-decoded visible text of a fragment."""
83
+ return _WS_RE.sub(" ", _html.unescape(_TAG_RE.sub(" ", inner_html))).strip()
84
+
85
+
86
+ def _char_norm(s: str) -> str:
87
+ """Lowercase, dropping whitespace and hyphens: the canonical form for the
88
+ character-conservation check (so a word split closing up reads as no change)."""
89
+ return re.sub(r"[\s\-‐‑]", "", _html.unescape(s).lower())
90
+
91
+
92
+ def collect_runheads(htmls: list[str]) -> set[str]:
93
+ """Running headers/footers/watermarks: short non-numeric blocks repeated across
94
+ the whole book. Whole-book scope is why this takes every content document."""
95
+ counter: Counter[str] = Counter()
96
+ for html_text in htmls:
97
+ for m in _P_RE.finditer(html_text):
98
+ text = _visible(m.group(0)[m.group(0).index(">") + 1 : -4])
99
+ if text and len(text) <= RUNHEAD_MAX_LEN and number_value(text) is None:
100
+ counter[text] += 1
101
+ return {t for t, n in counter.items() if n >= RUNHEAD_MIN_REPEAT}
102
+
103
+
104
+ def detect_page_layer(htmls: list[str], runheads: set[str]) -> bool:
105
+ """Decide whether a book has a genuine print-page-number layer, which licenses the
106
+ aggressive between-paragraph deletion. Two signals must BOTH hold, so a merely
107
+ chapter-numbered book is never mistaken for a paginated one:
108
+
109
+ 1. a substantial body of standalone arabic numbers (>= 20), more than any chapter
110
+ list; and
111
+ 2. several confident sentence interrupts (>= 3), the fingerprint of page numbers
112
+ bleeding into the text flow. Chapter numbers open chapters with a capital, so a
113
+ chapter-numbered book scores zero here regardless of how many chapters it has.
114
+
115
+ The interrupt count, not an ascending run, is the discriminator, because content
116
+ documents are visited in archive order (not reading order), which scrambles any
117
+ cross-file sequence."""
118
+ vals = 0
119
+ confident = 0
120
+ for html_text in htmls:
121
+ blocks = [_Block(m) for m in _P_RE.finditer(html_text)]
122
+ for b in blocks:
123
+ if b.kind == "prose" and b.text in runheads:
124
+ b.kind = "runhead"
125
+ for i, b in enumerate(blocks):
126
+ if b.kind == "number":
127
+ if b.text.isdigit():
128
+ vals += 1
129
+ if _is_baked(blocks, i)[1]:
130
+ confident += 1
131
+ return vals >= 20 and confident >= 3
132
+
133
+
134
+ class _Block:
135
+ __slots__ = ("start", "end", "open_tag", "inner", "text", "kind", "num")
136
+
137
+ def __init__(self, m: re.Match):
138
+ full = m.group(0)
139
+ gt = full.index(">") + 1
140
+ self.start = m.start()
141
+ self.end = m.end()
142
+ self.open_tag = full[:gt]
143
+ self.inner = full[gt:-4] # strip the trailing </p>
144
+ self.text = _visible(self.inner)
145
+ self.num = number_value(self.text)
146
+ if self.num is not None:
147
+ self.kind = "number"
148
+ elif self.text == "":
149
+ self.kind = "empty"
150
+ else:
151
+ self.kind = "prose" # 'runhead' is assigned later, needs the global set
152
+
153
+ @property
154
+ def is_prose(self) -> bool:
155
+ return self.kind == "prose"
156
+
157
+
158
+ def _nearest(blocks: list[_Block], i: int, step: int):
159
+ """The nearest non-empty block in direction `step`, or None. An empty <p> is
160
+ layout padding, so it never counts as the neighbour of a page number."""
161
+ j = i + step
162
+ while 0 <= j < len(blocks):
163
+ if blocks[j].kind != "empty":
164
+ return j
165
+ j += step
166
+ return None
167
+
168
+
169
+ def _is_baked(blocks: list[_Block], i: int) -> tuple[bool, bool]:
170
+ """Return (baked, confident_interrupt) for the number block at index i."""
171
+ pp = _nearest_prose(blocks, i, -1)
172
+ np = _nearest_prose(blocks, i, +1)
173
+ prose_prev = pp is not None and len(blocks[pp].text) > PROSE_MIN
174
+ prose_next = np is not None and len(blocks[np].text) > PROSE_MIN
175
+ if not (prose_prev or prose_next):
176
+ return False, False
177
+ prev_ne = _nearest(blocks, i, -1)
178
+ next_ne = _nearest(blocks, i, +1)
179
+ word_split = bool(pp is not None and _visible(blocks[pp].inner).endswith("-"))
180
+ lower_cont = bool(
181
+ next_ne is not None
182
+ and blocks[next_ne].is_prose
183
+ and blocks[next_ne].text[:1].islower()
184
+ )
185
+ prev_runhead = prev_ne is not None and blocks[prev_ne].kind == "runhead"
186
+ next_runhead = next_ne is not None and blocks[next_ne].kind == "runhead"
187
+ prev_unfinished = bool(
188
+ prose_prev
189
+ and pp is not None
190
+ and (blocks[pp].text[-1].islower() or blocks[pp].text[-1] == ",")
191
+ )
192
+ confident = word_split or lower_cont
193
+ baked = (
194
+ confident
195
+ or (prev_unfinished and (prose_next or next_runhead))
196
+ or (prev_runhead and next_runhead)
197
+ )
198
+ return baked, confident
199
+
200
+
201
+ def _nearest_prose(blocks: list[_Block], i: int, step: int):
202
+ j = i + step
203
+ while 0 <= j < len(blocks):
204
+ if blocks[j].is_prose:
205
+ return j
206
+ j += step
207
+ return None
208
+
209
+
210
+ def strip_pagination_doc(
211
+ html_text: str, runheads: set[str], delete_layer: bool = False
212
+ ) -> tuple[str, int]:
213
+ """Remove baked page numbers/running headers from one content document.
214
+
215
+ A number is removed when it is a "baked" interrupt (the conservative default) or,
216
+ when `delete_layer` is set (the book has a confirmed dense page-number layer), when
217
+ it is an arabic page number sitting in the body text. Paragraphs are rejoined ONLY
218
+ on a confident interrupt (lowercase continuation or word split); every other removal
219
+ is delete-only, leaving the paragraph break as-is. Roman numerals are removed only on
220
+ a confident interrupt, so roman chapter/front-matter numbering is preserved.
221
+
222
+ Returns (new_html, blocks_removed); the input unchanged (count 0) if a safety net
223
+ fails."""
224
+ blocks = [_Block(m) for m in _P_RE.finditer(html_text)]
225
+ if not blocks:
226
+ return html_text, 0
227
+ for b in blocks:
228
+ if b.kind == "prose" and b.text in runheads:
229
+ b.kind = "runhead"
230
+
231
+ prose_idx = [i for i, b in enumerate(blocks) if b.is_prose]
232
+ drop: set[int] = set()
233
+ unions: list[tuple[int, int]] = [] # (left prose idx, right prose idx) to merge
234
+
235
+ # Block-centric: decide each number independently, so page numbers are caught
236
+ # wherever they sit (between prose, beside a heading, next to a chapter number).
237
+ for i, b in enumerate(blocks):
238
+ if b.kind != "number":
239
+ continue
240
+ baked, confident = _is_baked(blocks, i)
241
+ is_arabic = b.text.isdigit()
242
+ pp = _nearest_prose(blocks, i, -1)
243
+ np = _nearest_prose(blocks, i, +1)
244
+ # In a confirmed page-layer book every standalone arabic <p>N</p> is a page
245
+ # number (char conservation guarantees only digits are lost), so the layer path
246
+ # needs no prose-proximity gate; that gate would miss numbers among short
247
+ # dialogue lines. Roman numerals still fall through (chapter numbers preserved).
248
+ removable = baked or (delete_layer and is_arabic)
249
+ if not removable:
250
+ continue # roman chapter numbers, sparse-book numbers, year-range values
251
+ drop.add(i)
252
+ # a running header/footer hugging a removed number is the same page furniture
253
+ for side in (_nearest(blocks, i, -1), _nearest(blocks, i, +1)):
254
+ if side is not None and blocks[side].kind == "runhead":
255
+ drop.add(side)
256
+ if confident and pp is not None and np is not None:
257
+ unions.append((pp, np))
258
+ for j in range(pp + 1, np): # cruft between the merged halves
259
+ if blocks[j].kind in ("number", "runhead", "empty"):
260
+ drop.add(j)
261
+
262
+ if not drop and not unions:
263
+ return html_text, 0
264
+
265
+ # Union consecutive prose blocks into merge groups.
266
+ parent = {i: i for i in prose_idx}
267
+
268
+ def find(x):
269
+ while parent[x] != x:
270
+ parent[x] = parent[parent[x]]
271
+ x = parent[x]
272
+ return x
273
+
274
+ for a, c in unions:
275
+ parent[find(c)] = find(a)
276
+ groups: dict[int, list[int]] = {}
277
+ for i in prose_idx:
278
+ groups.setdefault(find(i), []).append(i)
279
+
280
+ # Build the splice edits: (start, end, replacement).
281
+ edits: list[tuple[int, int, str]] = []
282
+ handled: set[int] = set()
283
+ for members in groups.values():
284
+ members.sort()
285
+ if len(members) == 1:
286
+ continue # untouched prose paragraph
287
+ first, last = members[0], members[-1]
288
+ # Everything in [first.start, last.end] is replaced by one merged <p>.
289
+ anchors: list[str] = []
290
+ for j in range(first, last + 1):
291
+ if j in members:
292
+ continue
293
+ m_id = _OPEN_ID_RE.search(blocks[j].open_tag)
294
+ if m_id:
295
+ # A removed <p id=...> cannot keep its shell inside the merged <p>
296
+ # (p cannot nest p), so its id survives as an empty anchor.
297
+ anchors.append(f"<a id={m_id.group(1)}/>")
298
+ anchors += _ID_ANCHOR_RE.findall(blocks[j].inner)
299
+ parts = [blocks[members[0]].inner]
300
+ for k in range(1, len(members)):
301
+ left = blocks[members[k - 1]]
302
+ if _visible(left.inner).endswith("-"):
303
+ parts[-1] = _TRAIL_HYPHEN_RE.sub(r"\1", parts[-1])
304
+ sep = ""
305
+ else:
306
+ sep = " "
307
+ parts.append(sep + blocks[members[k]].inner)
308
+ merged_inner = parts[0] + "".join(anchors) + "".join(parts[1:])
309
+ merged = blocks[first].open_tag + merged_inner + "</p>"
310
+ edits.append((blocks[first].start, blocks[last].end, merged))
311
+ for j in range(first, last + 1):
312
+ handled.add(j)
313
+
314
+ # Delete-only drops that are not inside a merge span.
315
+ for j in sorted(drop):
316
+ if j in handled:
317
+ continue
318
+ b = blocks[j]
319
+ anchors = _ID_ANCHOR_RE.findall(b.inner)
320
+ if anchors or _OPEN_ID_RE.search(b.open_tag):
321
+ # An id anywhere in the block is a navigation target (page-list, internal
322
+ # link): keep an emptied shell so those references still resolve.
323
+ repl = b.open_tag + "".join(anchors) + "</p>"
324
+ else:
325
+ repl = ""
326
+ edits.append((b.start, b.end, repl))
327
+
328
+ edits.sort(key=lambda e: e[0], reverse=True)
329
+ out = html_text
330
+ for start, end, repl in edits:
331
+ out = out[:start] + repl + out[end:]
332
+
333
+ removed = sum(1 for j in drop if blocks[j].kind == "number")
334
+ if removed == 0:
335
+ return html_text, 0
336
+ if not _safe(html_text, out, blocks, drop):
337
+ return html_text, 0
338
+ return out, removed
339
+
340
+
341
+ def _safe(before: str, after: str, blocks: list[_Block], drop: set[int]) -> bool:
342
+ """Character conservation + tag balance. Either failing means the splice went
343
+ wrong, so the caller keeps the original document."""
344
+ removed_chars = Counter()
345
+ for j in drop:
346
+ removed_chars += Counter(_char_norm(blocks[j].text))
347
+ if Counter(_char_norm(_visible(after))) + removed_chars != Counter(
348
+ _char_norm(_visible(before))
349
+ ):
350
+ return False
351
+ for tag in ("p", "a"):
352
+ # Count opening tags that are NOT self-closing (`<p/>` needs no `</p>`), so
353
+ # the pre-existing self-closing tags these messy EPUBs carry do not read as an
354
+ # imbalance. A correct splice keeps real opens == closes.
355
+ opens = len(re.findall(rf"<{tag}\b[^>]*?(?<!/)>", after, re.IGNORECASE))
356
+ closes = len(re.findall(rf"</{tag}\b", after, re.IGNORECASE))
357
+ if opens != closes:
358
+ return False
359
+ return True
bindery/reserialize.py ADDED
@@ -0,0 +1,42 @@
1
+ """Last-resort structural repair: re-parse a malformed document and re-emit it as
2
+ well-formed XHTML.
3
+
4
+ This is the only part of Bindery that is not a minimal, byte-level edit: it parses the
5
+ whole document with html5lib's lenient HTML5 parser (the same recovery a browser does)
6
+ and serializes the result back as XHTML, which closes unclosed elements (`<p>`, `<div>`,
7
+ `<span>`, `<blockquote>`, ...) that the regex transforms cannot. Because it reformats,
8
+ it runs only on documents that are *not* already well-formed, leaving good files exactly
9
+ as they are, and only when the user opts in with --reserialize. html5lib is imported
10
+ lazily so the rest of Bindery has no third-party dependency.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import xml.etree.ElementTree as ET
16
+
17
+ XHTML_NS = "http://www.w3.org/1999/xhtml"
18
+
19
+
20
+ def reserialize_if_broken(s: str) -> tuple[str, int]:
21
+ """If `s` is not well-formed XML, re-parse it leniently and re-emit as XHTML.
22
+
23
+ Returns (text, 1) if it was rebuilt, or (s, 0) if it already parsed. Raises
24
+ RuntimeError if html5lib is needed but not installed.
25
+ """
26
+ try:
27
+ ET.fromstring(s)
28
+ return s, 0
29
+ except ET.ParseError:
30
+ pass
31
+
32
+ try:
33
+ import html5lib
34
+ except ImportError as e: # pragma: no cover
35
+ raise RuntimeError(
36
+ "--reserialize requires html5lib (install it: uv pip install html5lib)"
37
+ ) from e
38
+
39
+ root = html5lib.parse(s, treebuilder="etree", namespaceHTMLElements=False)
40
+ root.set("xmlns", XHTML_NS)
41
+ body = ET.tostring(root, encoding="unicode")
42
+ return f'<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE html>\n{body}', 1