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/transforms.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""Deterministic, well-formedness-only repair transforms for (X)HTML/XML text.
|
|
2
|
+
|
|
3
|
+
Every transform is a pure function `str -> (str, int)` returning the rewritten text
|
|
4
|
+
and how many fixes it made. None of them change document semantics: they only make
|
|
5
|
+
already-intended markup well-formed (self-close void elements, turn undeclared named
|
|
6
|
+
entities into numeric character references, escape stray ampersands, strip junk before
|
|
7
|
+
the XML prolog, drop a duplicated root xmlns). Anything deeper than that is out of
|
|
8
|
+
scope and is left for the epubcheck gate to reject. See spec.md.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from functools import wraps
|
|
16
|
+
from html.entities import html5, name2codepoint
|
|
17
|
+
|
|
18
|
+
Transform = Callable[[str], tuple[str, int]]
|
|
19
|
+
|
|
20
|
+
VOID = "area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr"
|
|
21
|
+
XML_PREDEFINED = {"amp", "lt", "gt", "quot", "apos"}
|
|
22
|
+
|
|
23
|
+
# Match an open void-element tag. The lookahead after the name is essential: without
|
|
24
|
+
# it `<col` matches inside `<colgroup>` and we self-close a non-void element, orphaning
|
|
25
|
+
# its end-tag (the bug that introduced fatals on real books). A plain `\b` is not
|
|
26
|
+
# enough either: `-`, `:`, and `.` are valid XML name characters but not \w, so `\b`
|
|
27
|
+
# still matched `<col` inside a custom `<col-group>`. Attributes are matched
|
|
28
|
+
# quote-aware so a `>` inside an attribute value does not end the tag early. Group 3
|
|
29
|
+
# captures an existing trailing slash so already-self-closed tags are left untouched.
|
|
30
|
+
_VOID_RE = re.compile(
|
|
31
|
+
rf"""<({VOID})(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
|
|
32
|
+
re.IGNORECASE | re.DOTALL,
|
|
33
|
+
)
|
|
34
|
+
_VOID_END_RE = re.compile(rf"""</(?:{VOID})\s*>""", re.IGNORECASE)
|
|
35
|
+
_NAMED_ENTITY_RE = re.compile(r"&([a-zA-Z][a-zA-Z0-9]*);")
|
|
36
|
+
_BARE_AMP_RE = re.compile(r"&(?![a-zA-Z][a-zA-Z0-9]*;|#[0-9]+;|#[xX][0-9a-fA-F]+;)")
|
|
37
|
+
# A start tag (quote-aware) and an attribute within it, for invalid-attribute stripping.
|
|
38
|
+
_START_TAG_RE = re.compile(r"""<[a-zA-Z][\w:.-]*(?:"[^"]*"|'[^']*'|[^>])*>""")
|
|
39
|
+
# The unquoted-value branch stops at whitespace or the end of the tag, and the `/?>`
|
|
40
|
+
# lookahead is what keeps it off the tag's own self-closing slash: a plain `[^\s>]+`
|
|
41
|
+
# swallowed it, so dropping the attribute in `<img 31=x/>` left `<img>` and turned a
|
|
42
|
+
# well-formed tag into an unclosed one. A `/` mid-value (a URL) is still consumed,
|
|
43
|
+
# because only a `/` immediately before `>` can be the tag's.
|
|
44
|
+
_ATTR_RE = re.compile(
|
|
45
|
+
r"""(\s+)([^\s=/>]+)(\s*=\s*)("[^"]*"|'[^']*'|[^\s>]*?(?=\s|/?>))"""
|
|
46
|
+
)
|
|
47
|
+
_XMLNS_DECL_RE = re.compile(r"xmlns:([A-Za-z_][\w.-]*)\s*=")
|
|
48
|
+
_HTML_TAG_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
|
|
49
|
+
_XMLNS_ATTR_RE = re.compile(r'\s+xmlns="[^"]*"')
|
|
50
|
+
_EPUB_PREFIX_ATTR_RE = re.compile(
|
|
51
|
+
r"""(?:\bepub:)?prefix\s*=\s*(?:"([^"]*)"|'([^']*)')"""
|
|
52
|
+
)
|
|
53
|
+
_EPUB_PREFIX_VAL_RE = re.compile(r"(?:^|\s)([A-Za-z_][\w.-]*)\s*:\s*\S+")
|
|
54
|
+
|
|
55
|
+
# CDATA sections and comments hold literal text: a bare `&`, an entity name, or a
|
|
56
|
+
# `<br>` inside them is already legal XML, and rewriting it would change the content
|
|
57
|
+
# (e.g. `&` in CDATA-wrapped CSS/JS renders as `&`; escaped, it renders as `&`).
|
|
58
|
+
# Splitting on this regex yields alternating outside/protected segments, so the
|
|
59
|
+
# markup transforms run only on the even (outside) indices.
|
|
60
|
+
_PROTECTED_RE = re.compile(r"(<!\[CDATA\[.*?\]\]>|<!--.*?-->)", re.DOTALL)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _outside_protected(fn: Transform) -> Transform:
|
|
64
|
+
"""Wrap a transform so it never touches CDATA sections or comments."""
|
|
65
|
+
|
|
66
|
+
@wraps(fn)
|
|
67
|
+
def wrapped(s: str) -> tuple[str, int]:
|
|
68
|
+
if "<!" not in s: # fast path: nothing to protect
|
|
69
|
+
return fn(s)
|
|
70
|
+
parts = _PROTECTED_RE.split(s)
|
|
71
|
+
total = 0
|
|
72
|
+
for i in range(0, len(parts), 2):
|
|
73
|
+
parts[i], n = fn(parts[i])
|
|
74
|
+
total += n
|
|
75
|
+
return "".join(parts), total
|
|
76
|
+
|
|
77
|
+
return wrapped
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@_outside_protected
|
|
81
|
+
def self_close_void(s: str) -> tuple[str, int]:
|
|
82
|
+
"""Self-close void elements that were left open (`<br>` -> `<br/>`).
|
|
83
|
+
|
|
84
|
+
Already-self-closed tags are returned unchanged and not counted, so the fix is
|
|
85
|
+
idempotent and reports only real changes.
|
|
86
|
+
"""
|
|
87
|
+
count = 0
|
|
88
|
+
|
|
89
|
+
def repl(m: re.Match) -> str:
|
|
90
|
+
nonlocal count
|
|
91
|
+
if m.group(0).endswith("/>"):
|
|
92
|
+
return m.group(0)
|
|
93
|
+
count += 1
|
|
94
|
+
return f"<{m.group(1)}{m.group(2)}/>"
|
|
95
|
+
|
|
96
|
+
s = _VOID_RE.sub(repl, s)
|
|
97
|
+
|
|
98
|
+
# Strip any remaining end tags for void elements (e.g. </br>) which would
|
|
99
|
+
# otherwise cause fatal XML parse errors since we self-closed their start tags.
|
|
100
|
+
s, end_count = _VOID_END_RE.subn("", s)
|
|
101
|
+
count += end_count
|
|
102
|
+
|
|
103
|
+
return s, count
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def strip_invalid_attributes(s: str) -> tuple[str, int]:
|
|
107
|
+
"""Remove attributes that make the XML unparseable: a name starting with a digit
|
|
108
|
+
(e.g. a mangled `31=""`), or a namespaced name whose prefix is not declared anywhere
|
|
109
|
+
in the document (e.g. Office VML `v:shapes` with no `xmlns:v`).
|
|
110
|
+
|
|
111
|
+
A well-formed document has no such attributes by definition, so this is a no-op on
|
|
112
|
+
good files and only touches already-malformed ones. The fix is surgical: only the
|
|
113
|
+
offending attribute is dropped, everything else is preserved byte-for-byte.
|
|
114
|
+
"""
|
|
115
|
+
# The declared-prefix set is computed over the whole document (over-collecting
|
|
116
|
+
# from comments only makes the fix more conservative), but tags are rewritten
|
|
117
|
+
# only outside CDATA/comment spans.
|
|
118
|
+
declared = set(_XMLNS_DECL_RE.findall(s)) | {"xml", "xmlns"}
|
|
119
|
+
for m in _EPUB_PREFIX_ATTR_RE.finditer(s):
|
|
120
|
+
val = m.group(1) or m.group(2) or ""
|
|
121
|
+
declared.update(_EPUB_PREFIX_VAL_RE.findall(val))
|
|
122
|
+
count = 0
|
|
123
|
+
|
|
124
|
+
def fix_tag(tag: re.Match) -> str:
|
|
125
|
+
def drop(attr: re.Match) -> str:
|
|
126
|
+
nonlocal count
|
|
127
|
+
name = attr.group(2)
|
|
128
|
+
prefix = name.split(":", 1)[0] if ":" in name else None
|
|
129
|
+
if name[0].isdigit() or (prefix is not None and prefix not in declared):
|
|
130
|
+
count += 1
|
|
131
|
+
return ""
|
|
132
|
+
return attr.group(0)
|
|
133
|
+
|
|
134
|
+
return _ATTR_RE.sub(drop, tag.group(0))
|
|
135
|
+
|
|
136
|
+
parts = _PROTECTED_RE.split(s)
|
|
137
|
+
for i in range(0, len(parts), 2):
|
|
138
|
+
parts[i] = _START_TAG_RE.sub(fix_tag, parts[i])
|
|
139
|
+
return "".join(parts), count
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _entity_refs(name: str) -> str | None:
|
|
143
|
+
"""Numeric character reference(s) for an HTML entity name, or None if unknown.
|
|
144
|
+
|
|
145
|
+
Most entities are one codepoint; the handful that expand to several (`fj`,
|
|
146
|
+
`≂̸`, ...) become one numeric reference per codepoint, which renders
|
|
147
|
+
identically.
|
|
148
|
+
"""
|
|
149
|
+
if name in name2codepoint:
|
|
150
|
+
return f"&#{name2codepoint[name]};"
|
|
151
|
+
ch = html5.get(name + ";") or html5.get(name)
|
|
152
|
+
if not ch:
|
|
153
|
+
return None
|
|
154
|
+
return "".join(f"&#{ord(c)};" for c in ch)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@_outside_protected
|
|
158
|
+
def fix_named_entities(s: str) -> tuple[str, int]:
|
|
159
|
+
"""Replace undeclared HTML named entities with numeric refs (` ` -> ` `).
|
|
160
|
+
|
|
161
|
+
XML only predefines five entity names; everything else (` `, `°`,
|
|
162
|
+
`é`, ...) is a fatal "entity not declared" unless turned into a numeric
|
|
163
|
+
reference, which every XML parser understands.
|
|
164
|
+
"""
|
|
165
|
+
count = 0
|
|
166
|
+
|
|
167
|
+
def repl(m: re.Match) -> str:
|
|
168
|
+
nonlocal count
|
|
169
|
+
name = m.group(1)
|
|
170
|
+
if name in XML_PREDEFINED:
|
|
171
|
+
return m.group(0)
|
|
172
|
+
refs = _entity_refs(name)
|
|
173
|
+
if refs is None:
|
|
174
|
+
return m.group(0)
|
|
175
|
+
count += 1
|
|
176
|
+
return refs
|
|
177
|
+
|
|
178
|
+
return _NAMED_ENTITY_RE.sub(repl, s), count
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# A DOCTYPE internal subset can declare custom entities (<!DOCTYPE x [<!ENTITY ...>]>),
|
|
182
|
+
# making an "unknown" name legitimate; the escape skips such documents wholesale.
|
|
183
|
+
_INTERNAL_SUBSET_RE = re.compile(r"<!DOCTYPE[^>\[]*\[", re.IGNORECASE)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@_outside_protected
|
|
187
|
+
def _escape_unknown_part(s: str) -> tuple[str, int]:
|
|
188
|
+
count = 0
|
|
189
|
+
|
|
190
|
+
def repl(m: re.Match) -> str:
|
|
191
|
+
nonlocal count
|
|
192
|
+
name = m.group(1)
|
|
193
|
+
if name in XML_PREDEFINED or _entity_refs(name) is not None:
|
|
194
|
+
return m.group(0)
|
|
195
|
+
count += 1
|
|
196
|
+
return "&" + m.group(0)[1:]
|
|
197
|
+
|
|
198
|
+
return _NAMED_ENTITY_RE.sub(repl, s), count
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def escape_unknown_entities(s: str) -> tuple[str, int]:
|
|
202
|
+
"""Escape entity references whose name is unknown (`&foo;` -> `&foo;`).
|
|
203
|
+
|
|
204
|
+
An undeclared entity outside the HTML5 table stays a fatal "entity not declared";
|
|
205
|
+
escaping it renders exactly as browsers already render an unknown entity (the
|
|
206
|
+
literal text `&foo;`). Conditionally semantics-preserving: identical rendering
|
|
207
|
+
EXCEPT against a document whose DOCTYPE internal subset declares the entity, so
|
|
208
|
+
any document carrying an internal subset is skipped wholesale. Opt-in
|
|
209
|
+
(--escape-unknown-entities), never a core transform.
|
|
210
|
+
"""
|
|
211
|
+
if _INTERNAL_SUBSET_RE.search(s):
|
|
212
|
+
return s, 0
|
|
213
|
+
return _escape_unknown_part(s)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# An <img> start tag, quote-aware like _VOID_RE; group 1 is the attribute block and
|
|
217
|
+
# group 2 an existing self-closing slash, so the tag is rebuilt without reordering.
|
|
218
|
+
_IMG_TAG_RE = re.compile(
|
|
219
|
+
r"""<img(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>])*?)\s*(/?)>""",
|
|
220
|
+
re.IGNORECASE | re.DOTALL,
|
|
221
|
+
)
|
|
222
|
+
# Whitespace before `alt` keeps data-alt/xml:alt-style names from matching; a quoted
|
|
223
|
+
# value containing ` alt=` can false-positive, which only makes the fix skip that tag.
|
|
224
|
+
_ALT_PRESENT_RE = re.compile(r"""(?:^|\s)alt\s*=""", re.IGNORECASE)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@_outside_protected
|
|
228
|
+
def add_img_alt(s: str) -> tuple[str, int]:
|
|
229
|
+
"""Add `alt=""` to an <img> that has no alt attribute (the RSC-005 "missing
|
|
230
|
+
required attribute" error).
|
|
231
|
+
|
|
232
|
+
Rendering is unchanged (empty alt draws nothing), but this is the one transform
|
|
233
|
+
that ADDS markup the author never wrote, and `alt=""` asserts "decorative" to a
|
|
234
|
+
screen reader where a missing alt did not. Hence opt-in (--add-img-alt), never a
|
|
235
|
+
core transform. Idempotent: a tag that already carries alt is left untouched.
|
|
236
|
+
"""
|
|
237
|
+
count = 0
|
|
238
|
+
|
|
239
|
+
def repl(m: re.Match) -> str:
|
|
240
|
+
nonlocal count
|
|
241
|
+
attrs, slash = m.group(1), m.group(2)
|
|
242
|
+
if _ALT_PRESENT_RE.search(attrs):
|
|
243
|
+
return m.group(0)
|
|
244
|
+
count += 1
|
|
245
|
+
return f'<img{attrs} alt=""{slash}>'
|
|
246
|
+
|
|
247
|
+
return _IMG_TAG_RE.sub(repl, s), count
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@_outside_protected
|
|
251
|
+
def escape_bare_amp(s: str) -> tuple[str, int]:
|
|
252
|
+
"""Escape a `&` that does not begin a valid entity/character reference."""
|
|
253
|
+
return _BARE_AMP_RE.subn("&", s)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def strip_prolog_junk(s: str) -> tuple[str, int]:
|
|
257
|
+
"""Remove a BOM or stray bytes before the first `<` ("content not allowed in prolog").
|
|
258
|
+
|
|
259
|
+
Leading whitespace is junk only when an XML declaration follows it (a declaration
|
|
260
|
+
must be the very first thing in the document). Before a DOCTYPE or the root element
|
|
261
|
+
it is legal prolog whitespace and is left alone: counting it as a fix marked the
|
|
262
|
+
document changed, which forced repair_epub's decode("utf-8", "replace") round-trip
|
|
263
|
+
on a file that had nothing wrong with it.
|
|
264
|
+
"""
|
|
265
|
+
stripped = s.lstrip(" \t\r\n")
|
|
266
|
+
i = stripped.find("<")
|
|
267
|
+
if i > 0:
|
|
268
|
+
stripped = stripped[i:]
|
|
269
|
+
if stripped == s:
|
|
270
|
+
return s, 0
|
|
271
|
+
removed = s[: len(s) - len(stripped)]
|
|
272
|
+
if not removed.strip(" \t\r\n") and not stripped.startswith("<?xml"):
|
|
273
|
+
return s, 0
|
|
274
|
+
return stripped, 1
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def drop_duplicate_xmlns(s: str) -> tuple[str, int]:
|
|
278
|
+
"""Keep only the first `xmlns="..."` on the root <html> element."""
|
|
279
|
+
m = _HTML_TAG_RE.search(s)
|
|
280
|
+
if not m:
|
|
281
|
+
return s, 0
|
|
282
|
+
tag = m.group(0)
|
|
283
|
+
seen = False
|
|
284
|
+
count = 0
|
|
285
|
+
|
|
286
|
+
def repl(mm: re.Match) -> str:
|
|
287
|
+
nonlocal seen, count
|
|
288
|
+
if seen:
|
|
289
|
+
count += 1
|
|
290
|
+
return ""
|
|
291
|
+
seen = True
|
|
292
|
+
return mm.group(0)
|
|
293
|
+
|
|
294
|
+
new_tag = _XMLNS_ATTR_RE.sub(repl, tag)
|
|
295
|
+
if count:
|
|
296
|
+
s = s[: m.start()] + new_tag + s[m.start() + len(tag) :]
|
|
297
|
+
return s, count
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# Transforms applied to full (X)HTML content documents, in order. Prolog and root-tag
|
|
301
|
+
# fixes first, then ampersand/entity normalization, then void self-closing.
|
|
302
|
+
@_outside_protected
|
|
303
|
+
def fix_ncx_playorder(s: str) -> tuple[str, int]:
|
|
304
|
+
count = 0
|
|
305
|
+
playorder = 1
|
|
306
|
+
|
|
307
|
+
def repl(m: re.Match) -> str:
|
|
308
|
+
nonlocal count, playorder
|
|
309
|
+
val = m.group(2)
|
|
310
|
+
if val != str(playorder):
|
|
311
|
+
count += 1
|
|
312
|
+
res = f'{m.group(1)}"{playorder}"'
|
|
313
|
+
playorder += 1
|
|
314
|
+
return res
|
|
315
|
+
|
|
316
|
+
s, n = re.subn(
|
|
317
|
+
r'(playOrder\s*=\s*)["\']([^"\']+)["\']', repl, s, flags=re.IGNORECASE
|
|
318
|
+
)
|
|
319
|
+
return s, count
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@_outside_protected
|
|
323
|
+
def unwrap_block_in_inline(s: str) -> tuple[str, int]:
|
|
324
|
+
"""Unwrap a <span> that illegally wraps a block element (`<span><div>...</div>
|
|
325
|
+
</span>` -> the div alone).
|
|
326
|
+
|
|
327
|
+
Opt-in (--unwrap-block-in-inline): it restructures nesting, not just
|
|
328
|
+
well-formedness tokens, so it is never part of the core pass. Inner text is
|
|
329
|
+
always preserved.
|
|
330
|
+
"""
|
|
331
|
+
count = 0
|
|
332
|
+
|
|
333
|
+
def repl(m: re.Match) -> str:
|
|
334
|
+
nonlocal count
|
|
335
|
+
count += 1
|
|
336
|
+
return m.group(1)
|
|
337
|
+
|
|
338
|
+
s, n = re.subn(
|
|
339
|
+
r"<span[^>]*>\s*(<(div|p|blockquote)[^>]*>.*?</\2>)\s*</span>",
|
|
340
|
+
repl,
|
|
341
|
+
s,
|
|
342
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
343
|
+
)
|
|
344
|
+
return s, n
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@_outside_protected
|
|
348
|
+
def strip_invalid_value(s: str) -> tuple[str, int]:
|
|
349
|
+
"""Strip misplaced `value="..."` attributes from non-form elements.
|
|
350
|
+
|
|
351
|
+
Opt-in (--strip-invalid-value): deleting attributes goes beyond making markup
|
|
352
|
+
parseable, so it is never part of the core pass.
|
|
353
|
+
"""
|
|
354
|
+
count = 0
|
|
355
|
+
|
|
356
|
+
def repl(m: re.Match) -> str:
|
|
357
|
+
nonlocal count
|
|
358
|
+
count += 1
|
|
359
|
+
tag = m.group(1)
|
|
360
|
+
before = m.group(2) or ""
|
|
361
|
+
after = m.group(4) or ""
|
|
362
|
+
return f"<{tag} {before}{after}>"
|
|
363
|
+
|
|
364
|
+
s, n = re.subn(
|
|
365
|
+
r'<(div|span|p|a|img|h[1-6]|ul|li|meta|table|tr|td|th)(\s+[^>]*\b)?value\s*=\s*(["\'][^"\']*["\'])([^>]*)>',
|
|
366
|
+
repl,
|
|
367
|
+
s,
|
|
368
|
+
flags=re.IGNORECASE,
|
|
369
|
+
)
|
|
370
|
+
return s, n
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# The tags --unwrap-illegal-tags removes wherever they appear. A tag name that any
|
|
374
|
+
# stylesheet references as an element selector is skipped for that book (see
|
|
375
|
+
# css_protected_tags): unwrapping styled elements would silently destroy formatting,
|
|
376
|
+
# which no lossy convenience is worth.
|
|
377
|
+
ILLEGAL_TAGS = ("st", "sentence", "o", "w", "pagebreak")
|
|
378
|
+
|
|
379
|
+
_CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
380
|
+
_STYLE_BLOCK_RE = re.compile(r"<style\b[^>]*>(.*?)</style>", re.IGNORECASE | re.DOTALL)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def css_protected_tags(
|
|
384
|
+
*sheets: str, tags: tuple[str, ...] = ILLEGAL_TAGS
|
|
385
|
+
) -> frozenset[str]:
|
|
386
|
+
"""Element-selector-referenced tag names in the given CSS text(s),
|
|
387
|
+
from `tags` (default: the illegal-tag set).
|
|
388
|
+
|
|
389
|
+
Selector-aware, so `.st { ... }` (a class) or `#w { ... }` (an id) does NOT
|
|
390
|
+
protect anything while `st { ... }` or `p st, x > w { ... }` does. Comments are
|
|
391
|
+
stripped first; matching is case-insensitive and returns lowercase names. This
|
|
392
|
+
ports scripts/find_css_illegal_tags.py into the library so the transform can
|
|
393
|
+
enforce its own precondition instead of trusting callers to scan.
|
|
394
|
+
"""
|
|
395
|
+
found: set[str] = set()
|
|
396
|
+
for css in sheets:
|
|
397
|
+
stripped = _CSS_COMMENT_RE.sub("", css)
|
|
398
|
+
# Candidate selector lists are whatever precedes a '{'. Scanning this way
|
|
399
|
+
# (rather than splitting on '}') keeps nested at-rules working: in
|
|
400
|
+
# `@media print { o > sentence {} }`, `o > sentence` precedes the INNER '{'
|
|
401
|
+
# and is found, where a naive outer split would lose it. Declarations never
|
|
402
|
+
# precede a '{'.
|
|
403
|
+
for m in re.finditer(r"([^{}]*)\{", stripped):
|
|
404
|
+
selectors = m.group(1)
|
|
405
|
+
for tag in tags:
|
|
406
|
+
# The trailing boundary includes . and #: `pagebreak.new:after`
|
|
407
|
+
# styles pagebreak ELEMENTS, so it must protect the name, while a
|
|
408
|
+
# LEADING . or # stays unprotected (`div.st` styles a class).
|
|
409
|
+
if re.search(
|
|
410
|
+
rf"(^|[\s,>+~]){tag}([\s,>+~:\[.#]|$)", selectors, re.IGNORECASE
|
|
411
|
+
):
|
|
412
|
+
found.add(tag)
|
|
413
|
+
return frozenset(found)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def style_block_tags(doc: str, tags: tuple[str, ...] = ILLEGAL_TAGS) -> frozenset[str]:
|
|
417
|
+
"""css_protected_tags over every inline `<style>` block in a content document."""
|
|
418
|
+
return css_protected_tags(*_STYLE_BLOCK_RE.findall(doc), tags=tags)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def unwrap_illegal_tags(
|
|
422
|
+
s: str, protected_tags: frozenset[str] = frozenset()
|
|
423
|
+
) -> tuple[str, int]:
|
|
424
|
+
"""Remove illegal/deprecated tags (`<st>`, `<sentence>`, `<o>`, `<w>`,
|
|
425
|
+
`<pagebreak>`) outright, keeping their inner text.
|
|
426
|
+
|
|
427
|
+
Opt-in (`--unwrap-illegal-tags`) because deleting elements is more than
|
|
428
|
+
well-formedness repair. Names in `protected_tags` (the union of every
|
|
429
|
+
stylesheet's element selectors, see css_protected_tags/style_block_tags) are
|
|
430
|
+
left untouched: if a book styles one of these tags, unwrapping it would change
|
|
431
|
+
how the text renders, so preservation wins.
|
|
432
|
+
"""
|
|
433
|
+
count = 0
|
|
434
|
+
for tag in ILLEGAL_TAGS:
|
|
435
|
+
if tag in protected_tags:
|
|
436
|
+
continue
|
|
437
|
+
s, n1 = re.subn(rf"<{tag}\b[^>]*>", "", s, flags=re.IGNORECASE)
|
|
438
|
+
s, n2 = re.subn(rf"</{tag}\s*>", "", s, flags=re.IGNORECASE)
|
|
439
|
+
count += n1 + n2
|
|
440
|
+
return s, count
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@_outside_protected
|
|
444
|
+
def fix_empty_body(s: str) -> tuple[str, int]:
|
|
445
|
+
"""Append ` ` to a strictly empty `<body></body>` ("body incomplete").
|
|
446
|
+
|
|
447
|
+
Opt-in (--fix-empty-body): this ADDS visible content the author never wrote,
|
|
448
|
+
the same reason --add-img-alt is opt-in; it is never part of the core pass.
|
|
449
|
+
"""
|
|
450
|
+
count = 0
|
|
451
|
+
|
|
452
|
+
def repl(m: re.Match) -> str:
|
|
453
|
+
nonlocal count
|
|
454
|
+
count += 1
|
|
455
|
+
return m.group(1) + " " + m.group(2)
|
|
456
|
+
|
|
457
|
+
s, n = re.subn(r"(<body[^>]*>\s*)(</body>)", repl, s, flags=re.IGNORECASE)
|
|
458
|
+
return s, n
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@_outside_protected
|
|
462
|
+
def fix_missing_title(s: str) -> tuple[str, int]:
|
|
463
|
+
"""Inject `<title>Unknown</title>` when a document has no usable title.
|
|
464
|
+
|
|
465
|
+
Opt-in (--fix-missing-title): it fabricates content the author never wrote,
|
|
466
|
+
so it is never part of the core pass. An existing non-empty <title> anywhere
|
|
467
|
+
in the document means no-op.
|
|
468
|
+
"""
|
|
469
|
+
count = 0
|
|
470
|
+
if re.search(r"<title\b[^>]*>.*?</title>", s, re.IGNORECASE | re.DOTALL):
|
|
471
|
+
return s, 0
|
|
472
|
+
|
|
473
|
+
# Check for empty self-closing title
|
|
474
|
+
def repl(m: re.Match) -> str:
|
|
475
|
+
nonlocal count
|
|
476
|
+
count += 1
|
|
477
|
+
return "<title>Unknown</title>"
|
|
478
|
+
|
|
479
|
+
s, n = re.subn(r"<title\b[^>]*>\s*</title>", repl, s, flags=re.IGNORECASE)
|
|
480
|
+
if n > 0:
|
|
481
|
+
return s, n
|
|
482
|
+
|
|
483
|
+
# No title tag at all, inject into head
|
|
484
|
+
def repl_head(m: re.Match) -> str:
|
|
485
|
+
nonlocal count
|
|
486
|
+
count += 1
|
|
487
|
+
return m.group(1) + "\n<title>Unknown</title>"
|
|
488
|
+
|
|
489
|
+
s, n = re.subn(r"(<head[^>]*>)", repl_head, s, flags=re.IGNORECASE)
|
|
490
|
+
return s, n
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@_outside_protected
|
|
494
|
+
def fix_id_colons(s: str) -> tuple[str, int]:
|
|
495
|
+
"""Translate illegal colons in `id="X:Y"` and matching `#X:Y` fragments to `_`.
|
|
496
|
+
|
|
497
|
+
Opt-in (--fix-id-colons): it rewrites every id-bearing attribute and internal
|
|
498
|
+
fragment reference, so it stays out of the core pass despite being
|
|
499
|
+
rendering-neutral. Word boundaries keep external URLs intact.
|
|
500
|
+
"""
|
|
501
|
+
count = 0
|
|
502
|
+
|
|
503
|
+
def repl_id(m: re.Match) -> str:
|
|
504
|
+
nonlocal count
|
|
505
|
+
count += 1
|
|
506
|
+
return m.group(1) + m.group(2).replace(":", "_") + m.group(3)
|
|
507
|
+
|
|
508
|
+
s, n1 = re.subn(
|
|
509
|
+
r'\b(id\s*=\s*["\'])([^"\']+)(["\'])', repl_id, s, flags=re.IGNORECASE
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def repl_href(m: re.Match) -> str:
|
|
513
|
+
nonlocal count
|
|
514
|
+
if ":" in m.group(3):
|
|
515
|
+
count += 1
|
|
516
|
+
return (
|
|
517
|
+
m.group(1)
|
|
518
|
+
+ m.group(2)
|
|
519
|
+
+ "#"
|
|
520
|
+
+ m.group(3).replace(":", "_")
|
|
521
|
+
+ m.group(4)
|
|
522
|
+
)
|
|
523
|
+
return m.group(0)
|
|
524
|
+
|
|
525
|
+
s, n2 = re.subn(
|
|
526
|
+
r'\b(href\s*=\s*["\'])([^"#]*?)#([^"\']+)(["\'])',
|
|
527
|
+
repl_href,
|
|
528
|
+
s,
|
|
529
|
+
flags=re.IGNORECASE,
|
|
530
|
+
)
|
|
531
|
+
return s, count
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# The always-on core: exactly the five semantics-preserving well-formedness fixes
|
|
535
|
+
# the spec names, nothing else. Everything that adds markup, deletes attributes, or
|
|
536
|
+
# removes/restructures elements lives behind an explicit CLI flag (threaded through
|
|
537
|
+
# repair_epub), so the default pass can never change more than parsing requires.
|
|
538
|
+
# See spec.md "Transforms" and the per-flag sections.
|
|
539
|
+
HTML_TRANSFORMS = (
|
|
540
|
+
strip_prolog_junk,
|
|
541
|
+
drop_duplicate_xmlns,
|
|
542
|
+
escape_bare_amp,
|
|
543
|
+
fix_named_entities,
|
|
544
|
+
self_close_void,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# A lighter set for XML sidecars (NCX): no HTML-specific element rewriting.
|
|
548
|
+
XML_TRANSFORMS = (
|
|
549
|
+
strip_prolog_junk,
|
|
550
|
+
escape_bare_amp,
|
|
551
|
+
fix_named_entities,
|
|
552
|
+
fix_ncx_playorder,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def apply_transforms(
|
|
557
|
+
s: str, transforms: Iterable[Transform]
|
|
558
|
+
) -> tuple[str, dict[str, int]]:
|
|
559
|
+
"""Run a pipeline of transforms, returning the result and per-transform counts."""
|
|
560
|
+
counts: dict[str, int] = {}
|
|
561
|
+
for fn in transforms:
|
|
562
|
+
s, n = fn(s)
|
|
563
|
+
if n:
|
|
564
|
+
counts[fn.__name__] = counts.get(fn.__name__, 0) + n
|
|
565
|
+
return s, counts
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# Matches leaked closing tags without angle brackets (e.g. /div>) OR common formatting tags with escaped angle brackets.
|
|
569
|
+
_BROKEN_TAGS_RE = re.compile(
|
|
570
|
+
r"(?<!<)(?<!<)/[a-zA-Z]+>|</(?:p|div|span|h[1-6]|i|em|b|strong)>",
|
|
571
|
+
re.IGNORECASE,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def strip_broken_tags(text: str) -> tuple[str, int]:
|
|
576
|
+
"""Strip leaked HTML closing tags that render as raw text in readers.
|
|
577
|
+
This lossily removes the leaked text fragment and should be gated behind `validate.no_worse`.
|
|
578
|
+
"""
|
|
579
|
+
return _BROKEN_TAGS_RE.subn("", text)
|