DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Excalidraw's restore() Will Repair Your Broken Diagram JSON, And That Is Exactly the Problem

Everybody who generates Excalidraw files discovers the same pleasant surprise: the format is forgiving. Leave out roundness, leave out boundElements, leave index undefined — the file still opens, still draws, still looks right. Ship it.

It is not forgiving. It is silent. Excalidraw runs a function called restore() on every import whose entire job is to patch imported files back into shape, and it patches yours before you ever see the canvas. So the file you shipped and the file the user opens are two different files, and you have no idea which parts were yours.

You find out later. Someone drags a box, and every arrow stays behind.

I built an MCP server that turns a sentence into a real .excalidraw document — project #1 of my Weekend Builds series — and almost all the engineering went into refusing that generosity.

Three failures that hide behind a valid-looking file

Schema validation catches none of these. All three produce JSON that Excalidraw opens without a murmur.

One-sided bindings. An arrow names the shapes it connects in startBinding and endBinding. That is half the record. Each of those shapes must name the arrow back, in its own boundElements array. Write only the arrow's half and the diagram looks perfect and behaves like a pile of stickers: move a box and the arrows do not follow, because the box does not know they exist.

Floating arrows. This one is subtler and it is my favourite. Excalidraw decides an arrow is bound from the binding records — but it draws the shaft from the element's own points array. Two independent sources of truth. Get the bindings right and the points wrong and you have an arrow that is bound, in the data model, to a shape it is visibly nowhere near.

Boxes on top of boxes. Schema-valid JSON that renders as an unreadable pile is still a failed diagram, and no amount of type checking will tell you.

So the validator in this project checks three separate things — schema, referential integrity, and geometry — and only the third catches the failure that actually matters in practice.

An arrow tip is a distance, so measure it

The geometry check is four lines of idea. For each arrow, take the first and last of its own points, convert them to absolute coordinates, and measure the distance to the outline of the shape the binding claims:

distance = distance_to_box(point, shape["x"], shape["y"], shape["width"], shape["height"])
if distance > BINDING_TOLERANCE:
    report.error(f"arrow {element['id']} {side} endpoint is {distance:.1f}px from "
                 f"shape {binding['elementId']} -- the arrow is floating")
Enter fullscreen mode Exit fullscreen mode

The tolerance is 12px, which is the 4px binding gap plus room for rounding. A test proves the check can fail: build a valid scene, shove one shape 500 pixels sideways without touching a single binding, and assert the validator screams. A validator that has never rejected anything is not evidence of much.

Producing endpoints that pass is a separate problem, and it is where "somewhere near the box" becomes a real equation. The outline of a rectangle, an ellipse and a diamond are three different curves, and the arrow has to land on the right one:

if shape == "ellipse":
    scale = 1.0 / math.sqrt((dx / half_w) ** 2 + (dy / half_h) ** 2)
elif shape == "diamond":
    scale = 1.0 / (abs(dx) / half_w + abs(dy) / half_h)
else:
    scale = min(half_w / abs(dx) if dx else math.inf,
                half_h / abs(dy) if dy else math.inf)
Enter fullscreen mode Exit fullscreen mode

Cast a ray from the centre toward the other shape's centre and solve for where it crosses the boundary. That is exactly the geometry Excalidraw itself describes with focus: 0, which is why the drawn shaft and the declared binding end up agreeing instead of merely coexisting.

Emit the fields so nothing needs repairing

The other half of refusing restore() is emitting everything it would otherwise fill in. Every shape in the output carries 26 fields, every text element 35, every arrow 33 — the complete base set, always present, never omitted because a default would do.

One field earns a comment:

# Excalidraw's own restore() normalises a null here to an empty list, so
# emitting [] up front means an imported file needs no repair at all.
"boundElements": [],
Enter fullscreen mode Exit fullscreen mode

That is the standard the whole project is aiming at, and it is measurable, which brings me to the interesting part.

The harness that renders the file with Excalidraw's own engine

Here is the trap I did not want to fall into. I have 114 passing tests. Every one of them is a Python assertion about a Python dictionary. A dictionary can satisfy every rule I thought to write and still draw as spaghetti — the tests and the bug would share the same blind spot, because I wrote both.

So the repo has a second harness, deliberately outside the package and outside the test suite: bundle the real @excalidraw/excalidraw with esbuild, serve the scene to headless Chrome, and ask the actual renderer.

npx esbuild entry.mjs --bundle --outfile=bundle.js --format=iife \
  --define:process.env.NODE_ENV=\"production\" --loader:.woff2=dataurl
Enter fullscreen mode Exit fullscreen mode

Fourteen megabytes, which is why it is not a dependency. The entry point exposes two functions and nothing else:

import { exportToSvg, restore } from "@excalidraw/excalidraw";
window.ExcalidrawHarness = { exportToSvg, restore };
Enter fullscreen mode Exit fullscreen mode

exportToSvg draws the scene with the production renderer, so a malformed element shows up as a broken or empty picture instead of a passing assertion. And restore is used as an oracle — run our file through Excalidraw's own repair function and diff the result against what we generated, ignoring only the fields it legitimately owns:

const IGNORED = new Set(["index", "version", "versionNonce", "updated", "seed"]);
const restored = restore(JSON.parse(JSON.stringify(scene)), null, null);
scene.elements.forEach((ours, i) => {
  const changes = diffElement(ours, restored.elements[i]);
  if (changes.length) diffs.push({ id: ours.id, type: ours.type, changes });
});
Enter fullscreen mode Exit fullscreen mode

If restore() changes nothing, the file needed no repair. That is a much stronger claim than "it opens".

The run on the nine-box example:

[PASS] example-checkout.excalidraw
        svg        : 2260 x 953.7184460878227
        drawn nodes: 129 (20 text)
        restore()  : 0 element(s) changed
Enter fullscreen mode Exit fullscreen mode

Zero. And 129 SVG nodes emitted, 20 of them text, which is the check that the renderer actually drew something rather than handing back an empty frame with the right dimensions.

There is one more headless-Chrome gotcha worth stealing. exportToSvg inlines woff2 fonts by awaiting document.fonts, which in headless can simply never resolve. Race it:

const svg = await Promise.race([
  exportToSvg({ elements, appState, files, exportPadding: 24, skipInliningFonts: true }),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("exportToSvg timed out after 30s")), 30000)),
]);
Enter fullscreen mode Exit fullscreen mode

Then look at the picture

The harness also writes a PNG, cropped to the drawing. This is the step people skip, and it is the only one that catches the failure no assertion describes.

I looked at mine. Nine boxes, no overlaps, every arrowhead genuinely touching a shape, kinds correctly coloured — Postgres a green ellipse, Kafka a dashed orange rectangle, Stripe dashed grey because it is somebody else's system. And one edge label reading:

authenticate
s with

The wrap had hard-split a word in the middle. Every test passed. restore() changed nothing. The JSON was flawless and the diagram was embarrassing.

The cause was a floor: an arrow label was allowed to wrap at max(60, span_x * 0.7), and a short arrow between adjacent layers gave 60 pixels, which "authenticates" does not fit into at 12pt. Excalidraw does hard-split over-long words, so the wrapper was faithfully copying the right behaviour in a situation where the right behaviour is wrong. An arrow label floats over its shaft rather than sitting inside a box, so it is allowed to be wider than its own arrow:

widest_word = max((measure(word, label_font) for word in edge.label.split()), default=0.0)
max_width = max(60.0, widest_word, span_x * ARROW_LABEL_WIDTH_FRACTION)
Enter fullscreen mode Exit fullscreen mode

Given the choice between a label that overhangs and a word broken in half, take the overhang. There is now a regression test named test_an_edge_label_is_never_broken_mid_word, but no test would have found it in the first place — I found it by looking.

Layout: reserve the corridor before you draw the line

The placement is a small Sugiyama pass: break cycles, layer by longest path, insert dummies, order by barycentre sweeps, assign coordinates. The stage that earns its keep is the dummies.

An edge spanning more than one layer, drawn as a straight line from its source to a distant target, will happily pass straight through whatever boxes sit between them. The fix is not to detect the collision afterwards — it is to put a placeholder node in every layer the edge crosses, and let it take part in the ordering sweeps like any other node:

crossed = list(range(start + step, end, step))
for layer_index in crossed:
    dummy_key = f"\x00dummy\x00{edge.source}\x00{edge.target}\x00{layer_index}"
    slots.append(_Slot(dummy_key, layer_index, True))
Enter fullscreen mode Exit fullscreen mode

The dummy is invisible and 8 pixels wide. Its only job is to occupy a slot, which pushes the real boxes apart and leaves a corridor, and to hand the arrow a waypoint to bend around. Two of the nine arrows in the example are routed this way.

Non-overlap of boxes then falls out structurally rather than being checked and repaired: layers occupy disjoint bands along the main axis, siblings stack with a fixed gap along the cross axis. The validator asserts it anyway, and so do the tests, across five different descriptions.

The model never touches a coordinate

There is an optional model backend — any OpenAI-compatible endpoint, free NVIDIA NIM by default. Its entire job is prose in, this out:

{"title": "...", "direction": "right",
 "nodes": [{"key": "api", "label": "API Gateway", "kind": "service"}],
 "edges": [{"source": "api", "target": "db", "label": "queries"}]}
Enter fullscreen mode Exit fullscreen mode

It never sees Excalidraw's schema and never produces a number that ends up as an x or a y. Layout, geometry, bindings and field emission are all deterministic Python. That split is the whole design: keep the part that has to be exactly right out of the hands of the part that is only usually right. A model failure degrades to the offline rule parser, not to a broken file.

And the offline parser is a real path, not a consolation prize — the renderer downstream cannot tell which front end produced the graph it is drawing. It is a table of about fifty verb phrases walked verb by verb, which matters more than it sounds, because "queries Postgres and publishes to Kafka" and "queries Postgres and Redis" use the same word for opposite jobs and you cannot tell which until you know where the next verb starts.

What it still cannot do

The screenshot that caught the wrap bug also shows an arrow crossing. Barycentre ordering is a heuristic; it reduces crossings and does not eliminate them, and in general nothing can. Boxes are guaranteed not to overlap. Arrows are not.

Asked to draw a worker that "fetches the file from S3 and writes a thumbnail back to S3", an 8B model gave me one edge, not two. --require-model gets you the model's reading of your description, not a transcription of it.

Text widths are an estimate, because there is no browser here to measure with — deliberately a slight over-estimate, since a box a few pixels too wide looks fine and one a few pixels too narrow clips its own label.

All of that is in the README, because a limits section that only lists things you were never asked to do is marketing.

The takeaway

The lesson generalises past Excalidraw. Any format with a lenient importer will let you ship something broken and feel good about it, and the fix is the same three moves every time: emit the complete record instead of the minimum the parser accepts, use the target's own repair function as an oracle and demand it change nothing, and then look at the output, because the failure that matters most is usually the one no assertion describes.

114 tests, nine commits, and one embarrassing screenshot that no test would ever have written.

Code: https://github.com/dev48v/excalidraw-mcp-agent

Top comments (0)