#!/usr/bin/env python3 """ PPT Master - Visual Review Renderer Renders project SVGs at their root viewBox dimensions to PNGs that match the live-preview browser view (inlined , resolved , full font fallback including CJK). The pure renderer for the visual-review stage — does not edit SVGs, does not interpret the rubric. Backend: Playwright (Chromium). The cairosvg backend was evaluated and rejected because cairo's text API has no font-fallback chain — CJK characters render as tofu boxes for any deck whose font-family list relies on system fallback. Usage: python3 scripts/visual_review.py python3 scripts/visual_review.py --pages 02 03 python3 scripts/visual_review.py --server-url http://localhost:5050 Exit codes (per references/visual-review.md §7): 0 — all requested pages rendered 2 — live-preview server not reachable for this project 3 — rendering backend (playwright + chromium) missing or unable to launch 4 — one or more page-level render failures (details in stderr) Output: JSON summary printed to stdout, PNGs written to /.preview/. """ from __future__ import annotations import argparse import io import json import math import os import sys import time import urllib.error import urllib.parse import urllib.request from contextlib import contextmanager from decimal import Decimal from pathlib import Path from xml.etree import ElementTree as ET from console_encoding import configure_utf8_stdio from server_common import lock_pid, process_alive, read_lock from slide_roster import discover_slide_svgs from svg_to_pptx.canvas_contract import parse_project_svg_root configure_utf8_stdio() # Histogram threshold: PNG counts as "all background" if a single quantized # color bucket holds >= ALL_BG_THRESHOLD of pixels. Guards against blank # renders without false-firing on legitimate sparse dark layouts. ALL_BG_THRESHOLD = 0.99 def _safe_print(msg: str) -> None: print(msg, file=sys.stderr, flush=True) @contextmanager def file_lock(lock_path: Path, timeout: float = 30.0): """POSIX advisory lock via fcntl. Falls back to lockless on Windows.""" try: import fcntl except ImportError: yield return lock_path.parent.mkdir(parents=True, exist_ok=True) fp = open(lock_path, 'w') deadline = time.monotonic() + timeout while True: try: fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) break except BlockingIOError: if time.monotonic() >= deadline: fp.close() raise TimeoutError(f"render lock contended for {timeout}s at {lock_path}") time.sleep(0.1) try: fp.write(str(os.getpid())) fp.flush() yield finally: fcntl.flock(fp.fileno(), fcntl.LOCK_UN) fp.close() try: lock_path.unlink() except FileNotFoundError: pass def is_all_background(png_bytes: bytes) -> bool: """Histogram check: quantize each channel to 4 bits, count dominant bucket. Returns True only when the PNG is essentially monochrome (blank render).""" try: from PIL import Image except ImportError: # PIL not installed — skip this check, the rubric subagent will # re-validate visually. return False img = Image.open(io.BytesIO(png_bytes)).convert('RGB') pixels = img.getdata() total = img.width * img.height if total == 0: return True counts: dict[tuple[int, int, int], int] = {} for r, g, b in pixels: key = (r >> 4, g >> 4, b >> 4) counts[key] = counts.get(key, 0) + 1 dominant = max(counts.values()) return dominant / total >= ALL_BG_THRESHOLD def fetch_slide_content(server_url: str, page_name: str, timeout: float = 5.0) -> str: """Return the live-preview server's inlined SVG content for one slide.""" url = f"{server_url.rstrip('/')}/api/slide/{urllib.parse.quote(page_name)}" req = urllib.request.Request(url, headers={'Accept': 'application/json'}) with urllib.request.urlopen(req, timeout=timeout) as resp: payload = json.loads(resp.read().decode('utf-8')) content = payload.get('content') if isinstance(payload, dict) else None if not isinstance(content, str): raise RuntimeError(f'unexpected response shape from {url}: {payload!r}') return content def _json_number(value: Decimal) -> int | float: """Keep integral canvas values compact while preserving fractional input.""" if value == value.to_integral_value(): return int(value) return float(value) def parse_slide_canvas(svg_content: str, page_name: str) -> dict: """Read the authoritative canvas from one inlined SVG root viewBox.""" try: root = ET.fromstring(svg_content) except ET.ParseError as exc: raise ValueError(f'{page_name}: unable to parse root SVG: {exc}') from exc viewbox = parse_project_svg_root(root, context=page_name) width = _json_number(viewbox.width) height = _json_number(viewbox.height) return { 'view_box': [_json_number(value) for value in viewbox.values], 'width': width, 'height': height, 'png_width': math.ceil(float(viewbox.width)), 'png_height': math.ceil(float(viewbox.height)), } def render_pages(server_url: str, pages: list[str], preview_dir: Path) -> list[dict]: """Render all requested pages in a single browser session. Each render: page.goto(server_url) anchors the base URL so the SVG's relative resolves against the server. Then fetch the slide via the server's /api/slide endpoint (which inlines references) and inject it as the document body. """ from playwright.sync_api import sync_playwright preview_dir.mkdir(parents=True, exist_ok=True) records: list[dict] = [] inject_js = """ ({svgContent, width, height}) => { document.documentElement.innerHTML = '' + '' + svgContent + ''; return { len: svgContent.length }; } """ with sync_playwright() as p: browser = p.chromium.launch() try: context = browser.new_context() for page_name in pages: rec: dict = {'page': page_name, 'ok': False} try: svg_content = fetch_slide_content(server_url, page_name) canvas = parse_slide_canvas(svg_content, page_name) rec['canvas'] = canvas except urllib.error.URLError as e: rec['error'] = f'server_unreachable: {e!r}' records.append(rec) continue except Exception as e: # noqa: BLE001 rec['error'] = f'{type(e).__name__}: {e}' records.append(rec) continue stem = page_name[:-4] if page_name.endswith('.svg') else page_name out_path = preview_dir / f'{stem}.png' pg = None try: pg = context.new_page() pg.set_viewport_size({ 'width': canvas['png_width'], 'height': canvas['png_height'], }) pg.goto(server_url, wait_until='domcontentloaded') pg.evaluate(inject_js, { 'svgContent': svg_content, 'width': canvas['width'], 'height': canvas['height'], }) # Wait one frame so font/text shaping settles before capture. pg.wait_for_timeout(100) png_bytes = pg.screenshot(type='png', full_page=False) out_path.write_bytes(png_bytes) rec['path'] = str(out_path) rec['bytes'] = len(png_bytes) rec['all_background'] = is_all_background(png_bytes) rec['ok'] = True except Exception as e: # noqa: BLE001 — best-effort per-page rec['error'] = f'{type(e).__name__}: {e}' finally: if pg is not None: try: pg.close() except Exception: # noqa: BLE001 — cleanup is best-effort pass records.append(rec) finally: browser.close() return records def discover_pages(project_path: Path, requested: list[str] | None) -> list[str]: svg_dir = project_path / 'svg_output' if not svg_dir.is_dir(): raise FileNotFoundError(f'no svg_output/ in {project_path}') all_svgs = [path.name for path in discover_slide_svgs(svg_dir)] if not requested: return all_svgs selected: list[str] = [] for token in requested: match = next((n for n in all_svgs if n.startswith(token) or n == token), None) if match is None: raise ValueError(f'no SVG matches token {token!r} in {svg_dir}') selected.append(match) return selected def discover_server_url(project_path: Path) -> str: """Return the live-preview URL recorded for one project.""" lock_paths = ( project_path / 'live_preview' / 'lock.json', project_path / '.live_preview.lock', ) for lock_path in lock_paths: lock = read_lock(lock_path) if not lock or not process_alive(lock_pid(lock)): continue try: port = int(lock.get('port', 0) or 0) except (TypeError, ValueError): port = 0 if 1 <= port <= 65535: return f'http://127.0.0.1:{port}' raise RuntimeError( f'no running live-preview server recorded for project: {project_path}' ) def check_server(server_url: str, project_path: Path) -> None: """Require a live-preview server that belongs to the target project.""" url = f"{server_url.rstrip('/')}/api/health" try: with urllib.request.urlopen(url, timeout=3.0) as resp: if resp.status != 200: raise RuntimeError(f'{url} returned HTTP {resp.status}') data = json.load(resp) except (urllib.error.URLError, OSError, ValueError) as e: raise RuntimeError(f'live-preview server not reachable at {server_url}: {e}') expected_project = str(project_path) expected_svg_output = str((project_path / 'svg_output').resolve()) service = data.get('service') if isinstance(data, dict) else None legacy_live_preview = ( service is None and isinstance(data, dict) and data.get('svg_output') == expected_svg_output ) if ( not isinstance(data, dict) or data.get('project') != expected_project or (service != 'live_preview' and not legacy_live_preview) ): raise RuntimeError( f'URL does not belong to this project live preview: {server_url}' ) def main() -> int: parser = argparse.ArgumentParser( description='Render project SVGs to PNGs for visual review.', ) parser.add_argument('project_path', help='Path to project directory (contains svg_output/)') parser.add_argument( '--pages', nargs='+', default=None, help='Page tokens to render (default: all SVGs in svg_output/). ' "Accepts '02', '02_three_steps', or '02_three_steps.svg'.", ) parser.add_argument( '--server-url', default=None, help='Explicit live-preview URL (default: discover it from the project lock)', ) parser.add_argument( '--lock-timeout', type=float, default=30.0, help='Seconds to wait for render lock (default: 30)', ) args = parser.parse_args() project_path = Path(args.project_path).resolve() if not project_path.is_dir(): _safe_print(f'project path not found: {project_path}') return 2 try: from playwright.sync_api import sync_playwright # noqa: F401 except ImportError: _safe_print( 'playwright not installed. Install with:\n' ' pip install playwright\n' ' python3 -m playwright install chromium\n' '(see skills/ppt-master/requirements.txt)' ) return 3 try: server_url = args.server_url or discover_server_url(project_path) check_server(server_url, project_path) except RuntimeError as e: _safe_print(str(e)) _safe_print( 'start it with:\n' f' python3 skills/ppt-master/scripts/svg_editor/server.py {project_path}' ) return 2 try: pages = discover_pages(project_path, args.pages) except (FileNotFoundError, ValueError) as e: _safe_print(str(e)) return 2 preview_dir = project_path / '.preview' lock_path = preview_dir / '.render.lock' with file_lock(lock_path, timeout=args.lock_timeout): try: records = render_pages(server_url, pages, preview_dir) except Exception as e: # noqa: BLE001 — browser launch failure _safe_print(f'browser session failed: {type(e).__name__}: {e}') _safe_print( 'try: python3 -m playwright install chromium' ) return 3 for rec in records: if not rec['ok']: _safe_print(f"[FAIL] {rec['page']}: {rec.get('error')}") elif rec.get('all_background'): _safe_print(f"[WARN] {rec['page']}: PNG rendered but is all-background") summary = { 'project': str(project_path), 'server_url': server_url, 'rendered': sum(1 for r in records if r['ok']), 'failed': sum(1 for r in records if not r['ok']), 'all_background': sum(1 for r in records if r.get('all_background')), 'pages': records, } print(json.dumps(summary, indent=2, ensure_ascii=False)) if summary['failed']: return 4 return 0 if __name__ == '__main__': sys.exit(main())