Architecture

One library with no I/O in it, three hosts that have all of it, and a scene in the middle that neither SVG nor draw.io can see.

The shape of it

SVG text ─parse─▶ resolved elements ─scene─▶ Scene ─layout─▶ Scene ─emit─▶ mxfile XML

The scene in the middle is the point. It knows nothing about SVG and nothing about draw.io, so reading a drawing and guessing what it means can be tested apart from each other, and a profile can build the scene directly without reimplementing the half that writes draw.io.

The crates

CrateWhat it isWhat it may touch
the libraryThe conversion, at the workspace rootNothing. No filesystem, clock, randomness, threads or environment
accent-draw-cliThe accent-draw commandFiles, arguments, exit codes
accent-draw-wasmThe browser bindingWhatever the page gives it
accent-draw-miroThe Miro command, for foldersFiles

The library's restriction is enforced rather than intended: a lint configuration bans the standard library's routes to all five, and a host that needs them carries its own configuration allowing them. That is what makes the WebAssembly build work without shims, and it is why the same input gives the same bytes in a browser and in a shell.

Why not usvg

usvg is the obvious crate for reading SVG in Rust, and it is the wrong one here. It is built for rendering: it turns rect, circle and ellipse into paths, dissolves markers into plain geometry, and needs fonts loaded to keep text at all. Every one of those is precisely the information a diagram converter exists to keep.

So the reading is roxmltree for the tree, svgtypes for path data, transforms, colours and lengths, and simplecss for stylesheet rules, with the cascade and the transform resolution written here. resvg is still used - as the reference rasterizer in tests, which is a different job.

Deterministic output

Cell ids are stable and numbers are formatted one way, so:

  • converting the same SVG twice gives the same file, byte for byte;
  • a change to the converter shows up as a readable diff over the whole corpus, rather than as noise;
  • the browser and the command line can be compared exactly - and they are, on every change, by running the corpus through the WebAssembly build under a WASI runtime and diffing it against the native one.

The roundtrip

  1. SVG to .drawio - this project. Lossy, the first time, and the interface says so.
  2. Edit in draw.io, embedded in a page: an iframe on the configured origin, sent {action: "load", xml}.
  3. Back to SVG with {action: "export", format: "xmlsvg"} - an SVG picture carrying the diagram in its root's content attribute.

Which is why an SVG that already carries a diagram is never converted from its picture: that diagram is returned as it is. The first trip is an approximation; every one after it is exact.

Editing elsewhere

draw.io's documentation says embed mode is supported only on embed.diagrams.net. It was tested against a self-hosted draw.io 31.4.6 anyway, and the whole flow - open, load, edit, export, download - ran unchanged three ways: from the official Docker image, from that image's webapp served as plain static files, and from those files under a path prefix.

So a draw.io of your own works, with two cautions. Nothing promises the next draw.io release keeps it, so pin the version and try editing again after each upgrade. And the image sends no framing header of its own - the policy it does set is a <meta> tag, which cannot restrict framing - so have your server send Content-Security-Policy: frame-ancestors <your page's origin>.

A Rust and WebAssembly port of the editor was sized instead of built: about 200,000 lines of draw.io's JavaScript before any shape libraries are counted, against a converter of a few thousand, with two dozen draw.io releases in the three months the question was asked. The recommendation was not to port, to self-host where content must stay in-house, and to keep embed.diagrams.net as the default.

How it is verified

By looking at it. Every change converts the whole corpus and renders each source SVG beside the exported diagram. Several defects in this project were invisible in the XML and obvious in the picture, which is the whole argument for doing it this way.

By counting. A selfcheck run reports, per file, the cell count, the shapes that moved, the style keys gained or lost, and the edges that lost a binding. "No shapes moved across the corpus" is a claim worth making; "it looks right in one file" is not.

Against other tools. Two existing converters - one JavaScript, one Python - were run over the same corpus first, and their numbers are the bar: connectors glued to real shapes, and labels sitting inside the shapes they belong to. A connector bound to the page rather than to a shape does not count as glued, because moving the page would then drag the arrows.

Against golden data. One of those projects publishes its scene for each of its samples - which connector joins which shape, which text became which label, which shape sits in which frame. Those are expected answers for the layout stage and are checked as such.