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