epub2md/tests/conftest.py

163 lines
6.2 KiB
Python
Raw Normal View History

"""Builders for small synthetic EPUB files used throughout the tests."""
from __future__ import annotations
import json
import sys
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from epub2md.cli import main # noqa: E402
# 1x1 transparent PNG
PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da63f8ffff3f0005fe02fea7d6a5b60000000049454e44ae426082"
)
SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>'
XHTML = """<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="de">
<head><title>{title}</title></head>
<body>{body}</body>
</html>"""
@dataclass
class Doc:
name: str # file name inside OEBPS/
body: str
title: str = ""
linear: bool = True
@dataclass
class Book:
docs: list[Doc]
version: str = "3.0"
title: str = "Testbuch"
authors: list[str] = field(default_factory=lambda: ["Erika Musterfrau"])
language: str = "de"
identifier: str = "urn:isbn:9783161484100"
media: dict[str, bytes] = field(default_factory=dict) # OEBPS-relative path -> bytes
nav: list[tuple[str, str]] | None = None # (label, href) – default: one entry per doc
ncx: bool = False
extra_entries: dict[str, bytes] = field(default_factory=dict) # raw ZIP entries (any name)
container: str | None = None
opf_override: str | None = None
spine_extra: str = ""
cover: str | None = None
MEDIA_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".gif": "image/gif",
".mp3": "audio/mpeg", ".css": "text/css", ".otf": "font/otf"}
def build_epub(path: Path, book: Book) -> Path:
manifest = []
spine = []
for i, d in enumerate(book.docs):
manifest.append(f'<item id="d{i}" href="{d.name}" media-type="application/xhtml+xml"/>')
spine.append(f'<itemref idref="d{i}"' + ("" if d.linear else ' linear="no"') + "/>")
for i, (name, _) in enumerate(book.media.items()):
ext = "." + name.rsplit(".", 1)[-1].lower()
props = ' properties="cover-image"' if book.cover == name else ""
manifest.append(f'<item id="m{i}" href="{name}" media-type="{MEDIA_TYPES.get(ext, "application/octet-stream")}"{props}/>')
nav_entries = book.nav if book.nav is not None else [(d.title or d.name, d.name) for d in book.docs]
epub3 = book.version.startswith("3")
if epub3:
manifest.append('<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>')
if book.ncx:
manifest.append('<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>')
creators = "".join(f"<dc:creator>{a}</dc:creator>" for a in book.authors)
opf = book.opf_override or f"""<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="{book.version}" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>{book.title}</dc:title>{creators}
<dc:language>{book.language}</dc:language>
<dc:identifier id="uid">{book.identifier}</dc:identifier>
<dc:publisher>Testverlag</dc:publisher>
<dc:date>2024-01-01</dc:date>
</metadata>
<manifest>{''.join(manifest)}</manifest>
<spine{' toc="ncx"' if book.ncx else ''}>{''.join(spine)}{book.spine_extra}</spine>
</package>"""
nav_xhtml = XHTML.format(
title="Inhalt",
body='<nav epub:type="toc"><ol>' + "".join(f'<li><a href="{h}">{lbl}</a></li>' for lbl, h in nav_entries) + "</ol></nav>",
)
ncx = (
'<?xml version="1.0" encoding="utf-8"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><navMap>'
+ "".join(
f'<navPoint id="n{i}" playOrder="{i + 1}"><navLabel><text>{lbl}</text></navLabel><content src="{h}"/></navPoint>'
for i, (lbl, h) in enumerate(nav_entries)
)
+ "</navMap></ncx>"
)
container = book.container or (
'<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
'<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>'
)
with zipfile.ZipFile(path, "w") as z:
z.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
z.writestr("META-INF/container.xml", container)
z.writestr("OEBPS/content.opf", opf)
if epub3:
z.writestr("OEBPS/nav.xhtml", nav_xhtml)
if book.ncx:
z.writestr("OEBPS/toc.ncx", ncx)
for d in book.docs:
z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, body=d.body))
for name, data in book.media.items():
z.writestr(f"OEBPS/{name}", data)
for name, data in book.extra_entries.items():
z.writestr(zipfile.ZipInfo(name), data)
return path
@dataclass
class Run:
rc: int
out: Path
stdout: str
stderr: str
def chapter(self, prefix: str) -> str:
files = sorted((self.out / "chapters").glob(f"{prefix}*.md"))
assert files, f"no chapter {prefix} in {sorted(p.name for p in (self.out / 'chapters').glob('*.md'))}"
return files[0].read_text(encoding="utf-8")
def chapters(self) -> list[Path]:
return sorted((self.out / "chapters").glob("*.md"))
def all_md(self) -> str:
return "\n".join(p.read_text(encoding="utf-8") for p in self.chapters())
@property
def report(self) -> dict:
return json.loads((self.out / "conversion-report.json").read_text(encoding="utf-8"))
@pytest.fixture
def convert(tmp_path, capsys):
counter = {"n": 0}
def run(book_or_path, *args: str, out: Path | None = None) -> Run:
counter["n"] += 1
if isinstance(book_or_path, Book):
epub = build_epub(tmp_path / f"book{counter['n']}.epub", book_or_path)
else:
epub = Path(book_or_path)
target = out or tmp_path / f"out{counter['n']}"
rc = main([str(epub), str(target), *args])
captured = capsys.readouterr()
return Run(rc, target, captured.out, captured.err)
return run