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/__init__.py +12 -0
- bindery/__main__.py +4 -0
- bindery/audit.py +2137 -0
- bindery/cli.py +961 -0
- bindery/epub.py +618 -0
- bindery/library.py +244 -0
- bindery/pagination.py +359 -0
- bindery/reserialize.py +42 -0
- bindery/transforms.py +579 -0
- bindery/validate.py +254 -0
- bindery/watermark.py +244 -0
- bindery_cli-0.27.0.dist-info/METADATA +218 -0
- bindery_cli-0.27.0.dist-info/RECORD +16 -0
- bindery_cli-0.27.0.dist-info/WHEEL +4 -0
- bindery_cli-0.27.0.dist-info/entry_points.txt +2 -0
- bindery_cli-0.27.0.dist-info/licenses/LICENSE +21 -0
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
|