""" Generate an HTML page that previews the quality-check outputs. Each template gets a section with summary stats and a horizontally- scrolling strip of every generated infographic SVG, plus per-card links to chart.svg / chart.html for the per-data subfolder so failures can be inspected. Designed to be safe to run mid-run; it just renders whatever is currently in _tasks.csv. Usage: python scripts/build_quality_preview.py \\ --output-dir output/quality_check \\ --out output/quality_check/preview.html """ import argparse import csv import os from collections import defaultdict from pathlib import Path from typing import Optional def parse_args(): p = argparse.ArgumentParser() p.add_argument("--output-dir", default="output/quality_check") p.add_argument("--out", default=None, help="HTML output path (default: /preview.html)") return p.parse_args() CSS = """ body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f5f5f7; color: #1d1d1f; margin: 0; padding: 24px; } h1 { font-size: 22px; margin: 0 0 12px; } .toc { background: white; padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; line-height: 1.7; } .toc a { color: #0066cc; margin-right: 12px; text-decoration: none; } .tpl { background: white; border-radius: 8px; margin-bottom: 16px; padding: 16px 18px; box-shadow: 0 1px 2px rgba(0,0,0,.06); } .tpl h2 { font-size: 16px; margin: 0 0 4px; } .tpl .meta { font-size: 12px; color: #6e6e73; margin-bottom: 10px; } .bad { color: #d70015; } .warn { color: #b25000; } .good { color: #248a3d; } .strip { display: flex; overflow-x: auto; gap: 10px; padding-bottom: 8px; } .card { flex: 0 0 auto; width: 240px; background: #fafafa; border: 1px solid #e5e5ea; border-radius: 6px; padding: 6px; } .card img, .card object, .card iframe { width: 100%; height: 200px; object-fit: contain; background: white; border: 0; display: block; } .card .lbl { font-size: 10px; color: #6e6e73; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card.failed { border-color: #d70015; } .card.empty { border-color: #b25000; } .card.fb { border-color: #d70015; background: #fff5f5; } """ def severity_class(row): """Return a CSS class for the per-template summary row. Works against the schema produced by scripts/_progress.py's ProgressTracker._rollups (target/done/n_success/n_fail/n_fallback_png/...). """ if int(row.get("n_fail", 0) or 0) > 0: return "bad" if int(row.get("n_fallback_png", 0) or 0) > 0: return "bad" if int(row.get("n_empty", 0) or 0) > 0: return "warn" return "good" def build_preview(out_dir: Path, html_path: Optional[Path] = None) -> Path: """Render preview.html from CSVs in out_dir. Returns the html path.""" out_dir = Path(out_dir) if html_path is None: html_path = out_dir / "preview.html" html_path = Path(html_path) tasks_csv = out_dir / "_tasks.csv" summary_csv = out_dir / "_summary.csv" if not tasks_csv.exists() or not summary_csv.exists(): raise FileNotFoundError( f"Expected {tasks_csv} and {summary_csv} but at least one is missing" ) tasks = list(csv.DictReader(open(tasks_csv))) summary = list(csv.DictReader(open(summary_csv))) # _summary.csv is already worst-first. by_tpl = defaultdict(list) for t in tasks: by_tpl[t["chart_name"]].append(t) # Make all paths relative to the HTML file's parent dir for browser display. html_parent = html_path.parent.resolve() def rel(p): if not p: return "" p = Path(p) if not p.is_absolute(): p = (Path.cwd() / p).resolve() return os.path.relpath(p, html_parent) n_failed = sum(1 for t in tasks if t["ok"] != "True") n_fb = sum(1 for t in tasks if t["chart_svg_fallback_png"] == "True") n_empty = sum( 1 for t in tasks if t["ok"] == "True" and (int(t.get("n_shapes") or 0) + int(t.get("n_text") or 0)) < 8 ) parts = [ "", "ChartPipeline Quality Check", f"", "

Chart Template Quality Check

", f"
Templates: {len(summary)}  |  " f"Tasks: {len(tasks)}  |  " f"Failed: {n_failed}  |  " f"Fallback PNG: {n_fb}  |  " f"Empty: {n_empty}
", ] parts.append("Jump to: ") for s in summary: cls = severity_class(s) parts.append( f"{s['chart_name']}" ) parts.append("
") for s in summary: chart_name = s["chart_name"] cls = severity_class(s) parts.append(f"
") parts.append(f"

{chart_name}

") parts.append( f"
" f"{s.get('chart_type', '')} · " f"done {s.get('done', '?')}/{s.get('target', '?')} · " f"ok {s['n_success']} · " f"fail {s.get('n_fail', 0)} · " f"fallback_png {s['n_fallback_png']} · " f"empty {s.get('n_empty', 0)} · " f"mean_svg {s.get('mean_size_kb', s.get('mean_final_svg_kb', '?'))}KB · " f"mean_shapes {s.get('mean_shapes', s.get('mean_n_shapes', '?'))} · " f"mean_time {s['mean_elapsed_s']}s
" ) parts.append("
") for t in by_tpl.get(chart_name, []): card_cls = "card" if t["ok"] != "True": card_cls += " failed" elif t["chart_svg_fallback_png"] == "True": card_cls += " fb" elif int(t.get("n_shapes") or 0) + int(t.get("n_text") or 0) < 8: card_cls += " empty" img_html = "" if t["final_svg"]: img_html = f"" elif t["chart_svg"]: img_html = ( f"
" f"render failed
" ) label = ( f"{t['input'][:30]}
" f"shapes={t['n_shapes']} t={t['elapsed_s']}s " f"size={int(t['final_svg_bytes'])//1024}KB" ) parts.append( f"
{img_html}" f"
{label}
" ) parts.append("
") parts.append("") html_path.write_text("\n".join(parts)) return html_path def main(): args = parse_args() out_dir = Path(args.output_dir) html_path = Path(args.out) if args.out else None written = build_preview(out_dir, html_path) print(f"Wrote {written}") print(f"Open with: file://{written.resolve()}") if __name__ == "__main__": main()