The rendering logic is a custom clean-room implementation inspired by Excalidraw's architecture. It is not a port of Excalidraw's export code — the rendering pipeline was written from scratch using the same underlying libraries.
The output format is determined by the -o/--output file extension:
| Extension | Format | Notes |
|---|---|---|
.svg |
SVG | Vector format, scalable |
.png |
PNG | Raster format (default) |
.pdf |
Vector format via Cairo PDF backend | |
| (none) | PNG | Default when no extension specified |
Examples:
excalirender diagram.excalidraw -o output.svg→ SVG outputexcalirender diagram.excalidraw -o output.png→ PNG outputexcalirender diagram.excalidraw -o output.pdf→ PDF outputexcalirender diagram.excalidraw -o output→ PNG output (default)excalirender diagram.excalidraw→diagram.png(default)
excalirender supports Unix-style piping for composability with other CLI tools.
stdin input: Use - as the input path to read .excalidraw JSON from stdin:
cat diagram.excalidraw | excalirender - -o output.png
curl -s https://example.com/diagram.excalidraw | excalirender - -o output.svgstdout output: Use -o - to write the rendered image to stdout:
excalirender diagram.excalidraw -o - > output.png
excalirender diagram.excalidraw -o - --format svg > output.svgFull pipe: Combine both for end-to-end piping:
cat diagram.excalidraw | excalirender - -o - > output.png
cat diagram.excalidraw | excalirender - -o - --format svg | other-toolWhen writing to stdout, the --format flag specifies the output format (png or svg), defaulting to png. Status messages are redirected to stderr so they don't corrupt the binary output.
Piping is not compatible with recursive mode (-r). For diff, only one of old/new can be stdin.
Font files are sourced from excalidraw/packages/excalidraw/fonts/ in WOFF2 format. They are converted to TTF using scripts/convert-fonts.ts, which uses the wawoff2 library to decompress WOFF2 → TTF. The resulting TTF files live in assets/fonts/.
Fonts are embedded into the compiled binary at build time via Bun's import ... with { type: "file" } syntax. At runtime, embedded fonts are extracted from Bun's virtual filesystem (/$bunfs/) to a temp directory so that node-canvas's native registerFont can access them.
Font family mapping (matches Excalidraw's numbering):
| ID | Font | Style | Status |
|---|---|---|---|
| 1 | Virgil | Hand-drawn serif | Deprecated |
| 2 | Helvetica | System sans-serif | Deprecated |
| 3 | Cascadia | Monospace | Deprecated |
| 5 | Excalifont | Hand-drawn (default) | Active |
| 6 | Nunito | Sans-serif | Active |
| 7 | Lilita One | Display | Active |
| 8 | Comic Shanns | Comic-style monospace | Active |
| 9 | Liberation Sans | Server-side export | Active |
Unicode coverage: All unicode segments from Excalidraw's font splits are embedded:
| Font | Segments | Unicode Coverage |
|---|---|---|
| Excalifont | 7 | Latin, Latin Extended, Cyrillic, Cyrillic Extended, Greek, Combining Marks, Diacritics |
| Nunito | 5 | Latin, Latin Extended, Cyrillic, Cyrillic Extended, Vietnamese |
| Lilita One | 2 | Latin, Latin Extended |
| Comic Shanns | 4 | Latin, Latin Extended, Combining Marks, Greek Lambda |
| Virgil | 1 | Full (single file) |
| Cascadia | 1 | Full (single file) |
| Liberation Sans | 1 | Full (single file) |
Multiple TTF segments for the same font family are registered with node-canvas under the same family name. fontconfig handles automatic glyph fallback across segments.
Note: CJK characters are not supported (no CJK segments available in Excalidraw's font files).
Same libraries Excalidraw uses for rendering:
- Rough.js — hand-drawn, sketchy shapes (rectangles, ellipses, diamonds, lines, arrows)
- perfect-freehand — pressure-sensitive freehand stroke paths
- Read JSON — parse
.excalidrawfile - Parse elements — extract element array, filter deleted elements
- Preload images — decode base64 data URLs from the
filesrecord into image objects - Calculate canvas bounds — compute bounding box across all elements (accounting for rotation)
- Create node-canvas — sized to bounds × scale factor, filled with background color
- Render elements — iterate elements sorted by index, draw each one (child elements of frames are clipped to frame bounds)
- Write output — stream canvas to PNG file, SVG document, or PDF (based on output extension)
| Element | Renderer | Notes |
|---|---|---|
| rectangle | Rough.js | Rounded corners via SVG path (rc.path()) |
| diamond | Rough.js | 4-point polygon, rounded corners via cubic bezier |
| ellipse | Rough.js | rc.ellipse() |
| line | Rough.js | Single segment or multi-point curve |
| arrow | Rough.js + canvas | Line via Rough.js, arrowheads via canvas API |
| freedraw | perfect-freehand | getStroke() → native canvas quadratic bezier path |
| text | Native canvas | Multi-line, 7 embedded font families (+ Helvetica fallback), alignment, all font sizes |
| image | Native canvas | Base64 data URL decoding, crop, flip (scale), rotation, rounded corners |
| frame | Native canvas | Rounded rectangle border, label text, child element clipping |
| magicframe | Native canvas | Same rendering as frame |
| embeddable | Rough.js + canvas | Rectangle shape with centered placeholder text (URL or "Empty Web-Embed") |
| iframe | Rough.js + canvas | Rectangle shape with centered placeholder text ("IFrame element") |
- Rough.js options:
seed,strokeWidth,roughness,stroke,fill,fillStyle,strokeLineDash,disableMultiStroke— all read from element properties - Fill styles:
hachure,cross-hatch,solid,zigzag(via Rough.js) - Stroke styles:
solid(default),dashed(pattern[8, 8+strokeWidth]),dotted(pattern[1.5, 6+strokeWidth]). Non-solid strokes disable multi-stroke and add 0.5 to strokeWidth for visual consistency - Opacity: applied per-element via
ctx.globalAlpha - Rotation: handled via canvas
translate()+rotate()around element center - Corner radius: proportional (25% of min dimension), adaptive (fixed radius with cutoff), or legacy. Applied to rectangles and diamonds
- Dark mode: supported via CLI flag (
--dark). Appliesinvert(93%) + hue-rotate(180°)color transformation to all colors (background, strokes, fills) and image pixel data — matching Excalidraw'sapplyDarkModeFilter()algorithm. For PNG export, image pixels are transformed viagetImageData/putImageData. For SVG export, images get a CSSfilter: invert(0.93) hue-rotate(180deg)style - Transparent background: supported via CLI flag (
--transparent). For PNG, the canvas backgroundfillRectis skipped, leaving the default transparent canvas. For SVG, the background<rect>element is omitted. Can be combined with--darkfor dark-mode elements on a transparent background - Freedraw: uses
perfect-freehandwithsize: strokeWidth * 4.25,thinning: 0.6,smoothing: 0.5,streamline: 0.5,easing: easeOutSine,last: true— matching Excalidraw's parameters. Closed paths (first/last point within 8px) get background fill via Rough.js curve with simplified points - Text: font family selected from element's
fontFamilyID (see Font family mapping table). Supports all font sizes via thefontSizeproperty. Text alignment (left,center,right) positions text within the element's width. Multi-line text is split on\nand rendered line-by-line with configurablelineHeight(default 1.25). Deprecated font IDs (1=Virgil, 2=Helvetica, 3=Cascadia) are mapped to their embedded TTFs where available; Helvetica (ID 2) has no embedded font and falls back to system default. Text color uses the element'sstrokeColor - Images: loaded from
filesrecord viafileId, supports crop (source region), horizontal/vertical flip viascaleproperty, opacity, rotation, and rounded corners clipping - Frames: rendered as rounded rectangle borders (8px radius,
#bbbstroke, 2px width) with a label above the frame. Label uses 14px sans-serif font,#999999color (light) /#7a7a7a(dark). Child elements (those withframeIdmatching the frame) are clipped to the frame bounds. Frame names default to "Frame" whennameis null. Long names are truncated with ellipsis to fit frame width. Rotation is fully supported — border, label, and clipping region all rotate together - Frame-only export:
--frame <name>exports only a specific frame's contents. Matches by frame name first, then by element ID. The output is sized exactly to the frame dimensions (no padding), children are clipped to frame bounds, and the frame border/label is omitted. If the frame is not found, an error lists available frames - Embeddables/iframes: rendered as rectangles (via Rough.js) with a centered placeholder text label. For
embeddableelements, the label shows thelinkURL or "Empty Web-Embed" if no link is set. Foriframeelements, the label shows "IFrame element". Font size is adaptive based on element width and text length. Text wraps to fit within the element width (20px padding). This matches Excalidraw's static export behavior — interactive embed content cannot be rendered in PNG
SVG export is auto-detected from the output file extension (.svg). The SVG renderer uses rough.generator() to generate Drawable objects, then gen.toPaths() to convert them to SVG <path> elements — no DOM required.
All element types supported by PNG export are also supported in SVG:
- Shapes (rectangle, diamond, ellipse, line, arrow): Rough.js generator → PathInfo → SVG
<path>elements - Freedraw: perfect-freehand stroke points → SVG
<path>with quadratic bezier curves - Text: SVG
<text>elements with font-family, font-size, text-anchor. Fonts are embedded as base64@font-facerules in<defs><style>withunicode-rangedescriptors — only fonts actually used in the document are included - Images: SVG
<image>elements with inline data URLs; crop via nested<svg>viewBox - Frames: SVG
<rect>+<text>for border/label;<clipPath>+clip-pathfor child clipping - Embeddables/iframes: Rough.js rectangle + centered SVG
<text>placeholder
Dark mode, frame-only export, opacity, and rotation are all supported via SVG attributes (fill, opacity, transform).
- CJK text (no CJK font segments available)