"""svg_preview.py: 用无头 Chrome/Edge 把 SVG 页渲成 PNG,供交付前肉眼/vision 验收。 SVG-first 管线里 SVG 就是视觉真相(导出的 pptx 与之 1:1),所以验收直接渲 SVG 最忠实 —— 比渲最终 pptx 更早、更准地暴露"标题层级 / 卡片过挤过空 / 文字掉色 / 节奏单调"这类观感问题。 用法: python svg_preview.py # 渲 /svg_output 全部页 python svg_preview.py --pages 1,3,5 # 只渲第 1/3/5 页(按文件排序) python svg_preview.py -o # 指定 PNG 输出目录(默认 /preview) python svg_preview.py # 直接渲某个目录/单文件 约定:优先渲 /svg_output;没有则退而渲 本身。 依赖:本机装了 Chrome 或 Edge(无需 pip 包)。两者都没有则报错退出。 产物默认 2x 超采样,够清晰看版面。 """ from __future__ import annotations import argparse import re import subprocess import sys try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") except Exception: pass import tempfile from pathlib import Path _CHROME_CANDIDATES = [ r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", ] def find_browser() -> str: for c in _CHROME_CANDIDATES: if Path(c).exists(): return c import shutil for name in ("chrome", "chrome.exe", "msedge", "msedge.exe"): p = shutil.which(name) if p: return p raise SystemExit( "[fatal] 未找到 Chrome / Edge,无法渲染 SVG 预览。请安装其一,或用浏览器手动打开 svg_output/*.svg 验收。" ) _VIEWBOX_RE = re.compile(r'viewBox\s*=\s*["\']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["\']') _WH_RE = re.compile(r'\b(width|height)\s*=\s*["\']\s*([\d.]+)') def _dims(svg_text: str) -> tuple[float, float]: m = _VIEWBOX_RE.search(svg_text) if m: return float(m.group(3)), float(m.group(4)) w = h = None for name, val in _WH_RE.findall(svg_text): if name == "width": w = float(val) elif name == "height": h = float(val) return (w or 1280.0), (h or 720.0) def _wrap_html(svg_path: Path, w: float, h: float) -> str: # 内联引用本地 svg,固定画布尺寸、去边距,Chrome 截图即得整页 uri = svg_path.resolve().as_uri() return ( "" f"" "" ) def render(browser: str, svg_path: Path, out_png: Path, scale: float = 2.0) -> None: svg_text = svg_path.read_text(encoding="utf-8", errors="ignore") w, h = _dims(svg_text) html = _wrap_html(svg_path, w, h) with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f: f.write(html) html_path = Path(f.name) try: out_png.parent.mkdir(parents=True, exist_ok=True) subprocess.run( [ browser, "--headless", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--force-device-scale-factor=%s" % scale, "--window-size=%d,%d" % (round(w), round(h)), "--default-background-color=FFFFFFFF", "--screenshot=%s" % str(out_png), html_path.as_uri(), ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60, ) finally: try: html_path.unlink() except OSError: pass def _collect(target: Path) -> tuple[list[Path], Path]: """返回 (svg 文件列表, 默认输出目录)。""" if target.is_file() and target.suffix.lower() == ".svg": return [target], target.parent / "preview" # 目录:优先 svg_final(finalize 后图标/配图已内嵌,渲出来最忠实); # 没 svg_final 就退而渲 svg_output(生成中验收,此时图标仍是占位符不显示) if (target / "svg_final").is_dir() and any((target / "svg_final").glob("*.svg")): svg_dir = target / "svg_final" elif (target / "svg_output").is_dir(): svg_dir = target / "svg_output" else: svg_dir = target files = sorted(svg_dir.glob("*.svg")) default_out = target / "preview" return files, default_out def _select(files: list[Path], pages: str | None) -> list[Path]: if not pages: return files idxs = [] for tok in pages.split(","): tok = tok.strip() if tok.isdigit(): idxs.append(int(tok) - 1) return [files[i] for i in idxs if 0 <= i < len(files)] def main() -> None: ap = argparse.ArgumentParser(description="把 SVG 页渲成 PNG 供肉眼/vision 验收") ap.add_argument("target", type=Path, help="project_dir / svg 目录 / 单个 .svg 文件") ap.add_argument("--pages", default=None, help="只渲指定页,如 1,3,5(按文件排序)") ap.add_argument("-o", "--out", type=Path, default=None, help="PNG 输出目录") ap.add_argument("--scale", type=float, default=2.0, help="超采样倍数,默认 2") args = ap.parse_args() files, default_out = _collect(args.target) if not files: raise SystemExit(f"[fatal] 没找到 SVG:{args.target}") files = _select(files, args.pages) if not files: raise SystemExit(f"[fatal] --pages {args.pages} 没选中任何页(共 {len(_collect(args.target)[0])} 页)") out_dir = args.out or default_out browser = find_browser() print(f"[svg_preview] browser={browser}") done = [] for svg in files: png = out_dir / (svg.stem + ".png") render(browser, svg, png, scale=args.scale) if png.exists(): done.append(png) print(f" [ok] {svg.name} -> {png}") else: print(f" [FAIL] {svg.name} 未生成 PNG") print(f"[svg_preview] {len(done)}/{len(files)} 页渲好,输出目录:{out_dir}") if __name__ == "__main__": main()