Spaces:
Sleeping
Sleeping
| """ | |
| 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: <output-dir>/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 = [ | |
| "<!DOCTYPE html><html><head><meta charset='utf-8'>", | |
| "<title>ChartPipeline Quality Check</title>", | |
| f"<style>{CSS}</style></head><body>", | |
| "<h1>Chart Template Quality Check</h1>", | |
| f"<div class='toc'><b>Templates:</b> {len(summary)} | " | |
| f"<b>Tasks:</b> {len(tasks)} | " | |
| f"<b class='bad'>Failed:</b> {n_failed} | " | |
| f"<b class='bad'>Fallback PNG:</b> {n_fb} | " | |
| f"<b class='warn'>Empty:</b> {n_empty}<br>", | |
| ] | |
| parts.append("<b>Jump to:</b> ") | |
| for s in summary: | |
| cls = severity_class(s) | |
| parts.append( | |
| f"<a class='{cls}' href='#{s['chart_name']}'>{s['chart_name']}</a>" | |
| ) | |
| parts.append("</div>") | |
| for s in summary: | |
| chart_name = s["chart_name"] | |
| cls = severity_class(s) | |
| parts.append(f"<div class='tpl' id='{chart_name}'>") | |
| parts.append(f"<h2 class='{cls}'>{chart_name}</h2>") | |
| parts.append( | |
| f"<div class='meta'>" | |
| f"<b>{s.get('chart_type', '')}</b> · " | |
| f"done {s.get('done', '?')}/{s.get('target', '?')} · " | |
| f"ok {s['n_success']} · " | |
| f"<span class='bad'>fail {s.get('n_fail', 0)}</span> · " | |
| f"<span class='bad'>fallback_png {s['n_fallback_png']}</span> · " | |
| f"<span class='warn'>empty {s.get('n_empty', 0)}</span> · " | |
| 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</div>" | |
| ) | |
| parts.append("<div class='strip'>") | |
| 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"<object data='{rel(t['final_svg'])}' type='image/svg+xml'></object>" | |
| elif t["chart_svg"]: | |
| img_html = ( | |
| f"<div style='display:flex;align-items:center;justify-content:center;" | |
| f"height:200px;color:#d70015;font-size:11px;'>" | |
| f"render failed</div>" | |
| ) | |
| label = ( | |
| f"{t['input'][:30]}<br>" | |
| f"shapes={t['n_shapes']} t={t['elapsed_s']}s " | |
| f"size={int(t['final_svg_bytes'])//1024}KB" | |
| ) | |
| parts.append( | |
| f"<div class='{card_cls}'>{img_html}" | |
| f"<div class='lbl'>{label}</div></div>" | |
| ) | |
| parts.append("</div></div>") | |
| parts.append("</body></html>") | |
| 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() | |