163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
|
|
"""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
|