pboProperty Ontology
python98 lines5.9 KB
RawDownload
1#!/usr/bin/env python3
2"""Real renderer validation for an Ontology Semantic Atlas (headless Chrome + Mermaid).
3
4Extracts every ```mermaid block from the atlas, checks that each starts with the ELK frontmatter, renders
5each block with mermaid.render() under securityLevel 'strict' in headless Chrome for every --mermaid
6bundle given, and tallies REAL results (an <svg> was produced AND the page's error element is empty).
7When two configurations are given, it also reports which blocks lay out identically.
8On Mermaid 12+, pass --layout dagre=dagre to force a true dagre comparison.
9
10Usage:
11 render_check.py <atlas.md> --mermaid elk=path/to/mermaid-elk.bundle.js --mermaid dagre=path/to/mermaid.min.js
12 --layout dagre=dagre [--work DIR] [--json render-report.json] [--shots b00,b05] [--jobs 6]
13
14Exit codes: 0 all blocks rendered in every configuration; 2 some block failed; 3 no headless Chrome found
15(renderer validation NOT executed — say so, do not claim it).
16"""
17from __future__ import annotations
18import argparse, json, re, shutil, subprocess, sys
19from concurrent.futures import ThreadPoolExecutor
20from pathlib import Path
21
22ap = argparse.ArgumentParser()
23ap.add_argument("atlas", type=Path)
24ap.add_argument("--mermaid", action="append", required=True, help="tag=path/to/bundle.js (repeatable)")
25ap.add_argument("--layout", action="append", default=[], help="tag=layout override for comparison, e.g. dagre=dagre")
26ap.add_argument("--work", type=Path, default=Path("render-check"))
27ap.add_argument("--json", type=Path, default=None)
28ap.add_argument("--shots", default="")
29ap.add_argument("--jobs", type=int, default=6)
30a = ap.parse_args()
31chrome = next((shutil.which(x) for x in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome") if shutil.which(x)), None)
32if not chrome:
33 print("No headless Chrome/Chromium found — renderer validation NOT executed.", file=sys.stderr); sys.exit(3)
34W = a.work.resolve(); W.mkdir(parents=True, exist_ok=True)
35text = a.atlas.read_text(encoding="utf-8")
36blocks = re.findall(r"```mermaid\n(.*?)```", text, re.S)
37ELK = "---\nconfig:\n layout: elk\n---\n"
38no_elk = [i for i, b in enumerate(blocks) if not b.startswith(ELK)]
39bundles = []
40for spec in a.mermaid:
41 tag, _, path = spec.partition("=")
42 if not path: tag, path = f"cfg{len(bundles)}", tag
43 bundles.append((tag, Path(path).resolve()))
44layout_overrides = {}
45for spec in a.layout:
46 tag, sep, layout = spec.partition("=")
47 if not sep or not tag or not layout:
48 ap.error(f"Invalid --layout value: {spec!r}; expected tag=layout")
49 layout_overrides[tag] = layout
50CHROME = [chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", "--virtual-time-budget=90000"]
51def page(b, js):
52 return f'''<!doctype html><html><head><meta charset="utf-8"><script src="file://{js}"></script>
53<style>body{{margin:0;background:#fff}}</style></head><body><pre id="m" style="margin:0"></pre><pre id="log"></pre>
54<script>(async()=>{{const src={json.dumps(b)};
55mermaid.initialize({{startOnLoad:false,securityLevel:'strict',maxTextSize:900000,maxEdges:5000,logLevel:'warn'}});
56try{{const r=await mermaid.render('s',src);document.getElementById('m').innerHTML=r.svg;}}catch(e){{document.getElementById('log').textContent='RENDER ERROR: '+e.message;}}
57}})();</script></body></html>'''
58jobs = []
59for tag, js in bundles:
60 for i, b in enumerate(blocks):
61 rendered_source = b
62 if tag in layout_overrides:
63 rendered_source = re.sub(r"(?m)^(\s*layout:\s*)\S+\s*$", rf"\g<1>{layout_overrides[tag]}", b, count=1)
64 f = W / f"single_{tag}_b{i:02d}.html"; f.write_text(page(rendered_source, js), encoding="utf-8"); jobs.append((tag, i, f))
65def run(job):
66 tag, i, f = job
67 try: out = subprocess.run(CHROME + ["--dump-dom", f"file://{f}"], capture_output=True, text=True, timeout=240).stdout
68 except subprocess.TimeoutExpired: out = ""
69 (W / f"dom_{tag}_b{i:02d}.html").write_text(out, encoding="utf-8")
70 ok = "<svg" in out and '<pre id="log"></pre>' in out
71 err = re.search(r'id="log">([^<]*)', out)
72 return tag, i, ok, (err.group(1)[:300] if err and err.group(1) else ("no output" if not out else ""))
73with ThreadPoolExecutor(max_workers=a.jobs) as ex: res = list(ex.map(run, jobs))
74report = {"atlas": str(a.atlas), "total_blocks": len(blocks), "blocks_without_elk_frontmatter": no_elk, "layout_overrides": layout_overrides, "configs": {}, "identical_layouts": None, "different_layout_count": None}
75exit_code = 0
76for tag, _ in bundles:
77 rows = [r for r in res if r[0] == tag]
78 fails = [{"block": f"b{i:02d}", "error": e} for _, i, ok, e in rows if not ok]
79 report["configs"][tag] = {"rendered": sum(1 for r in rows if r[2]), "total": len(rows), "failures": fails}
80 print(f"{tag}: {report['configs'][tag]['rendered']}/{len(rows)} rendered; failures: {fails}")
81 if fails: exit_code = 2
82if len(bundles) >= 2:
83 t0, t1 = bundles[0][0], bundles[1][0]; same = []
84 for i in range(len(blocks)):
85 pa = re.findall(r'transform="translate\([^"]*\)"', (W / f"dom_{t0}_b{i:02d}.html").read_text(encoding="utf-8"))
86 pb = re.findall(r'transform="translate\([^"]*\)"', (W / f"dom_{t1}_b{i:02d}.html").read_text(encoding="utf-8"))
87 if pa == pb: same.append(f"b{i:02d}")
88 report["identical_layouts"] = same
89 report["different_layout_count"] = len(blocks) - len(same)
90 print(f"identical layouts under {t0} and {t1} (expected only for non-graph diagrams such as timelines): {same}")
91if no_elk: print(f"WARNING: blocks without ELK frontmatter: {no_elk}")
92if a.shots:
93 (W / "shots").mkdir(exist_ok=True); tag = bundles[0][0]
94 for bid in a.shots.split(","):
95 subprocess.run(CHROME + ["--window-size=2200,1600", f"--screenshot={W/'shots'/(bid+'.png')}", f"file://{W/('single_'+tag+'_'+bid+'.html')}"], capture_output=True, timeout=240)
96 print("screenshots in", W / "shots")
97if a.json: a.json.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
98sys.exit(exit_code)