// Convert an Excalidraw JSON document to a self-contained SVG string.
//
// The Excalidraw renderer emits a flat list of elements with absolute
// positions; we translate the subset we actually produce: rectangle,
// ellipse, line, arrow, text. Strokes are routed through `roughjs`
// (the same library Excalidraw uses internally) so the exported SVG
// inherits the recognisable hand-drawn wobble — and since the PNG
// path rasterises this very SVG, the PNG keeps the look too.
//
// The roughjs Generator is dependency-free at runtime: it produces
// SVG path commands as strings, no DOM required.
import rough from "roughjs";
import { getExcalifontFontFace, EXCALIFONT_FONT_STACK } from "../style/font.mjs";
import {
escapeAttribute as platformEscapeAttribute,
escapeText as platformEscapeText,
} from "../platform/security_base.mjs";
const FONT_FAMILY = EXCALIFONT_FONT_STACK;
const ROUGHNESS = 1; // Excalidraw's default roughness.
const BOWING = 1;
const FILL_WEIGHT = 0; // 0 → roughjs picks a sensible default.
// Arrowhead size in user-space pixels. Sized so multiple parallel
// arrowheads stacked along the same node side (ELK distributes them
// evenly across the side height — typically 20–30 px apart for dense
// diagrams) do not overlap their tips. Increasing this beyond ~25 px
// causes visible overlap on heavily connected nodes.
const ARROWHEAD_PX = 20;
const generator = rough.generator();
/**
* Convert an Excalidraw JSON document to a stand-alone SVG string.
*
* @param {any} doc The Excalidraw JSON document.
* @param {object} [opts]
* @param {number} [opts.padding=16] Whitespace around the bounds.
* @param {string} [opts.background] Optional background fill colour.
* @returns {string} A complete `... ` document.
*/
export function excalidrawToSvg(doc, opts = {}) {
const padding = opts.padding ?? 16;
const elements = (doc.elements || []).filter((/** @type {any} */ e) => !e.isDeleted);
const bounds = computeBounds(elements);
const w = Math.max(1, bounds.maxX - bounds.minX) + padding * 2;
const h = Math.max(1, bounds.maxY - bounds.minY) + padding * 2;
const tx = -bounds.minX + padding;
const ty = -bounds.minY + padding;
const bg = opts.background ?? doc.appState?.viewBackgroundColor ?? "#ffffff";
const out = [];
out.push(
``,
);
// Collect every (arrowhead-type, end, stroke-colour) triple that
// actually appears so we only emit the markers we need. Each marker
// bakes the colour into its `fill` / `stroke` so SVG renderers
// without `context-stroke` support (resvg-js, some Markdown
// sanitisers) still produce coloured arrowheads.
/** @type {Set} */
const markerKeys = new Set();
for (const el of elements) {
if (el.type !== "arrow") continue;
const color = el.strokeColor || "#000000";
if (el.startArrowhead) markerKeys.add(`${el.startArrowhead}|start|${color}`);
if (el.endArrowhead) markerKeys.add(`${el.endArrowhead}|end|${color}`);
}
// Root-level : font-face + arrowhead markers. Keeping both here
// (rather than inside the translate ) ensures every SVG renderer
// finds the markers, as some implementations (Safari, resvg, some
// Markdown sanitisers) only resolve IDs when the
// is a direct child of the root element.
out.push(
`${arrowheadMarkers(markerKeys)} `,
);
out.push(` `);
out.push(``);
for (const el of elements) {
const node = renderOne(el);
if (node) out.push(node);
}
out.push(` `);
return out.join("\n");
}
/** @internal */
/**
* Compute the axis-aligned bounding box that covers every element.
* @param {any[]} elements Excalidraw element list.
* @returns {{minX:number,minY:number,maxX:number,maxY:number}} Bounding rectangle in absolute coords.
*/
function computeBounds(elements) {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const include = (/** @type {number} */ x, /** @type {number} */ y) => {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
};
for (const e of elements) {
if (e.type === "arrow" || e.type === "line") {
for (const [dx, dy] of e.points || []) include(e.x + dx, e.y + dy);
} else {
include(e.x, e.y);
include(e.x + (e.width || 0), e.y + (e.height || 0));
}
}
if (!Number.isFinite(minX)) {
minX = 0;
minY = 0;
maxX = 1;
maxY = 1;
}
return { minX, minY, maxX, maxY };
}
/** @internal */
/**
* Render one Excalidraw element as an SVG fragment.
* Elements with a non-zero `angle` are wrapped in a ``.
* Excalidraw rotates around the element centre in degrees; SVG rotates in
* degrees too so the conversion is direct.
* @param {any} el Excalidraw element.
* @returns {string} SVG markup for the element (empty string when unsupported).
*/
function renderOne(el) {
let inner;
switch (el.type) {
case "rectangle":
inner = roughRect(el);
break;
case "ellipse":
inner = roughEllipse(el);
break;
case "line":
inner = roughPolyline(el, false);
break;
case "arrow":
inner = roughPolyline(el, true);
break;
case "text":
inner = svgText(el);
break;
default:
return "";
}
if (!inner) return "";
// Wrap in a rotation transform when the element has a non-zero angle.
// Excalidraw stores radians; SVG rotate() takes degrees.
const angleDeg = ((el.angle || 0) * 180) / Math.PI;
if (Math.abs(angleDeg) < 0.001) return inner;
// Rotation centre: element centre (x + w/2, y + h/2). For arrows/lines
// that have no explicit width/height we fall back to their first point.
const cx = el.x + (el.width || 0) / 2;
const cy = el.y + (el.height || 0) / 2;
return `${inner} `;
}
// ── roughjs helpers ───────────────────────────────────────────────────────
// Pull a deterministic seed off each Excalidraw element so re-rendering
// the same JSON document yields the same wobble. Excalidraw assigns
// `seed` to every element; if it's missing we fall back to a hash of
// the element's id so output is still deterministic per-document.
/** @internal */
/**
* Stable seed derived from the element id so re-renders are deterministic.
* @param {any} el Excalidraw element.
* @returns {number} Non-negative 31-bit seed.
*/
function seedFor(el) {
if (typeof el.seed === "number") return el.seed % 2_147_483_647;
if (el.id) {
let h = 0;
for (const c of String(el.id)) h = (h * 31 + c.charCodeAt(0)) | 0;
return Math.abs(h) % 2_147_483_647;
}
return 1;
}
/** @internal */
/**
* Build the options object passed to roughjs for one element.
* @param {any} el Excalidraw element.
* @returns {any} roughjs options.
*/
function roughOpts(el) {
// Honour the per-element roughness Excalidraw stores: arrows /
// connection lines are emitted with `roughness: 0` so their SVG
// representation must be perfectly straight too. Falling back to
// the default (1) keeps Excalidraw's hand-drawn look for boxes.
const r = typeof el.roughness === "number" ? el.roughness : ROUGHNESS;
return {
seed: seedFor(el),
roughness: r,
bowing: r === 0 ? 0 : BOWING,
stroke: el.strokeColor || "#000",
strokeWidth: el.strokeWidth || 1.5,
fill: /** @type {string|undefined} */ (undefined),
fillStyle: "solid",
fillWeight: FILL_WEIGHT,
disableMultiStroke: r === 0,
};
}
/** @internal */
/**
* Translate Excalidraw `strokeStyle` to an SVG `stroke-dasharray` value.
* Returns an empty string for solid strokes so the caller can interpolate
* directly without a null-check.
* @param {any} el Excalidraw element.
* @returns {string} `stroke-dasharray` value, or `""` for solid strokes.
*/
function dasharray(el) {
if (el.strokeStyle === "dashed") return "8 4";
if (el.strokeStyle === "dotted") return "1.5 6";
return "";
}
/**
* Convert a roughjs drawable to SVG markup, preserving fill/stroke/dash.
* @param {any} drawable roughjs drawable produced by the generator.
* @param {any} el Originating Excalidraw element (provides colours).
* @returns {string} SVG markup.
*/
function drawableToSvg(drawable, el) {
// roughjs returns one or more `OpSet`s. We render path-typed sets
// as elements. fillSketch/fillPath sets get the fill colour;
// path sets get the stroke colour.
const stroke = el.strokeColor || "#000";
const strokeWidth = el.strokeWidth || 1.5;
const dash = dasharray(el);
const dashAttr = dash ? ` stroke-dasharray="${dash}"` : "";
const out = [];
for (const set of drawable.sets) {
const d = generator.opsToPath(set);
if (!d) continue;
if (set.type === "fillPath" || set.type === "fillSketch") {
const fill =
el.backgroundColor && el.backgroundColor !== "transparent" ? el.backgroundColor : null;
if (!fill) continue;
out.push(` `);
} else if (set.type === "path") {
out.push(
` `,
);
}
}
return out.join("");
}
/**
* Render a rectangle using roughjs (sketchy stroke).
* @param {any} el Rectangle element.
* @returns {string} SVG markup.
*/
function roughRect(el) {
const fill = colorOrNone(el.backgroundColor);
// Solid fill underneath. roughjs' fill modes look noisy on small
// shapes, and Excalidraw's own export uses solid fills + sketchy
// outlines, so we mirror that.
const fillSvg =
fill !== "none" ? ` ` : "";
const drawable = el.roundness
? generator.path(rectPath(el), roughOpts(el))
: generator.rectangle(el.x, el.y, el.width, el.height, roughOpts(el));
return fillSvg + drawableToSvg(drawable, el);
}
/**
* Build an SVG `path` `d` attribute for a (rounded) rectangle.
* @param {any} el Rectangle element.
* @returns {string} `d` value (no `` wrapper).
*/
function rectPath(el) {
// `roundness: {type: 3}` (proportional) mirrors Excalidraw's "rounded
// corners" toggle. Excalidraw uses ~10% of the shorter side, clamped to
// a sensible maximum. We replicate that formula here so the SVG output
// matches the visual look of the Excalidraw editor.
const r = el.roundness ? Math.min(el.width * 0.1, el.height * 0.1, 32) : 0;
if (!r) {
return `M${el.x},${el.y} h${el.width} v${el.height} h${-el.width} z`;
}
const x = el.x,
y = el.y,
w = el.width,
h = el.height;
return [
`M${x + r},${y}`,
`h${w - 2 * r}`,
`a${r},${r} 0 0 1 ${r},${r}`,
`v${h - 2 * r}`,
`a${r},${r} 0 0 1 ${-r},${r}`,
`h${-(w - 2 * r)}`,
`a${r},${r} 0 0 1 ${-r},${-r}`,
`v${-(h - 2 * r)}`,
`a${r},${r} 0 0 1 ${r},${-r}`,
"z",
].join(" ");
}
/**
* Render an ellipse using roughjs.
* @param {any} el Ellipse element.
* @returns {string} SVG markup.
*/
function roughEllipse(el) {
const cx = el.x + el.width / 2;
const cy = el.y + el.height / 2;
const fill = colorOrNone(el.backgroundColor);
const fillSvg =
fill !== "none"
? ` `
: "";
const drawable = generator.ellipse(cx, cy, el.width, el.height, roughOpts(el));
return fillSvg + drawableToSvg(drawable, el);
}
/**
* Render a polyline (with optional arrowhead) using roughjs.
* @param {any} el Line / arrow element.
* @param {boolean} withArrow When `true`, attach an arrowhead at the end.
* @returns {string} SVG markup.
*/
function roughPolyline(el, withArrow) {
const pts = (el.points || []).map((/** @type {[number,number]} */ [dx, dy]) => [
el.x + dx,
el.y + dy,
]);
if (pts.length < 2) return "";
const drawable = generator.linearPath(pts, roughOpts(el));
let body = drawableToSvg(drawable, el);
// Arrowheads: keep the same marker-based mechanism, but apply it to
// a thin invisible so the marker ends up at the geometric
// endpoint (not a wobble-jittered endpoint of the rough path).
if (withArrow && (el.startArrowhead || el.endArrowhead)) {
const polyPts = pts.map((/** @type {[number,number]} */ [x, y]) => `${x},${y}`).join(" ");
const colorSuffix = colorMarkerSuffix(el.strokeColor || "#000000");
const startMarker = el.startArrowhead
? ` marker-start="url(#m_${el.startArrowhead}_start_${colorSuffix})"`
: "";
const endMarker = el.endArrowhead
? ` marker-end="url(#m_${el.endArrowhead}_end_${colorSuffix})"`
: "";
// Marker rendering rules vary across renderers:
// - stroke-width="0" : Safari and resvg skip markers (degenerate stroke)
// - stroke="none" : some renderers also skip markers
// Safest combination: a real, non-zero stroke width in a fully
// transparent colour. The polyline contributes no visible pixels
// but markers still attach at the vertices everywhere.
body += ` `;
}
return body;
}
/**
* Render a text element as an SVG `` block (with line wrapping).
* @param {any} el Text element.
* @returns {string} SVG markup.
*/
function svgText(el) {
const fill = el.strokeColor || "#000";
const fs = el.fontSize || 16;
const lineHeight = fs * (el.lineHeight || 1.2);
const lines = String(el.text ?? "").split("\n");
const align = el.textAlign || "left";
const anchor = align === "center" ? "middle" : align === "right" ? "end" : "start";
let xRef = el.x;
if (align === "center") xRef = el.x + (el.width || 0) / 2;
else if (align === "right") xRef = el.x + (el.width || 0);
const yTop = el.y + fs;
const tspans = lines
.map((l, i) => `${escapeText(l)} `)
.join("");
return `${tspans} `;
}
// ── arrowheads ────────────────────────────────────────────────────────────
/**
* Sanitise an arbitrary CSS colour into a marker-id-safe suffix.
* Hex colours collapse to their 6-digit lowercase form; everything
* else is reduced to its alphanumerics. Used by both the marker
* registry and the polyline marker reference so they always agree.
* @param {string} color
* @returns {string}
*/
function colorMarkerSuffix(color) {
return String(color || "")
.toLowerCase()
.replace(/[^0-9a-z]/g, "");
}
// Marker geometry presets: viewBox, refX (for end-side), markerWidth /
// markerHeight, and the path. Outline variants flip the fill rule:
// `outline` markers use `fill="" stroke=""`, while solid
// markers use `fill=""`. The canvas colour is currently fixed
// at white (matching the Excalidraw default canvas).
const ARROWHEAD_GEOMETRY = {
// Excalidraw "arrow" type is an open chevron (two stroke lines, no fill)
// — analogous to ">" — not a filled triangle. Use `open: true` so the
// marker builder emits `fill="none" stroke=""` instead of
// `fill=""`. The path has no closing `z`.
arrow: {
viewBox: "0 0 10 10",
refXEnd: 9,
refXStart: 1,
width: ARROWHEAD_PX,
height: ARROWHEAD_PX,
path: "M0.7,0.6 L9.4,5 L0.9,9.5",
outline: false,
open: true,
},
triangle: {
viewBox: "0 0 10 10",
refXEnd: 9,
refXStart: 1,
width: ARROWHEAD_PX,
height: ARROWHEAD_PX,
path: "M0.7,0.7 L9.4,5 L1.1,9.3 z",
outline: false,
open: false,
},
triangle_outline: {
viewBox: "0 0 10 10",
refXEnd: 9,
refXStart: 1,
width: ARROWHEAD_PX + 2,
height: ARROWHEAD_PX + 2,
path: "M0.7,0.7 L9.4,5 L1.1,9.3 z",
outline: true,
open: false,
},
diamond: {
viewBox: "0 0 12 10",
refXEnd: 11,
refXStart: 1,
width: Math.round(ARROWHEAD_PX * 1.3),
height: ARROWHEAD_PX,
path: "M0.8,5 L6.1,0.8 L11.5,5 L6,9.2 z",
outline: false,
open: false,
},
diamond_outline: {
viewBox: "0 0 12 10",
refXEnd: 11,
refXStart: 1,
width: Math.round(ARROWHEAD_PX * 1.3),
height: ARROWHEAD_PX,
path: "M0.8,5 L6.1,0.8 L11.5,5 L6,9.2 z",
outline: true,
open: false,
},
circle: {
viewBox: "0 0 10 10",
refXEnd: 9,
refXStart: 9,
width: ARROWHEAD_PX,
height: ARROWHEAD_PX,
path: "M5.1,5 m-3.9,0 a3.9,4.1 0 1,0 7.8,0 a3.9,4.1 0 1,0 -7.8,0",
outline: false,
open: false,
},
circle_outline: {
viewBox: "0 0 10 10",
refXEnd: 9,
refXStart: 9,
width: ARROWHEAD_PX,
height: ARROWHEAD_PX,
path: "M5.1,5 m-3.9,0 a3.9,4.1 0 1,0 7.8,0 a3.9,4.1 0 1,0 -7.8,0",
outline: true,
open: false,
},
dot: {
viewBox: "0 0 10 10",
refXEnd: 9,
refXStart: 9,
width: Math.round(ARROWHEAD_PX * 0.8),
height: Math.round(ARROWHEAD_PX * 0.8),
path: "M5.1,5 m-2.8,0 a2.8,3.1 0 1,0 5.6,0 a2.8,3.1 0 1,0 -5.6,0",
outline: false,
open: false,
},
bar: {
viewBox: "0 0 10 10",
refXEnd: 5,
refXStart: 5,
width: Math.round(ARROWHEAD_PX * 0.8),
height: ARROWHEAD_PX,
path: "M5.2,0.5 L4.8,9.5",
outline: false,
open: true,
},
partial_top: {
viewBox: "0 0 10 10",
refXEnd: 9,
refXStart: 9,
width: ARROWHEAD_PX,
height: ARROWHEAD_PX,
path: "M0.6,0.8 L9.5,5",
outline: false,
open: true,
},
partial_bottom: {
viewBox: "0 0 10 10",
refXEnd: 9,
refXStart: 9,
width: ARROWHEAD_PX,
height: ARROWHEAD_PX,
path: "M0.8,9.2 L9.4,5",
outline: false,
open: true,
},
};
// Returns only the elements (no wrapping ). The caller
// merges them into the root block so SVG renderers that only
// resolve marker IDs from root-level (Safari, resvg) work.
//
// `keys` is a Set of `${type}|${start|end}|${color}` triples collected
// from the actual arrow elements; one marker is emitted per triple so
// each arrowhead inherits the colour of its arrow.
/**
* @param {Set} keys
* @returns {string}
*/
function arrowheadMarkers(keys) {
const out = [];
for (const key of keys) {
const [type, side, color] = key.split("|");
const geom = ARROWHEAD_GEOMETRY[/** @type {keyof typeof ARROWHEAD_GEOMETRY} */ (type)];
if (!geom) continue;
const suffix = colorMarkerSuffix(color);
const id = `m_${type}_${side}_${suffix}`;
// Both start and end markers are anchored at the visual tip. With
// `auto-start-reverse`, using the base-side refX for start markers
// makes the arrowhead begin at the endpoint instead of pointing to it.
const refX = geom.refXEnd;
const orient = side === "end" ? "auto" : "auto-start-reverse";
const safeColor = escapeAttr(color);
// Three fill/stroke modes:
// open — open chevron: fill=none, stroke=color (e.g. Excalidraw "arrow")
// outline — hollow shape: fill=white, stroke=color
// solid — filled shape: fill=color
const fillStroke = geom.open
? `fill="none" stroke="${safeColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"`
: geom.outline
? `fill="#fff" stroke="${safeColor}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"`
: `fill="${safeColor}" stroke="${safeColor}" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round"`;
out.push(
// markerUnits="userSpaceOnUse" makes width/height explicit pixel
// values (ARROWHEAD_PX) independent of the element's stroke-width.
` `,
);
}
return out.join("\n ");
}
// ── misc ──────────────────────────────────────────────────────────────────
/**
* Map an Excalidraw colour to an SVG fill/stroke (or `"none"`).
* @param {string|null|undefined} c Excalidraw colour value.
* @returns {string} SVG colour string.
*/
function colorOrNone(c) {
if (!c || c === "transparent") return "none";
return c;
}
/**
* Escape a string for safe interpolation into an SVG attribute value.
* Escapes `&`, `"`, `<` and `>` so attacker-controlled input cannot
* break out of the surrounding `"..."` and inject extra attributes
* or markup.
* @param {string} s Raw string.
* @returns {string} Escaped string.
*/
export function escapeAttr(s) {
return platformEscapeAttribute(s);
}
/**
* Escape a string for safe interpolation into SVG text content.
* @param {string} s Raw string.
* @returns {string} Escaped string.
*/
export function escapeText(s) {
return platformEscapeText(s);
}