#!/usr/bin/env python3 """Render Excalidraw JSON to PNG using Playwright + headless Chromium. Usage: python3 render_excalidraw.py [--output path.png] [--scale 2] [--width 1920] Dependencies (playwright, chromium) are provided by the Nix flake / direnv environment. """ from __future__ import annotations import argparse import json import sys from pathlib import Path def validate_excalidraw(data: dict) -> list[str]: """Validate Excalidraw JSON structure. Returns list of errors (empty = valid).""" errors: list[str] = [] if data.get("type") != "excalidraw": errors.append(f"Expected type 'excalidraw', got '{data.get('type')}'") if "elements" not in data: errors.append("Missing 'elements' array") elif not isinstance(data["elements"], list): errors.append("'elements' must be an array") elif len(data["elements"]) == 0: errors.append("'elements' array is empty — nothing to render") return errors def compute_bounding_box(elements: list[dict]) -> tuple[float, float, float, float]: """Compute bounding box (min_x, min_y, max_x, max_y) across all elements.""" min_x = float("inf") min_y = float("inf") max_x = float("-inf") max_y = float("-inf") for el in elements: if el.get("isDeleted"): continue x = el.get("x", 0) y = el.get("y", 0) w = el.get("width", 0) h = el.get("height", 0) # For arrows/lines, points array defines the shape relative to x,y if el.get("type") in ("arrow", "line") and "points" in el: for px, py in el["points"]: min_x = min(min_x, x + px) min_y = min(min_y, y + py) max_x = max(max_x, x + px) max_y = max(max_y, y + py) else: min_x = min(min_x, x) min_y = min(min_y, y) max_x = max(max_x, x + abs(w)) max_y = max(max_y, y + abs(h)) if min_x == float("inf"): return (0, 0, 800, 600) return (min_x, min_y, max_x, max_y) def render( excalidraw_path: Path, output_path: Path | None = None, scale: int = 2, max_width: int = 1920, ) -> Path: """Render an .excalidraw file to PNG. Returns the output PNG path.""" # Import playwright here so validation errors show before import errors try: from playwright.sync_api import sync_playwright except ImportError: print("ERROR: playwright not installed.", file=sys.stderr) print("Ensure the Nix dev shell is active (direnv allow).", file=sys.stderr) sys.exit(1) # Read and validate raw = excalidraw_path.read_text(encoding="utf-8") try: data = json.loads(raw) except json.JSONDecodeError as e: print(f"ERROR: Invalid JSON in {excalidraw_path}: {e}", file=sys.stderr) sys.exit(1) errors = validate_excalidraw(data) if errors: print(f"ERROR: Invalid Excalidraw file:", file=sys.stderr) for err in errors: print(f" - {err}", file=sys.stderr) sys.exit(1) # Compute viewport size from element bounding box elements = [e for e in data["elements"] if not e.get("isDeleted")] min_x, min_y, max_x, max_y = compute_bounding_box(elements) padding = 80 diagram_w = max_x - min_x + padding * 2 diagram_h = max_y - min_y + padding * 2 # Cap viewport width, let height be natural vp_width = min(int(diagram_w), max_width) vp_height = max(int(diagram_h), 600) # Output path if output_path is None: output_path = excalidraw_path.with_suffix(".png") # Template path (same directory as this script) template_path = Path(__file__).parent / "render_template.html" if not template_path.exists(): print(f"ERROR: Template not found at {template_path}", file=sys.stderr) sys.exit(1) template_url = template_path.as_uri() with sync_playwright() as p: try: browser = p.chromium.launch(headless=True) except Exception as e: if "Executable doesn't exist" in str(e) or "browserType.launch" in str(e): print("ERROR: Chromium not installed for Playwright.", file=sys.stderr) print("Ensure the Nix dev shell is active (direnv allow).", file=sys.stderr) sys.exit(1) raise page = browser.new_page( viewport={"width": vp_width, "height": vp_height}, device_scale_factor=scale, ) # Load the template page.goto(template_url) # Wait for the ES module to load (imports from esm.sh) page.wait_for_function("window.__moduleReady === true", timeout=30000) # Inject the diagram data and render json_str = json.dumps(data) result = page.evaluate(f"window.renderDiagram({json_str})") if not result or not result.get("success"): error_msg = ( result.get("error", "Unknown render error") if result else "renderDiagram returned null" ) print(f"ERROR: Render failed: {error_msg}", file=sys.stderr) browser.close() sys.exit(1) # Wait for render completion signal page.wait_for_function("window.__renderComplete === true", timeout=15000) # Screenshot the SVG element svg_el = page.query_selector("#root svg") if svg_el is None: print("ERROR: No SVG element found after render.", file=sys.stderr) browser.close() sys.exit(1) svg_el.screenshot(path=str(output_path)) browser.close() return output_path def main() -> None: """Entry point for rendering Excalidraw JSON files to PNG.""" parser = argparse.ArgumentParser(description="Render Excalidraw JSON to PNG") parser.add_argument("input", type=Path, help="Path to .excalidraw JSON file") parser.add_argument( "--output", "-o", type=Path, default=None, help="Output PNG path (default: same name with .png)", ) parser.add_argument( "--scale", "-s", type=int, default=2, help="Device scale factor (default: 2)" ) parser.add_argument( "--width", "-w", type=int, default=1920, help="Max viewport width (default: 1920)", ) args = parser.parse_args() if not args.input.exists(): print(f"ERROR: File not found: {args.input}", file=sys.stderr) sys.exit(1) png_path = render(args.input, args.output, args.scale, args.width) print(str(png_path)) if __name__ == "__main__": main()