epub2md/tools/qa/make_fixtures.py
dschlueter 01f60a41c4 chore: QA-Skripte nach tools/qa übernommen
Die Werkzeuge der QA-Runden lagen nur im ignorierten .qa/: CLI-Matrix
(89 Fälle), Fixture-Generator, unabhängiger Markdown-Validator,
Wortvergleich gegen Datenverlust, Pandoc-Prüfung (HTML, EPUB, PDF).
Jetzt versioniert in tools/qa/ mit README und requirements.txt; erzeugte
Daten landen weiterhin in .qa/. Pfade auf die neue Lage angepasst
(Repo-Wurzel über parents[2], aufrufender Python-Interpreter).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:14:54 +02:00

266 lines
16 KiB
Python
Executable file
Raw Permalink 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.

"""Erzeugt die QA-Fixtures 01–14 (synthetisch, DRM-frei) unter .qa/generated-fixtures/."""
from __future__ import annotations
import sys
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tests"))
from conftest import PNG, SVG, Book, Doc, build_epub # noqa: E402
OUT = ROOT / ".qa" / "generated-fixtures"
M = 'xmlns="http://www.w3.org/1998/Math/MathML"'
def rewrite(path: Path, changes: dict[str, bytes | None], extra: dict[str, bytes] | None = None) -> None:
"""Replace/delete entries of an existing ZIP and add raw entries (names unchanged, even unsafe ones)."""
with zipfile.ZipFile(path) as z:
entries = [(i, z.read(i)) for i in z.infolist()]
with zipfile.ZipFile(path, "w") as z:
for info, data in entries:
if info.filename in changes:
if changes[info.filename] is None:
continue
data = changes[info.filename]
z.writestr(info, data)
for name, data in (extra or {}).items():
z.writestr(zipfile.ZipInfo(name), data)
def f01() -> Book:
return Book(version="2.0", ncx=True, title="Minimal EPUB 2", authors=["Ada Lovelace"], language="en",
identifier="urn:uuid:11111111-2222-3333-4444-555555555555",
docs=[Doc("chapter1.xhtml", "<h1>Chapter One</h1><p>Hello <em>EPUB 2</em> world.</p>", "Chapter One")])
def f02() -> Book:
return Book(title="Minimal EPUB 3", authors=["Grace Hopper"],
docs=[Doc("c1.xhtml", "<h1>Erstes Kapitel</h1><p>Eins.</p>", "Erstes Kapitel"),
Doc("c2.xhtml", "<h1>Zweites Kapitel</h1><p>Zwei.</p>", "Zweites Kapitel")])
def f03() -> Book:
a = ('<h1 id="a-start">Kapitel A</h1><p>Zu <a href="b.xhtml">Kapitel B</a>, zum <a href="#a-unten">Anker unten</a>, '
'zum <a href="b.xhtml#b-ziel">Ziel in B</a> und ins <a href="nirgendwo.xhtml#weg">Nichts</a>, '
'sowie <a href="b.xhtml#gibt-es-nicht">fehlender Anker</a>.</p>'
'<h2 id="a-sec">A.1 Abschnitt</h2><p>Text A.1</p>'
'<h1 id="a2">Kapitel A2 in derselben Datei</h1><p id="a-unten">Unten in A2.</p>'
'<h2>A2.1 Unterabschnitt</h2><p>Mehr.</p>')
b = ('<h1>Kapitel B</h1><p>B-Text.</p><h2 id="b-sec">B.1</h2><p id="b-ziel">Ziel in B.</p>'
'<p><a href="a.xhtml#a-sec">zurück zu A.1</a></p>')
c = '<h1>Kapitel C</h1><p>Ende mit <a href="a.xhtml">Link auf A</a>.</p>'
return Book(title="Kapitel und Links", docs=[Doc("a.xhtml", a, "A"), Doc("b.xhtml", b, "B"), Doc("c.xhtml", c, "C")])
PY = """class Stack:
def __init__(self):
self.items = []
def push(self, x):
\tself.items.append(x) # Tab-Einrückung
if len(self.items) &gt; 10 and x &lt; 0:
raise ValueError("&amp; zu voll")
"""
def f04() -> Book:
body = (
"<h1>Code und Tabellen</h1>"
"<p>Inline: <code>len(x) &lt; 3 &amp;&amp; y</code>, mit Backtick <code>a`b</code> und <code>*nicht kursiv*</code>.</p>"
f'<pre><code class="language-python">{PY}</code></pre>'
"<pre class=\"shell\">$ find . -name '*.py' | xargs grep -n \"TODO\" \\\n | sort -u &gt; todo.txt\n$ echo $HOME</pre>"
"<pre><code>Markdown mit ```fences``` und `ticks`\n eingerückt</code></pre>"
'<pre data-language="json">{\n "a": [1, 2],\n "b": "&lt;tag&gt;"\n}</pre>'
"<pre>#include &lt;stdio.h&gt;\nint main(void) { return 0; }</pre>"
"<ul><li>Punkt 1<ul><li>Punkt 1.1<ol><li>Schritt a</li><li>Schritt b</li></ol></li></ul></li>"
"<li>Punkt 2</li></ul>"
"<ol start=\"3\"><li>drei</li><li>vier<ul><li>vier.a</li></ul></li></ol>"
'<p>Unbekanntes Element: <x-widget data-k="v" onclick="evil()">Widget-Inhalt</x-widget>.</p>'
"<table><thead><tr><th>Befehl</th><th>Bedeutung</th></tr></thead>"
"<tbody><tr><td><code>ls | wc -l</code></td><td>Dateien <strong>zählen</strong></td></tr>"
"<tr><td><code>a &amp;&amp; b</code></td><td>und</td></tr></tbody></table>"
)
return Book(title="Code und Tabellen", docs=[Doc("code.xhtml", body, "Code")])
def f05() -> Book:
body = (
"<h1>Bilder</h1>"
'<figure id="fig1"><img src="images/foto%201%20%C3%A4.png" alt="Ein Foto" title="Fototitel"/>'
"<figcaption>Abbildung 1.1: Ein Foto mit Unterschrift.</figcaption></figure>"
'<p>Nochmals dasselbe Foto: <img src="images/foto%201%20%C3%A4.png" alt="Wiederholung"/></p>'
'<figure><img src="images/diagramm.svg" alt=""/><figcaption>Abbildung 1.2: Ein SVG-Diagramm.</figcaption></figure>'
'<p>Inline-SVG: <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><title>Kreis</title>'
'<circle cx="10" cy="10" r="8"/></svg></p>'
'<p>Siehe <a href="#fig1">Abbildung 1.1</a> und <a href="images/diagramm.svg">SVG direkt</a>.</p>'
'<p>Fehlendes Bild: <img src="images/fehlt.png" alt="weg"/></p>'
)
return Book(title="Bilder und SVG", docs=[Doc("bilder.xhtml", body, "Bilder")],
media={"images/foto 1 ä.png": PNG, "images/diagramm.svg": SVG, "images/unbenutzt.png": PNG})
def f06() -> Book:
body = (
"<h1>Formeln</h1>"
f"<p>Bruch inline: <math {M}><mfrac><mi>a</mi><mi>b</mi></mfrac></math>.</p>"
f"<p>Potenz: <math {M}><msup><mi>x</mi><mn>2</mn></msup></math>, Index: <math {M}><msub><mi>a</mi><mi>i</mi></msub></math>, "
f"beides: <math {M}><msubsup><mi>x</mi><mi>i</mi><mn>2</mn></msubsup></math>.</p>"
f'<div class="equation" id="eq1"><math {M} display="block"><mrow><msup><mi>e</mi><mrow><mi>i</mi><mi>π</mi></mrow></msup>'
'<mo>+</mo><mn>1</mn><mo>=</mo><mn>0</mn></mrow></math><span class="eqno">(2.1)</span></div>'
'<p>Nach Gleichung <a href="#eq1">(2.1)</a> folgt …</p>'
f'<p>Nicht unterstützt: <math {M}><menclose notation="box"><mi>z</mi></menclose></math>.</p>'
f'<p>Mit TeX-Annotation: <math {M}><semantics><mi>y</mi><annotation encoding="application/x-tex">\\vec{{y}}</annotation>'
"</semantics></math>.</p>"
f'<p>alttext: <math {M} alttext="\\sqrt{{2}}"><msqrt><mn>2</mn></msqrt></math>.</p>'
'<p>SVG-Formel: <span class="math"><img src="f/formel.svg" alt="Formel E=mc²"/></span></p>'
'<p>Bildformel: <span class="equation"><img src="f/formel.png" alt="Summe über i"/></span></p>'
'<div class="Equation"><img src="f/tex.png" alt="$$\\int_0^1 x\\,dx = \\frac{1}{2}$$"/> <span>(2.2)</span></div>'
)
return Book(title="MathML und Formeln", docs=[Doc("math.xhtml", body, "Formeln")],
media={"f/formel.svg": SVG, "f/formel.png": PNG, "f/tex.png": PNG})
def f07() -> Book:
ch1 = (
"<h1>Kapitel mit Fußnoten</h1>"
'<p>Aussage A<sup><a epub:type="noteref" href="#fn1" id="ref1">1</a></sup> und B'
'<sup><a role="doc-noteref" href="#fn2" id="ref2">2</a></sup>, nochmal A<sup><a epub:type="noteref" href="#fn1" id="ref1b">1</a></sup>.</p>'
'<p>Endnote<sup><a epub:type="noteref" href="notes.xhtml#en1" id="eref1">3</a></sup> und kaputt<a epub:type="noteref" href="#fehlt">9</a>.</p>'
'<aside epub:type="footnote" id="fn1"><p><a href="#ref1">1</a> Erster Absatz mit <em>Hervorhebung</em> und '
'<a href="https://example.org/quelle">Link</a>.</p><p>Zweiter Absatz.</p></aside>'
f'<aside epub:type="footnote" id="fn2"><p><a href="#ref2">2</a> Mit Formel <math {M}><msup><mi>x</mi><mn>3</mn></msup></math> '
"und Code <code>f(x)</code>.</p></aside>"
)
notes = (
'<section epub:type="endnotes"><h1>Anmerkungen</h1>'
'<aside epub:type="endnote" id="en1"><p><a href="ch1.xhtml#eref1">3</a> Eine Endnote mit Rücklink.</p></aside></section>'
)
return Book(title="Fußnoten und Endnoten", docs=[Doc("ch1.xhtml", ch1, "Kapitel"), Doc("notes.xhtml", notes, "Anmerkungen")])
def f08() -> Book:
ch = ('<h1>Hauptteil</h1><p>Der Begriff <a href="glossar.xhtml#g-api">API</a> und '
'<span id="idx-rekursion">Rekursion</span> nach <span id="p-knuth">Knuth</span> [<a href="lit.xhtml#knuth">Knu97</a>].</p>')
glossary = ('<section epub:type="glossary"><h1>Glossar</h1><dl>'
'<dt id="g-api">API</dt><dd>Application Programming Interface, eine Schnittstelle.</dd>'
"<dt>Rekursion</dt><dd><p>Selbstaufruf einer Funktion.</p><p>Siehe auch Iteration.</p></dd></dl></section>")
lit = ('<section epub:type="bibliography"><h1>Literatur</h1><ol>'
'<li id="knuth">Knuth, D. E.: <em>The Art of Computer Programming</em>, Bd. 1, 3. Aufl., Addison-Wesley, 1997, '
"ISBN 978-0-201-89683-1, S. 1–650.</li>"
'<li>Doe, J.: Ein Artikel. <em>Journal</em> 12 (2020), DOI <a href="https://doi.org/10.1000/xyz123">10.1000/xyz123</a>.</li>'
'<li>Webquelle: <a href="https://example.org/seite?a=1&amp;b=2">https://example.org/seite?a=1&amp;b=2</a> '
"(Zugriff: 01.02.2024).</li></ol></section>")
sach = ('<section epub:type="index"><h1>Sachregister</h1>'
'<div class="group"><h2>A</h2><p class="index1">API <a href="glossar.xhtml#g-api">Glossar</a></p></div>'
'<div class="group"><h2>R</h2><p class="index1">Rekursion <a href="main.xhtml#idx-rekursion">17</a>, 42</p>'
'<p class="index2">endständige, 45</p></div></section>')
pers = ('<section epub:type="index"><h1>Personenregister</h1><h2>K</h2>'
'<p>Knuth, Donald E. <a href="main.xhtml#p-knuth">17</a></p></section>')
return Book(title="Glossar, Literatur, Register", docs=[
Doc("main.xhtml", ch, "Hauptteil"), Doc("glossar.xhtml", glossary, "Glossar"), Doc("lit.xhtml", lit, "Literatur"),
Doc("sach.xhtml", sach, "Sachregister"), Doc("pers.xhtml", pers, "Personenregister")])
def f09() -> Book:
body = (
"<h1>Komplexe Tabelle</h1>"
"<table><caption>Tabelle 9.1: Messwerte</caption>"
'<thead><tr><th rowspan="2">Probe</th><th colspan="2">Messung</th></tr><tr><th>vorher</th><th>nachher</th></tr></thead>'
"<tbody><tr><td>A</td><td><p>1,5</p><p><em>korrigiert</em></p></td><td><strong>2,0</strong></td></tr>"
'<tr><td>B</td><td colspan="2">ausgefallen<br/>siehe Text</td></tr></tbody></table>'
)
return Book(title="Komplexe Tabelle", docs=[Doc("t.xhtml", body, "Tabelle")])
def f10() -> Book:
body = ('<h1>Böse Pfade</h1><p><img src="../../outside.png" alt="raus"/> '
'<a href="../escape.xhtml">escape</a> <img src="/absolute/path.png" alt="abs"/></p>')
return Book(title="Malicious", docs=[Doc("ok.xhtml", body, "OK")],
extra_entries={"../../outside.txt": b"evil", "../escape.xhtml": b"<html/>", "/absolute/path.txt": b"evil",
"OEBPS/../../../tmp/qa-epub2md-escape.txt": b"evil"})
def f12() -> Book:
text = ("Ärger über Öl und Straße – „deutsche Anführungszeichen“, ‚einfach‘ und «guillemets» — "
"café, crème brûlée, naïve, Noël; Ελληνικά: αβγ ΔΣΩ; Mathe: ∀x∈ℝ: x² ≥ 0, ∑ᵢ aᵢ ≈ π, ∞ ≠ ∅ … ")
body = f'<h1 id="größe">Größe & Übermaß</h1><p>{text}</p><p><a href="#größe">Rücksprung</a></p>'
return Book(title="Kodierung – Ümläute", authors=["Zoë Ørsted"],
docs=[Doc("kapitel-ü.xhtml", body, "Größe"), Doc("café.xhtml", "<h1>Café crème</h1><p>Ça va.</p>", "Café")])
TYPO_CSS = b"""
p { font-family: Georgia, serif; }
.kursiv { font-style: italic; } .fett { font-weight: bold; } span.u { text-decoration: underline; }
.weg { display: none; } .markiert { background-color: #ffff00; } .rot { color: #cc0000; }
.gross { font-size: 1.6em; } .kap { font-variant: small-caps; } p.mitte { text-align: center; }
@media print { .kursiv { font-weight: bold; } }
"""
def f14() -> Book:
"""Typografie: CSS-Hervorhebungen, unsichtbare Zeichen, Szenenwechsel, Gedicht, Kästen, Stile, Seitenmarken."""
c1 = ('<h1>Gestaltung</h1>'
'<p>Ein <span class="kursiv">kursives</span> und <span class="fett">fettes</span> Wort, '
'<span class="u">unterstrichen</span><span class="weg">VERSTECKT</span>.</p>'
'<p>Null\u200bbreite und wei\u00adches Trenn\u00adzeichen.</p>'
'<p>Szene eins endet hier.</p><p class="leer"> </p><p>Szene zwei beginnt hier.</p>'
'<div class="poem"><p>Zeile eins</p><p>Zeile zwei</p></div>'
'<p>Ein <span class="markiert">markierter</span>, <span class="rot">roter</span> und '
'<span class="gross">großer</span> Text in <span class="kap">Kapitälchen</span>, '
'<span lang="en">an English phrase</span>.</p>'
'<p class="mitte">Zentrierte Widmung</p>'
'<p>Seite elf endet<span epub:type="pagebreak" id="page12" title="12"/> und Seite zwölf '
'geht weiter<a id="page_13"/> bis dreizehn.</p>'
'<pre><code>x = 1\u200b # bleibt</code></pre>')
c2 = ('<h1>Kästen</h1>'
'<div class="box hinweis"><h3 class="boxheading"><span class="box_icon">[»]</span>Wichtige Regel</h3>'
'<p>Immer testen.</p></div>'
'<aside epub:type="tip"><p>Zwischendurch speichern.</p></aside><p>Nachher.</p>')
return Book(title="Typografie", authors=["Tina Typo"], media={"style.css": TYPO_CSS},
docs=[Doc("c1.xhtml", c1, "Gestaltung"), Doc("c2.xhtml", c2, "Kästen")])
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
for name, book in [
("01-minimal-epub2.epub", f01()), ("02-minimal-epub3-nav.epub", f02()), ("03-chapters-and-links.epub", f03()),
("04-code-and-tables.epub", f04()), ("05-images-and-svg.epub", f05()), ("06-mathml-and-equations.epub", f06()),
("07-footnotes-and-endnotes.epub", f07()), ("08-glossary-bibliography-index.epub", f08()),
("09-complex-table.epub", f09()), ("10-malicious-paths.epub", f10()), ("12-encoding-edge-cases.epub", f12()),
("14-typography.epub", f14()),
]:
build_epub(OUT / name, book)
# 10: additionally a manifest item that points outside the EPUB root
p10 = OUT / "10-malicious-paths.epub"
with zipfile.ZipFile(p10) as z:
opf = z.read("OEBPS/content.opf").decode()
opf = opf.replace("</manifest>", '<item id="evil" href="../../escape.xhtml" media-type="application/xhtml+xml"/></manifest>')
rewrite(p10, {"OEBPS/content.opf": opf.encode()})
# 11: broken container variants
base = Book(title="Defekt", docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>", "A")])
p11 = build_epub(OUT / "11-broken-container.epub", base)
rewrite(p11, {"META-INF/container.xml": (
b'<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
b'<rootfiles><rootfile full-path="OEBPS/gibt-es-nicht.opf" media-type="application/oebps-package+xml"/>'
b"</rootfiles></container>")})
p11b = build_epub(OUT / "11b-broken-spine.epub", Book(title="Spine", docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>", "A")],
spine_extra='<itemref idref="fehlt"/>'))
assert p11b.exists()
p11c = build_epub(OUT / "11c-broken-opf-xml.epub", base)
rewrite(p11c, {"OEBPS/content.opf": b"<package><metadata><dc:title>kaputt"})
p11d = build_epub(OUT / "11d-missing-spine-file.epub", base)
rewrite(p11d, {"OEBPS/a.xhtml": None})
(OUT / "11e-not-a-zip.epub").write_bytes(b"PK\x03\x04 kein echtes zip")
p11f = build_epub(OUT / "11f-missing-container.epub", base)
rewrite(p11f, {"META-INF/container.xml": None})
p11g = build_epub(OUT / "11g-broken-xhtml.epub", base)
rewrite(p11g, {"OEBPS/a.xhtml": b"<html><body><h1>Kaputt<p>nicht geschlossen &nbsp; &unbekannt; <b>fett</body>"})
print("\n".join(sorted(p.name for p in OUT.glob("*.epub"))))
if __name__ == "__main__":
main()