返回 ppt-master
visual_review.py
根目录 / skills / ppt-master / scripts / visual_review.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Visual Review Renderer
4
5 Renders project SVGs at their root viewBox dimensions to PNGs that match the
6 live-preview browser view (inlined <use data-icon>, resolved <image href>, full
7 font fallback including CJK). The pure renderer for the visual-review stage —
8 does not edit SVGs, does not interpret the rubric.
9
10 Backend: Playwright (Chromium). The cairosvg backend was evaluated and rejected
11 because cairo's text API has no font-fallback chain — CJK characters render as
12 tofu boxes for any deck whose font-family list relies on system fallback.
13
14 Usage:
15 python3 scripts/visual_review.py <project_path>
16 python3 scripts/visual_review.py <project_path> --pages 02 03
17 python3 scripts/visual_review.py <project_path> --server-url http://localhost:5050
18
19 Exit codes (per references/visual-review.md §7):
20 0 — all requested pages rendered
21 2 — live-preview server not reachable for this project
22 3 — rendering backend (playwright + chromium) missing or unable to launch
23 4 — one or more page-level render failures (details in stderr)
24
25 Output: JSON summary printed to stdout, PNGs written to <project>/.preview/.
26 """
27
28 from __future__ import annotations
29
30 import argparse
31 import io
32 import json
33 import math
34 import os
35 import sys
36 import time
37 import urllib.error
38 import urllib.parse
39 import urllib.request
40 from contextlib import contextmanager
41 from decimal import Decimal
42 from pathlib import Path
43 from xml.etree import ElementTree as ET
44
45 from console_encoding import configure_utf8_stdio
46 from server_common import lock_pid, process_alive, read_lock
47 from slide_roster import discover_slide_svgs
48 from svg_to_pptx.canvas_contract import parse_project_svg_root
49
50 configure_utf8_stdio()
51
52
53 # Histogram threshold: PNG counts as "all background" if a single quantized
54 # color bucket holds >= ALL_BG_THRESHOLD of pixels. Guards against blank
55 # renders without false-firing on legitimate sparse dark layouts.
56 ALL_BG_THRESHOLD = 0.99
57
58
59 def _safe_print(msg: str) -> None:
60 print(msg, file=sys.stderr, flush=True)
61
62
63 @contextmanager
64 def file_lock(lock_path: Path, timeout: float = 30.0):
65 """POSIX advisory lock via fcntl. Falls back to lockless on Windows."""
66 try:
67 import fcntl
68 except ImportError:
69 yield
70 return
71
72 lock_path.parent.mkdir(parents=True, exist_ok=True)
73 fp = open(lock_path, 'w')
74 deadline = time.monotonic() + timeout
75 while True:
76 try:
77 fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
78 break
79 except BlockingIOError:
80 if time.monotonic() >= deadline:
81 fp.close()
82 raise TimeoutError(f"render lock contended for {timeout}s at {lock_path}")
83 time.sleep(0.1)
84 try:
85 fp.write(str(os.getpid()))
86 fp.flush()
87 yield
88 finally:
89 fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
90 fp.close()
91 try:
92 lock_path.unlink()
93 except FileNotFoundError:
94 pass
95
96
97 def is_all_background(png_bytes: bytes) -> bool:
98 """Histogram check: quantize each channel to 4 bits, count dominant bucket.
99 Returns True only when the PNG is essentially monochrome (blank render)."""
100 try:
101 from PIL import Image
102 except ImportError:
103 # PIL not installed — skip this check, the rubric subagent will
104 # re-validate visually.
105 return False
106
107 img = Image.open(io.BytesIO(png_bytes)).convert('RGB')
108 pixels = img.getdata()
109 total = img.width * img.height
110 if total == 0:
111 return True
112 counts: dict[tuple[int, int, int], int] = {}
113 for r, g, b in pixels:
114 key = (r >> 4, g >> 4, b >> 4)
115 counts[key] = counts.get(key, 0) + 1
116 dominant = max(counts.values())
117 return dominant / total >= ALL_BG_THRESHOLD
118
119
120 def fetch_slide_content(server_url: str, page_name: str, timeout: float = 5.0) -> str:
121 """Return the live-preview server's inlined SVG content for one slide."""
122 url = f"{server_url.rstrip('/')}/api/slide/{urllib.parse.quote(page_name)}"
123 req = urllib.request.Request(url, headers={'Accept': 'application/json'})
124 with urllib.request.urlopen(req, timeout=timeout) as resp:
125 payload = json.loads(resp.read().decode('utf-8'))
126 content = payload.get('content') if isinstance(payload, dict) else None
127 if not isinstance(content, str):
128 raise RuntimeError(f'unexpected response shape from {url}: {payload!r}')
129 return content
130
131
132 def _json_number(value: Decimal) -> int | float:
133 """Keep integral canvas values compact while preserving fractional input."""
134 if value == value.to_integral_value():
135 return int(value)
136 return float(value)
137
138
139 def parse_slide_canvas(svg_content: str, page_name: str) -> dict:
140 """Read the authoritative canvas from one inlined SVG root viewBox."""
141 try:
142 root = ET.fromstring(svg_content)
143 except ET.ParseError as exc:
144 raise ValueError(f'{page_name}: unable to parse root SVG: {exc}') from exc
145
146 viewbox = parse_project_svg_root(root, context=page_name)
147 width = _json_number(viewbox.width)
148 height = _json_number(viewbox.height)
149 return {
150 'view_box': [_json_number(value) for value in viewbox.values],
151 'width': width,
152 'height': height,
153 'png_width': math.ceil(float(viewbox.width)),
154 'png_height': math.ceil(float(viewbox.height)),
155 }
156
157
158 def render_pages(server_url: str, pages: list[str], preview_dir: Path) -> list[dict]:
159 """Render all requested pages in a single browser session.
160
161 Each render: page.goto(server_url) anchors the base URL so the SVG's
162 relative <image href="../images/..."> resolves against the server.
163 Then fetch the slide via the server's /api/slide endpoint (which inlines
164 <use data-icon> references) and inject it as the document body.
165 """
166 from playwright.sync_api import sync_playwright
167
168 preview_dir.mkdir(parents=True, exist_ok=True)
169 records: list[dict] = []
170
171 inject_js = """
172 ({svgContent, width, height}) => {
173 document.documentElement.innerHTML =
174 '<head><style>html,body{margin:0;padding:0;background:#0E1116;overflow:hidden}'
175 + ' svg{display:block;width:' + width + 'px;height:' + height + 'px}</style></head>'
176 + '<body>' + svgContent + '</body>';
177 return { len: svgContent.length };
178 }
179 """
180
181 with sync_playwright() as p:
182 browser = p.chromium.launch()
183 try:
184 context = browser.new_context()
185 for page_name in pages:
186 rec: dict = {'page': page_name, 'ok': False}
187 try:
188 svg_content = fetch_slide_content(server_url, page_name)
189 canvas = parse_slide_canvas(svg_content, page_name)
190 rec['canvas'] = canvas
191 except urllib.error.URLError as e:
192 rec['error'] = f'server_unreachable: {e!r}'
193 records.append(rec)
194 continue
195 except Exception as e: # noqa: BLE001
196 rec['error'] = f'{type(e).__name__}: {e}'
197 records.append(rec)
198 continue
199
200 stem = page_name[:-4] if page_name.endswith('.svg') else page_name
201 out_path = preview_dir / f'{stem}.png'
202
203 pg = None
204 try:
205 pg = context.new_page()
206 pg.set_viewport_size({
207 'width': canvas['png_width'],
208 'height': canvas['png_height'],
209 })
210 pg.goto(server_url, wait_until='domcontentloaded')
211 pg.evaluate(inject_js, {
212 'svgContent': svg_content,
213 'width': canvas['width'],
214 'height': canvas['height'],
215 })
216 # Wait one frame so font/text shaping settles before capture.
217 pg.wait_for_timeout(100)
218 png_bytes = pg.screenshot(type='png', full_page=False)
219
220 out_path.write_bytes(png_bytes)
221 rec['path'] = str(out_path)
222 rec['bytes'] = len(png_bytes)
223 rec['all_background'] = is_all_background(png_bytes)
224 rec['ok'] = True
225 except Exception as e: # noqa: BLE001 — best-effort per-page
226 rec['error'] = f'{type(e).__name__}: {e}'
227 finally:
228 if pg is not None:
229 try:
230 pg.close()
231 except Exception: # noqa: BLE001 — cleanup is best-effort
232 pass
233 records.append(rec)
234 finally:
235 browser.close()
236
237 return records
238
239
240 def discover_pages(project_path: Path, requested: list[str] | None) -> list[str]:
241 svg_dir = project_path / 'svg_output'
242 if not svg_dir.is_dir():
243 raise FileNotFoundError(f'no svg_output/ in {project_path}')
244 all_svgs = [path.name for path in discover_slide_svgs(svg_dir)]
245 if not requested:
246 return all_svgs
247 selected: list[str] = []
248 for token in requested:
249 match = next((n for n in all_svgs if n.startswith(token) or n == token), None)
250 if match is None:
251 raise ValueError(f'no SVG matches token {token!r} in {svg_dir}')
252 selected.append(match)
253 return selected
254
255
256 def discover_server_url(project_path: Path) -> str:
257 """Return the live-preview URL recorded for one project."""
258 lock_paths = (
259 project_path / 'live_preview' / 'lock.json',
260 project_path / '.live_preview.lock',
261 )
262 for lock_path in lock_paths:
263 lock = read_lock(lock_path)
264 if not lock or not process_alive(lock_pid(lock)):
265 continue
266 try:
267 port = int(lock.get('port', 0) or 0)
268 except (TypeError, ValueError):
269 port = 0
270 if 1 <= port <= 65535:
271 return f'http://127.0.0.1:{port}'
272 raise RuntimeError(
273 f'no running live-preview server recorded for project: {project_path}'
274 )
275
276
277 def check_server(server_url: str, project_path: Path) -> None:
278 """Require a live-preview server that belongs to the target project."""
279 url = f"{server_url.rstrip('/')}/api/health"
280 try:
281 with urllib.request.urlopen(url, timeout=3.0) as resp:
282 if resp.status != 200:
283 raise RuntimeError(f'{url} returned HTTP {resp.status}')
284 data = json.load(resp)
285 except (urllib.error.URLError, OSError, ValueError) as e:
286 raise RuntimeError(f'live-preview server not reachable at {server_url}: {e}')
287 expected_project = str(project_path)
288 expected_svg_output = str((project_path / 'svg_output').resolve())
289 service = data.get('service') if isinstance(data, dict) else None
290 legacy_live_preview = (
291 service is None
292 and isinstance(data, dict)
293 and data.get('svg_output') == expected_svg_output
294 )
295 if (
296 not isinstance(data, dict)
297 or data.get('project') != expected_project
298 or (service != 'live_preview' and not legacy_live_preview)
299 ):
300 raise RuntimeError(
301 f'URL does not belong to this project live preview: {server_url}'
302 )
303
304
305 def main() -> int:
306 parser = argparse.ArgumentParser(
307 description='Render project SVGs to PNGs for visual review.',
308 )
309 parser.add_argument('project_path', help='Path to project directory (contains svg_output/)')
310 parser.add_argument(
311 '--pages', nargs='+', default=None,
312 help='Page tokens to render (default: all SVGs in svg_output/). '
313 "Accepts '02', '02_three_steps', or '02_three_steps.svg'.",
314 )
315 parser.add_argument(
316 '--server-url', default=None,
317 help='Explicit live-preview URL (default: discover it from the project lock)',
318 )
319 parser.add_argument(
320 '--lock-timeout', type=float, default=30.0,
321 help='Seconds to wait for render lock (default: 30)',
322 )
323 args = parser.parse_args()
324
325 project_path = Path(args.project_path).resolve()
326 if not project_path.is_dir():
327 _safe_print(f'project path not found: {project_path}')
328 return 2
329
330 try:
331 from playwright.sync_api import sync_playwright # noqa: F401
332 except ImportError:
333 _safe_print(
334 'playwright not installed. Install with:\n'
335 ' pip install playwright\n'
336 ' python3 -m playwright install chromium\n'
337 '(see skills/ppt-master/requirements.txt)'
338 )
339 return 3
340
341 try:
342 server_url = args.server_url or discover_server_url(project_path)
343 check_server(server_url, project_path)
344 except RuntimeError as e:
345 _safe_print(str(e))
346 _safe_print(
347 'start it with:\n'
348 f' python3 skills/ppt-master/scripts/svg_editor/server.py {project_path}'
349 )
350 return 2
351
352 try:
353 pages = discover_pages(project_path, args.pages)
354 except (FileNotFoundError, ValueError) as e:
355 _safe_print(str(e))
356 return 2
357
358 preview_dir = project_path / '.preview'
359 lock_path = preview_dir / '.render.lock'
360
361 with file_lock(lock_path, timeout=args.lock_timeout):
362 try:
363 records = render_pages(server_url, pages, preview_dir)
364 except Exception as e: # noqa: BLE001 — browser launch failure
365 _safe_print(f'browser session failed: {type(e).__name__}: {e}')
366 _safe_print(
367 'try: python3 -m playwright install chromium'
368 )
369 return 3
370
371 for rec in records:
372 if not rec['ok']:
373 _safe_print(f"[FAIL] {rec['page']}: {rec.get('error')}")
374 elif rec.get('all_background'):
375 _safe_print(f"[WARN] {rec['page']}: PNG rendered but is all-background")
376
377 summary = {
378 'project': str(project_path),
379 'server_url': server_url,
380 'rendered': sum(1 for r in records if r['ok']),
381 'failed': sum(1 for r in records if not r['ok']),
382 'all_background': sum(1 for r in records if r.get('all_background')),
383 'pages': records,
384 }
385 print(json.dumps(summary, indent=2, ensure_ascii=False))
386
387 if summary['failed']:
388 return 4
389 return 0
390
391
392 if __name__ == '__main__':
393 sys.exit(main())
394
394 lines PYTHON