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/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;` -> `&amp;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 &nbsp; 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