Files
stack/tests/rex/comments/test_epub_text.py
kert 77fd48eb31 feat(rex,pfs): EPUB attachments readable by the corpus indexer (F2)
extract_attachment returned "unsupported" for .epub, so CPT 2021/2022
and CPT Changes 2023 had no text at all, and 2018/2019/2024 indexed
only from their PDF siblings. Added rex.comments.epub_text — stdlib
only, shared with pfs.cpt_epub — that resolves an EPUB's own reading
order via META-INF/container.xml -> the OPF's manifest + spine (falling
back to sorted .xhtml/.html names when container.xml is missing), and
strips tags/entities into plain text. extract_attachment's new .epub
branch returns the same ExtractResult shape the PDF branch does; no
change needed in llm.source._attachment_sections, which already tries
every bib attachment regardless of extension.

pfs.cpt_epub used to hard-code "OPS/" as the content-file prefix in
three places; it now resolves the same way via epub_text.content_root,
so a differently-templated EPUB would still locate its content instead
of silently parsing to nothing.
2026-09-09 21:11:04 -04:00

107 lines
4.1 KiB
Python

"""rex.comments.epub_text — shared EPUB container/spine resolution."""
from __future__ import annotations
import zipfile
from pathlib import Path
from rex.comments.epub_text import content_root, extract_text, spine_paths
_CONTAINER_XML = (
'<?xml version="1.0"?>\n'
'<container version="1.0" '
'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n'
"<rootfiles>\n"
'<rootfile full-path="OEBPS/content.opf" '
'media-type="application/oebps-package+xml"/>\n'
"</rootfiles>\n</container>"
)
def _opf(spine_idrefs: list[str]) -> str:
spine = "\n".join(f'<itemref idref="{i}"/>' for i in spine_idrefs)
return (
'<?xml version="1.0"?>\n'
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0">\n'
"<manifest>\n"
'<item id="a" href="a.xhtml" media-type="application/xhtml+xml"/>\n'
'<item id="b" href="b.xhtml" media-type="application/xhtml+xml"/>\n'
"</manifest>\n"
f"<spine>\n{spine}\n</spine>\n"
"</package>"
)
def _build_epub_with_container(path: Path, *, spine_order: list[str]) -> None:
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("mimetype", "application/epub+zip")
zf.writestr("META-INF/container.xml", _CONTAINER_XML)
zf.writestr("OEBPS/content.opf", _opf(spine_order))
zf.writestr("OEBPS/a.xhtml", "<html><body><p>Section A text.</p></body></html>")
zf.writestr("OEBPS/b.xhtml", "<html><body><p>Section B text.</p></body></html>")
def _build_epub_without_container(path: Path) -> None:
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("mimetype", "application/epub+zip")
# Deliberately named so name-sort order differs from any
# plausible spine order — proves the fallback really is name-sort.
zf.writestr("OPS/z_first.xhtml", "<html><body><p>Z content.</p></body></html>")
zf.writestr("OPS/a_second.xhtml", "<html><body><p>A content.</p></body></html>")
class TestContentRoot:
def test_resolves_via_container_xml(self, tmp_path):
path = tmp_path / "book.epub"
_build_epub_with_container(path, spine_order=["a", "b"])
with zipfile.ZipFile(path) as zf:
assert content_root(zf) == "OEBPS/"
def test_falls_back_to_ops_without_container_xml(self, tmp_path):
path = tmp_path / "book.epub"
_build_epub_without_container(path)
with zipfile.ZipFile(path) as zf:
assert content_root(zf) == "OPS/"
class TestSpinePaths:
def test_resolves_spine_order_from_the_opf(self, tmp_path):
path = tmp_path / "book.epub"
_build_epub_with_container(path, spine_order=["b", "a"])
with zipfile.ZipFile(path) as zf:
assert spine_paths(zf) == ["OEBPS/b.xhtml", "OEBPS/a.xhtml"]
def test_none_without_container_xml(self, tmp_path):
path = tmp_path / "book.epub"
_build_epub_without_container(path)
with zipfile.ZipFile(path) as zf:
assert spine_paths(zf) is None
class TestExtractText:
def test_extracts_in_spine_order_not_alphabetical(self, tmp_path):
path = tmp_path / "book.epub"
# Spine deliberately reversed from alphabetical (a, b) name order.
_build_epub_with_container(path, spine_order=["b", "a"])
text = extract_text(path)
assert text.index("Section B text") < text.index("Section A text")
def test_falls_back_to_sorted_names_without_container_xml(self, tmp_path):
path = tmp_path / "book.epub"
_build_epub_without_container(path)
text = extract_text(path)
# a_second.xhtml sorts before z_first.xhtml by name.
assert text.index("A content") < text.index("Z content")
def test_strips_tags_and_unescapes_entities(self, tmp_path):
path = tmp_path / "book.epub"
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("mimetype", "application/epub+zip")
zf.writestr(
"OPS/a.xhtml",
"<html><body><p>CPT&#174; codes &amp; values</p></body></html>",
)
text = extract_text(path)
assert "<p>" not in text
assert "CPT® codes & values" in text