Site Navigation

Plans

Massive Wiki Roadmap
Pier2Pier
Massive Wiki Builder redesign

For Testing

wiki link test page
Mistletoe parser test page
Mistletoe & the ampersand story

Edit on GitHub


Tech Note - Rendering Excalidraw Diagrams with a Node.js CLI for Static Sites

WORK IN PROGRESS: THIS PAGE WAS GENERATED BY AI AND HAS NOT BEEN VALIDATED BY HUMAN EXPERTS YET. (Generated by Google Gemini 2.5 Flash on 2025-07-11.)

This document explains how to create a self-contained Node.js command-line utility for rendering Excalidraw diagrams into image files. This approach is particularly useful for integrating Excalidraw rendering into automated workflows like Continuous Integration (CI) pipelines for static site generators, where spinning up and managing a separate, long-running HTTP server is undesirable.

If the Excalidraw drawings are coming from Obsidian, you may need .excalidraw.md to .excalidraw conversion. See sample code and documentation pointers in Massive-Wiki/obsidian-excalidraw-converter.

Some Excalidraw drawings embed images that can be pulled from the same vault. The renderer may need to find and pass the images to Excalidraw as well as just the Excalidraw source.

Here is a list of possible candidate tools for command-line rendering: https://github.com/markpub/markpub/issues/19#issuecomment-3067056938

Use Case and the Need for Puppeteer

When building static websites, you often want to embed dynamic content or content generated from specific formats. Excalidraw diagrams, stored as JSON data, are a prime example. While Excalidraw is primarily a web-based drawing tool designed to run in a browser, the goal here is to convert these JSON diagrams into static image assets (like PNGs) during your site's build process.

The challenge is that Excalidraw's rendering logic relies heavily on a browser environment (specifically, the Document Object Model or DOM, and Canvas API). It doesn't offer a direct "render to image" function that works purely in a Node.js server environment without a virtual browser. This is where Puppeteer comes in.

Puppeteer is a Node.js library that provides a high-level API to control Chrome or Chromium over the DevTools Protocol. By using Puppeteer, we can:

  1. Launch a headless browser (a browser without a visible user interface) in the background.
  2. Load a simple HTML page within this headless browser.
  3. Inject the Excalidraw library and your diagram's JSON data into that page.
  4. Execute JavaScript code within the browser context to render the Excalidraw diagram onto an HTML <canvas> element.
  5. "Screenshot" the rendered canvas, saving it as an image file.

This allows us to leverage Excalidraw's browser-specific rendering capabilities in a server-side or CI environment, making it a robust solution for generating static image assets from your Excalidraw JSON.

Node.js CLI Application

First, let's set up the Node.js command-line utility.

1. Project Setup

Create a new directory for your Node.js CLI tool and initialize a new Node.js project:

mkdir excalidraw-cli-renderer
cd excalidraw-cli-renderer
npm init -y

2. Install Dependencies

You'll need puppeteer to control the headless browser and @excalidraw/excalidraw for its rendering utilities.

npm install puppeteer @excalidraw/excalidraw

3. Create renderDiagram.js

This is the core Node.js script that will perform the rendering.

// renderDiagram.js
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');

// Define the path to the HTML page that Puppeteer will load.
// This page will contain the Excalidraw library and a container for the diagram.
const RENDER_PAGE_HTML_PATH = path.join(__dirname, 'renderPage.html');

/**
 * Renders an Excalidraw diagram to a PNG image file using a headless browser.
 * @param {object} diagramData - The Excalidraw diagram data (elements, appState, files).
 * @param {string} outputPath - The file path where the rendered image will be saved (e.g., 'output/diagram.png').
 */
async function renderExcalidrawDiagram(diagramData, outputPath) {
    let browser;
    try {
        // Launch a new headless Chromium browser instance.
        // '--no-sandbox' and '--disable-setuid-sandbox' are often necessary
        // when running Puppeteer in CI/Docker environments.
        browser = await puppeteer.launch({
            args: ['--no-sandbox', '--disable-setuid-sandbox'],
            // headless: false // Uncomment for debugging to see the browser UI
        });
        const page = await browser.newPage();

        // Navigate the browser page to our local HTML file.
        // This file will load the Excalidraw library.
        await page.goto(`file://${RENDER_PAGE_HTML_PATH}`);

        // Set the viewport size. This is important to ensure the diagram
        // has enough space to render without being clipped.
        await page.setViewport({ width: 1200, height: 800 }); // Adjust as needed

        // Execute JavaScript code within the browser's context.
        // This is where the Excalidraw rendering happens.
        const buffer = await page.evaluate(async (data) => {
            // Destructure the Excalidraw data passed from Node.js.
            const { elements, appState, files } = data;
            // Access the Excalidraw library, which was loaded by renderPage.html
            const { renderStaticScene } = window.ExcalidrawLib; 

            // Get the container element where Excalidraw will render.
            const root = document.getElementById('excalidraw-container');
            if (!root) {
                console.error("Excalidraw container not found in renderPage.html.");
                return null;
            }

            // Create a canvas element to render the Excalidraw scene onto.
            const canvas = document.createElement('canvas');
            root.appendChild(canvas);

            // Render the Excalidraw scene onto the canvas.
            // `fitToContent: true` is crucial as it automatically adjusts the
            // canvas dimensions to fit the entire diagram content, preventing clipping.
            // `exportPadding` adds a margin around the diagram.
            const { width, height } = renderStaticScene({
                elements,
                appState,
                files,
                scale: appState.zoom ? appState.zoom.value : 1, // Use appState zoom if available
                canvas,
                fitToContent: true,
                exportPadding: 10, 
            });

            // After rendering with fitToContent, update the canvas dimensions
            // to precisely match the rendered content's bounds.
            canvas.width = width;
            canvas.height = height;

            // Re-render one more time after adjusting canvas size to ensure
            // everything is perfectly aligned and scaled.
            renderStaticScene({
                elements,
                appState,
                files,
                scale: appState.zoom ? appState.zoom.value : 1,
                canvas,
                fitToContent: true,
                exportPadding: 10,
            });

            // Wait a very short moment to ensure all rendering operations have settled.
            await new Promise(resolve => setTimeout(resolve, 100));

            // Return the canvas content as a Data URL (Base64 encoded image).
            return canvas.toDataURL('image/png'); // Can be 'image/jpeg' or 'image/svg+xml'
        }, diagramData); // Pass diagramData to the page.evaluate function

        if (!buffer) {
            throw new Error('Failed to obtain image data from headless browser.');
        }

        // Extract the base64 data part from the Data URL.
        const base64Data = buffer.replace(/^data:image\/png;base64,/, "");
        // Convert the base64 string to a Node.js Buffer.
        const imageBuffer = Buffer.from(base64Data, 'base64');

        // Write the image buffer to the specified output file.
        fs.writeFileSync(outputPath, imageBuffer);
        console.log(`Excalidraw diagram rendered and saved to: ${outputPath}`);

    } catch (error) {
        console.error('Error during Excalidraw rendering:', error);
        throw error; // Re-throw the error to indicate failure to the calling process.
    } finally {
        // Ensure the browser is closed even if an error occurs.
        if (browser) {
            await browser.close();
        }
    }
}

// --- CLI Entry Point ---
// Parse command-line arguments: `node renderDiagram.js <input-json-path> <output-image-path>`
const args = process.argv.slice(2); 

// Validate the number of arguments.
if (args.length !== 2) {
    console.error('Usage: node renderDiagram.js <path-to-excalidraw-json> <output-image-path>');
    process.exit(1); // Exit with an error code.
}

const inputJsonPath = args[0];
const outputImagePath = args[1];

try {
    // Read the Excalidraw JSON data from the input file.
    const diagramJsonString = fs.readFileSync(inputJsonPath, 'utf8');
    const diagramData = JSON.parse(diagramJsonString);

    // Call the rendering function and handle its promise.
    renderExcalidrawDiagram(diagramData, outputImagePath)
        .then(() => process.exit(0)) // Exit successfully on completion.
        .catch((error) => {
            console.error(`Rendering failed: ${error.message}`);
            process.exit(1); // Exit with an error code if rendering fails.
        });

} catch (error) {
    console.error(`Error reading or parsing input JSON file: ${error.message}`);
    process.exit(1); // Exit with an error code for file system or JSON parsing issues.
}

4. Create renderPage.html

This simple HTML file is loaded by Puppeteer. It needs to include the Excalidraw library (via a CDN) and a designated container for the diagram. Place this file in the same directory as renderDiagram.js.

<!-- renderPage.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Excalidraw Renderer Page</title>
    <style>
        /* Basic styling to center the content and ensure canvas visibility */
        body { 
            margin: 0; 
            display: flex; 
            justify-content: center; 
            align-items: center; 
            min-height: 100vh; 
            background-color: #f0f0f0; 
        }
        #excalidraw-container {
            position: relative;
            /* Flex properties for centering the canvas within its container */
            display: flex;
            justify-content: center;
            align-items: center;
            /* Provide a minimum size, though fitToContent will dynamically adjust */
            min-width: 100px;
            min-height: 100px;
            overflow: hidden; /* Important for preventing scrollbars if content overflows initially */
        }
        canvas {
            display: block; /* Removes extra space below the canvas element */
        }
    </style>
</head>
<body>
    <!--
        Load the Excalidraw library from a CDN.
        It's crucial to use a stable and compatible version.
        window.Excalidraw will be available after this script loads.
    -->
    <script src="https://unpkg.com/@excalidraw/excalidraw@0.17.6/dist/excalidraw.min.js"></script>
    <script>
        // Make the Excalidraw library accessible globally on the window object
        // so that Puppeteer's `page.evaluate` can reference it.
        window.ExcalidrawLib = window.Excalidraw;
    </script>
    <!-- This div will serve as the container where Excalidraw will render its canvas. -->
    <div id="excalidraw-container"></div>
</body>
</html>

5. Example Excalidraw JSON Data (diagram.json)

Create a sample JSON file containing your Excalidraw diagram data. This is the input that renderDiagram.js will read.

{
  "elements": [
    {
      "id": "e_z24G93gZ92g",
      "type": "rectangle",
      "x": 100,
      "y": 100,
      "width": 200,
      "height": 100,
      "strokeColor": "#000000",
      "backgroundColor": "transparent",
      "fillStyle": "hachure",
      "strokeWidth": 1,
      "strokeStyle": "solid",
      "roughness": 1,
      "opacity": 100,
      "groupIds": [],
      "roundness": null,
      "seed": 12345,
      "version": 1,
      "versionNonce": 12345,
      "isDeleted": false,
      "angle": 0,
      "startBinding": null,
      "endBinding": null,
      "updated": 1678886400000,
      "link": null,
      "locked": false,
      "boundElements": [],
      "code": "A Box"
    },
    {
      "id": "text1",
      "type": "text",
      "x": 150,
      "y": 140,
      "width": 100,
      "height": 24,
      "strokeColor": "#000000",
      "backgroundColor": "transparent",
      "fillStyle": "hachure",
      "strokeWidth": 1,
      "strokeStyle": "solid",
      "roughness": 1,
      "opacity": 100,
      "groupIds": [],
      "roundness": null,
      "seed": 67890,
      "version": 1,
      "versionNonce": 67890,
      "isDeleted": false,
      "angle": 0,
      "startBinding": null,
      "endBinding": null,
      "updated": 1678886400000,
      "link": null,
      "locked": false,
      "text": "Hello Excalidraw!",
      "fontSize": 20,
      "fontFamily": 1,
      "textAlign": "center",
      "verticalAlign": "middle",
      "baseline": 18
    }
  ],
  "appState": {
    "viewBackgroundColor": "#ffffff",
    "gridSize": 20,
    "zoom": {
      "value": 1.0
    }
  },
  "files": {}
}

Python Script Integration

Now, your Python static site generation script will interact with this Node.js CLI tool by executing it as a subprocess.

import os
import subprocess
import json

# Define paths for your project structure
# Path to your Node.js CLI script
EXCALIDRAW_CLI_PATH = "./excalidraw-cli-renderer/renderDiagram.js"
# Directory where your static site output will be generated
OUTPUT_DIR = "static_site_output"
# Directory where your Excalidraw JSON source files are stored
DIAGRAM_JSON_DIR = "excalidraw_sources" 
# Directory within the output where images will be saved
IMAGE_OUTPUT_DIR = os.path.join(OUTPUT_DIR, "images")

def create_dirs():
    """Ensures necessary directories exist."""
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    os.makedirs(DIAGRAM_JSON_DIR, exist_ok=True)
    os.makedirs(IMAGE_OUTPUT_DIR, exist_ok=True)

def render_excalidraw_diagram_cli(input_json_path, output_image_path):
    """
    Calls the Node.js CLI script to render an Excalidraw diagram.
    
    Args:
        input_json_path (str): Path to the Excalidraw JSON input file.
        output_image_path (str): Path where the rendered image will be saved.
        
    Returns:
        bool: True if rendering was successful, False otherwise.
    """
    # Construct the command to execute the Node.js script.
    # 'node' should be available in your system's PATH.
    command = ["node", EXCALIDRAW_CLI_PATH, input_json_path, output_image_path]
    print(f"Executing Node.js CLI: {' '.join(command)}")
    try:
        # Run the subprocess. `check=True` will raise a CalledProcessError
        # if the Node.js script exits with a non-zero status code (indicating an error).
        # `capture_output=True` captures stdout and stderr.
        # `text=True` decodes stdout/stderr as text.
        result = subprocess.run(command, check=True, capture_output=True, text=True)
        print(f"Node.js stdout:\n{result.stdout}")
        if result.stderr:
            print(f"Node.js stderr:\n{result.stderr}")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Error calling Excalidraw renderer CLI (exit code {e.returncode}): {e}")
        print(f"Node.js stdout:\n{e.stdout}")
        print(f"Node.js stderr:\n{e.stderr}")
        return False
    except FileNotFoundError:
        print(f"Error: 'node' command or Node.js CLI script not found.")
        print("Please ensure Node.js is installed, 'node' is in your system's PATH,")
        print(f"and the script path '{EXCALIDRAW_CLI_PATH}' is correct.")
        return False

def generate_static_site():
    """Main function to generate the static site, including Excalidraw rendering."""
    create_dirs()

    # --- Example: Prepare an Excalidraw JSON file for rendering ---
    # In a real static site generator, you would typically read this JSON
    # from a content file (e.g., a markdown file with embedded Excalidraw data,
    # or a dedicated '.excalidraw' file).
    example_diagram_source = {
        "elements": [
            {
                "id": "e_z24G93gZ92g",
                "type": "rectangle",
                "x": 100,
                "y": 100,
                "width": 200,
                "height": 100,
                "strokeColor": "#000000",
                "backgroundColor": "transparent",
                "fillStyle": "hachure",
                "strokeWidth": 1,
                "strokeStyle": "solid",
                "roughness": 1,
                "opacity": 100,
                "groupIds": [],
                "roundness": None,
                "seed": 12345,
                "version": 1,
                "versionNonce": 12345,
                "isDeleted": False,
                "angle": 0,
                "startBinding": None,
                "endBinding": None,
                "updated": 1678886400000,
                "link": None,
                "locked": False,
                "boundElements": [],
                "code": "A Box"
            },
            {
                "id": "text1",
                "type": "text",
                "x": 150,
                "y": 140,
                "width": 100,
                "height": 24,
                "strokeColor": "#000000",
                "backgroundColor": "transparent",
                "fillStyle": "hachure",
                "strokeWidth": 1,
                "strokeStyle": "solid",
                "roughness": 1,
                "opacity": 100,
                "groupIds": [],
                "roundness": None,
                "seed": 67890,
                "version": 1,
                "versionNonce": 67890,
                "isDeleted": False,
                "angle": 0,
                "startBinding": None,
                "endBinding": None,
                "updated": 1678886400000,
                "link": None,
                "locked": False,
                "text": "Hello Excalidraw!",
                "fontSize": 20,
                "fontFamily": 1,
                "textAlign": "center",
                "verticalAlign": "middle",
                "baseline": 18
            }
        ],
        "appState": {
            "viewBackgroundColor": "#ffffff",
            "gridSize": 20,
            "zoom": {
                "value": 1.0
            }
        },
        "files": {} # Placeholder for embedded images if your diagram uses them
    }

    # Define a temporary path for the JSON file that the Node.js CLI will read.
    diagram_json_path = os.path.join(DIAGRAM_JSON_DIR, "diagram_to_render.json")
    with open(diagram_json_path, "w", encoding="utf-8") as f:
        json.dump(example_diagram_source, f, indent=2)
    print(f"Prepared Excalidraw JSON for rendering: {diagram_json_path}")

    # --- Render the diagram using the Node.js CLI ---
    output_image_filename = "my_diagram.png"
    output_image_path = os.path.join(IMAGE_OUTPUT_DIR, output_image_filename)

    print(f"Initiating Excalidraw rendering from '{diagram_json_path}' to '{output_image_path}'...")
    render_success = render_excalidraw_diagram_cli(diagram_json_path, output_image_path)

    if render_success:
        print(f"Excalidraw diagram successfully rendered.")
        
        # --- Embed the rendered image into your static HTML page ---
        html_content = f"""
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>My Static Site with Excalidraw</title>
            <style>
                body {{ font-family: sans-serif; margin: 20px; }}
                h1 {{ color: #333; }}
                img {{ 
                    border: 1px solid #ccc; 
                    max-width: 100%; 
                    height: auto; 
                    display: block; /* Ensures image takes its own line */
                    margin-top: 20px;
                    box-shadow: 0 4px 8px rgba(0,0,0,0.1);
                    border-radius: 8px;
                }}
                p {{ line-height: 1.6; color: #555; }}
            </style>
        </head>
        <body>
            <h1>Welcome to My Static Site</h1>
            <p>This page demonstrates embedding a dynamically rendered Excalidraw diagram:</p>
            <img src="./images/{output_image_filename}" alt="Excalidraw Diagram">
            <p>The diagram above was generated during the site build process using a Node.js CLI tool.</p>
            <p>You can replace the diagram source with your own Excalidraw JSON data.</p>
        </body>
        </html>
        """

        # Write the final HTML content to an index file in the output directory.
        with open(os.path.join(OUTPUT_DIR, "index.html"), "w", encoding="utf-8") as f:
            f.write(html_content)
        print(f"Static site HTML generated in {OUTPUT_DIR}/index.html")
    else:
        print("Excalidraw diagram rendering failed. Static site generation continued without the diagram.")


if __name__ == "__main__":
    generate_static_site()

CI Pipeline Integration Steps (Conceptual)

Integrating this into your CI pipeline involves a few key steps:

  1. Checkout Repository: Your CI job starts by cloning your project repository, which should contain both your Python static site generator and the excalidraw-cli-renderer Node.js project.

  2. Install Node.js Dependencies:

    Navigate to the excalidraw-cli-renderer directory and install its npm dependencies. This includes puppeteer, which will download a compatible Chromium browser.

    # Example for a CI script
    cd excalidraw-cli-renderer
    npm install
    
  3. Install Puppeteer System Dependencies:

    This is a critical step. Puppeteer requires certain system-level libraries to run Chromium in a headless environment. These often include font libraries, display server dependencies, and other graphics-related packages. The exact packages depend on your CI environment's operating system (e.g., Ubuntu, Debian, Alpine Linux).

    For Debian/Ubuntu-based systems, common dependencies include:

    sudo apt-get update
    sudo apt-get install -y ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libindicator3-7 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils
    

    If you're using a Docker image for your CI, it's highly recommended to use an image that already has these dependencies pre-installed (e.g., puppeteer/puppeteer images or a custom image based on a suitable base).

  4. Install Python Dependencies:

    If your Python script has its own dependencies (like requests if you were using the server-based approach, though not strictly needed for the CLI approach), install them.

    pip install -r requirements.txt # If you use a requirements.txt file
    
  5. Run Static Site Generator:

    Execute your main Python script. This script will, in turn, call the Node.js renderDiagram.js for each Excalidraw diagram it needs to process.

    python your_static_site_generator.py
    
  6. Deploy Static Site:

    Once the Python script completes, your static_site_output directory will contain all the generated HTML, CSS, and the newly rendered Excalidraw images. You can then proceed to deploy these files to your static hosting service.

Considerations and Improvements

This setup provides a robust and efficient way to incorporate Excalidraw diagram rendering directly into your static site build process within a CI environment.