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/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 `&amp;`).
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 (`&fjlig;`,
146
+ `&NotEqualTilde;`, ...) 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 (`&nbsp;` -> `&#160;`).
160
+
161
+ XML only predefines five entity names; everything else (`&nbsp;`, `&deg;`,
162
+ `&eacute;`, ...) 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 "&amp;" + 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;` -> `&amp;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("&amp;", 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 `&nbsp;` 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) + "&nbsp;" + 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&gt;) OR common formatting tags with escaped angle brackets.
569
+ _BROKEN_TAGS_RE = re.compile(
570
+ r"(?<!<)(?<!&lt;)/[a-zA-Z]+&gt;|&lt;/(?:p|div|span|h[1-6]|i|em|b|strong)&gt;",
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)