Skip to main content

Overview

The render pipeline converts .excalidraw JSON files into high-quality PNG images using a Python script that orchestrates Playwright, headless Chromium, and the Excalidraw library. Location: references/render_excalidraw.py

Quick Start

First-Time Setup

cd .claude/skills/excalidraw-diagram/references
uv sync
uv run playwright install chromium

Basic Usage

cd .claude/skills/excalidraw-diagram/references
uv run python render_excalidraw.py <path-to-file.excalidraw>
This creates <filename>.png next to the source .excalidraw file.

Advanced Options

uv run python render_excalidraw.py diagram.excalidraw \
  --output custom-name.png \
  --scale 2 \
  --width 1920
input
path
required
Path to the .excalidraw JSON file to render
--output, -o
path
Output PNG path (default: same name as input with .png extension)
--scale, -s
int
default:"2"
Device scale factor for high-DPI rendering (2 = retina quality)
--width, -w
int
default:"1920"
Maximum viewport width in pixels

Architecture

Pipeline Stages

JSON File → Validation → Bounding Box → Viewport Setup → Browser Render → Screenshot → PNG

1. Validation

The script validates the Excalidraw JSON structure before attempting to render:
references/render_excalidraw.py
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
What it checks:
  • type field must be "excalidraw"
  • elements array must exist and be a valid array
  • At least one element must be present (no empty diagrams)

2. Bounding Box Calculation

The script computes the smallest rectangle that contains all diagram elements:
references/render_excalidraw.py
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)
Special handling:
  • Skips deleted elements (isDeleted: true)
  • Handles arrows/lines by iterating through their points array
  • Adds 80px padding on all sides
  • Falls back to 800×600 if no elements found

3. Viewport Configuration

references/render_excalidraw.py
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)
Sizing logic:
  • Calculates natural diagram dimensions from bounding box
  • Adds 80px padding on all sides
  • Caps width at max_width (default 1920px)
  • Ensures minimum height of 600px

4. Browser Rendering

The script uses Playwright to control a headless Chromium browser:
references/render_excalidraw.py
with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    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})")

    # 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")
    svg_el.screenshot(path=str(output_path))
    browser.close()
Render sequence:
  1. Launch headless Chromium with specified viewport and scale
  2. Load render_template.html from the same directory
  3. Wait for Excalidraw library to load from esm.sh CDN
  4. Call window.renderDiagram() with the JSON data
  5. Wait for render completion signal
  6. Screenshot the SVG element (not the whole page)

5. HTML Template

The template (render_template.html) provides a minimal HTML page that loads Excalidraw:
references/render_template.html
<script type="module">
  import { exportToSvg } from "https://esm.sh/@excalidraw/excalidraw?bundle";

  window.renderDiagram = async function(jsonData) {
    try {
      const data = typeof jsonData === "string" ? JSON.parse(jsonData) : jsonData;
      const elements = data.elements || [];
      const appState = data.appState || {};
      const files = data.files || {};

      // Force white background in appState
      appState.viewBackgroundColor = appState.viewBackgroundColor || "#ffffff";
      appState.exportWithDarkMode = false;

      const svg = await exportToSvg({
        elements: elements,
        appState: {
          ...appState,
          exportBackground: true,
        },
        files: files,
      });

      // Clear any previous render
      const root = document.getElementById("root");
      root.innerHTML = "";
      root.appendChild(svg);

      window.__renderComplete = true;
      window.__renderError = null;
      return { success: true, width: svg.getAttribute("width"), height: svg.getAttribute("height") };
    } catch (err) {
      window.__renderComplete = true;
      window.__renderError = err.message;
      return { success: false, error: err.message };
    }
  };

  // Signal that the module is loaded and ready
  window.__moduleReady = true;
</script>
Key features:
  • Loads @excalidraw/excalidraw from esm.sh CDN (no build step)
  • Forces white background for consistent exports
  • Sets coordination flags (__moduleReady, __renderComplete) for Python to await
  • Returns structured success/error response

Dependencies

From pyproject.toml:
references/pyproject.toml
[project]
name = "excalidraw-render"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "playwright>=1.40.0",
]
Runtime dependencies:
  • Python 3.11+
  • Playwright Python library
  • Chromium browser (installed via playwright install chromium)
  • Internet connection (to load Excalidraw library from esm.sh)

Error Handling

The script provides clear error messages for common failure modes:

Invalid JSON

ERROR: Invalid JSON in diagram.excalidraw: Expecting ',' delimiter: line 42 column 5 (char 1234)

Invalid Excalidraw Structure

ERROR: Invalid Excalidraw file:
  - Expected type 'excalidraw', got 'null'
  - Missing 'elements' array

Playwright Not Installed

ERROR: playwright not installed.
Run: cd .claude/skills/excalidraw-diagram/references && uv sync && uv run playwright install chromium

Chromium Not Installed

ERROR: Chromium not installed for Playwright.
Run: cd .claude/skills/excalidraw-diagram/references && uv run playwright install chromium

Render Failure

ERROR: Render failed: Cannot read property 'x' of undefined

Missing SVG Element

ERROR: No SVG element found after render.

Output Format

On success, the script prints the output path to stdout:
/path/to/diagram.png
This allows the render command to be used in scripts:
PNG_PATH=$(uv run python render_excalidraw.py diagram.excalidraw)
echo "Rendered to: $PNG_PATH"

Performance Characteristics

  • Cold start: ~3-5 seconds (browser launch + library load)
  • Warm render: ~1-2 seconds (subsequent renders in same process)
  • Memory: ~150-300 MB per browser instance
  • Network: Downloads Excalidraw library (~500KB) on first page load

Troubleshooting

The template can’t load the Excalidraw library from esm.sh. Check your internet connection or firewall settings.
The bounding box calculation may not account for all elements. Try increasing the --width parameter or checking for elements with unusual coordinate values.
Increase the --scale parameter. Default is 2 (retina quality). Try --scale 3 for even sharper text.
The render uses Excalidraw’s exportToSvg function, which may render colors slightly differently than the interactive editor. This is expected behavior.

Integration with Workflow

The render pipeline is a mandatory step in the diagram creation workflow (see Quality Checklist):
Generate JSON → Render to PNG → View PNG → Identify Issues → Fix JSON → Re-render → Repeat
See the Render & Validate section in SKILL.md for the complete validation loop.

Build docs developers (and LLMs) love