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/epub.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""EPUB-level repair: apply the text transforms across an archive and rewrite it.
|
|
2
|
+
|
|
3
|
+
Like oceanstrip's rewrite, this copies entries one at a time and forces the mimetype
|
|
4
|
+
entry first and stored, so the output is never less conformant than the input. Content
|
|
5
|
+
documents get the full HTML transform pipeline; the NCX sidecar gets the lighter XML
|
|
6
|
+
pipeline plus a dtb:uid sync to the OPF unique identifier (the NCX-001 fix). The OPF
|
|
7
|
+
itself is left untouched to keep Calibre's embedded metadata pristine.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import zipfile
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .pagination import collect_runheads, detect_page_layer, strip_pagination_doc
|
|
18
|
+
from .reserialize import reserialize_if_broken
|
|
19
|
+
from .transforms import (
|
|
20
|
+
HTML_TRANSFORMS,
|
|
21
|
+
XML_TRANSFORMS,
|
|
22
|
+
add_img_alt,
|
|
23
|
+
apply_transforms,
|
|
24
|
+
css_protected_tags,
|
|
25
|
+
escape_unknown_entities,
|
|
26
|
+
fix_empty_body,
|
|
27
|
+
fix_id_colons,
|
|
28
|
+
fix_missing_title,
|
|
29
|
+
strip_broken_tags,
|
|
30
|
+
strip_invalid_attributes,
|
|
31
|
+
strip_invalid_value,
|
|
32
|
+
style_block_tags,
|
|
33
|
+
unwrap_block_in_inline,
|
|
34
|
+
unwrap_illegal_tags,
|
|
35
|
+
)
|
|
36
|
+
from .watermark import strip_watermark_html
|
|
37
|
+
|
|
38
|
+
CONTENT_SUFFIXES = (".xhtml", ".html", ".htm", ".xml")
|
|
39
|
+
|
|
40
|
+
# The OCF-mandated content of the mimetype entry: exact bytes, no trailing newline.
|
|
41
|
+
MIMETYPE = b"application/epub+zip"
|
|
42
|
+
# The timestamp for a mimetype entry we are adding, where there is nothing to inherit.
|
|
43
|
+
# A constant, not the wall clock, so repairing the same book twice is byte-identical.
|
|
44
|
+
# 1980-01-01 is the earliest a zip can represent.
|
|
45
|
+
MIMETYPE_EPOCH = (1980, 1, 1, 0, 0, 0)
|
|
46
|
+
|
|
47
|
+
# Stray marker entries injected by producers (case-insensitive base name)
|
|
48
|
+
MARKER_NAMES = {"oceanofpdf.com"}
|
|
49
|
+
|
|
50
|
+
# Attribute regexes accept either quote style: a single-quoting toolchain would
|
|
51
|
+
# otherwise make the NCX-001 sync and OPF location silently no-op. The ([\"']) group
|
|
52
|
+
# plus the tempered (?:(?!\1).)* body match a value up to its own quote character.
|
|
53
|
+
_UID_ATTR_RE = re.compile(r"unique-identifier=([\"'])((?:(?!\1).)+)\1")
|
|
54
|
+
_ITEM_ID_RE = re.compile(r'(<item\b[^>]*?\bid=")([^"]*)(")', re.IGNORECASE)
|
|
55
|
+
_ROOTFILE_RE = re.compile(r"full-path=([\"'])((?:(?!\1).)+)\1")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _locate_opf(z: zipfile.ZipFile) -> str | None:
|
|
59
|
+
"""The package document path, from META-INF/container.xml when possible.
|
|
60
|
+
|
|
61
|
+
Falling back to the first .opf in archive order is a last resort: broken EPUBs
|
|
62
|
+
sometimes carry stray duplicate .opf entries, and picking the wrong one would
|
|
63
|
+
sync the wrong uid into the NCX.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
container = z.read("META-INF/container.xml").decode("utf-8", "replace")
|
|
67
|
+
except KeyError:
|
|
68
|
+
container = ""
|
|
69
|
+
m = _ROOTFILE_RE.search(container)
|
|
70
|
+
if m and m.group(2) in z.namelist():
|
|
71
|
+
return m.group(2)
|
|
72
|
+
return next((n for n in z.namelist() if n.lower().endswith(".opf")), None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_invalid_ncname(s: str) -> bool:
|
|
76
|
+
"""True if `s` cannot be an XML id (NCName): empty, leading non-letter/underscore,
|
|
77
|
+
or containing a colon. This is what epubcheck flags as RSC-005 'must be an XML name'."""
|
|
78
|
+
if not s:
|
|
79
|
+
return True
|
|
80
|
+
if ":" in s:
|
|
81
|
+
return True
|
|
82
|
+
return not (s[0].isalpha() or s[0] == "_")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _plan_renames(existing: set[str]) -> dict[str, str]:
|
|
86
|
+
"""Map every invalid id in `existing` to a valid replacement no other id claims.
|
|
87
|
+
|
|
88
|
+
Iteration is sorted rather than in set order because two invalid ids can want the
|
|
89
|
+
same replacement (`1:2` and `1_2` both yield `id_1_2`); set order made which one
|
|
90
|
+
got the extra `_` prefix depend on the hash seed, so the same book repaired to
|
|
91
|
+
different bytes from run to run.
|
|
92
|
+
"""
|
|
93
|
+
rename: dict[str, str] = {}
|
|
94
|
+
taken = set(existing)
|
|
95
|
+
for old in sorted(existing):
|
|
96
|
+
if not _is_invalid_ncname(old):
|
|
97
|
+
continue
|
|
98
|
+
new = "id_" + old.replace(":", "_")
|
|
99
|
+
while new in taken:
|
|
100
|
+
new = "_" + new
|
|
101
|
+
taken.add(new)
|
|
102
|
+
rename[old] = new
|
|
103
|
+
return rename
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fix_manifest_ids(opf_text: str) -> tuple[str, int]:
|
|
107
|
+
"""Rename manifest item ids that are not valid XML names (e.g. start with a digit)
|
|
108
|
+
and update every reference to them: spine idref, spine toc, item fallback and
|
|
109
|
+
media-overlay, and the EPUB 2 cover meta. Returns (text, count).
|
|
110
|
+
|
|
111
|
+
Calibre-converted books often carry manifest ids copied from random filenames that
|
|
112
|
+
start with a digit; epubcheck rejects them. The href/filenames are untouched.
|
|
113
|
+
"""
|
|
114
|
+
rename = _plan_renames({m.group(2) for m in _ITEM_ID_RE.finditer(opf_text)})
|
|
115
|
+
if not rename:
|
|
116
|
+
return opf_text, 0
|
|
117
|
+
|
|
118
|
+
def repl_attr(m: re.Match) -> str:
|
|
119
|
+
return m.group(1) + rename.get(m.group(2), m.group(2)) + m.group(3)
|
|
120
|
+
|
|
121
|
+
out = _ITEM_ID_RE.sub(repl_attr, opf_text)
|
|
122
|
+
out = re.sub(r'(\bidref=")([^"]*)(")', repl_attr, out)
|
|
123
|
+
out = re.sub(r'(\bfallback=")([^"]*)(")', repl_attr, out)
|
|
124
|
+
out = re.sub(r'(\bmedia-overlay=")([^"]*)(")', repl_attr, out)
|
|
125
|
+
out = re.sub(
|
|
126
|
+
r'(<spine\b[^>]*?\btoc=")([^"]*)(")', repl_attr, out, flags=re.IGNORECASE
|
|
127
|
+
)
|
|
128
|
+
# The EPUB 2 cover convention points at a manifest id; Calibre and most readers
|
|
129
|
+
# find the cover through it, so a renamed cover item must be re-pointed.
|
|
130
|
+
out = re.sub(
|
|
131
|
+
r'(<meta\b[^>]*\bname="cover"[^>]*\bcontent=")([^"]*)(")',
|
|
132
|
+
repl_attr,
|
|
133
|
+
out,
|
|
134
|
+
flags=re.IGNORECASE,
|
|
135
|
+
)
|
|
136
|
+
out = re.sub(
|
|
137
|
+
r'(<meta\b[^>]*\bcontent=")([^"]*)("[^>]*\bname="cover")',
|
|
138
|
+
repl_attr,
|
|
139
|
+
out,
|
|
140
|
+
flags=re.IGNORECASE,
|
|
141
|
+
)
|
|
142
|
+
return out, len(rename)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Any id attribute, either quote style (the OPF _ITEM_ID_RE is item-specific and
|
|
146
|
+
# double-quote-only; NCX toolchains emit both styles).
|
|
147
|
+
_XML_ID_RE = re.compile(r"""(\bid=)(["'])((?:(?!\2).)*)(\2)""", re.IGNORECASE)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def fix_ncx_ids(ncx_text: str) -> tuple[str, int]:
|
|
151
|
+
"""Rename NCX ids that are not valid XML names (RSC-005), same scheme as
|
|
152
|
+
fix_manifest_ids. Returns (text, count of ids renamed).
|
|
153
|
+
|
|
154
|
+
Old conversions stamp navPoint ids from UUIDs (digit-led) or colon-bearing
|
|
155
|
+
strings; epubcheck rejects every one. Unlike manifest ids, NCX ids are internal
|
|
156
|
+
to the NCX (nothing in the OPF or content documents references a navPoint id),
|
|
157
|
+
so the rename needs no cross-file bookkeeping.
|
|
158
|
+
"""
|
|
159
|
+
rename = _plan_renames({m.group(3) for m in _XML_ID_RE.finditer(ncx_text)})
|
|
160
|
+
if not rename:
|
|
161
|
+
return ncx_text, 0
|
|
162
|
+
|
|
163
|
+
def repl(m: re.Match) -> str:
|
|
164
|
+
return m.group(1) + m.group(2) + rename.get(m.group(3), m.group(3)) + m.group(4)
|
|
165
|
+
|
|
166
|
+
return _XML_ID_RE.sub(repl, ncx_text), len(rename)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Both accept single or double quotes; group 3 is the uid value and group(1)+group(4)
|
|
170
|
+
# reconstruct everything around it, so the replacement logic is quote-agnostic too.
|
|
171
|
+
_DTB_UID_RE = re.compile(
|
|
172
|
+
r"(<meta\b[^>]*\bname=[\"']dtb:uid[\"'][^>]*\bcontent=([\"']))((?:(?!\2).)*)(\2)",
|
|
173
|
+
re.IGNORECASE,
|
|
174
|
+
)
|
|
175
|
+
_DTB_UID_RE_REV = re.compile(
|
|
176
|
+
r"(<meta\b[^>]*\bcontent=([\"']))((?:(?!\2).)*)"
|
|
177
|
+
r"(\2[^>]*\bname=[\"']dtb:uid[\"'][^>]*>)",
|
|
178
|
+
re.IGNORECASE,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class RepairReport:
|
|
184
|
+
"""What a repair did, aggregated across the archive."""
|
|
185
|
+
|
|
186
|
+
fixes: dict[str, int] = field(default_factory=dict)
|
|
187
|
+
files_changed: int = 0
|
|
188
|
+
ncx_uid_synced: bool = False
|
|
189
|
+
|
|
190
|
+
def add(self, counts: dict[str, int]) -> None:
|
|
191
|
+
for k, v in counts.items():
|
|
192
|
+
self.fixes[k] = self.fixes.get(k, 0) + v
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def total(self) -> int:
|
|
196
|
+
return sum(self.fixes.values()) + (1 if self.ncx_uid_synced else 0)
|
|
197
|
+
|
|
198
|
+
def __bool__(self) -> bool:
|
|
199
|
+
return self.total > 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
_SPINE_PAGE_MAP_RE = re.compile(r"(<spine\b[^>]*?)\s+page-map=(?:\"[^\"]*\"|'[^']*')")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def strip_page_map(opf_text: str) -> tuple[str, int]:
|
|
206
|
+
"""Remove the non-standard ``page-map`` attribute from ``<spine>``.
|
|
207
|
+
|
|
208
|
+
Older conversion pipelines (HarperCollins / Anna's Archive output) stamp
|
|
209
|
+
``<spine ... page-map="page-map">``; the attribute is not part of the OPF
|
|
210
|
+
schema and epubcheck rejects the package over it. The page-map file itself
|
|
211
|
+
(if any) is left alone — this only drops the dangling reference.
|
|
212
|
+
"""
|
|
213
|
+
return _SPINE_PAGE_MAP_RE.subn(r"\1", opf_text)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_PAGELIST_OPEN_RE = re.compile(r"<pageList(?=[\s/>])([^>]*)")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def fix_pagelist_class(ncx_text: str) -> tuple[str, int]:
|
|
220
|
+
"""Add ``class="pages"`` to ``<pageList>`` elements missing the attribute.
|
|
221
|
+
|
|
222
|
+
NCX 2.x requires ``class`` on ``<pageList>``; older conversions leave it
|
|
223
|
+
out and epubcheck answers RSC-005 'missing required attribute "class"'.
|
|
224
|
+
``class="pages"`` is the value upstream's own tooling emits, and a
|
|
225
|
+
pageList that already carries a class is left untouched.
|
|
226
|
+
"""
|
|
227
|
+
count = 0
|
|
228
|
+
|
|
229
|
+
def _add(m: re.Match) -> str:
|
|
230
|
+
nonlocal count
|
|
231
|
+
attrs = m.group(1)
|
|
232
|
+
if re.search(r"\bclass\s*=", attrs, re.IGNORECASE):
|
|
233
|
+
return m.group(0)
|
|
234
|
+
count += 1
|
|
235
|
+
return f'<pageList class="pages"{attrs}'
|
|
236
|
+
|
|
237
|
+
return _PAGELIST_OPEN_RE.sub(_add, ncx_text), count
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
_EPUB3_ATTR_RE = re.compile(
|
|
241
|
+
r"\s+(?:page-progression-direction|epub:type|aria-label)"
|
|
242
|
+
r'=(?:"[^"]*"|\'[^\']*\')'
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def strip_epub3_attributes(text: str) -> tuple[str, int]:
|
|
247
|
+
"""Scrub the EPUB3-only attributes older conversions sprinkle onto EPUB2
|
|
248
|
+
documents: ``page-progression-direction``, ``epub:type`` and
|
|
249
|
+
``aria-label`` — epubcheck answers RSC-005 for each on an EPUB2 package
|
|
250
|
+
(the 2026-09-02 verdict: scrub, not tolerate). Rendering is unchanged;
|
|
251
|
+
none of them carries visible content. The set is fixed and documented:
|
|
252
|
+
extend it only with a named epubcheck finding, never speculatively, so a
|
|
253
|
+
reader-legitimate attribute can never be swept up by accident.
|
|
254
|
+
"""
|
|
255
|
+
return _EPUB3_ATTR_RE.subn("", text)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# The EPUB3/HTML5 semantic elements an EPUB2 (XHTML 1.1) document cannot
|
|
259
|
+
# carry, and the element each downgrades to. figcaption becomes a paragraph
|
|
260
|
+
# (its content is phrasing text); figure and section become divs.
|
|
261
|
+
EPUB3_DOWNGRADE_TAGS = {"figure": "div", "figcaption": "p", "section": "div"}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _append_class(attrs: str, name: str) -> str:
|
|
265
|
+
"""Add `name` to the class attribute in `attrs` (existing classes kept)."""
|
|
266
|
+
class_re = re.compile(r"class=(?:\"([^\"]*)\"|'([^']*)')")
|
|
267
|
+
m = class_re.search(attrs)
|
|
268
|
+
if m:
|
|
269
|
+
existing = m.group(1) if m.group(1) is not None else m.group(2)
|
|
270
|
+
quote = '"' if m.group(1) is not None else "'"
|
|
271
|
+
merged = f"{existing} {name}".strip()
|
|
272
|
+
return class_re.sub(
|
|
273
|
+
lambda _: f"class={quote}{merged}{quote}",
|
|
274
|
+
attrs,
|
|
275
|
+
count=1,
|
|
276
|
+
)
|
|
277
|
+
return f'{attrs} class="{name}"'
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def downgrade_epub3_tags(
|
|
281
|
+
text: str, protected_tags: frozenset[str] = frozenset()
|
|
282
|
+
) -> tuple[str, int]:
|
|
283
|
+
"""Downgrade EPUB3/HTML5 semantic elements to their EPUB2 equivalents.
|
|
284
|
+
|
|
285
|
+
`<figure>` becomes a `<div class="figure ...">`, `<figcaption>` a
|
|
286
|
+
`<p class="figcaption ...">`, `<section>` a `<div class="section ...">`;
|
|
287
|
+
existing classes are kept so class-selector stylesheets keep working, and
|
|
288
|
+
the semantic name is appended as the styling hook. Names in
|
|
289
|
+
`protected_tags` (element-selector references in the book's stylesheets,
|
|
290
|
+
see transforms.css_protected_tags with tags=EPUB3_DOWNGRADE_TAGS) are left
|
|
291
|
+
untouched: if a book styles `figure { ... }`, downgrading it would change
|
|
292
|
+
how the text renders, so preservation wins — and the book keeps its
|
|
293
|
+
RSC-005 findings, which is the honest outcome.
|
|
294
|
+
"""
|
|
295
|
+
count = 0
|
|
296
|
+
for tag, new in EPUB3_DOWNGRADE_TAGS.items():
|
|
297
|
+
if tag in protected_tags:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
def repl_open(m: re.Match, tag: str = tag, new: str = new) -> str:
|
|
301
|
+
nonlocal count
|
|
302
|
+
attrs = m.group(1)
|
|
303
|
+
self_close = attrs.rstrip().endswith("/")
|
|
304
|
+
if self_close:
|
|
305
|
+
attrs = attrs.rstrip()[:-1]
|
|
306
|
+
count += 1
|
|
307
|
+
return f"<{new}{_append_class(attrs, tag)}{'/' if self_close else ''}>"
|
|
308
|
+
|
|
309
|
+
def repl_end(m: re.Match, new: str = new) -> str:
|
|
310
|
+
nonlocal count
|
|
311
|
+
count += 1
|
|
312
|
+
return f"</{new}>"
|
|
313
|
+
|
|
314
|
+
text = re.sub(rf"<{tag}\b([^>]*)>", repl_open, text, flags=re.IGNORECASE)
|
|
315
|
+
text = re.sub(rf"</{tag}\s*>", repl_end, text, flags=re.IGNORECASE)
|
|
316
|
+
return text, count
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def opf_unique_id(opf_text: str) -> str | None:
|
|
320
|
+
"""The dc:identifier value referenced by the OPF unique-identifier attribute."""
|
|
321
|
+
attr = _UID_ATTR_RE.search(opf_text)
|
|
322
|
+
if not attr:
|
|
323
|
+
return None
|
|
324
|
+
idpat = rf"id=[\"']{re.escape(attr.group(2))}[\"']"
|
|
325
|
+
m = re.search(rf"{idpat}[^>]*>([^<]+)<", opf_text) or re.search(
|
|
326
|
+
rf"<dc:identifier[^>]*{idpat}[^>]*>([^<]+)", opf_text
|
|
327
|
+
)
|
|
328
|
+
return m.group(1).strip() if m else None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def sync_ncx_uid(ncx_text: str, uid: str) -> tuple[str, bool]:
|
|
332
|
+
"""Set the NCX dtb:uid meta to `uid`. Returns (text, changed)."""
|
|
333
|
+
cur = _DTB_UID_RE.search(ncx_text) or _DTB_UID_RE_REV.search(ncx_text)
|
|
334
|
+
if not cur:
|
|
335
|
+
return ncx_text, False
|
|
336
|
+
if cur.group(3) == uid:
|
|
337
|
+
return ncx_text, False
|
|
338
|
+
|
|
339
|
+
# Replace via a function so a uid containing backslashes is inserted literally
|
|
340
|
+
# instead of being parsed as a regex replacement template.
|
|
341
|
+
def repl(m: re.Match) -> str:
|
|
342
|
+
return m.group(1) + uid + m.group(4)
|
|
343
|
+
|
|
344
|
+
new = _DTB_UID_RE.sub(repl, ncx_text)
|
|
345
|
+
if new == ncx_text:
|
|
346
|
+
new = _DTB_UID_RE_REV.sub(repl, ncx_text)
|
|
347
|
+
return new, True
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def ncx_uid_mismatch(src: Path) -> bool:
|
|
351
|
+
"""Cheaply detect NCX-001 (toc.ncx dtb:uid != OPF unique-identifier) without epubcheck."""
|
|
352
|
+
try:
|
|
353
|
+
with zipfile.ZipFile(src) as z:
|
|
354
|
+
opf = _locate_opf(z)
|
|
355
|
+
ncx = next((n for n in z.namelist() if n.lower().endswith(".ncx")), None)
|
|
356
|
+
if not opf or not ncx:
|
|
357
|
+
return False
|
|
358
|
+
uid = opf_unique_id(z.read(opf).decode("utf-8", "replace"))
|
|
359
|
+
if not uid:
|
|
360
|
+
return False
|
|
361
|
+
text = z.read(ncx).decode("utf-8", "replace")
|
|
362
|
+
m = _DTB_UID_RE.search(text) or _DTB_UID_RE_REV.search(text)
|
|
363
|
+
return bool(m and m.group(3) != uid)
|
|
364
|
+
except zipfile.BadZipFile, OSError:
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def repair_epub(
|
|
369
|
+
src: Path,
|
|
370
|
+
dst: Path,
|
|
371
|
+
*,
|
|
372
|
+
fix_ids: bool = False,
|
|
373
|
+
reserialize: bool = False,
|
|
374
|
+
strip_attrs: bool = False,
|
|
375
|
+
strip_pagination: bool = False,
|
|
376
|
+
strip_brokentags: bool = False,
|
|
377
|
+
strip_watermarks: bool = False,
|
|
378
|
+
escape_entities: bool = False,
|
|
379
|
+
img_alt: bool = False,
|
|
380
|
+
empty_body: bool = False,
|
|
381
|
+
missing_title: bool = False,
|
|
382
|
+
id_colons: bool = False,
|
|
383
|
+
block_in_inline: bool = False,
|
|
384
|
+
invalid_value: bool = False,
|
|
385
|
+
illegal_tags: bool = False,
|
|
386
|
+
page_map: bool = False,
|
|
387
|
+
strip_epub3_attrs: bool = False,
|
|
388
|
+
downgrade_epub3: bool = False,
|
|
389
|
+
) -> RepairReport:
|
|
390
|
+
"""Write a repaired copy of `src` to `dst`. Returns a RepairReport.
|
|
391
|
+
|
|
392
|
+
With `fix_ids`, also rewrite invalid manifest ids in the OPF (off by default, since
|
|
393
|
+
it touches the OPF; the dc: metadata is never altered, only item ids and their refs)
|
|
394
|
+
and invalid ids in the NCX.
|
|
395
|
+
With `img_alt`, add `alt=""` to <img> elements missing the required attribute.
|
|
396
|
+
With `strip_attrs`, drop attributes that are invalid XML (digit-led names, unbound
|
|
397
|
+
namespace prefixes like Office VML `v:shapes`).
|
|
398
|
+
With `reserialize`, rebuild any content document that is still not well-formed via
|
|
399
|
+
html5lib (closes unclosed non-void elements); good documents are left untouched.
|
|
400
|
+
With `escape_entities`, escape entity names outside the HTML5 table
|
|
401
|
+
(`&foo;` -> `&foo;`); documents with a DOCTYPE internal subset are skipped.
|
|
402
|
+
With `strip_brokentags`, strip leaked HTML closing tags (e.g. </P>) that render as text.
|
|
403
|
+
With `strip_watermarks`, remove known producer watermarks (e.g. OceanofPDF).
|
|
404
|
+
|
|
405
|
+
The structural repairs are all opt-in, never part of the core pass:
|
|
406
|
+
with `empty_body`, append to a strictly empty <body>; with `missing_title`,
|
|
407
|
+
inject a <title>Unknown</title> fallback; with `id_colons`, translate illegal
|
|
408
|
+
colons in id attributes and their #fragment references; with `block_in_inline`,
|
|
409
|
+
unwrap a <span> that illegally wraps a block element; with `invalid_value`, strip
|
|
410
|
+
misplaced value="..." attributes; with `illegal_tags`, delete illegal/deprecated
|
|
411
|
+
tags (<st>, <sentence>, <o>, <w>, <pagebreak>) keeping their inner text — any tag
|
|
412
|
+
name an EPUB stylesheet styles as an element selector is protected for the whole
|
|
413
|
+
book, so styled formatting can never be silently destroyed.
|
|
414
|
+
With `page_map`, normalize legacy page-map markup: drop the non-standard page-map
|
|
415
|
+
attribute from the OPF spine and add the required class="pages" to classless
|
|
416
|
+
<pageList> elements in the NCX (older HarperCollins / Anna's Archive conversions
|
|
417
|
+
fail epubcheck on both).
|
|
418
|
+
With `strip_epub3_attrs`, scrub the EPUB3-only attributes epubcheck rejects on an
|
|
419
|
+
EPUB2 package (page-progression-direction, epub:type, aria-label; fixed set).
|
|
420
|
+
With `downgrade_epub3`, downgrade EPUB3/HTML5 semantic elements (figure, figcaption,
|
|
421
|
+
section) to their EPUB2 equivalents with the semantic name kept as a class; names a
|
|
422
|
+
stylesheet styles as an element selector are protected for the whole book.
|
|
423
|
+
"""
|
|
424
|
+
report = RepairReport()
|
|
425
|
+
|
|
426
|
+
# `src` is opened before `dst`, so an unreadable archive still raises before the
|
|
427
|
+
# output file is created.
|
|
428
|
+
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w") as zout:
|
|
429
|
+
opf = _locate_opf(zin)
|
|
430
|
+
uid = opf_unique_id(zin.read(opf).decode("utf-8", "replace")) if opf else None
|
|
431
|
+
# Running-header detection and the page-layer decision need the whole book, so
|
|
432
|
+
# collect content text once up front. Only when the lossy strip is requested.
|
|
433
|
+
runheads: set[str] = set()
|
|
434
|
+
delete_layer = False
|
|
435
|
+
if strip_pagination:
|
|
436
|
+
htmls = [
|
|
437
|
+
zin.read(i).decode("utf-8", "replace")
|
|
438
|
+
for i in zin.infolist()
|
|
439
|
+
if i.filename.lower().endswith(CONTENT_SUFFIXES)
|
|
440
|
+
]
|
|
441
|
+
runheads = collect_runheads(htmls)
|
|
442
|
+
delete_layer = detect_page_layer(htmls, runheads)
|
|
443
|
+
|
|
444
|
+
# The CSS precondition for --unwrap-illegal-tags is a whole-book property:
|
|
445
|
+
# a stylesheet anywhere can style a content document, so scan every stylesheet
|
|
446
|
+
# once up front and protect those tag names everywhere (inline <style> blocks
|
|
447
|
+
# are added per document below).
|
|
448
|
+
book_css_tags: frozenset[str] = frozenset()
|
|
449
|
+
downgrade_css_tags: frozenset[str] = frozenset()
|
|
450
|
+
if illegal_tags or downgrade_epub3:
|
|
451
|
+
css_texts = [
|
|
452
|
+
zin.read(i).decode("utf-8", "replace")
|
|
453
|
+
for i in zin.infolist()
|
|
454
|
+
if i.filename.lower().endswith(".css")
|
|
455
|
+
]
|
|
456
|
+
if illegal_tags:
|
|
457
|
+
book_css_tags = css_protected_tags(*css_texts)
|
|
458
|
+
if downgrade_epub3:
|
|
459
|
+
downgrade_css_tags = css_protected_tags(
|
|
460
|
+
*css_texts, tags=tuple(EPUB3_DOWNGRADE_TAGS)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# The mimetype content is an OCF constant, so adding a missing entry and
|
|
464
|
+
# normalizing wrong or whitespace-padded content is deterministic and
|
|
465
|
+
# semantics-preserving; the gate checks it like any other fix.
|
|
466
|
+
src_mime = zin.getinfo("mimetype") if "mimetype" in zin.namelist() else None
|
|
467
|
+
if src_mime is None:
|
|
468
|
+
report.add({"mimetype_added": 1})
|
|
469
|
+
elif zin.read(src_mime) != MIMETYPE:
|
|
470
|
+
report.add({"mimetype_normalized": 1})
|
|
471
|
+
# A bare string arcname would make zipfile stamp this entry with the current
|
|
472
|
+
# clock, and it was the only such entry in the archive (every other one is
|
|
473
|
+
# written from its source ZipInfo), so two repairs of one book differed in
|
|
474
|
+
# exactly those bytes. Carry the source timestamp over instead.
|
|
475
|
+
#
|
|
476
|
+
# A fresh ZipInfo rather than the source one: OCF requires the mimetype entry
|
|
477
|
+
# to carry no extra field, so reusing the source entry wholesale would
|
|
478
|
+
# propagate a violation from an already-broken book. external_attr matches
|
|
479
|
+
# what writestr sets for a string arcname, leaving the timestamp as the only
|
|
480
|
+
# change to the output.
|
|
481
|
+
mime_info = zipfile.ZipInfo(
|
|
482
|
+
"mimetype", date_time=src_mime.date_time if src_mime else MIMETYPE_EPOCH
|
|
483
|
+
)
|
|
484
|
+
mime_info.external_attr = 0o600 << 16
|
|
485
|
+
zout.writestr(mime_info, MIMETYPE, compress_type=zipfile.ZIP_STORED)
|
|
486
|
+
|
|
487
|
+
# An entry is re-encoded only when a fix actually fired; an untouched entry is
|
|
488
|
+
# copied byte-for-byte. Re-encoding the decode("utf-8", "replace") round-trip
|
|
489
|
+
# of an unchanged file would silently swap any non-UTF-8 bytes for U+FFFD.
|
|
490
|
+
for item in zin.infolist():
|
|
491
|
+
name = item.filename
|
|
492
|
+
if name == "mimetype":
|
|
493
|
+
continue
|
|
494
|
+
if strip_watermarks and name.rsplit("/", 1)[-1].lower() in MARKER_NAMES:
|
|
495
|
+
report.files_changed += 1
|
|
496
|
+
report.add({"dropped_marker": 1})
|
|
497
|
+
continue
|
|
498
|
+
# read(item), not read(name): with duplicate entry names (seen in broken
|
|
499
|
+
# EPUBs), read(name) returns the first entry's bytes for every duplicate.
|
|
500
|
+
data = zin.read(item)
|
|
501
|
+
low = name.lower()
|
|
502
|
+
|
|
503
|
+
if low.endswith(".ncx"):
|
|
504
|
+
text = data.decode("utf-8", "replace")
|
|
505
|
+
text, counts = apply_transforms(text, XML_TRANSFORMS)
|
|
506
|
+
if fix_ids:
|
|
507
|
+
text, n = fix_ncx_ids(text)
|
|
508
|
+
if n:
|
|
509
|
+
counts["fix_ncx_ids"] = n
|
|
510
|
+
if page_map:
|
|
511
|
+
text, n = fix_pagelist_class(text)
|
|
512
|
+
if n:
|
|
513
|
+
counts["pagelist_class_added"] = n
|
|
514
|
+
synced = False
|
|
515
|
+
if uid:
|
|
516
|
+
text, synced = sync_ncx_uid(text, uid)
|
|
517
|
+
if synced:
|
|
518
|
+
report.ncx_uid_synced = True
|
|
519
|
+
if counts or synced:
|
|
520
|
+
report.add(counts)
|
|
521
|
+
report.files_changed += 1
|
|
522
|
+
data = text.encode("utf-8")
|
|
523
|
+
elif low.endswith(".opf") and (fix_ids or page_map or strip_epub3_attrs):
|
|
524
|
+
text = data.decode("utf-8", "replace")
|
|
525
|
+
opf_changed = False
|
|
526
|
+
if fix_ids:
|
|
527
|
+
text, n = fix_manifest_ids(text)
|
|
528
|
+
if n:
|
|
529
|
+
report.add({"fix_manifest_ids": n})
|
|
530
|
+
opf_changed = True
|
|
531
|
+
if page_map:
|
|
532
|
+
text, n = strip_page_map(text)
|
|
533
|
+
if n:
|
|
534
|
+
report.add({"page_map_stripped": n})
|
|
535
|
+
opf_changed = True
|
|
536
|
+
if strip_epub3_attrs:
|
|
537
|
+
text, n = strip_epub3_attributes(text)
|
|
538
|
+
if n:
|
|
539
|
+
report.add({"epub3_attrs_stripped": n})
|
|
540
|
+
opf_changed = True
|
|
541
|
+
if opf_changed:
|
|
542
|
+
report.files_changed += 1
|
|
543
|
+
data = text.encode("utf-8")
|
|
544
|
+
elif low.endswith(CONTENT_SUFFIXES):
|
|
545
|
+
text = data.decode("utf-8", "replace")
|
|
546
|
+
text, counts = apply_transforms(text, HTML_TRANSFORMS)
|
|
547
|
+
if escape_entities:
|
|
548
|
+
text, n = escape_unknown_entities(text)
|
|
549
|
+
if n:
|
|
550
|
+
counts["escape_unknown_entities"] = n
|
|
551
|
+
if strip_attrs:
|
|
552
|
+
text, n = strip_invalid_attributes(text)
|
|
553
|
+
if n:
|
|
554
|
+
counts["stripped_invalid_attrs"] = n
|
|
555
|
+
if img_alt:
|
|
556
|
+
text, n = add_img_alt(text)
|
|
557
|
+
if n:
|
|
558
|
+
counts["img_alt_added"] = n
|
|
559
|
+
if reserialize:
|
|
560
|
+
text, n = reserialize_if_broken(text)
|
|
561
|
+
if n:
|
|
562
|
+
counts["reserialized"] = n
|
|
563
|
+
if strip_epub3_attrs:
|
|
564
|
+
text, n = strip_epub3_attributes(text)
|
|
565
|
+
if n:
|
|
566
|
+
counts["epub3_attrs_stripped"] = n
|
|
567
|
+
if downgrade_epub3:
|
|
568
|
+
protected = downgrade_css_tags | style_block_tags(
|
|
569
|
+
text, tags=tuple(EPUB3_DOWNGRADE_TAGS)
|
|
570
|
+
)
|
|
571
|
+
text, n = downgrade_epub3_tags(text, protected_tags=protected)
|
|
572
|
+
if n:
|
|
573
|
+
counts["epub3_tags_downgraded"] = n
|
|
574
|
+
if empty_body:
|
|
575
|
+
text, n = fix_empty_body(text)
|
|
576
|
+
if n:
|
|
577
|
+
counts["fix_empty_body"] = n
|
|
578
|
+
if missing_title:
|
|
579
|
+
text, n = fix_missing_title(text)
|
|
580
|
+
if n:
|
|
581
|
+
counts["fix_missing_title"] = n
|
|
582
|
+
if id_colons:
|
|
583
|
+
text, n = fix_id_colons(text)
|
|
584
|
+
if n:
|
|
585
|
+
counts["fix_id_colons"] = n
|
|
586
|
+
if block_in_inline:
|
|
587
|
+
text, n = unwrap_block_in_inline(text)
|
|
588
|
+
if n:
|
|
589
|
+
counts["unwrap_block_in_inline"] = n
|
|
590
|
+
if invalid_value:
|
|
591
|
+
text, n = strip_invalid_value(text)
|
|
592
|
+
if n:
|
|
593
|
+
counts["strip_invalid_value"] = n
|
|
594
|
+
if illegal_tags:
|
|
595
|
+
protected = book_css_tags | style_block_tags(text)
|
|
596
|
+
text, n = unwrap_illegal_tags(text, protected_tags=protected)
|
|
597
|
+
if n:
|
|
598
|
+
counts["unwrap_illegal_tags"] = n
|
|
599
|
+
if strip_watermarks:
|
|
600
|
+
text, n = strip_watermark_html(text)
|
|
601
|
+
if n:
|
|
602
|
+
counts["stripped_watermarks"] = n
|
|
603
|
+
if strip_brokentags:
|
|
604
|
+
text, n = strip_broken_tags(text)
|
|
605
|
+
if n:
|
|
606
|
+
counts["stripped_broken_tags"] = n
|
|
607
|
+
if strip_pagination:
|
|
608
|
+
text, n = strip_pagination_doc(text, runheads, delete_layer)
|
|
609
|
+
if n:
|
|
610
|
+
counts["stripped_pagination"] = n
|
|
611
|
+
if counts:
|
|
612
|
+
report.add(counts)
|
|
613
|
+
report.files_changed += 1
|
|
614
|
+
data = text.encode("utf-8")
|
|
615
|
+
|
|
616
|
+
zout.writestr(item, data, compress_type=item.compress_type)
|
|
617
|
+
|
|
618
|
+
return report
|