chore: QA-Skripte nach tools/qa übernommen
Die Werkzeuge der QA-Runden lagen nur im ignorierten .qa/: CLI-Matrix (89 Fälle), Fixture-Generator, unabhängiger Markdown-Validator, Wortvergleich gegen Datenverlust, Pandoc-Prüfung (HTML, EPUB, PDF). Jetzt versioniert in tools/qa/ mit README und requirements.txt; erzeugte Daten landen weiterhin in .qa/. Pfade auf die neue Lage angepasst (Repo-Wurzel über parents[2], aufrufender Python-Interpreter). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
57d485baa3
commit
01f60a41c4
1 changed files with 1210 additions and 2 deletions
118
tools/qa/check_pandoc.py
Executable file
118
tools/qa/check_pandoc.py
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
"""Prüft Pandoc-Ausgaben (HTML und EPUB) eines konvertierten Buches.
|
||||
|
||||
Aufruf: python tools/qa/check_pandoc.py BUCHORDNER [...] -> JSON auf stdout
|
||||
Geprüft: interne Links/Anker, Bilder, Reste von '[!TIP]', Lage des Inhaltsverzeichnisses, TeX-Reste.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
from lxml import html as lhtml
|
||||
|
||||
ID_RE = re.compile(r"""\b(?:id|name)\s*=\s*["']([^"']+)["']""")
|
||||
|
||||
|
||||
def _ids(text: str) -> set[str]:
|
||||
return set(ID_RE.findall(text))
|
||||
|
||||
|
||||
def _links(doc) -> list[str]:
|
||||
return [a.get("href") for a in doc.iter("a") if a.get("href")]
|
||||
|
||||
|
||||
def _images(doc) -> list[str]:
|
||||
srcs = [i.get("src") for i in doc.iter("img") if i.get("src")]
|
||||
srcs += [i.get("{http://www.w3.org/1999/xlink}href") or i.get("href") for i in doc.iter("image")]
|
||||
return [s for s in srcs if s and not s.startswith("data:")]
|
||||
|
||||
|
||||
def _parse(text: str):
|
||||
return lhtml.fromstring(text.encode("utf-8"))
|
||||
|
||||
|
||||
def _toc_position(doc) -> dict:
|
||||
"""Where does the TOC (nav#TOC) stand relative to the first chapter heading?"""
|
||||
nav = doc.xpath("//*[@id='TOC']")
|
||||
if not nav:
|
||||
return {"toc": False}
|
||||
body_text = lhtml.tostring(doc, encoding="unicode")
|
||||
pos = body_text.find('id="TOC"')
|
||||
before = re.sub(r"<[^>]+>", " ", body_text[:pos])
|
||||
h1_before = len(re.findall(r"<h1\b", body_text[:pos]))
|
||||
return {"toc": True, "h1_before_toc": h1_before, "words_before_toc": len(before.split()),
|
||||
"toc_entries": len(nav[0].xpath(".//a"))}
|
||||
|
||||
|
||||
def check_html(path: Path) -> dict:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
doc = _parse(text)
|
||||
ids = _ids(text)
|
||||
broken = [h for h in _links(doc) if h.startswith("#") and unquote(h[1:]) not in ids]
|
||||
missing_img = [s for s in _images(doc) if not (path.parent / unquote(s)).exists()]
|
||||
plain = doc.text_content()
|
||||
return {
|
||||
"file": path.name, "links_internal": sum(1 for h in _links(doc) if h.startswith("#")),
|
||||
"broken_internal": len(broken), "broken_examples": broken[:5],
|
||||
"images": len(_images(doc)), "missing_images": missing_img[:5],
|
||||
"callout_residue": len(re.findall(r"\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]", plain)),
|
||||
"tex_residue": len(re.findall(r"\\(?:frac|begin\{|mathbb|alpha|sum_)", plain)),
|
||||
"math_elements": len(doc.xpath("//*[local-name()='math']")),
|
||||
**_toc_position(doc),
|
||||
}
|
||||
|
||||
|
||||
def check_epub(path: Path) -> dict:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = set(z.namelist())
|
||||
docs = {n: z.read(n).decode("utf-8") for n in names if n.endswith((".xhtml", ".html"))}
|
||||
ids = {n: _ids(t) for n, t in docs.items()}
|
||||
broken: list[str] = []
|
||||
internal = 0
|
||||
missing_img: list[str] = []
|
||||
residue = tex = math = 0
|
||||
for name, text in docs.items():
|
||||
doc = _parse(text)
|
||||
base = posixpath.dirname(name)
|
||||
for h in _links(doc):
|
||||
if re.match(r"^[a-z]+:", h):
|
||||
continue
|
||||
internal += 1
|
||||
file, _, frag = h.partition("#")
|
||||
target = posixpath.normpath(posixpath.join(base, unquote(file))) if file else name
|
||||
if target not in names:
|
||||
broken.append(f"{name}: {h}")
|
||||
elif frag and unquote(frag) not in ids.get(target, set()):
|
||||
broken.append(f"{name}: {h}")
|
||||
for s in _images(doc):
|
||||
if posixpath.normpath(posixpath.join(base, unquote(s))) not in names:
|
||||
missing_img.append(f"{name}: {s}")
|
||||
plain = doc.text_content()
|
||||
residue += len(re.findall(r"\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]", plain))
|
||||
tex += len(re.findall(r"\\(?:frac|begin\{|mathbb|alpha|sum_)", plain))
|
||||
math += len(doc.xpath("//*[local-name()='math']"))
|
||||
return {"file": path.name, "documents": len(docs), "links_internal": internal, "broken_internal": len(broken),
|
||||
"broken_examples": broken[:5], "missing_images": missing_img[:5], "callout_residue": residue,
|
||||
"tex_residue": tex, "math_elements": math, "nav": any(n.endswith("nav.xhtml") for n in names)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
results = []
|
||||
for d in map(Path, sys.argv[1:]):
|
||||
entry = {"book": d.name}
|
||||
for f in sorted(d.glob("*.html")):
|
||||
entry["html"] = check_html(f)
|
||||
for f in sorted(d.glob("*.epub")):
|
||||
entry["epub"] = check_epub(f)
|
||||
results.append(entry)
|
||||
json.dump(results, sys.stdout, ensure_ascii=False, indent=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue