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>
This commit is contained in:
Dieter Schlüter 2026-10-06 22:14:54 +02:00
commit 01f60a41c4
13 changed files with 1210 additions and 2 deletions

34
tools/qa/README.md Normal file
View file

@ -0,0 +1,34 @@
# QA-Werkzeuge
Skripte für die Qualitätsprüfung nach `Prompt_Kontrolle.md`. Sie ergänzen `pytest` um CLI-Integrationstests,
einen unabhängigen Validator und die Prüfung des Pandoc-Wegs. Mit ihnen wurden die Befunde QA3-01 bis
QA3-06 gefunden (siehe `CHANGELOG.md`). Alle Aufrufe im Repo-Wurzelverzeichnis; erzeugte Daten landen in
`.qa/` (von Git ignoriert), Kommandos mit Rückgabecode in `.qa/commands.log`.
```bash
python3 -m venv .qa/venv && . .qa/venv/bin/activate
pip install -r tools/qa/requirements.txt
```
| Skript | Zweck |
| --- | --- |
| `make_fixtures.py` | erzeugt synthetische Test-EPUBs 01–14 (u. a. Path Traversal, kaputter Container, MathML, Fußnoten, Typografie) in `.qa/generated-fixtures/` |
| `run_matrix.py` | CLI-Matrix (89 Fälle) über alle Optionen und Fehlercodes → `.qa/output/matrix-results.json`; braucht die Fixtures |
| `validate_output.py AUSGABE…` | unabhängiger Validator (markdown-it-py statt `epub2md/linkcheck.py`): Links, Anker, Bilder, Fences, YAML, JSON, Python-Einrückung, Fußnotenpaare → JSON |
| `worddiff.py ALT NEU` | Wortvergleich zweier Ausgaben – zeigt Datenverlust nach einer Codeänderung |
| `pyblocks.py DATEIEN…` | Python-Codeblöcke mit Einrückungsfehlern samt Inhalt (Vergleich mit der Quelle) |
| `pandoc_full.sh` | alle EPUBs aus `Beispiele/` konvertieren, zusammenfügen und mit den Pandoc-Befehlen der Buch-README nach HTML, EPUB und PDF (weasyprint, xelatex) bringen → `.qa/output/pandoc/` |
| `pandoc_run.sh`, `pandoc_pdfs.sh` | Teilschritte von `pandoc_full.sh` für einzelne Buchordner |
| `check_pandoc.py BUCHORDNER…` | HTML/EPUB von Pandoc: interne Links und Anker, Bilder, `[!TIP]`-Reste, Formeln, Lage des Inhaltsverzeichnisses |
| `check_pdf.sh PDF…` | Seiten, Seite des Inhaltsverzeichnisses, Text-Reste, nicht eingebettete Schriften (poppler-utils) |
| `log.sh` | `. tools/qa/log.sh; qa BEFEHL` protokolliert Befehl und rc |
Typischer Ablauf nach einer Änderung:
```bash
python -m pytest -q
python tools/qa/make_fixtures.py && python tools/qa/run_matrix.py
python epub2md.py Beispiele/BUCH.epub .qa/output/neu/BUCH
python tools/qa/worddiff.py .qa/output/alt/BUCH .qa/output/neu/BUCH # alt: z. B. mit git stash erzeugt
python tools/qa/validate_output.py .qa/output/neu/BUCH
```

118
tools/qa/check_pandoc.py Executable file
View file

@ -0,0 +1,118 @@
"""Prüft Pandoc-Ausgaben (HTML und EPUB) eines konvertierten Buches.
Aufruf: python tools/qa/check_pandoc.py BUCHORDNER [...] -> JSON auf stdout
Geprüft: interne Links/Anker, Bilder, Reste von '[!TIP]', Lage des Inhaltsverzeichnisses, TeX-Reste.
"""
from __future__ import annotations
import json
import posixpath
import re
import sys
import zipfile
from pathlib import Path
from urllib.parse import unquote
from lxml import html as lhtml
ID_RE = re.compile(r"""\b(?:id|name)\s*=\s*["']([^"']+)["']""")
def _ids(text: str) -> set[str]:
return set(ID_RE.findall(text))
def _links(doc) -> list[str]:
return [a.get("href") for a in doc.iter("a") if a.get("href")]
def _images(doc) -> list[str]:
srcs = [i.get("src") for i in doc.iter("img") if i.get("src")]
srcs += [i.get("{http://www.w3.org/1999/xlink}href") or i.get("href") for i in doc.iter("image")]
return [s for s in srcs if s and not s.startswith("data:")]
def _parse(text: str):
return lhtml.fromstring(text.encode("utf-8"))
def _toc_position(doc) -> dict:
"""Where does the TOC (nav#TOC) stand relative to the first chapter heading?"""
nav = doc.xpath("//*[@id='TOC']")
if not nav:
return {"toc": False}
body_text = lhtml.tostring(doc, encoding="unicode")
pos = body_text.find('id="TOC"')
before = re.sub(r"<[^>]+>", " ", body_text[:pos])
h1_before = len(re.findall(r"<h1\b", body_text[:pos]))
return {"toc": True, "h1_before_toc": h1_before, "words_before_toc": len(before.split()),
"toc_entries": len(nav[0].xpath(".//a"))}
def check_html(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
doc = _parse(text)
ids = _ids(text)
broken = [h for h in _links(doc) if h.startswith("#") and unquote(h[1:]) not in ids]
missing_img = [s for s in _images(doc) if not (path.parent / unquote(s)).exists()]
plain = doc.text_content()
return {
"file": path.name, "links_internal": sum(1 for h in _links(doc) if h.startswith("#")),
"broken_internal": len(broken), "broken_examples": broken[:5],
"images": len(_images(doc)), "missing_images": missing_img[:5],
"callout_residue": len(re.findall(r"\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]", plain)),
"tex_residue": len(re.findall(r"\\(?:frac|begin\{|mathbb|alpha|sum_)", plain)),
"math_elements": len(doc.xpath("//*[local-name()='math']")),
**_toc_position(doc),
}
def check_epub(path: Path) -> dict:
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
docs = {n: z.read(n).decode("utf-8") for n in names if n.endswith((".xhtml", ".html"))}
ids = {n: _ids(t) for n, t in docs.items()}
broken: list[str] = []
internal = 0
missing_img: list[str] = []
residue = tex = math = 0
for name, text in docs.items():
doc = _parse(text)
base = posixpath.dirname(name)
for h in _links(doc):
if re.match(r"^[a-z]+:", h):
continue
internal += 1
file, _, frag = h.partition("#")
target = posixpath.normpath(posixpath.join(base, unquote(file))) if file else name
if target not in names:
broken.append(f"{name}: {h}")
elif frag and unquote(frag) not in ids.get(target, set()):
broken.append(f"{name}: {h}")
for s in _images(doc):
if posixpath.normpath(posixpath.join(base, unquote(s))) not in names:
missing_img.append(f"{name}: {s}")
plain = doc.text_content()
residue += len(re.findall(r"\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]", plain))
tex += len(re.findall(r"\\(?:frac|begin\{|mathbb|alpha|sum_)", plain))
math += len(doc.xpath("//*[local-name()='math']"))
return {"file": path.name, "documents": len(docs), "links_internal": internal, "broken_internal": len(broken),
"broken_examples": broken[:5], "missing_images": missing_img[:5], "callout_residue": residue,
"tex_residue": tex, "math_elements": math, "nav": any(n.endswith("nav.xhtml") for n in names)}
def main() -> None:
results = []
for d in map(Path, sys.argv[1:]):
entry = {"book": d.name}
for f in sorted(d.glob("*.html")):
entry["html"] = check_html(f)
for f in sorted(d.glob("*.epub")):
entry["epub"] = check_epub(f)
results.append(entry)
json.dump(results, sys.stdout, ensure_ascii=False, indent=1)
if __name__ == "__main__":
main()

11
tools/qa/check_pdf.sh Executable file
View file

@ -0,0 +1,11 @@
#!/bin/bash
# Kurzprüfung von PDFs: Seiten, Inhaltsverzeichnis-Seite, Text-Reste, nicht eingebettete Schriften
for pdf in "$@"; do
pages=$(pdfinfo "$pdf" 2>/dev/null | awk '/^Pages:/{print $2}')
txt=$(pdftotext -enc UTF-8 "$pdf" - 2>/dev/null)
tocpage=$(for p in $(seq 1 15); do pdftotext -f $p -l $p "$pdf" - 2>/dev/null | grep -qiE '^(Inhalt|Inhaltsverzeichnis|Contents)$' && { echo $p; break; }; done)
first=$(for p in 1 2 3 4; do printf '%s | ' "$(pdftotext -f $p -l $p "$pdf" - 2>/dev/null | grep -v '^\s*$' | head -1 | cut -c1-28)"; done)
printf '%s\tSeiten=%s\tInhalt-S.=%s\t[!]=%s\t<a id=%s\tTeX=%s\tfehlende-Einbettung=%s\n\t\tS.1-4: %s\n' "$(basename "$pdf" | cut -c1-14)" "$pages" "${tocpage:--}" \
"$(grep -c '\[!' <<<"$txt")" "$(grep -c '<a id' <<<"$txt")" "$(grep -cE '\\\\(frac|begin|mathbb)|\$\$' <<<"$txt")" \
"$(pdffonts "$pdf" 2>/dev/null | awk 'NR>2 && $(NF-4)=="no"' | wc -l)" "$first"
done

9
tools/qa/log.sh Executable file
View file

@ -0,0 +1,9 @@
# usage: . tools/qa/log.sh; qa <command...> – runs a command, appends command + rc to .qa/commands.log
qa() {
local start rc
start=$(date -Iseconds)
"$@"; rc=$?
mkdir -p "$(git rev-parse --show-toplevel)/.qa"
printf '%s\trc=%s\t%s\n' "$start" "$rc" "$*" >> "$(git rev-parse --show-toplevel)/.qa/commands.log"
return $rc
}

266
tools/qa/make_fixtures.py Executable file
View file

@ -0,0 +1,266 @@
"""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()

18
tools/qa/pandoc_full.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/bash
# Kompletter Pandoc-Durchlauf: jedes EPUB aus Beispiele/ konvertieren, zusammenfügen, alle Pandoc-Befehle der
# Buch-README ausführen, beide PDF-Varianten erzeugen und prüfen. Aufruf im Repo-Wurzelverzeichnis:
# tools/qa/pandoc_full.sh Ergebnis in .qa/output/pandoc/ (check.json, pdf-check.txt)
# Python über $PYTHON (Standard: python3); braucht pandoc, weasyprint, xelatex, poppler-utils.
PY=${PYTHON:-python3}
Q=$(dirname "$0")
P=.qa/output/pandoc; rm -rf $P; mkdir -p $P
for f in Beispiele/*.epub; do
n=$(basename "$f" .epub | cut -d_ -f1 | cut -d- -f1); n=${n/Krooß/Krooss}
$PY epub2md.py "$f" "$P/$n" > /dev/null 2> "$P/$n.convert.err"; echo "$n convert rc=$?"
$PY epub2md_merge.py "$P/$n" > "$P/$n.merge.log" 2>&1; echo "$n merge rc=$? $(head -1 "$P/$n.merge.log" | cut -c1-120)"
SKIP_PDF=1 "$Q/pandoc_run.sh" "$P/$n"
"$Q/pandoc_pdfs.sh" "$P/$n"
done
$PY "$Q/check_pandoc.py" $P/*/ > $P/check.json
"$Q/check_pdf.sh" $P/*/*.pdf > $P/pdf-check.txt
echo FERTIG

15
tools/qa/pandoc_pdfs.sh Executable file
View file

@ -0,0 +1,15 @@
#!/bin/bash
# Erzeugt beide PDF-Varianten aus der Buch-README unter getrennten Namen: *.weasy.pdf und *.latex.pdf
# (in der README schreiben beide Befehle dieselbe Datei). Aufruf im Repo-Wurzelverzeichnis.
mkdir -p .qa
for dir in "$@"; do
mkdir -p "$dir/pandoc-logs"
grep -E '^pandoc .*\.pdf' "$dir/README.md" | while IFS= read -r cmd; do
case "$cmd" in *weasyprint*) kind=weasy;; *) kind=latex;; esac
cmd=$(printf '%s' "$cmd" | sed -E "s/\.pdf('?)\$/.$kind.pdf\1/")
s=$(date +%s)
(cd "$dir" && timeout 1800 bash -c "$cmd") > "$dir/pandoc-logs/$kind.out" 2> "$dir/pandoc-logs/$kind.err"; rc=$?
printf '%s\t%s\trc=%s\t%ss\n' "$(basename "$dir")" $kind $rc $(( $(date +%s)-s ))
printf '%s\trc=%s\tpandoc %s-PDF (%s)\n' "$(date -Iseconds)" $rc $kind "$(basename "$dir")" >> .qa/commands.log
done
done

18
tools/qa/pandoc_run.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/bash
# Führt jede "pandoc …"-Zeile aus der README.md eines konvertierten Buches im Buchordner aus.
# Aufruf im Repo-Wurzelverzeichnis: tools/qa/pandoc_run.sh BUCHORDNER... (SKIP_PDF=1: ohne PDF-Befehle)
# Logs: BUCHORDNER/pandoc-logs/NN.{cmd,out,err} und summary.tsv; Kommandos mit rc in .qa/commands.log
mkdir -p .qa
for dir in "$@"; do
mkdir -p "$dir/pandoc-logs"
n=0
grep -E '^pandoc ' "$dir/README.md" | { [ -n "$SKIP_PDF" ] && grep -v '\.pdf' || cat; } | while IFS= read -r cmd; do
n=$((n+1)); f=$(printf '%s/pandoc-logs/%02d' "$dir" $n)
printf '%s\n' "$cmd" > "$f.cmd"
s=$(date +%s)
(cd "$dir" && timeout 1800 bash -c "$cmd") > "$f.out" 2> "$f.err"; rc=$?
printf '%s\trc=%s\t%ss\t%s warn\t%s\n' "$(basename "$dir")" $rc $(( $(date +%s)-s )) "$(grep -c . "$f.err")" \
"$(echo "$cmd" | grep -o -- '-o [^ ]*\.[a-z]*' | tail -1)" | tee -a "$dir/pandoc-logs/summary.tsv"
printf '%s\trc=%s\tpandoc (%s)\n' "$(date -Iseconds)" $rc "$(basename "$dir") #$n" >> .qa/commands.log
done
done

36
tools/qa/pyblocks.py Executable file
View file

@ -0,0 +1,36 @@
"""Listet Python-Codeblöcke mit Einrückungsfehlern in Markdown-Dateien (mit Inhalt, zum Vergleich mit der Quelle).
Aufruf: python tools/qa/pyblocks.py AUSGABE/chapters/*.md
"""
from __future__ import annotations
import ast
import re
import sys
def main() -> None:
for path in sys.argv[1:]:
with open(path, encoding="utf-8") as fh:
lines = fh.read().split("\n")
i = 0
while i < len(lines):
m = re.match(r"^(`{3,})python\s*$", lines[i])
if m:
j = i + 1
while j < len(lines) and not lines[j].startswith(m.group(1)):
j += 1
try:
ast.parse("\n".join(lines[i + 1:j]))
except IndentationError as exc:
print(f"== {path}:{i + 1} {exc.msg} (Blockzeile {exc.lineno})")
print("\n".join(f"{k + 1:3}|{line}" for k, line in enumerate(lines[i + 1:j])))
except SyntaxError:
pass # fragments and program output are no indentation defects
i = j
i += 1
if __name__ == "__main__":
main()

View file

@ -0,0 +1,5 @@
# Zusätzlich zu requirements-dev.txt für die QA-Skripte (unabhängiger Markdown-Validator, Coverage)
-r ../../requirements-dev.txt
markdown-it-py>=3.0
mdit-py-plugins>=0.4
coverage>=7.0

418
tools/qa/run_matrix.py Executable file
View file

@ -0,0 +1,418 @@
"""CLI-Testmatrix gegen die QA-Fixtures. Jeder Lauf schreibt in ein neues Verzeichnis unter .qa/output/.
Ergebnis: .qa/output/matrix-results.json, Kommandos mit Rückgabecode in .qa/commands.log
"""
from __future__ import annotations
import json
import os
import re
import shutil
import stat
import subprocess
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
QA = ROOT / ".qa"
FX = QA / "generated-fixtures"
OUT = QA / "output" / "matrix"
PY = sys.executable
sys.path.insert(0, str(Path(__file__).resolve().parent))
from validate_output import validate # noqa: E402
results: list[dict] = []
def run(args: list[str]) -> subprocess.CompletedProcess:
cmd = [PY, "epub2md.py", *args]
cp = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
with open(QA / "commands.log", "a", encoding="utf-8") as fh:
rel = " ".join(a.replace(str(ROOT) + "/", "") for a in cmd[1:])
fh.write(f"{datetime.now().isoformat(timespec='seconds')}\trc={cp.returncode}\tpython {rel}\n")
return cp
def merge_case(name: str, book_dir: Path, expect_rc: int, checks=None) -> dict:
"""epub2md_merge.py on an existing output directory."""
cmd = [PY, "epub2md_merge.py", str(book_dir)]
cp = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
with open(QA / "commands.log", "a", encoding="utf-8") as fh:
fh.write(f"{datetime.now().isoformat(timespec='seconds')}\trc={cp.returncode}\tpython epub2md_merge.py "
f"{str(book_dir).replace(str(ROOT) + '/', '')}\n")
entry: dict = {"case": name, "args": [str(book_dir)], "rc": cp.returncode, "expected_rc": expect_rc,
"ok": cp.returncode == expect_rc and "Traceback" not in cp.stderr, "notes": []}
for label, fn in checks or []:
good = bool(fn(book_dir, cp))
entry["ok"] = entry["ok"] and good
entry["notes"].append(("OK: " if good else "FEHLER: ") + label)
results.append(entry)
print(("PASS " if entry["ok"] else "FAIL ") + name, "rc=", cp.returncode)
return entry
def read(out: Path, pattern: str) -> str:
return "\n".join(p.read_text(encoding="utf-8") for p in sorted(out.glob(pattern)))
def case(name: str, fixture: str, args: list[str], expect_rc: int, checks=None, validate_out: bool = True,
out: Path | None = None, keep: bool = False) -> dict:
out = out or OUT / name
if out.exists() and not keep:
shutil.rmtree(out)
src = str(FX / fixture) if fixture and not fixture.startswith("/") else fixture
argv = ([src] if src else []) + ([str(out)] if src else []) + args
cp = run(argv)
entry: dict = {"case": name, "fixture": fixture, "args": args, "rc": cp.returncode, "expected_rc": expect_rc,
"ok": cp.returncode == expect_rc, "notes": [], "stderr_tail": cp.stderr.strip().splitlines()[-3:]}
if not entry["ok"]:
entry["notes"].append(f"Rückgabecode {cp.returncode}, erwartet {expect_rc}")
if "Traceback (most recent call last)" in cp.stderr:
entry["ok"] = False
entry["notes"].append("Traceback im Normalmodus")
if checks:
for label, fn in checks:
try:
good = bool(fn(out, cp))
except Exception as exc: # noqa: BLE001
good = False
label = f"{label} (Exception {type(exc).__name__}: {exc})"
if not good:
entry["ok"] = False
entry["notes"].append(("OK: " if good else "FEHLER: ") + label)
if validate_out and cp.returncode == 0 and out.exists() and "--dry-run" not in args:
v = validate(out)
entry["validator"] = v["severity_counts"]
entry["validator_issues"] = [i for i in v["issues"] if i["severity"] != "info"][:15]
if any(i["severity"] in ("critical", "high") for i in v["issues"]):
entry["ok"] = False
entry["notes"].append("Validator: kritische/hohe Befunde")
results.append(entry)
print(("PASS " if entry["ok"] else "FAIL ") + name, "rc=", cp.returncode, "; ".join(n for n in entry["notes"] if n.startswith("FEHLER") or "erwartet" in n or "Validator" in n or "Traceback" in n))
return entry
def ch(out: Path, prefix: str = "") -> str:
return read(out / "chapters", f"{prefix}*.md")
def report(out: Path) -> dict:
return json.loads((out / "conversion-report.json").read_text(encoding="utf-8"))
def main() -> None:
if OUT.exists():
shutil.rmtree(OUT)
OUT.mkdir(parents=True)
# --- help
cp = run(["--help"])
opts = ["--format", "--extract-media", "--no-extract-media", "--copy-original", "--chapter-level", "--toc", "--no-toc",
"--frontmatter", "--no-frontmatter", "--footnotes", "--math", "--images", "--slug-language", "--keep-html",
"--strict", "--force", "--verbose", "--dry-run", "--report", "--code-language", "--keep-wrap-markers",
"--keep-invisible-chars", "--css-emphasis", "--no-css-emphasis", "--boxes", "--styles", "--page-markers"]
missing = [o for o in opts if o not in cp.stdout]
results.append({"case": "help", "rc": cp.returncode, "expected_rc": 0, "ok": cp.returncode == 0 and not missing,
"notes": [f"fehlende Optionen in --help: {missing}"] if missing else ["alle Optionen dokumentiert"]})
print("PASS help" if not missing else f"FAIL help {missing}")
# --- basics on all good fixtures
for fx in ["01-minimal-epub2", "02-minimal-epub3-nav", "03-chapters-and-links", "04-code-and-tables", "05-images-and-svg",
"06-mathml-and-equations", "07-footnotes-and-endnotes", "08-glossary-bibliography-index", "09-complex-table",
"12-encoding-edge-cases"]:
case(f"basic-{fx[:2]}", f"{fx}.epub", [], 0)
f03 = "03-chapters-and-links.epub"
case("dry-run", f03, ["--dry-run", "--report", str(OUT / "dry-report.json")], 0, [
("kein Ausgabeordner", lambda o, c: not o.exists()),
("kein Report", lambda o, c: not (OUT / "dry-report.json").exists()),
("Planung im stdout", lambda o, c: "chapters/001-" in c.stdout)])
case("verbose", f03, ["--verbose"], 0, [("Schritte auf stderr", lambda o, c: "Rendere" in c.stderr and "Kapitel gebildet" in c.stderr)])
case("chapter-level-1", f03, ["--chapter-level", "1"], 0, [
("3+1 Kapitel (A, A2, B, C)", lambda o, c: len(list((o / "chapters").glob("*.md"))) == 4)])
case("chapter-level-2", f03, ["--chapter-level", "2"], 0, [
("h2 erzeugt eigene Dateien", lambda o, c: len(list((o / "chapters").glob("*.md"))) == 7)])
case("links-03", f03, [], 0, [
("Link Kapitel→Kapitel", lambda o, c: "[Kapitel B](003-kapitel-b.md)" in ch(o, "001")),
("Anker im selben EPUB-Dokument, anderes Kapitel", lambda o, c: "[Anker unten](002-kapitel-a2-in-derselben-datei.md#a-unten)" in ch(o, "001")),
("Anker in anderem Kapitel", lambda o, c: "[Ziel in B](003-kapitel-b.md#b-ziel)" in ch(o, "001")),
("nicht auflösbar → Text + Report", lambda o, c: "Nichts" in ch(o, "001") and "nirgendwo" not in ch(o, "001")
and any(u["href"] == "nirgendwo.xhtml#weg" for u in report(o)["unresolved_links"])),
("fehlender Anker → Warnung", lambda o, c: any(w["code"] == "anchor-missing" for w in report(o)["warnings"]))])
f04 = "04-code-and-tables.epub"
case("format-gfm", f04, ["--format", "gfm"], 0, [
("Tabelle mit | in Code als HTML (QA2-01)", lambda o, c: "<code>ls | wc -l</code>" in ch(o) and "| Befehl |" not in ch(o))])
case("format-commonmark", f04, ["--format", "commonmark"], 0, [("HTML-Tabelle statt Pipe", lambda o, c: "<table>" in ch(o) and "| Befehl |" not in ch(o))])
case("code-04", f04, [], 0, [
("Python-Einrückung + Tab exakt", lambda o, c: " def push(self, x):\n\tself.items.append(x) # Tab-Einrückung\n if len(self.items) > 10 and x < 0:" in ch(o)),
("Sprachlabel python", lambda o, c: "```python\nclass Stack:" in ch(o)),
("Shell exakt, Label aus class=shell", lambda o, c: "```bash\n$ find . -name '*.py' | xargs grep -n \"TODO\" \\\n | sort -u > todo.txt\n$ echo $HOME\n```" in ch(o)),
("Fence länger als Backticks im Code", lambda o, c: "````\nMarkdown mit ```fences```" in ch(o)),
("json via data-language", lambda o, c: "```json\n{" in ch(o)),
("c via #include", lambda o, c: "```c\n#include <stdio.h>" in ch(o)),
("Inline-Code mit < & ", lambda o, c: "`len(x) < 3 && y`" in ch(o)),
("Inline-Code mit Backtick", lambda o, c: "``a`b``" in ch(o)),
("verschachtelte Listen", lambda o, c: "- Punkt 1\n\n - Punkt 1.1\n\n 1. Schritt a\n 2. Schritt b" in ch(o) or "- Punkt 1\n - Punkt 1.1" in ch(o)),
("ol start=3", lambda o, c: "3. drei" in ch(o)),
("unbekanntes Element gemeldet", lambda o, c: "x-widget" in report(o)["unsupported_elements"] and "Widget-Inhalt" in ch(o))])
case("keep-html", f04, ["--keep-html"], 0, [
("unbekanntes Element als HTML, ohne onclick", lambda o, c: '<x-widget data-k="v">Widget-Inhalt</x-widget>' in ch(o) and "evil" not in ch(o))])
f05 = "05-images-and-svg.epub"
case("images-relative", f05, ["--images", "relative"], 0, [
("Foto einmal extrahiert", lambda o, c: len([p for p in (o / "media/images").iterdir() if "foto" in p.name]) == 1),
("SVG bleibt SVG", lambda o, c: any(p.suffix == ".svg" for p in (o / "media/images").iterdir())),
("alt + title", lambda o, c: '![Ein Foto](../media/images/' in ch(o) and '"Fototitel")' in ch(o)),
("Bildunterschrift", lambda o, c: "*Abbildung 1.1: Ein Foto mit Unterschrift.*" in ch(o)),
("Caption als Alt bei leerem alt", lambda o, c: "![Abbildung 1.2: Ein SVG-Diagramm.](" in ch(o)),
("Inline-SVG extrahiert", lambda o, c: any(m["role"] == "inline-svg" for m in report(o)["media"]["files"])),
("fehlendes Bild gewarnt", lambda o, c: any(w["code"] == "image-missing" for w in report(o)["warnings"])),
("alles im Ausgabeordner", lambda o, c: all(o.resolve() in p.resolve().parents for p in o.rglob("*")))])
case("images-flat", f05, ["--images", "flat"], 0, [("media/<name>", lambda o, c: (o / "media" / "foto 1 ä.png").exists())])
case("images-original-path", f05, ["--images", "original-path"], 0, [("media/images/<orig>", lambda o, c: (o / "media/images/foto 1 ä.png").exists())])
case("no-extract-media", f05, ["--no-extract-media"], 0, [("kein media/", lambda o, c: not (o / "media").exists()),
("Platzhalter statt Link", lambda o, c: "Bild: Ein Foto" in ch(o))])
case("extract-media", f05, ["--extract-media"], 0, [("media/ vorhanden", lambda o, c: (o / "media").exists())])
f06 = "06-mathml-and-equations.epub"
math_common = [
("Gleichungsnummer sichtbar", lambda o, c: "(2.1)" in ch(o) and "(2.2)" in ch(o)),
("SVG-Formel als SVG-Bild", lambda o, c: "![Formel E=mc²](" in ch(o)),
("Bildformel als Bild", lambda o, c: "![Summe über i](" in ch(o)),
("unbekanntes MathML nicht verloren", lambda o, c: "menclose" in ch(o))]
case("math-auto", f06, ["--math", "auto"], 0, math_common + [
("Bruch", lambda o, c: r"$\frac{a}{b}$" in ch(o)),
("Hoch/Index", lambda o, c: "$x^{2}$" in ch(o) and "$a_{i}$" in ch(o) and "$x_{i}^{2}$" in ch(o)),
("Block-Formel", lambda o, c: "$$\ne^{i \\pi } + 1 = 0\n$$" in ch(o) or "$$\ne^{i \\pi} + 1 = 0\n$$" in ch(o)),
("TeX-Annotation bevorzugt", lambda o, c: r"$\vec{y}$" in ch(o)),
("TeX aus alt-Bild", lambda o, c: r"\int_0^1 x\,dx = \frac{1}{2}" in ch(o)),
("Warnungen im Report", lambda o, c: {"mathml-kept", "formula-image"} <= {w["code"] for w in report(o)["warnings"]}),
("SVG-Formel als svg im Report", lambda o, c: "svg" in {f["origin"] for f in report(o)["formulas"]["items"]})])
case("math-tex", f06, ["--math", "tex"], 0, math_common + [
("\\( \\) Begrenzer", lambda o, c: r"\(\frac{a}{b}\)" in ch(o)),
("keine $-Begrenzer", lambda o, c: not re.search(r"(?<!\\)\$", ch(o).split("---\n", 2)[2]))])
case("math-mathml", f06, ["--math", "mathml"], 0, math_common + [("MathML erhalten", lambda o, c: "<mfrac><mi>a</mi><mi>b</mi></mfrac>" in ch(o))])
case("math-image", f06, ["--math", "image"], 0, math_common + [("TeX-Bild als Bild", lambda o, c: "tex" in " ".join(m["source"] for m in report(o)["media"]["files"]))])
f07 = "07-footnotes-and-endnotes.epub"
fn_common = [("Fußnotentext vorhanden", lambda o, c: "Erster Absatz mit" in read(o, "chapters/*.md") and "Zweiter Absatz." in read(o, "chapters/*.md")),
("Endnote vorhanden", lambda o, c: "Eine Endnote mit Rücklink." in read(o, "chapters/*.md")),
("Formel in Fußnote", lambda o, c: "x^{3}" in read(o, "chapters/*.md")),
("kaputter Verweis gewarnt", lambda o, c: any(w["code"] == "noteref-unresolved" for w in report(o)["warnings"]))]
case("footnotes-end-of-chapter", f07, ["--footnotes", "end-of-chapter"], 0, fn_common + [
("Mehrfachverweis auf eine Definition", lambda o, c: ch(o, "001").count("[^fn-001]") == 3),
("keine Doppelung im Fließtext", lambda o, c: ch(o, "001").count("Erster Absatz") == 1)])
case("footnotes-end-of-book", f07, ["--footnotes", "end-of-book"], 0, fn_common + [
("Anmerkungsdatei", lambda o, c: (o / "chapters" / "999-anmerkungen.md").exists()),
("Rücklinks", lambda o, c: "[↩](001-" in ch(o, "999"))])
case("footnotes-inline", f07, ["--footnotes", "inline"], 0, fn_common + [("inline", lambda o, c: "(Anmerkung: Erster Absatz" in ch(o, "001"))])
case("footnotes-keep-links", f07, ["--footnotes", "keep-links"], 0, fn_common + [("Links", lambda o, c: "](#fn1)" in ch(o, "001"))])
f08 = "08-glossary-bibliography-index.epub"
case("special-sections", f08, [], 0, [
("Typen erkannt", lambda o, c: {s["type"] for s in report(o)["special_sections"]} >= {"glossary", "bibliography", "index", "index-persons"}),
("Glossarbegriff", lambda o, c: "**API:** Application Programming Interface" in ch(o)),
("mehrteilige Definition", lambda o, c: "Selbstaufruf einer Funktion." in ch(o) and "Siehe auch Iteration." in ch(o)),
("Literatur vollständig", lambda o, c: all(s in ch(o) for s in ("ISBN 978-0-201-89683-1", "S. 1–650", "(Zugriff: 01.02.2024)"))),
("DOI/URL als Link", lambda o, c: "](https://doi.org/10.1000/xyz123)" in ch(o) and "](https://example.org/seite?a=1&b=2)" in ch(o)),
("Registerlink ins Kapitel", lambda o, c: "[17](001-hauptteil.md#idx-rekursion)" in ch(o)),
("Seitenzahl als Text", lambda o, c: ", 42" in ch(o)),
("Untereintrag eingerückt", lambda o, c: "– endständige, 45" in ch(o)),
("Buchstabengruppen", lambda o, c: "## R" in ch(o)),
("Querverweis ins Glossar", lambda o, c: "[API](002-glossar.md#g-api)" in ch(o, "001"))])
case("complex-table", "09-complex-table.epub", [], 0, [
("HTML-Tabelle mit colspan/rowspan", lambda o, c: 'rowspan="2"' in ch(o) and 'colspan="2"' in ch(o)),
("Zellinhalte vollständig", lambda o, c: all(s in ch(o) for s in ("1,5", "korrigiert", "2,0", "ausgefallen", "siehe Text", "Messwerte"))),
("Warnung", lambda o, c: any(w["code"] == "table-html" for w in report(o)["warnings"]))])
f12 = "12-encoding-edge-cases.epub"
enc_text = "Ärger über Öl und Straße – „deutsche Anführungszeichen“, ‚einfach‘ und «guillemets» — café, crème brûlée"
case("slug-de", f12, ["--slug-language", "de"], 0, [("ä→ae, ß→ss", lambda o, c: (o / "chapters/001-groesse-uebermass.md").exists()),
("Text unverändert UTF-8", lambda o, c: enc_text in ch(o) and "αβγ ΔΣΩ" in ch(o) and "∀x∈ℝ: x² ≥ 0" in ch(o))])
case("slug-en", f12, ["--slug-language", "en"], 0, [("ä→a", lambda o, c: (o / "chapters/001-grosse-ubermass.md").exists())])
case("slug-none", f12, ["--slug-language", "none"], 0, [("Unicode", lambda o, c: (o / "chapters/001-größe-übermaß.md").exists())])
f02 = "02-minimal-epub3-nav.epub"
case("copy-original", f02, ["--copy-original"], 0, [("source/", lambda o, c: (o / "source" / f02).exists())])
case("no-copy-original", f02, ["--no-copy-original"], 0, [("kein source/", lambda o, c: not (o / "source").exists())])
case("toc", f02, ["--toc"], 0, [("README mit TOC", lambda o, c: "## Inhaltsverzeichnis" in (o / "README.md").read_text())])
case("no-toc", f02, ["--no-toc"], 0, [("kein README", lambda o, c: not (o / "README.md").exists())])
case("frontmatter", f02, ["--frontmatter"], 0, [("YAML", lambda o, c: ch(o).startswith("---\n"))])
case("no-frontmatter", f02, ["--no-frontmatter"], 0, [("kein YAML", lambda o, c: not ch(o).startswith("---"))])
rep = OUT / "reports" / "r.json"
case("report", f02, ["--report", str(rep)], 0, [("Report-Datei", lambda o, c: json.loads(rep.read_text())["tool"] == "epub2md")])
# --- force / existing directory
fo = OUT / "force-target"
case("force-1-initial", f02, [], 0, out=fo)
case("force-2-exists-no-force", f02, [], 1, [("Meldung", lambda o, c: "existiert bereits" in c.stderr)], out=fo, keep=True, validate_out=False)
(fo / "zusatz.txt").write_text("x")
case("force-3-with-force", f02, ["--force"], 0, [("ersetzt", lambda o, c: not (o / "zusatz.txt").exists())], out=fo, keep=True)
foreign = OUT / "fremd"
foreign.mkdir()
(foreign / "wichtig.txt").write_text("nicht löschen")
case("force-4-foreign-dir", f02, ["--force"], 1, [("fremder Ordner bleibt", lambda o, c: (o / "wichtig.txt").exists())],
out=foreign, keep=True, validate_out=False)
# --- invalid options
case("invalid-math", f02, ["--math", "foo"], 1, [("Meldung", lambda o, c: "invalid choice" in c.stderr)], validate_out=False)
case("invalid-chapter-level", f02, ["--chapter-level", "0"], 1, validate_out=False)
case("invalid-chapter-level-text", f02, ["--chapter-level", "x"], 1, validate_out=False)
case("missing-input", "/nonexistent/datei.epub", [], 1, validate_out=False)
case("unknown-option", f02, ["--gibt-es-nicht"], 1, validate_out=False)
# --- broken input
case("broken-container", "11-broken-container.epub", [], 2, [("verständlich", lambda o, c: "OPF" in c.stderr), ("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False)
case("broken-spine", "11b-broken-spine.epub", [], 0, [("Fehler im Report", lambda o, c: report(o)["status"] == "success-with-errors")])
case("broken-spine-strict", "11b-broken-spine.epub", ["--strict"], 3, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False)
case("broken-opf-xml", "11c-broken-opf-xml.epub", [], 3, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False)
case("missing-spine-file", "11d-missing-spine-file.epub", [], 3, [("verständlich", lambda o, c: "Spine" in c.stderr)], validate_out=False)
case("not-a-zip", "11e-not-a-zip.epub", [], 2, validate_out=False)
case("missing-container", "11f-missing-container.epub", [], 2, validate_out=False)
case("broken-xhtml", "11g-broken-xhtml.epub", [], 0, [("Text gerettet", lambda o, c: "nicht geschlossen" in ch(o)),
("Hinweis html-fallback/Entity", lambda o, c: True)])
case("strict-ok", "01-minimal-epub2.epub", ["--strict"], 0)
# --- security
escape = Path("/tmp/qa-epub2md-escape.txt")
escape.unlink(missing_ok=True)
case("malicious", "10-malicious-paths.epub", [], 0, [
("nichts außerhalb geschrieben", lambda o, c: not escape.exists() and not (ROOT / "outside.txt").exists()
and not (QA / "outside.txt").exists() and not (OUT / "outside.txt").exists()),
("alle Dateien im Zielordner", lambda o, c: all(o.resolve() in p.resolve().parents for p in o.rglob("*"))),
("unsichere Einträge gemeldet", lambda o, c: any(e["code"] == "unsafe-zip-path" for e in report(o)["errors"])),
("unsicherer Manifest-Pfad gemeldet", lambda o, c: any(e["code"] == "unsafe-href" for e in report(o)["errors"]))])
case("malicious-strict", "10-malicious-paths.epub", ["--strict"], 2, [("keine Ausgabe", lambda o, c: not o.exists())], validate_out=False)
# --- write error: parent directory not writable
ro = OUT / "readonly"
ro.mkdir()
os.chmod(ro, stat.S_IRUSR | stat.S_IXUSR)
try:
case("write-error", f02, [], 1, [("verständliche Meldung", lambda o, c: "Traceback" not in c.stderr and c.stderr.strip() != "")],
out=ro / "ziel", validate_out=False)
finally:
os.chmod(ro, stat.S_IRWXU)
# --- typography (P1–P7): CSS emphasis, invisible chars, scene breaks, poems, boxes, styles, page markers
f14 = "14-typography.epub"
body = lambda o: ch(o, "001") # noqa: E731
boxes = lambda o: ch(o, "002") # noqa: E731
case("typo-default", f14, [], 0, [
("CSS-kursiv/fett/unterstrichen", lambda o, c: "Ein *kursives* und **fettes** Wort, <u>unterstrichen</u>." in body(o)),
("display:none entfällt ohne Leerzeichen-Rest", lambda o, c: "VERSTECKT" not in body(o) and "</u>." in body(o)),
("@media print ignoriert", lambda o, c: "***kursives***" not in body(o)),
("unsichtbare Zeichen im Text entfernt", lambda o, c: "Nullbreite und weiches Trennzeichen." in body(o)),
("unsichtbare Zeichen im Code erhalten", lambda o, c: "x = 1\u200b # bleibt" in body(o)),
("Szenenwechsel", lambda o, c: "Szene eins endet hier.\n\n* * *\n\nSzene zwei beginnt hier." in body(o)),
("Gedicht als Zitatblock", lambda o, c: "> Zeile eins\\\n> Zeile zwei" in body(o)),
("semantic: <mark>, keine Stile", lambda o, c: "<mark>markierter</mark>" in body(o) and "style=" not in body(o)),
("Kasten mit Klassentyp und Titel ohne Symbol", lambda o, c: "> [!NOTE]\n> **Wichtige Regel**\n>\n> Immer testen." in boxes(o)),
("aside tip", lambda o, c: "> [!TIP]\n> Zwischendurch speichern." in boxes(o)),
("keine Seitenmarken", lambda o, c: "S. 12" not in body(o)),
("Bericht", lambda o, c: report(o)["layout"] == {"scene_breaks": 1, "poems": 1, "boxes": 2, "page_markers": 0}
and report(o)["text_cleanup"]["invisible_chars_removed"] == 3 and report(o)["css"]["hidden_elements"] == 1),
("Buch-README mit Pandoc-Filter", lambda o, c: "--lua-filter" in (o / "README.md").read_text(encoding="utf-8"))])
case("typo-no-css-emphasis", f14, ["--no-css-emphasis"], 0, [
("keine CSS-Hervorhebung, Verstecktes bleibt", lambda o, c: "Ein kursives und fettes Wort" in body(o) and "VERSTECKT" in body(o))])
case("typo-keep-invisible-chars", f14, ["--keep-invisible-chars"], 0, [
("Zeichen erhalten", lambda o, c: "Null\u200bbreite" in body(o) and "wei\u00adches" in body(o)),
("Bericht 0", lambda o, c: report(o)["text_cleanup"]["invisible_chars_removed"] == 0)])
case("typo-styles-html", f14, ["--styles", "html"], 0, [
("Farbe/Größe/Kapitälchen/Hintergrund", lambda o, c: all(x in body(o) for x in (
'<span style="color: #cc0000">roter</span>', '<span style="font-size: 1.6em">großer</span>',
'<span style="font-variant: small-caps">Kapitälchen</span>',
'<span style="background-color: #ffff00">markierter</span>'))),
("Sprache", lambda o, c: '<span lang="en">an English phrase</span>' in body(o)),
("Ausrichtung", lambda o, c: '<div style="text-align: center">\n\nZentrierte Widmung\n\n</div>' in body(o)),
("Grundschrift nicht wiederholt", lambda o, c: "Georgia" not in body(o))])
case("typo-page-markers-visible", f14, ["--page-markers", "visible"], 0, [
("Marker", lambda o, c: "Seite elf endet\\[S. 12\\] und Seite zwölf geht weiter\\[S. 13\\] bis dreizehn." in body(o)),
("Bericht", lambda o, c: report(o)["layout"]["page_markers"] == 2)])
case("typo-page-markers-comment", f14, ["--page-markers", "comment"], 0, [
("Kommentar", lambda o, c: "endet<!-- S. 12 --> und" in body(o) and "weiter<!-- S. 13 --> bis" in body(o))])
case("typo-boxes-quote", f14, ["--boxes", "quote"], 0, [
("Zitatblock ohne [!", lambda o, c: "> **Wichtige Regel**\n>\n> Immer testen." in boxes(o) and "[!" not in boxes(o))])
case("typo-boxes-html", f14, ["--boxes", "html"], 0, [
("div mit Typklasse", lambda o, c: '<div class="box box-note">' in boxes(o) and '<div class="box box-tip">' in boxes(o))])
case("typo-boxes-plain", f14, ["--boxes", "plain"], 0, [
("normaler Text", lambda o, c: "Immer testen." in boxes(o) and "> " not in boxes(o) and "<div" not in boxes(o)),
("Bericht 0 Kästen, kein Filter", lambda o, c: report(o)["layout"]["boxes"] == 0
and "callouts.lua" not in (o / "README.md").read_text(encoding="utf-8"))])
case("invalid-boxes", f14, ["--boxes", "bunt"], 1, [("Meldung", lambda o, c: "invalid choice" in c.stderr)], validate_out=False)
case("invalid-styles", f14, ["--styles", "css"], 1, validate_out=False)
case("invalid-page-markers", f14, ["--page-markers", "ja"], 1, validate_out=False)
# --- epub2md_merge.py on a converted book (+ Pandoc filter if Pandoc is installed)
merge_case("merge-typo", OUT / "typo-default", 0, [
("Gesamtdatei mit Original-Namen", lambda o, c: (o / "14-typography.md").exists()),
("Inhalt beider Kapitel", lambda o, c: all(x in (o / "14-typography.md").read_text(encoding="utf-8")
for x in ("*kursives*", "> [!NOTE]", "Zwischendurch speichern.")))])
merge_case("merge-again-no-force", OUT / "typo-default", 1, [("Meldung", lambda o, c: c.stderr.strip() != "")])
merge_case("merge-missing-dir", OUT / "gibt-es-nicht", 1)
if shutil.which("pandoc"):
src = OUT / "typo-default" / "14-typography.md"
cp = subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), "-o", "-",
"-t", "html"], capture_output=True, text=True, timeout=120)
good = cp.returncode == 0 and 'class="callout callout-note"' in cp.stdout and "[!NOTE]" not in cp.stdout \
and "<strong>Tipp</strong>" in cp.stdout
results.append({"case": "pandoc-callouts-filter", "ok": good, "rc": cp.returncode,
"notes": ["Kästen als div.callout, keine [!…]-Marken" if good else cp.stderr[-300:]]})
print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter")
cp = subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-t", "html"],
capture_output=True, text=True, timeout=120)
h = cp.stdout
pos = [h.find(x) for x in ('id="title-block-header"', 'id="TOC"', 'class="chapter-start"')]
good = cp.returncode == 0 and -1 not in pos and pos == sorted(pos) and h.count('id="title-block-header"') == 1 \
and "epub2md-toc" not in h and 'href="#' in h[pos[1]:pos[2]]
results.append({"case": "pandoc-book-filter", "ok": good, "rc": cp.returncode,
"notes": ["Titelkopf → Inhaltsverzeichnis → Kapitel, Platzhalter ersetzt" if good else f"Reihenfolge {pos}"]})
print(("PASS " if good else "FAIL ") + "pandoc-book-filter")
step1 = OUT / "typo-step1.html"
subprocess.run(["pandoc", str(src), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-o", str(step1)],
capture_output=True, text=True, timeout=120)
cp = subprocess.run(["pandoc", str(step1), "-s", "--lua-filter", str(ROOT / "pandoc" / "book.lua"), "-t", "latex"],
capture_output=True, text=True, timeout=120, cwd=OUT)
t = cp.stdout
good = cp.returncode == 0 and t.count("\\tableofcontents") == 1 and "\\maketitle" not in t \
and t.index("Typografie}") < t.index("\\tableofcontents") and "a4paper" in t
results.append({"case": "pandoc-book-filter-latex", "ok": good, "rc": cp.returncode,
"notes": ["LaTeX: Titel → \\tableofcontents, kein \\maketitle, A4" if good else "LaTeX-Aufbau falsch"]})
print(("PASS " if good else "FAIL ") + "pandoc-book-filter-latex")
hdr = OUT / "header.html"
hdr.write_text("<style>body { color: black; }</style>\n", encoding="utf-8")
cp = subprocess.run(["pandoc", str(src), "-s", "-H", str(hdr), "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"),
"-t", "html"], capture_output=True, text=True, timeout=120)
good = cp.returncode == 0 and ".callout-tip {" in cp.stdout
results.append({"case": "pandoc-callouts-filter-with-H", "ok": good, "rc": cp.returncode,
"notes": ["Kasten-CSS trotz -H vorhanden" if good else "Kasten-CSS fehlt bei -H"]})
print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter-with-H")
epub = OUT / "typo.epub"
cp = subprocess.run(["pandoc", str(src), "--lua-filter", str(ROOT / "pandoc" / "callouts.lua"), "-o", str(epub)],
capture_output=True, text=True, timeout=120)
import zipfile
with zipfile.ZipFile(epub) as z:
css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css"))
chapters = [z.read(n).decode() for n in z.namelist() if "/ch0" in n]
good = cp.returncode == 0 and ".callout-tip {" in css and "font-family" in css \
and all("stylesheet" in c for c in chapters) and any('class="callout' in c for c in chapters)
results.append({"case": "pandoc-callouts-filter-epub", "ok": good, "rc": cp.returncode,
"notes": ["EPUB: Kasten-CSS + Standard-CSS, in allen Kapiteln verlinkt" if good else "EPUB-CSS fehlt"]})
print(("PASS " if good else "FAIL ") + "pandoc-callouts-filter-epub")
# --- determinism
a = case("determinism-a", "06-mathml-and-equations.epub", [], 0, out=OUT / "det-a")
b = case("determinism-b", "06-mathml-and-equations.epub", [], 0, out=OUT / "det-b")
diff = [p.name for p in (OUT / "det-a/chapters").glob("*.md") if p.read_bytes() != (OUT / "det-b/chapters" / p.name).read_bytes()]
results.append({"case": "determinism", "ok": not diff and a["ok"] and b["ok"], "notes": [f"abweichende Dateien: {diff}"] if diff else ["Kapiteldateien identisch"]})
(QA / "output" / "matrix-results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
failed = [r["case"] for r in results if not r["ok"]]
print(f"\n{len(results)} Fälle, {len(results) - len(failed)} ok, {len(failed)} fehlgeschlagen: {failed}")
if __name__ == "__main__":
main()

218
tools/qa/validate_output.py Executable file
View file

@ -0,0 +1,218 @@
"""Unabhängiger Validator für epub2md-Ausgaben (nutzt markdown-it-py, nicht epub2md/linkcheck.py).
Aufruf: python tools/qa/validate_output.py AUSGABEVERZEICHNIS [...] -> JSON auf stdout
"""
from __future__ import annotations
import ast
import hashlib
import json
import posixpath
import re
import sys
import unicodedata
from pathlib import Path
from urllib.parse import unquote
import yaml
from markdown_it import MarkdownIt
from mdit_py_plugins.dollarmath import dollarmath_plugin
from mdit_py_plugins.footnote import footnote_plugin
MD = MarkdownIt("commonmark").enable(["table", "strikethrough"]).use(footnote_plugin).use(dollarmath_plugin)
ATTR = re.compile(r"""\b(href|src|id)\s*=\s*"([^"]*)\"""")
NAV_RESIDUE = re.compile(r"^\s*(next|previous|prev|zurück|weiter|vorherige seite|nächste seite|table of contents|back to top)\s*$", re.I)
ENTITY_IN_CODE = re.compile(r"&(lt|gt|amp|quot|#\d+|#x[0-9a-f]+);", re.I)
def gh_slug(text: str) -> str:
text = unicodedata.normalize("NFC", text).strip().lower()
text = re.sub(r"[^\w\- ]", "", text)
return text.replace(" ", "-")
def split_frontmatter(text: str) -> tuple[dict | None, str, str | None]:
if not text.startswith("---\n"):
return None, text, None
end = text.find("\n---\n", 4)
if end < 0:
return None, text, "Frontmatter nicht geschlossen"
try:
data = yaml.safe_load(text[4:end])
except yaml.YAMLError as exc:
return None, text[end + 5 :], f"YAML ungültig: {exc}"
return data, text[end + 5 :], None
def walk(tokens):
for t in tokens:
yield t
if t.children:
yield from walk(t.children)
def analyse(rel: str, body: str) -> dict:
tokens = MD.parse(body)
info: dict = {"links": [], "anchors": set(), "fn_refs": set(), "fn_defs": set(), "code": [], "issues": []}
lines = body.split("\n")
heading_next = False
for t in walk(tokens):
if t.type == "heading_open":
heading_next = True
elif t.type == "inline" and heading_next:
heading_next = False
plain = re.sub(r"<[^>]+>", "", "".join(c.content for c in (t.children or []) if c.type in ("text", "code_inline")))
info["anchors"].add(gh_slug(plain))
if t.type == "link_open":
info["links"].append(t.attrs.get("href", ""))
elif t.type == "image":
info["links"].append(t.attrs.get("src", ""))
elif t.type in ("html_inline", "html_block"):
for kind, value in ATTR.findall(t.content):
if kind == "id":
info["anchors"].add(value)
else:
info["links"].append(value)
elif t.type == "footnote_ref":
info["fn_refs"].add(t.meta.get("label"))
elif t.type == "footnote_block_open":
pass
elif t.type == "footnote_open":
info["fn_defs"].add(t.meta.get("label"))
elif t.type == "fence":
info["code"].append((t.info.strip(), t.content, t.map))
# unclosed fence: markdown-it lets it run to the end of the document
if t.map and t.map[1] >= len(lines) and not re.match(r"^\s*(`{3,}|~{3,})\s*$", lines[t.map[1] - 1] if t.map[1] - 1 < len(lines) else ""):
info["issues"].append(("high", "unclosed-fence", f"Code-Fence ab Zeile {t.map[0] + 1} nicht geschlossen"))
# raw footnote definitions that markdown-it did not pick up (e.g. broken indentation)
for m in re.finditer(r"^\[\^([^\]]+)\]:", body, re.M):
info["fn_defs"].add(m.group(1))
for ln in lines:
if NAV_RESIDUE.match(ln):
info["issues"].append(("medium", "nav-residue", f"Navigationsrest: {ln.strip()!r}"))
for lang, code, rng in info["code"]:
if ENTITY_IN_CODE.search(code):
info["issues"].append(("high", "entity-in-code", f"HTML-Entity im Codeblock (Zeile {rng[0] + 1}): {ENTITY_IN_CODE.search(code).group(0)}"))
if lang == "python":
try:
ast.parse(code)
except TabError as exc: # mixed tabs/spaces: inherited from the source, not a conversion defect
info["issues"].append(("info", "python-tabs", f"Tab/Leerzeichen gemischt (Zeile {rng[0] + 1}): {exc.msg}"))
except IndentationError as exc:
info["issues"].append(("high", "python-indentation", f"Python-Einrückung kaputt (Zeile {rng[0] + 1}): {exc.msg}"))
except SyntaxError:
info["issues"].append(("info", "python-syntax", f"Python-Snippet nicht vollständig parsebar (Zeile {rng[0] + 1})"))
if "<EFBFBD>" in body:
info["issues"].append(("medium", "replacement-char", "Ersatzzeichen U+FFFD in der Ausgabe"))
return info
def validate(out: Path) -> dict:
result: dict = {"output": str(out), "issues": [], "stats": {}}
def issue(sev: str, code: str, msg: str, file: str = "") -> None:
result["issues"].append({"severity": sev, "code": code, "message": msg, "file": file})
md_files = sorted(p for p in out.rglob("*.md") if "diagnostics" not in p.parts)
analysed: dict[str, dict] = {}
bodies: dict[str, str] = {}
for p in md_files:
rel = p.relative_to(out).as_posix()
text = p.read_text(encoding="utf-8")
fm, body, err = split_frontmatter(text)
if err:
issue("high", "frontmatter", err, rel)
if rel.startswith("chapters/") and fm is not None:
for key in ("book_title", "chapter_number", "chapter_title", "source_epub", "conversion_status"):
if key not in fm:
issue("medium", "frontmatter-key", f"Frontmatter-Schlüssel fehlt: {key}", rel)
if rel.startswith("chapters/") and not body.strip():
issue("high", "empty-chapter", "Kapiteldatei ohne Inhalt", rel)
bodies[rel] = body
a = analyse(rel, body)
analysed[rel] = a
for sev, code, msg in a["issues"]:
issue(sev, code, msg, rel)
# links
existing = {p.relative_to(out).as_posix() for p in out.rglob("*") if p.is_file()}
n_links = 0
for rel, a in analysed.items():
for link in a["links"]:
n_links += 1
if not link or re.match(r"^[a-z][a-z0-9+.-]*:", link, re.I):
continue
target, _, frag = link.partition("#")
target, frag = unquote(target), unquote(frag)
resolved = posixpath.normpath(posixpath.join(posixpath.dirname(rel), target)) if target else rel
if resolved.startswith(".."):
issue("critical", "link-outside", f"Link verlässt Ausgabeordner: {link}", rel)
elif resolved not in existing:
issue("high", "broken-link", f"Ziel fehlt: {link}", rel)
elif frag and resolved in analysed and frag not in analysed[resolved]["anchors"] \
and not frag.startswith("fn"): # markdown-it footnote ids are renderer-specific
issue("high", "broken-anchor", f"Anker fehlt: {link}", rel)
missing_defs = a["fn_refs"] - a["fn_defs"]
unused_defs = a["fn_defs"] - a["fn_refs"]
if missing_defs:
issue("high", "footnote-missing-def", f"Fußnotenverweise ohne Definition: {sorted(missing_defs)}", rel)
if unused_defs:
issue("medium", "footnote-unused-def", f"Fußnotendefinitionen ohne Verweis: {sorted(unused_defs)}", rel)
# duplicate chapter bodies
seen: dict[str, str] = {}
for rel, body in bodies.items():
norm = re.sub(r"\s+", " ", body).strip()
if rel.startswith("chapters/") and len(norm) > 200:
h = hashlib.sha1(norm.encode()).hexdigest()
if h in seen:
issue("medium", "duplicate-chapter", f"Inhalt identisch mit {seen[h]}", rel)
seen[h] = rel
# JSON / YAML files
for name in ("manifest.json", "conversion-report.json"):
p = out / name
if not p.exists():
issue("high", "missing-file", f"{name} fehlt")
continue
try:
data = json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
issue("high", "json-invalid", f"{name}: {exc}")
continue
for ch in data.get("chapters", []):
if not (out / ch["file"]).is_file():
issue("high", "json-ref-missing", f"{name}: Kapitel fehlt {ch['file']}")
media = data.get("media", [])
media = media.get("files", []) if isinstance(media, dict) else media
for m in media:
if not (out / m["file"]).is_file():
issue("high", "json-ref-missing", f"{name}: Medium fehlt {m['file']}")
if name == "conversion-report.json":
n_files = len([p for p in md_files if p.parts[-2] == "chapters"])
if len(data["chapters"]) != n_files:
issue("medium", "report-mismatch", f"Bericht nennt {len(data['chapters'])} Kapitel, vorhanden {n_files}")
if data.get("warnings") and data.get("lossless") not in (False,):
issue("medium", "report-claims", "Bericht mit Warnungen behauptet nicht lossless=false")
result["report_status"] = data.get("status")
result["report_warnings"] = len(data.get("warnings", []))
if (out / "metadata.yaml").exists():
try:
yaml.safe_load((out / "metadata.yaml").read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
issue("high", "yaml-invalid", f"metadata.yaml: {exc}")
result["stats"] = {
"markdown_files": len(md_files),
"links_checked": n_links,
"code_blocks": sum(len(a["code"]) for a in analysed.values()),
"footnote_refs": sum(len(a["fn_refs"]) for a in analysed.values()),
"media_files": len([e for e in existing if e.startswith("media/")]),
}
counts: dict[str, int] = {}
for i in result["issues"]:
counts[i["severity"]] = counts.get(i["severity"], 0) + 1
result["severity_counts"] = counts
return result
if __name__ == "__main__":
results = [validate(Path(p)) for p in sys.argv[1:]]
print(json.dumps(results, ensure_ascii=False, indent=2))

37
tools/qa/worddiff.py Executable file
View file

@ -0,0 +1,37 @@
"""Vergleicht die Wörter zweier epub2md-Ausgaben (Datenverlust-Check nach einer Codeänderung).
Aufruf: python tools/qa/worddiff.py ALT_AUSGABE NEU_AUSGABE
Gezählt werden die Wörter aller chapters/*.md ohne Frontmatter, Markup, Link- und Bildziele. Gibt die Wörter
aus, die nur in einer der beiden Ausgaben vorkommen (Multimengen-Differenz).
"""
from __future__ import annotations
import collections
import glob
import re
import sys
def words(out_dir: str) -> collections.Counter[str]:
counter: collections.Counter[str] = collections.Counter()
for path in sorted(glob.glob(out_dir + "/chapters/*.md")):
with open(path, encoding="utf-8") as fh:
text = fh.read()
text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S)
text = re.sub(r"\]\([^)]*\)|<[^>]+>|[*_`#>|\\\[\]↪➥]", " ", text)
counter.update(re.findall(r"\w+", text))
return counter
def main() -> None:
old, new = words(sys.argv[1]), words(sys.argv[2])
lost, gained = old - new, new - old
print(f"Wörter alt {sum(old.values())}, neu {sum(new.values())}; "
f"nur alt {sum(lost.values())}, nur neu {sum(gained.values())}")
print("nur alt:", lost.most_common(25))
print("nur neu:", gained.most_common(25))
if __name__ == "__main__":
main()