epub2pdf: title-based export names, .pdf download headers, security hardening (68 tests green)

This commit is contained in:
BMad Master
2026-08-30 09:03:39 -04:00
commit 5580004aeb
22 changed files with 2351 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
"""Pure-function test helpers for the EPUB -> PDF converter suite.
No pytest fixtures here: every function takes its dependencies explicitly so
the helpers stay unit-testable and reusable.
"""
import io
import time
import zipfile
from pypdf import PdfReader
TERMINAL_STATUSES = ("done", "error")
def wait_for_job(client, job_id, timeout=90.0):
"""Poll GET /api/jobs/<job_id> until a terminal status; return snapshot."""
deadline = time.time() + timeout
last = None
while time.time() < deadline:
resp = client.get(f"/api/jobs/{job_id}")
assert resp.status_code == 200, f"job lookup failed: {resp.status_code}"
last = resp.get_json()
if last["status"] in TERMINAL_STATUSES:
return last
time.sleep(0.05)
raise TimeoutError(f"job {job_id} not terminal after {timeout}s: {last}")
def download_bytes(client, filename):
"""GET /download/<filename> and return the raw body (asserts 200)."""
resp = client.get(f"/download/{filename}")
assert resp.status_code == 200, (
f"download failed for {filename}: {resp.status_code}"
)
return resp.data
def pdf_text(data_bytes):
"""Extract all text from a PDF byte string (pypdf)."""
reader = PdfReader(io.BytesIO(data_bytes))
return "\n".join((page.extract_text() or "") for page in reader.pages)
def pdf_page_count(data_bytes):
return len(PdfReader(io.BytesIO(data_bytes)).pages)
def pdf_metadata_title(data_bytes):
reader = PdfReader(io.BytesIO(data_bytes))
meta = reader.metadata
return meta.get("/Title") if meta else None
def make_zip_without_container():
"""A valid zip that is not an EPUB (no META-INF/container.xml)."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("notes.txt", "hello, not an epub")
return buf.getvalue()
_CONTAINER_XML = (
'<?xml version="1.0" encoding="utf-8"?>'
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
'<rootfiles><rootfile full-path="OEBPS/book.opf" '
'media-type="application/oebps-package+xml"/></rootfiles></container>'
)
def make_epub_zip(items, spine_ids, title="Synthetic", creator="Synth"):
"""Build a minimal EPUB byte string from (id, href, media_type, props) items."""
manifest = "".join(
f'<item id="{i}" href="{h}" media-type="{m}"'
+ (f' properties="{p}"' if p else "")
+ "/>"
for i, h, m, p in items
)
refs = "".join(f'<itemref idref="{sid}"/>' for sid in spine_ids)
opf = (
'<?xml version="1.0" encoding="utf-8"?>'
'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" '
'unique-identifier="uid">'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
f"<dc:title>{title}</dc:title><dc:creator>{creator}</dc:creator>"
"</metadata>"
f"<manifest>{manifest}</manifest>"
f"<spine>{refs}</spine>"
"</package>"
)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("META-INF/container.xml", _CONTAINER_XML)
zf.writestr("OEBPS/book.opf", opf)
return buf.getvalue()
def build_epub_tree(base, *, items=None, spine_ids=None, pdir=None):
"""Materialize a minimal on-disk EPUB structure for parse_opf tests."""
items = items or [
("ch1", "chapter1.xhtml", "application/xhtml+xml", ""),
("ch2", "chapter2.xhtml", "application/xhtml+xml", ""),
("img1", "img.png", "image/png", ""),
]
spine_ids = (
list(spine_ids)
if spine_ids is not None
else [i for i, h, m, p in items if "html" in m]
)
pdir_attr = f' page-progression-direction="{pdir}"' if pdir else ""
manifest = "".join(
f'<item id="{i}" href="{h}" media-type="{m}"'
+ (f' properties="{p}"' if p else "")
+ "/>"
for i, h, m, p in items
)
refs = "".join(f'<itemref idref="{sid}"/>' for sid in spine_ids)
opf = (
'<?xml version="1.0" encoding="utf-8"?>'
f'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" '
f'unique-identifier="uid"{pdir_attr}>'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
"<dc:title>Tree Book</dc:title>"
"<dc:creator>Tree Author</dc:creator>"
"<dc:language>en</dc:language>"
"</metadata>"
f"<manifest>{manifest}</manifest>"
f"<spine>{refs}</spine>"
"</package>"
)
(base / "META-INF").mkdir(parents=True, exist_ok=True)
(base / "META-INF" / "container.xml").write_text(_CONTAINER_XML, encoding="utf-8")
(base / "OEBPS").mkdir(parents=True, exist_ok=True)
(base / "OEBPS" / "book.opf").write_text(opf, encoding="utf-8")
for i, h, m, p in items:
if "html" in m:
(base / "OEBPS" / h).write_text(
'<?xml version="1.0" encoding="utf-8"?>'
"<html><body><p>chapter body</p></body></html>",
encoding="utf-8",
)
return base