Files
AGENTS/skills/excalidraw/references/render_excalidraw.py

206 lines
6.6 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Render Excalidraw JSON to PNG using Playwright + headless Chromium.
Usage:
python3 render_excalidraw.py <path-to-file.excalidraw> [--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()