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