| 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 |