← Back to list

🦀 Porting Mermaid to Rust

Byte-for-byte or why “close enough” was never the goal

Enzo Lombardi in Rustaceans · 2026-07-03 16:10 · 32 claps · 8.1 min read paywalled
#rust #mermaid #data-visualization #compilers #open-source
Open on Medium ↗
Wiki topics: VIS · Visual & Graphic Design 🔓 · Open Source

🦀 Porting Mermaid to Rust

Byte-for-byte or why “close enough” was never the goal

Diagram-as-code tools occupy a strange corner of the developer world. You write a few lines of text, and a renderer turns them into boxes, arrows, and swimlanes. Mermaid is the tool most people reach for, and it is written in JavaScript. It runs in a browser, or it runs through mmdc, the mermaid command-line interface, which drives a headless Chrome to do the actual drawing. That works, but it means every diagram you render offline drags an entire browser along for the ride.

The obvious question is whether you can do it without the browser. Rewrite the renderer in a language that produces a static binary, skip Chrome entirely, and generate the SVG directly. The obvious answer is “sure, approximately.” Boxes here, arrows there, close enough for a README. That answer was not good enough. The project is called Sebastian, named after the crab, and its goal is uncompromising: for supported diagram types, the SVG it emits is byte-for-byte identical to what mmdc produces. Not visually similar. Not pixel-close. The same bytes.

Sebastian is the crab from the little mermaid. See?

Sebastian is the crab from the little mermaid. See?

This post is about why that constraint is interesting, what it costs, and what you learn when you refuse to let a single byte differ.

What “byte-exact” actually means

Most ports aim for behavioral equivalence. Sebastian aims for output equivalence at the level of the file itself. If you render a flowchart with mmdc and the same flowchart with Sebastian, then run diff on the two SVG files, the correct result is silence.

That sounds like a rounding-error distinction until you consider what an SVG contains. Every coordinate is a number formatted as a string. Every node has a computed width that depends on how wide its text label is. Every edge is a Bézier curve whose control points come from a layout engine. Every color is serialized through a specific CSS pipeline. A single diagram is thousands of decisions, and byte-exactness means getting every one of them right in the same way a specific version of Chrome running a specific version of mermaid would get them.

The pipeline mirrors mermaid’s own. The parser is a line-by-line port of mermaid’s jison grammar and its flowDb semantics, covering every node shape, every edge type, subgraphs, and class definitions. The layout is the dagre engine exactly as bundled in dagre-d3-es 7.0.14: network-simplex ranking, crossing minimization, Brandes-Köpf positioning. The rendering emits d3 curveBasis edges, the default-theme stylesheet, markers, and clusters. Each stage was validated against the JavaScript implementation with exact float equality.

The interesting part is the stage highlighted in yellow. Text measurement is where the browser stops being an implementation detail and becomes the specification.

The font is the specification

A flowchart node is only as wide as its label needs to be. Mermaid measures that label by asking the browser: it puts the text in an element and reads back the bounding rectangle. The browser answers using the actual font file, with actual kerning pairs, rounded to the browser’s internal layout grid. Node width flows into layout. Layout determines every coordinate in the file. So if your text measurement is off by a fraction of a pixel on one label, the ripple reaches every downstream byte.

This is the crux of the whole project. You cannot approximate text measurement and hope the error stays local. It does not stay local. It propagates through the layout engine and rewrites the entire coordinate space.

Mermaid measures with Trebuchet MS, the font preinstalled on macOS and Windows. Sebastian therefore depends on Trebuchet MS too. It reads the font’s advance widths and kerning tables, sums them the way Chrome’s text shaper does, and rounds to the same 1/64th-pixel grid Chrome uses internally, called the LayoutUnit. The word-wrapping threshold sits at exactly 200 pixels, and matching Chrome’s line-breaking rules means implementing a slice of the Unicode line-breaking algorithm: breaks after hyphens but not before a digit, breaks before an opening bracket when the preceding character is non-alphanumeric.

Here is where it gets genuinely strange. HTML measurement and SVG measurement of the same string do not round the same way. The HTML path ceils the advance sum to the LayoutUnit grid. The SVG path rounds half-up per tspan. The same kerning pair contributes differently depending on the rest of the string. “Ti” measures cleanly, while “Timeline” rounds the total up by one unit. Neither is wrong; they are two different code paths in the browser, and a byte-exact port has to know which one mermaid invoked for each label.

The browser leaks through the numbers

Once you commit to reproducing a browser without running one, you discover how much of the output is really just JavaScript’s arithmetic showing through.

Numbers in the SVG are formatted the way JavaScript’s String(number) formats them: integers with no decimal point, otherwise the shortest string that round-trips back to the same value, with ties broken half-to-even the way V8 does it. Rust’s default float formatter breaks ties differently, so it had to be replaced. Scientific notation appears only outside the exponent range that JavaScript reserves for plain decimals.

Trigonometry leaks too. Current V8 ships correctly-rounded Math.sin and Math.cos from the CORE-MATH project. System math libraries and most Rust libm ports differ in the last unit in the last place at certain angles. That last ulp is invisible until a rough.js ellipse in a state diagram lands on one of those angles and the coordinate string changes. The fix was to pull in a core-math crate that matches V8 bit-for-bit. Even Math.round needed care: JavaScript defines it as floor(x + 0.5), not round-half-away-from-zero, and that difference surfaces at exact half-pixel values.

Then there is getBBox. When a browser reports the bounding box of an SVG element, it is not doing clean 64-bit geometry. Blink’s pipeline quantizes to 32-bit floats at specific stages. A text bounding box height is not the font’s exact metrics; it is the integer font box, computed as the rounded ascender plus the rounded descender from the font’s hhea table, then extended by any glyph ink that reaches beyond it. The lowercase ‘g’ in Times New Roman dips far enough below the baseline to push the box taller than the integer descent would suggest. Multi-line text baselines accumulate in 32-bit float, so three lines at 16 pixels produce a baseline of 54.19999694824219 rather than a clean 54.2.

None of this is documented. All of it was found by rendering with mmdc, diffing against Sebastian’s output, and chasing the first byte that differed. The single most effective tool was driving Chrome directly with a puppeteer script against minimal SVG fragments, reading back getBBox values and parsed transform matrices, bisecting the browser’s float behavior empirically instead of guessing from source.

When the reference itself is random

There is one place where byte-exactness is impossible, and it is worth being honest about it. Mermaid draws certain shapes, the stadium and the odd node, through rough.js, a library that makes lines look hand-sketched. Rough.js seeds its curve control points from Math.random. That means two runs of mmdc on the identical diagram produce different bytes for those shapes. The reference is not stable, so no port can match it byte-for-byte.

The honest thing to do is say so and measure the gap. The geometry underneath is identical: the random control points are collinear, so the rendered curve is the same curve. A rasterized comparison shows Sebastian’s output falls within mermaid’s own run-to-run antialiasing variance, roughly a hundredth of a percent of the channel bytes. For the hand-drawn look enabled with look: handDrawn, Sebastian deliberately diverges by using a deterministic seeded PRNG, a port of rough.js’s mulberry32, so its sketchy output is at least stable from one run to the next. That is a place where reproducibility beats fidelity, and the trade is made on purpose.

The scoreboard

The verification is a corpus of real diagrams pulled from a large collection of technical writing, plus hand-made cases for the shapes the corpus does not exercise. The results, per diagram type, tell the story better than any claim:

+----------------------------+-------------------+------------------------------------------+
| Diagram type               | Cases             | Result                                   |
+============================+===================+==========================================+
| flowchart / graph          | 781 corpus blocks | byte-exact                               |
| sequenceDiagram            | 34                | byte-exact                               |
| stateDiagram-v2            | 29                | 23 byte-exact, 6 modulo rough randomness |
| classDiagram               | 9                 | byte-exact modulo rough randomness       |
| timeline                   | 4                 | byte-exact                               |
| pie / ER / xychart / gantt | fixtures each     | byte-exact modulo documented exceptions  |
+----------------------------+-------------------+------------------------------------------+

Out of 553 corpus diagrams, 544 are byte-identical, including every %%{init}%% directive case covering themes, theme variable overrides, and the htmlLabels: false mode. The remaining nine split into rough.js randomness, sub-hundredth-pixel arc noise, and one stubborn Chrome kerning quirk near space characters whose root cause is still open. That last one is documented rather than hidden, because a port that pretends to be perfect is less trustworthy than one that tells you exactly where its single unresolved 2-pixel discrepancy lives.

What a byte teaches you

The reason to chase byte-exactness is not obsession for its own sake. It is that a byte is an honest test. Visual similarity is a judgment call, and judgment calls accumulate into drift. When your acceptance criterion is a diff that must come back empty, you cannot fool yourself. Either the layout engine ranks the nodes the same way or it does not. Either your text measurement matches Chrome’s LayoutUnit rounding or a coordinate is wrong and the diff tells you which one.

That strictness turns a rendering project into an archaeology project. You end up cataloging the undocumented behavior of a specific browser, a specific JavaScript engine, and a specific font, because those behaviors are the real specification and the source code is only an approximation of them. The prize is a static Rust binary that renders mermaid diagrams with no browser, no Node, and no ambiguity about whether the output is correct. Correctness is not a matter of taste here. It is a matter of bytes, and the bytes agree.

Where you can help

The eleven diagram types that are done cover most of what people actually draw, but mermaid has more, and the ones still open are exactly the kind of work that rewards a second set of eyes. The quadrant chart already renders and just needs a fixture corpus to promote it from experimental to verified. The gitGraph port handles the left-to-right orientation but not the vertical ones. And the big one, routing flowchart layout through the ELK engine, is a port larger than the original dagre work and best tackled as its own effort from the readable Java sources.

The porting loop is refreshingly mechanical once you have a reference to diff against. Harvest real diagrams of the target type into fixtures, render them with mmdc to capture the reference SVGs, byte-diff against Sebastian’s output, and chase the first differing byte until the diff comes back empty. The most useful first contribution is often not even Rust: a pull request with .mmd fixtures and their mmdc reference SVGs gives the byte-exact loop something to run against, and that is where every finished diagram type started. The project lives at github.com/aovestdipaperino/sebastian.

Want more like this?

I write regularly about Rust, design patterns, and performance tips. Follow me here on Medium to stay updated.


메타데이터
post_id
b891583979b4
slug
porting-mermaid-to-rust-b891583979b4
url
https://medium.com/rustaceans/porting-mermaid-to-rust-b891583979b4
canonical_url
https://medium.com/rustaceans/porting-mermaid-to-rust-b891583979b4
author_url
https://medium.com/@enzo-lombardi
status
ok
fetched_at
2026-07-09 03:40:04