epub2md/tests/conftest.py
dschlueter 02449734a2 feat: epub2md – EPUB nach kapitelweisem Markdown
Kommandozeilenwerkzeug, das DRM-freie EPUB-2/3-Dateien offline in
kapitelweise Markdown-Dateien umwandelt. Erzeugt werden außerdem Medien,
metadata.yaml, manifest.json, conversion-report.json und Diagnose-Dateien.

- Spine-basierte Kapitelbildung mit --chapter-level, epub:type-Abschnitten,
  Fortsetzungsdateien und Erkennung von Layout-Überschriften
- Code mit exakter Einrückung, sicheren Fences und konservativer
  Spracherkennung
- MathML->TeX (konservativ), TeX aus alt-Texten, SVG-/Bildformeln
  bleiben Bilder
- Fußnoten-Modi end-of-chapter, end-of-book, inline und keep-links
- Interne Links/Anker werden umgeschrieben, Linkcheck über die Ausgabe
- Sicher: kein Entpacken, Schutz vor Path Traversal und XXE,
  DRM-Erkennung, atomares Schreiben, --force nur für frühere Ausgaben
- 82 pytest-Tests mit synthetischen EPUBs und Invariantentests für Beispiele/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:33:42 +02:00

163 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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