#!/usr/bin/env python3 """ 把 5.5 寸手持端设计稿(HTML 原型)渲染成截图,供逐像素比对。 设计稿是带 Tailwind + JS 的交互原型:列表页、Tab、详情页、筛选弹层往往挤在同一个 HTML 里,靠 JS 函数切换。只读源码容易漏掉视觉细节(间距、圆角、真实配色、 以及 JS 里 id 映射写错导致的字段错位),所以务必渲染成图来看。 用法: # 1) 先列出 HTML 里可用的状态切换函数与元素 id,据此决定要截哪些状态 python3 design_shots.py --html 出库交接.html --list # 2) 渲染:默认状态 + 注入 JS 触发的各个状态 python3 design_shots.py --html 出库交接.html --out /tmp/shots \ --state "01_列表:" \ --state "02_已交接Tab:switchTab('shipped');" \ --state "03_详情:showDetail({id:'PMC1',status:'已交接'});" \ --state "04_筛选弹层:toggleFilterDrawer(true);" 每个 --state 形如 "名称:JS",JS 可为空表示页面初始态。 输出 /<名称>.png,同时保留注入后的 <名称>.html 便于复查。 """ import argparse import os import re import shutil import subprocess import sys import urllib.parse CHROME_CANDIDATES = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", shutil.which("google-chrome") or "", shutil.which("chromium") or "", shutil.which("chromium-browser") or "", ] def find_chrome() -> str: for path in CHROME_CANDIDATES: if path and os.path.exists(path): return path sys.exit( "找不到 Chrome/Chromium。请安装,或用 --chrome 指定可执行文件路径。" ) def list_hooks(html: str) -> None: """列出可用于构造状态的 JS 函数和元素 id。""" funcs = sorted(set(re.findall(r"function\s+([A-Za-z_$][\w$]*)\s*\(", html))) onclicks = sorted(set(re.findall(r'onclick="([^"]+)"', html))) ids = sorted(set(re.findall(r'id="([^"]+)"', html))) print("== JS 函数(可直接注入调用)==") for f in funcs: print(f" {f}()") print("\n== onclick 表达式(含真实参数,照抄最省事)==") for o in onclicks[:40]: print(f" {o}") if len(onclicks) > 40: print(f" ... 其余 {len(onclicks) - 40} 条略") print("\n== 元素 id(判断有哪些页面/弹层)==") print(" " + ", ".join(ids)) def render(chrome: str, html: str, name: str, js: str, out_dir: str, width: int, height: int, scale: float, budget: int) -> str: # 等 load 之后再触发,确保 CDN 的 Tailwind/Iconify 已生效 inject = ( "" ) page_path = os.path.join(out_dir, f"{name}.html") with open(page_path, "w", encoding="utf-8") as fp: fp.write(html.replace("", inject + "")) png_path = os.path.join(out_dir, f"{name}.png") subprocess.run( [ chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", f"--force-device-scale-factor={scale}", f"--window-size={width},{height}", f"--virtual-time-budget={budget}", f"--screenshot={png_path}", "file://" + urllib.parse.quote(os.path.abspath(page_path)), ], capture_output=True, ) return png_path def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--html", required=True, help="设计稿 HTML 路径") ap.add_argument("--out", default="/tmp/design_shots", help="输出目录") ap.add_argument("--state", action="append", default=[], help='形如 "名称:JS",可重复;JS 为空表示初始态') ap.add_argument("--list", action="store_true", help="只列出可用的 JS 函数 / onclick / id,不截图") ap.add_argument("--chrome", default="", help="Chrome 可执行文件路径") # 原型容器一般是 375×812 的手机壳,留出边距后 460×900 刚好完整收进去 ap.add_argument("--width", type=int, default=460) ap.add_argument("--height", type=int, default=900) ap.add_argument("--scale", type=float, default=2.0, help="缩放倍数,2 便于看清细节") ap.add_argument("--budget", type=int, default=9000, help="虚拟时间预算(ms),需覆盖 CDN 脚本加载") args = ap.parse_args() with open(args.html, encoding="utf-8") as fp: html = fp.read() if args.list: list_hooks(html) return chrome = args.chrome or find_chrome() os.makedirs(args.out, exist_ok=True) states = args.state or ["01_default:"] for spec in states: name, _, js = spec.partition(":") png = render(chrome, html, name.strip(), js, args.out, args.width, args.height, args.scale, args.budget) ok = os.path.exists(png) and os.path.getsize(png) > 0 size = os.path.getsize(png) if ok else 0 print(f"{'✓' if ok else '✗'} {name.strip():<24} {png} ({size} bytes)") print(f"\n用 Read 工具逐张查看 {args.out}/*.png,再开始写布局。") if __name__ == "__main__": main()