"""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: "ls | wc -l" in ch(o) and "| Befehl |" not in ch(o))]) case("format-commonmark", f04, ["--format", "commonmark"], 0, [("HTML-Tabelle statt Pipe", lambda o, c: "" 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 " 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: 'Widget-Inhalt' 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/", lambda o, c: (o / "media" / "foto 1 ä.png").exists())]) case("images-original-path", f05, ["--images", "original-path"], 0, [("media/images/", 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"(?ab" 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, unterstrichen." in body(o)), ("display:none entfällt ohne Leerzeichen-Rest", lambda o, c: "VERSTECKT" not in body(o) and "." 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: , keine Stile", lambda o, c: "markierter" 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 ( 'roter', 'großer', 'Kapitälchen', 'markierter'))), ("Sprache", lambda o, c: 'an English phrase' in body(o)), ("Ausrichtung", lambda o, c: '
\n\nZentrierte Widmung\n\n
' 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 und" in body(o) and "weiter 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: '
' in boxes(o) and '
' 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 " [!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 "Tipp" 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("\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()