Skip to content

Commit 752fba3

Browse files
pancacakeclaude
andcommitted
fix(reading): read an EPUB that macOS re-zipped as the book it is
Finder's "Compress" does two things to an EPUB: it nests the whole book under one folder, and it writes a parallel `__MACOSX/` tree of AppleDouble resource forks that carry the content files' names and extensions. The package lookup only ever looked for `META-INF/container.xml` at the archive root, so a wrapped book found no container, no OPF and no spine, and fell back to matching file extensions over the whole archive. That fallback then read `__MACOSX/OEBPS/._index_split_000.xhtml` — 60 bytes of binary resource fork — as a chapter. Hence the phantom `._…` sections in the sidebar and "Could not load this section" on opening them (#1447). Both halves of the same rule, in one place. Packaging residue (`__MACOSX`, dotfiles) is not book content and never reaches either reader; the other archive readers in this codebase already drop it. And a single wrapper directory is resolved from wherever the container actually sits, so spine order survives it. The container/OPF chain was written out twice — once for the spine, once for the navigation — so a wrapped book silently lost its whole outline too, by a `return []` in the half nobody was looking at. One resolver now answers "where is this book's package document" for both. Co-Authored-By: Claude Opus 5 <[email protected]>
1 parent 883c90f commit 752fba3

2 files changed

Lines changed: 100 additions & 31 deletions

File tree

deeptutor/utils/document_extractor.py

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ def _current_limits() -> tuple[int, int, int, int]:
9696
_OOXML_MAGIC = b"PK\x03\x04"
9797

9898
_EPUB_CONTENT_EXTENSIONS: frozenset[str] = frozenset({".xhtml", ".html", ".htm"})
99+
#: Where an EPUB declares its package document, relative to the book root.
100+
_EPUB_CONTAINER_PATH = "META-INF/container.xml"
99101
_EPUB_MAX_MEMBERS = 4096
100102
_EPUB_MAX_MEMBER_BYTES = 20 * 1024 * 1024
101103
_EPUB_MAX_TOTAL_UNCOMPRESSED_BYTES = 200 * 1024 * 1024
@@ -642,29 +644,61 @@ def _epub_html_members(names: list[str]) -> list[str]:
642644
return [name for name in names if _ext(name) in _EPUB_CONTENT_EXTENSIONS]
643645

644646

645-
def _epub_content_files(zf: zipfile.ZipFile, filename: str) -> list[str]:
646-
"""Resolve the XHTML content documents of an EPUB in reading order.
647+
def _epub_is_packaging_residue(name: str) -> bool:
648+
"""Whether an archive member is packaging leftovers, not book content.
647649
648-
Follows the standard chain ``META-INF/container.xml`` -> OPF package
649-
document -> spine ``itemref`` order. Falls back to every HTML/XHTML
650-
member in archive order when package metadata is missing or unusable.
650+
macOS writes a ``__MACOSX/`` tree of AppleDouble resource forks (``._x``)
651+
alongside the real files. They carry the content file's extension while
652+
holding binary metadata, so a fallback that matches on extension alone
653+
reads them as chapters.
651654
"""
652-
names = zf.namelist()
653-
name_set = set(names)
655+
return any(part == "__MACOSX" or part.startswith(".") for part in name.split("/") if part)
654656

655-
container_root = _epub_parse_member(zf, "META-INF/container.xml", filename)
657+
658+
def _epub_open_package(
659+
zf: zipfile.ZipFile,
660+
filename: str,
661+
) -> tuple[list[str], str, Any | None]:
662+
"""Locate an EPUB's package document: content members, OPF path, OPF root.
663+
664+
The standard chain is ``META-INF/container.xml`` -> ``rootfile`` -> OPF.
665+
Finder's "Compress" wraps the selection in a folder, which puts that whole
666+
chain one level down; looking only at the archive root made every such
667+
book fall back to extension matching, losing spine order and picking up
668+
``__MACOSX`` resource forks as chapters (#1447). Resolving the wrapper
669+
here keeps both readers of the package — spine and navigation — agreeing
670+
on where the book is.
671+
"""
672+
names = [name for name in zf.namelist() if not _epub_is_packaging_residue(name)]
673+
container = next((name for name in names if name.endswith(_EPUB_CONTAINER_PATH)), "")
674+
if not container:
675+
return names, "", None
676+
prefix = container[: -len(_EPUB_CONTAINER_PATH)]
677+
678+
container_root = _epub_parse_member(zf, container, filename)
656679
if container_root is None:
657-
return _epub_html_members(names)
680+
return names, "", None
658681

659-
opf_path = ""
682+
rootfile = ""
660683
for node in container_root.iter():
661684
if _local_name(node.tag) == "rootfile":
662-
opf_path = node.get("full-path") or ""
685+
rootfile = node.get("full-path") or ""
663686
break
664-
if not opf_path or opf_path not in name_set:
665-
return _epub_html_members(names)
687+
opf_path = f"{prefix}{rootfile}" if rootfile else ""
688+
if not opf_path or opf_path not in set(names):
689+
return names, "", None
690+
691+
return names, opf_path, _epub_parse_member(zf, opf_path, filename)
666692

667-
opf_root = _epub_parse_member(zf, opf_path, filename)
693+
694+
def _epub_content_files(zf: zipfile.ZipFile, filename: str) -> list[str]:
695+
"""Resolve the XHTML content documents of an EPUB in reading order.
696+
697+
Falls back to every HTML/XHTML member in archive order when package
698+
metadata is missing or unusable.
699+
"""
700+
names, opf_path, opf_root = _epub_open_package(zf, filename)
701+
name_set = set(names)
668702
if opf_root is None:
669703
return _epub_html_members(names)
670704

@@ -706,20 +740,7 @@ def _epub_package_navigation(
706740
spine_members: list[str],
707741
) -> list[EpubOutlineItem]:
708742
"""Read EPUB3 nav or EPUB2 NCX entries and map them to spine locators."""
709-
container_root = _epub_parse_member(zf, "META-INF/container.xml", filename)
710-
if container_root is None:
711-
return []
712-
opf_path = next(
713-
(
714-
str(node.get("full-path") or "")
715-
for node in container_root.iter()
716-
if _local_name(node.tag) == "rootfile"
717-
),
718-
"",
719-
)
720-
if not opf_path:
721-
return []
722-
opf_root = _epub_parse_member(zf, opf_path, filename)
743+
_, opf_path, opf_root = _epub_open_package(zf, filename)
723744
if opf_root is None:
724745
return []
725746

tests/utils/test_document_extractor.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
EmptyDocumentError,
2222
UnsupportedDocumentError,
2323
extract_documents_from_records,
24+
extract_epub_spine,
2425
extract_text_from_bytes,
2526
extract_text_from_path,
2627
is_document_extension,
@@ -82,12 +83,18 @@ def _make_epub(
8283
opf_dir: str = "OEBPS",
8384
with_container: bool = True,
8485
with_opf: bool = True,
86+
wrapper: str = "",
87+
with_macosx: bool = False,
8588
) -> bytes:
8689
"""Build a minimal EPUB in memory.
8790
8891
``chapters`` maps member names (relative to ``opf_dir``) to XHTML body
8992
markup. ``spine`` is an ordered subset of chapter keys controlling the
9093
reading order; it defaults to the dict order.
94+
95+
``wrapper`` nests the whole book under one directory and ``with_macosx``
96+
adds AppleDouble resource forks — together, what macOS Finder's "Compress"
97+
produces.
9198
"""
9299
opf_path = f"{opf_dir}/content.opf"
93100
manifest = "".join(
@@ -102,18 +109,23 @@ def _make_epub(
102109
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0">'
103110
f"<manifest>{manifest}</manifest><spine>{spine_xml}</spine></package>"
104111
)
112+
root = f"{wrapper}/" if wrapper else ""
105113
buf = io.BytesIO()
106114
with zipfile.ZipFile(buf, "w") as zf:
107115
zf.writestr("mimetype", "application/epub+zip")
108116
if with_container:
109-
zf.writestr("META-INF/container.xml", _CONTAINER_XML.format(opf=opf_path))
117+
zf.writestr(f"{root}META-INF/container.xml", _CONTAINER_XML.format(opf=opf_path))
110118
if with_opf:
111-
zf.writestr(opf_path, opf)
119+
zf.writestr(f"{root}{opf_path}", opf)
112120
for name, body in chapters.items():
113121
zf.writestr(
114-
f"{opf_dir}/{name}",
122+
f"{root}{opf_dir}/{name}",
115123
f'<html xmlns="http://www.w3.org/1999/xhtml"><body>{body}</body></html>',
116124
)
125+
if with_macosx:
126+
# AppleDouble: the content file's name and extension, binary
127+
# resource-fork bytes inside.
128+
zf.writestr(f"__MACOSX/{opf_dir}/._{name}", b"\x00\x05\x16\x07" + b"\x00" * 60)
117129
return buf.getvalue()
118130

119131

@@ -281,6 +293,42 @@ def test_falls_back_to_archive_order_without_opf(self) -> None:
281293

282294
assert "Solo chapter." in text
283295

296+
def test_a_finder_compressed_epub_still_reads_as_the_book_it_is(self) -> None:
297+
"""macOS "Compress" wraps the book in a folder and adds ``__MACOSX``.
298+
299+
Both together used to defeat the package lookup: the container was no
300+
longer at the archive root, so resolution fell back to matching file
301+
extensions, which picked up the AppleDouble forks as chapters. The
302+
reader then reported "Could not load this section" on binary members
303+
that were never part of the book (#1447).
304+
"""
305+
data = _make_epub(
306+
{
307+
"index_split_000.xhtml": "<h1>Chapter One</h1><p>Alpha text.</p>",
308+
"index_split_001.xhtml": "<h1>Chapter Two</h1><p>Beta text.</p>",
309+
},
310+
spine=["index_split_001.xhtml", "index_split_000.xhtml"],
311+
wrapper="MyBook",
312+
with_macosx=True,
313+
)
314+
315+
units, _ = extract_epub_spine(data, "book.epub")
316+
317+
assert [unit.title for unit in units] == ["Chapter Two", "Chapter One"]
318+
assert all("__MACOSX" not in unit.href for unit in units)
319+
320+
def test_resource_forks_are_not_chapters_even_without_a_package(self) -> None:
321+
"""The extension-matching fallback must not read AppleDouble bytes."""
322+
data = _make_epub(
323+
{"a.xhtml": "<p>First member.</p>"},
324+
with_container=False,
325+
with_macosx=True,
326+
)
327+
328+
units, _ = extract_epub_spine(data, "book.epub")
329+
330+
assert [unit.href for unit in units] == ["OEBPS/a.xhtml"]
331+
284332
def test_malformed_xhtml_uses_tolerant_html_fallback(self) -> None:
285333
buf = io.BytesIO()
286334
with zipfile.ZipFile(buf, "w") as zf:

0 commit comments

Comments
 (0)