epub2pdf: title-based export names, .pdf download headers, security hardening (68 tests green)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user