diff --git a/.gitignore b/.gitignore index 8924a81..7ac2e8c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ htmlcov/ build/ dist/ -# QA-Läufe (Prompt_Kontrolle.md) +# QA-Läufe (Prompt_Kontrolle.md): erzeugte Daten von tools/qa/ .qa/ .qa-output/ .qa-fixtures/ diff --git a/README.md b/README.md index d2f6793..091b931 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,7 @@ pandoc/ ├── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel ├── callouts.css Gestaltung der Kästen (vom Filter eingebunden; muss neben callouts.lua liegen) └── print.css Druck-Layout für PDFs mit weasyprint (A4, Seitenzahlen, Kapitel auf neuer Seite, Codeumbruch) +tools/qa/ QA-Skripte: CLI-Matrix, Validator, Wortvergleich, Pandoc-Prüfung (siehe tools/qa/README.md) ``` Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und @@ -508,4 +509,8 @@ unsichtbare Zeichen, Szenenwechsel, Gedichte, Infokästen, `--styles`, `--page-m in der Buch-README. Liegen EPUBs unter `Beispiele/`, werden sie zusätzlich auf Struktur-Invarianten geprüft: keine kaputten Links, balancierte Code-Fences, gültiges YAML, alle Medien vorhanden. Dabei werden keine Buchtexte verglichen. `Beispiele/` ist per `.gitignore` vom Repository -ausgeschlossen. +ausgeschlossen.`tests/test_pandoc.py` prüft den Pandoc-Weg (nur mit installiertem Pandoc). + +Für gründlichere Prüfungen nach `Prompt_Kontrolle.md` gibt es `tools/qa/`: eine CLI-Matrix über alle Optionen +und Fehlercodes, einen unabhängigen Markdown-Validator, einen Wortvergleich gegen Datenverlust und die +Prüfung des Pandoc-Wegs (HTML, EPUB, PDF) für alle Beispielbücher. Details in `tools/qa/README.md`. diff --git a/tools/qa/README.md b/tools/qa/README.md new file mode 100644 index 0000000..c8ab5a9 --- /dev/null +++ b/tools/qa/README.md @@ -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 +``` diff --git a/tools/qa/check_pandoc.py b/tools/qa/check_pandoc.py new file mode 100755 index 0000000..cdbb9c1 --- /dev/null +++ b/tools/qa/check_pandoc.py @@ -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" 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() diff --git a/tools/qa/check_pdf.sh b/tools/qa/check_pdf.sh new file mode 100755 index 0000000..1f2e8f9 --- /dev/null +++ b/tools/qa/check_pdf.sh @@ -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/dev/null | awk 'NR>2 && $(NF-4)=="no"' | wc -l)" "$first" +done diff --git a/tools/qa/log.sh b/tools/qa/log.sh new file mode 100755 index 0000000..bed22ec --- /dev/null +++ b/tools/qa/log.sh @@ -0,0 +1,9 @@ +# usage: . tools/qa/log.sh; qa – 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 +} diff --git a/tools/qa/make_fixtures.py b/tools/qa/make_fixtures.py new file mode 100755 index 0000000..5880622 --- /dev/null +++ b/tools/qa/make_fixtures.py @@ -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", "

Chapter One

Hello EPUB 2 world.

", "Chapter One")]) + + +def f02() -> Book: + return Book(title="Minimal EPUB 3", authors=["Grace Hopper"], + docs=[Doc("c1.xhtml", "

Erstes Kapitel

Eins.

", "Erstes Kapitel"), + Doc("c2.xhtml", "

Zweites Kapitel

Zwei.

", "Zweites Kapitel")]) + + +def f03() -> Book: + a = ('

Kapitel A

Zu Kapitel B, zum Anker unten, ' + 'zum Ziel in B und ins Nichts, ' + 'sowie fehlender Anker.

' + '

A.1 Abschnitt

Text A.1

' + '

Kapitel A2 in derselben Datei

Unten in A2.

' + '

A2.1 Unterabschnitt

Mehr.

') + b = ('

Kapitel B

B-Text.

B.1

Ziel in B.

' + '

zurück zu A.1

') + c = '

Kapitel C

Ende mit Link auf A.

' + 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) > 10 and x < 0: + raise ValueError("& zu voll") +""" + + +def f04() -> Book: + body = ( + "

Code und Tabellen

" + "

Inline: len(x) < 3 && y, mit Backtick a`b und *nicht kursiv*.

" + f'
{PY}
' + "
$ find . -name '*.py' | xargs grep -n \"TODO\" \\\n    | sort -u > todo.txt\n$ echo $HOME
" + "
Markdown mit ```fences``` und `ticks`\n    eingerückt
" + '
{\n  "a": [1, 2],\n  "b": "<tag>"\n}
' + "
#include <stdio.h>\nint main(void) { return 0; }
" + "" + "
  1. drei
  2. vier
    • vier.a
" + '

Unbekanntes Element: Widget-Inhalt.

' + "" + "" + "
BefehlBedeutung
ls | wc -lDateien zählen
a && bund
" + ) + return Book(title="Code und Tabellen", docs=[Doc("code.xhtml", body, "Code")]) + + +def f05() -> Book: + body = ( + "

Bilder

" + '
Ein Foto' + "
Abbildung 1.1: Ein Foto mit Unterschrift.
" + '

Nochmals dasselbe Foto: Wiederholung

' + '
Abbildung 1.2: Ein SVG-Diagramm.
' + '

Inline-SVG: Kreis' + '

' + '

Siehe Abbildung 1.1 und SVG direkt.

' + '

Fehlendes Bild: weg

' + ) + 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 = ( + "

Formeln

" + f"

Bruch inline: ab.

" + f"

Potenz: x2, Index: ai, " + f"beides: xi2.

" + f'
eiπ' + '+1=0(2.1)
' + '

Nach Gleichung (2.1) folgt …

' + f'

Nicht unterstützt: z.

' + f'

Mit TeX-Annotation: y\\vec{{y}}' + ".

" + f'

alttext: 2.

' + '

SVG-Formel: Formel E=mc²

' + '

Bildformel: Summe über i

' + '
$$\\int_0^1 x\\,dx = \\frac{1}{2}$$ (2.2)
' + ) + 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 = ( + "

Kapitel mit Fußnoten

" + '

Aussage A1 und B' + '2, nochmal A1.

' + '

Endnote3 und kaputt9.

' + '' + f'" + ) + notes = ( + '

Anmerkungen

' + '
' + ) + return Book(title="Fußnoten und Endnoten", docs=[Doc("ch1.xhtml", ch1, "Kapitel"), Doc("notes.xhtml", notes, "Anmerkungen")]) + + +def f08() -> Book: + ch = ('

Hauptteil

Der Begriff API und ' + 'Rekursion nach Knuth [Knu97].

') + glossary = ('

Glossar

' + '
API
Application Programming Interface, eine Schnittstelle.
' + "
Rekursion

Selbstaufruf einer Funktion.

Siehe auch Iteration.

") + lit = ('

Literatur

    ' + '
  1. Knuth, D. E.: The Art of Computer Programming, Bd. 1, 3. Aufl., Addison-Wesley, 1997, ' + "ISBN 978-0-201-89683-1, S. 1–650.
  2. " + '
  3. Doe, J.: Ein Artikel. Journal 12 (2020), DOI 10.1000/xyz123.
  4. ' + '
  5. Webquelle: https://example.org/seite?a=1&b=2 ' + "(Zugriff: 01.02.2024).
") + sach = ('

Sachregister

' + '

A

API Glossar

' + '

R

Rekursion 17, 42

' + '

endständige, 45

') + pers = ('

Personenregister

K

' + '

Knuth, Donald E. 17

') + 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 = ( + "

Komplexe Tabelle

" + "" + '' + "" + '
Tabelle 9.1: Messwerte
ProbeMessung
vorhernachher
A

1,5

korrigiert

2,0
Bausgefallen
siehe Text
' + ) + return Book(title="Komplexe Tabelle", docs=[Doc("t.xhtml", body, "Tabelle")]) + + +def f10() -> Book: + body = ('

Böse Pfade

raus ' + 'escape abs

') + return Book(title="Malicious", docs=[Doc("ok.xhtml", body, "OK")], + extra_entries={"../../outside.txt": b"evil", "../escape.xhtml": b"", "/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'

Größe & Übermaß

{text}

Rücksprung

' + return Book(title="Kodierung – Ümläute", authors=["Zoë Ørsted"], + docs=[Doc("kapitel-ü.xhtml", body, "Größe"), Doc("café.xhtml", "

Café crème

Ça va.

", "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 = ('

Gestaltung

' + '

Ein kursives und fettes Wort, ' + 'unterstrichenVERSTECKT.

' + '

Null\u200bbreite und wei\u00adches Trenn\u00adzeichen.

' + '

Szene eins endet hier.

Szene zwei beginnt hier.

' + '

Zeile eins

Zeile zwei

' + '

Ein markierter, roter und ' + 'großer Text in Kapitälchen, ' + 'an English phrase.

' + '

Zentrierte Widmung

' + '

Seite elf endet und Seite zwölf ' + 'geht weiter bis dreizehn.

' + '
x = 1\u200b  # bleibt
') + c2 = ('

Kästen

' + '

[»]Wichtige Regel

' + '

Immer testen.

' + '

Nachher.

') + 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("", '') + rewrite(p10, {"OEBPS/content.opf": opf.encode()}) + + # 11: broken container variants + base = Book(title="Defekt", docs=[Doc("a.xhtml", "

A

x

", "A")]) + p11 = build_epub(OUT / "11-broken-container.epub", base) + rewrite(p11, {"META-INF/container.xml": ( + b'' + b'' + b"")}) + p11b = build_epub(OUT / "11b-broken-spine.epub", Book(title="Spine", docs=[Doc("a.xhtml", "

A

x

", "A")], + spine_extra='')) + assert p11b.exists() + p11c = build_epub(OUT / "11c-broken-opf-xml.epub", base) + rewrite(p11c, {"OEBPS/content.opf": b"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"

Kaputt

nicht geschlossen   &unbekannt; fett"}) + print("\n".join(sorted(p.name for p in OUT.glob("*.epub")))) + + +if __name__ == "__main__": + main() diff --git a/tools/qa/pandoc_full.sh b/tools/qa/pandoc_full.sh new file mode 100755 index 0000000..36ef93a --- /dev/null +++ b/tools/qa/pandoc_full.sh @@ -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 diff --git a/tools/qa/pandoc_pdfs.sh b/tools/qa/pandoc_pdfs.sh new file mode 100755 index 0000000..c248182 --- /dev/null +++ b/tools/qa/pandoc_pdfs.sh @@ -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 diff --git a/tools/qa/pandoc_run.sh b/tools/qa/pandoc_run.sh new file mode 100755 index 0000000..319f845 --- /dev/null +++ b/tools/qa/pandoc_run.sh @@ -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 diff --git a/tools/qa/pyblocks.py b/tools/qa/pyblocks.py new file mode 100755 index 0000000..0876dde --- /dev/null +++ b/tools/qa/pyblocks.py @@ -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() diff --git a/tools/qa/requirements.txt b/tools/qa/requirements.txt new file mode 100644 index 0000000..78cae10 --- /dev/null +++ b/tools/qa/requirements.txt @@ -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 diff --git a/tools/qa/run_matrix.py b/tools/qa/run_matrix.py new file mode 100755 index 0000000..f8eea23 --- /dev/null +++ b/tools/qa/run_matrix.py @@ -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: "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() diff --git a/tools/qa/validate_output.py b/tools/qa/validate_output.py new file mode 100755 index 0000000..3ef642f --- /dev/null +++ b/tools/qa/validate_output.py @@ -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 "�" 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)) diff --git a/tools/qa/worddiff.py b/tools/qa/worddiff.py new file mode 100755 index 0000000..3799f27 --- /dev/null +++ b/tools/qa/worddiff.py @@ -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()