Excalidraw SVG Metadata

We enjoyed drawing with Excalidraw and in this page explore a feature of their SVG export format. app

For example, this diagram from Reliability and Complexity.

We exported the SVG diagram from Excalidraw. By chance we noticed that when we loaded that diagram back into the Excalidraw app, it was able to reconstruct the connected relationships between the nodes in our graph. That sort of magic is not built into SVG, so they must be doing something clever to encode additional data into the SVG.

Import Frame Integration Promises and setup DOM helpers.

import * as frame from "https://wiki.dbbs.co/assets/v1/frame.js" const $ = (s, el=document) => el.querySelector(s) const $$ = (s, el=document) => Array.from(el.querySelectorAll(s))

Given a URL to an SVG document, or a data:image/svg+xml URL, we fetch the SVG DOM. We also remove width and height attributes so our processed images will scale to fit wiki's narrow pages.

async function getSvg(url) { let res = await fetch(url) let string = await res.text() let dom = new DOMParser() .parseFromString(string, "image/svg+xml") let svg = dom.documentElement svg.removeAttribute("width") svg.removeAttribute("height") return svg }

We find the URL for our example Excalidraw SVG by scraping the <img> tag in the first HTML item on this page.

async function whichURL() { const { /* pageKey, itemId, origin, site, slug, item, */ page } = await frame.context() const [ignore, url] = page.story .filter(item => item.type=="html")[0] .text .match(/src="(.*?)"/) return url }

There is some encoded data in XML comments at the top of the SVG file.

function textOrComment(node) { return ["#text", "#comment"].includes(node.nodeName) } function assertExcalidrawSVG(svg) { // excalidraw puts some comments & text nodes first for(let node of svg.childNodes) { if ( node.nodeName == "#comment" && node.textContent.trim() == "svg-source:excalidraw") { return true } if (! textOrComment(node)) { return false } } } function excalidrawPayload(svg) { for(let node of svg.childNodes) { console.log(node) if ( node.nodeName == "#comment" && node.textContent.trim()=="payload-start") { return node.nextSibling.textContent } if (! textOrComment(node)) { // maybe throw(new Error("missing payload")) return "" } } }

Here we port Excalidraw's source from typescript to javascript in order to decode the payload. github

import pako from 'https://cdn.jsdelivr.net/npm/pako@2.1.0/+esm' const {inflate} = pako function byteStringToArrayBuffer(byteString) { const buffer = new ArrayBuffer(byteString.length) const bufferView = new Uint8Array(buffer) for (let i = 0, len = byteString.length; i < len; i++) { bufferView[i] = byteString.charCodeAt(i) } return buffer } function byteStringToString(byteString) { return new TextDecoder("utf-8").decode(byteStringToArrayBuffer(byteString)) } async function decode(data) { let decoded switch (data.encoding) { case "bstring": // if compressed, do not double decode the bstring decoded = data.compressed ? data.encoded : await byteStringToString(data.encoded); break; default: throw new Error(`decode: unknown encoding "${data.encoding}"`) } if (data.compressed) { return inflate(new Uint8Array(byteStringToArrayBuffer(decoded)), { to: "string", }) } return decoded; }

There are a few layers of encoding to unpack.

function decodeBase64json(payload) { return JSON.parse(atob(payload)) }

Here we combine all of the above: find the <img> at the top of this page; use that url to fetch the SVG; verify it is an Excalidraw export format; unwrap the several layers of encoding; emit a page that includes the SVG and the now-decoded JSON.

export async function emit(el) { const url = await whichURL() const svg = await getSvg(url) Object.assign(window, {svg}) el.appendChild(svg) if (assertExcalidrawSVG(svg)) { const payload = excalidrawPayload(svg) const obj = JSON.parse( await decode(decodeBase64json(payload))) el.insertAdjacentHTML("beforeend", `<pre>${JSON.stringify(obj, null, 2)}</pre>`) } }

next...

code...

//wiki.dbbs.co/assets/pages/js-snippet-template/esm.html HEIGHT 400