| 1 | #!/usr/bin/env python3 |
| 2 | """Real renderer validation for an Ontology Semantic Atlas (headless Chrome + Mermaid). |
| 3 | |
| 4 | Extracts every ```mermaid block from the atlas, checks that each starts with the ELK frontmatter, renders |
| 5 | each block with mermaid.render() under securityLevel 'strict' in headless Chrome for every --mermaid |
| 6 | bundle given, and tallies REAL results (an <svg> was produced AND the page's error element is empty). |
| 7 | When two configurations are given, it also reports which blocks lay out identically. |
| 8 | On Mermaid 12+, pass --layout dagre=dagre to force a true dagre comparison. |
| 9 | |
| 10 | Usage: |
| 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 | |
| 14 | Exit 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 | """ |
| 17 | from __future__ import annotations |
| 18 | import argparse, json, re, shutil, subprocess, sys |
| 19 | from concurrent.futures import ThreadPoolExecutor |
| 20 | from pathlib import Path |
| 21 | |
| 22 | ap = argparse.ArgumentParser() |
| 23 | ap.add_argument("atlas", type=Path) |
| 24 | ap.add_argument("--mermaid", action="append", required=True, help="tag=path/to/bundle.js (repeatable)") |
| 25 | ap.add_argument("--layout", action="append", default=[], help="tag=layout override for comparison, e.g. dagre=dagre") |
| 26 | ap.add_argument("--work", type=Path, default=Path("render-check")) |
| 27 | ap.add_argument("--json", type=Path, default=None) |
| 28 | ap.add_argument("--shots", default="") |
| 29 | ap.add_argument("--jobs", type=int, default=6) |
| 30 | a = ap.parse_args() |
| 31 | chrome = next((shutil.which(x) for x in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome") if shutil.which(x)), None) |
| 32 | if not chrome: |
| 33 | print("No headless Chrome/Chromium found — renderer validation NOT executed.", file=sys.stderr); sys.exit(3) |
| 34 | W = a.work.resolve(); W.mkdir(parents=True, exist_ok=True) |
| 35 | text = a.atlas.read_text(encoding="utf-8") |
| 36 | blocks = re.findall(r"```mermaid\n(.*?)```", text, re.S) |
| 37 | ELK = "---\nconfig:\n layout: elk\n---\n" |
| 38 | no_elk = [i for i, b in enumerate(blocks) if not b.startswith(ELK)] |
| 39 | bundles = [] |
| 40 | for 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())) |
| 44 | layout_overrides = {} |
| 45 | for 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 |
| 50 | CHROME = [chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", "--virtual-time-budget=90000"] |
| 51 | def 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)}; |
| 55 | mermaid.initialize({{startOnLoad:false,securityLevel:'strict',maxTextSize:900000,maxEdges:5000,logLevel:'warn'}}); |
| 56 | try{{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>''' |
| 58 | jobs = [] |
| 59 | for 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)) |
| 65 | def 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 "")) |
| 73 | with ThreadPoolExecutor(max_workers=a.jobs) as ex: res = list(ex.map(run, jobs)) |
| 74 | report = {"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} |
| 75 | exit_code = 0 |
| 76 | for 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 |
| 82 | if 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}") |
| 91 | if no_elk: print(f"WARNING: blocks without ELK frontmatter: {no_elk}") |
| 92 | if 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") |
| 97 | if a.json: a.json.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") |
| 98 | sys.exit(exit_code) |