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>
168 lines
6.7 KiB
Python
168 lines
6.7 KiB
Python
"""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
|