epub2md/tests/conftest.py
dschlueter 568844e8c4 fix: nur eingebundene Stylesheets auswerten (QA3-01)
Bisher wirkten alle CSS-Dateien des Manifests, auch mitgelieferte Layouts
anderer Buchreihen. Im C-Buch (21 CSS, nur main.css verlinkt) wurden fette
Buchstaben zu Inline-Code und Registerbuchstaben zu <mark>; eine fremde
display:none-Regel konnte Text löschen.

_load_stylesheet wertet jetzt nur per <link rel="stylesheet"> eingebundene
Stylesheets (transitiv inkl. @import) und <style>-Elemente aus; nicht
eingebundene meldet die Info css-unlinked.

Test-Builder: Doc.css, Testdokumente verlinken ihr CSS wie echte EPUBs
(QA3-02). Regressionstests in tests/test_styles.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:07:49 +02:00

168 lines
6.7 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 posixpath
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}</head>
<body>{body}</body>
</html>"""
@dataclass
class Doc:
name: str # file name inside OEBPS/
body: str
title: str = ""
linear: bool = True
css: list[str] | None = None # linked stylesheets (OEBPS-relative) – default: every .css in Book.media
@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", head="",
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:
css = d.css if d.css is not None else [n for n in book.media if n.lower().endswith(".css")]
head = "".join(f'<link rel="stylesheet" type="text/css" href="{posixpath.relpath(c, posixpath.dirname(d.name) or ".")}"/>'
for c in css)
z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, head=head, 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