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

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

118 lines
4.4 KiB
Python
Executable file

"""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()