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:
- Launch a headless browser (a browser without a visible user interface) in the background.
- Load a simple HTML page within this headless browser.
- Inject the Excalidraw library and your diagram's JSON data into that page.
- Execute JavaScript code within the browser context to render the Excalidraw diagram onto an HTML
<canvas>element. - "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:
-
Checkout Repository: Your CI job starts by cloning your project repository, which should contain both your Python static site generator and the
excalidraw-cli-rendererNode.js project. -
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 -
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-utilsIf 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/puppeteerimages or a custom image based on a suitable base). -
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 -
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 -
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
- Error Handling: Both the Node.js and Python scripts include basic error handling. For production CI, consider more detailed logging, retry mechanisms, and clear failure notifications.
- Performance: Launching a new Puppeteer browser for every diagram can be slow if you have many diagrams.
- Optimization: For very large numbers of diagrams, you could modify the Node.js CLI to accept multiple input JSON paths and render them sequentially within a single browser instance, or even in parallel using multiple pages within one browser instance, before closing the browser.
- Excalidraw Data Source: The example Python script hardcodes a sample Excalidraw JSON. In a real scenario, your Python script would parse your content files (e.g., Markdown, custom format) to extract the Excalidraw JSON for each diagram.
- Output Format (PNG vs. SVG): The current Node.js script outputs PNG. If you prefer SVG for scalability, you would need to explore Excalidraw's
exportToSvgutility (which might require a different headless rendering approach thancanvas.toDataURLor additional processing) or use a library that can convert the rendered canvas to SVG. However,renderStaticSceneprimarily renders to a canvas (bitmap). - Font Handling: If your Excalidraw diagrams use custom fonts, ensure those fonts are available in the headless browser environment where Puppeteer runs. You might need to install them on your CI machine or ensure
renderPage.htmlloads them from a reliable CDN and Puppeteer waits for them to load. - Caching: If diagram content doesn't change frequently, you could implement a caching mechanism in your Python script to avoid re-rendering diagrams that haven't been modified since the last build. You could hash the JSON content and check if an image with that hash already exists.
This setup provides a robust and efficient way to incorporate Excalidraw diagram rendering directly into your static site build process within a CI environment.