|
|
Donner SVG Editor & Engine
SVG-native editor and embeddable SVG2 + CSS3 engine in C++20, with GPU (WebGPU) and compact CPU renderers, built for correctness, security, and performance.
|
Status: Implementing (waves 1 and 2 landed; remaining work ranked) Author: Claude Fable 5 (reviewed; drafted by Claude Opus 4.8) Model: Claude Fable 5 Created: 2026-07-10
Donner should build fast, stay small, embed easily into apps, and download cheaply as WebAssembly. This design makes binary size a first-class, measured property: it records a reproducible baseline for the representative native and wasm artifacts, extends the checked-in tooling so anyone can regenerate the full per-component report with one command, and then drives size down in a series of measured steps, stopping at diminishing returns.
Scope is the shipped surface a real embedder links: the SVG parser tool, the donner-svg CLI, and the tiny_skia WebAssembly renderer (parser + svg + software renderer + a thin bridge). The editor and Geode/WebGPU wasm builds are noted as follow-on surfaces but are not the primary focus.
The report is produced by tools/binary_size.sh, run from any Donner checkout:
Native targets build under --config={macos,linux}-binary-size (-c opt -Os, -ffunction-sections -fdata-sections, symbols preserved for attribution, then measured on the .stripped output). bloaty attributes size by donner_package,compileunits,symbols using the unstripped binary as the debug file. The script also reports the ThinLTO (--config=binary-size-lto) headline shipped sizes separately, since ThinLTO defeats the debug-map attribution. The wasm target builds under --config=wasm-size and is measured both raw and gzip -9 (the transfer-size proxy), with a bloaty section/symbol breakdown of the .wasm.
The JS-glue safety gate is //donner/svg/renderer/wasm:render_test, a Node-driven bazel py_test that renders through the public C API and asserts on a pixel hash; it is what makes closure minification safe to ship (R2). Run it with bazel test --config=wasm-size //donner/svg/renderer/wasm:render_test.
Native, --config=macos-binary-size, stripped, arm64 Mach-O:
| Target | Size (bytes) | Size |
|---|---|---|
| //donner/svg/parser:svg_parser_tool.stripped | 2,527,384 | 2.41 MiB |
| //donner/svg/tool:donner-svg.stripped | 4,182,688 | 3.99 MiB |
WebAssembly, //donner/svg/renderer/wasm:donner_wasm, current default --config=wasm (this is the shipped config today: no -c opt, so the module is built unoptimized at fastbuild -O0 with no wasm-opt pass):
| Artifact | Raw bytes | gzip -9 |
|---|---|---|
| donner_wasm_bin.wasm | 5,688,030 | 1,184,620 |
| donner_wasm_bin.js | 154,601 | 41,847 |
| Total transfer | 5,842,631 | 1,226,467 |
Top native compile units (bloaty, svg_parser_tool): donner package is 87% of the binary; the heaviest single units are parser/AttributeParser.cc (374 KiB), parser/SVGParser.cc (270 KiB), SVGDocument.cc (176 KiB), properties/PropertyRegistry.cc (100 KiB), and css/Token.cc (95 KiB).
All measured with the tooling on the same host; the current default is the baseline row. These informed the ranked plan; each will be re-measured when landed as a real change.
| Variant | wasm raw | wasm gzip | js raw | js gzip |
|---|---|---|---|---|
| baseline (--config=wasm, fastbuild -O0) | 5,688,030 | 1,184,620 | 154,601 | 41,847 |
| -c opt (-O2) | 2,808,926 | 890,331 | 58,319 | 16,565 |
| -c opt + -Oz (compile and link) | 1,610,071 | 617,970 | 57,617 | 16,362 |
| -Oz + emmalloc + --closure=1 + FS off | 1,603,318 | 614,923 | 5,486 | 2,713 |
Reading: -Oz on the wasm build is the single largest lever, cutting the raw .wasm by 72% (5.69 MiB to 1.61 MiB) and the gzip transfer by 48% (1.18 MiB to 618 KiB). emmalloc is a small, safe further win. --closure=1 and -sFILESYSTEM=0 shrink the JS glue by roughly 10x (58 KiB to 5.5 KiB), but --closure=1 can rename exported runtime methods and needs a headless render check before it ships, since the tiny_skia renderer wasm has no automated browser test today (only a manual test.html).
Adds a --config=wasm-size that layers -c opt --copt=-Oz --linkopt=-Oz on --config=wasm, plus -sMALLOC=emmalloc on the tiny_skia renderer wasm binary. --config=wasm alone is left unoptimized so dev iteration and the editor wasm CI lanes are unchanged. Measured with bash tools/binary_size.sh:
| Artifact | Baseline raw | R1 raw | Baseline gzip | R1 gzip |
|---|---|---|---|---|
| donner_wasm_bin.wasm | 5,688,030 | 1,603,318 | 1,184,620 | 614,919 |
| donner_wasm_bin.js | 154,601 | 57,617 | 41,847 | 16,358 |
| Total transfer | 5,842,631 | 1,660,935 | 1,226,467 | 631,277 |
Raw .wasm down 71.8%; total gzip transfer down 48.5% (1.23 MiB to 631 KiB). Validation: the module validates under wasm-opt --all-features, and the optimized JS glue exposes the same public C API (donner_init, donner_render_svg, donner_free_pixels, donner_get_last_error) as the baseline. No source, API, or feature changes; -Oz and emmalloc are behavior-preserving optimizer/allocator flags (no closure minification).
Wave 1 measured closure at roughly 10x on the glue but held it back for lack of an automated render check. Wave 2 builds that check first, then ships closure.
The gate is //donner/svg/renderer/wasm:render_test: a Node-driven bazel py_test (render_test.py + render_test_driver.mjs) that loads the built tiny_skia module exactly as a browser would (the same default ES6 module factory), supplying the .wasm bytes via wasmBinary so the web-targeted, assertions-off (opt) module runs headless. It renders a nontrivial SVG through the public C API, reads the RGBA pixels back through the exported HEAPU8 view, and asserts the render is non-blank and its FNV-1a pixel hash matches a checked-in golden. The hash comes from the wasm module, so it is invariant to glue changes; a glue regression (for example closure renaming cwrap/UTF8ToString/HEAPU8, or a dropped module export) instead breaks the render and fails the test. This was demonstrated: dropping cwrap from the exports turns the test red, restoring it turns it green. Run it against the shipped artifact: bazel test --config=wasm-size //donner/svg/renderer/wasm:render_test.
Building the gate surfaced a latent bug: the tiny_skia bridge did not export HEAPU8 (the Geode bridge already did), so under the size config the module could not return pixels to JS at all and test.html referenced an absent Module.HEAPU8. R2 adds HEAPU8 to the tiny_skia exports, making the module actually usable. This is an additive fix, not an API break.
With the gate in place, --closure=1 and -sFILESYSTEM=0 land on --config=wasm-size. Measured with bash tools/binary_size.sh:
| Artifact | R1 raw | R2 raw | R1 gzip | R2 gzip |
|---|---|---|---|---|
| donner_wasm_bin.wasm | 1,603,318 | 1,603,318 | 614,919 | 614,919 |
| donner_wasm_bin.js | 57,617 | 5,495 | 16,358 | 2,717 |
| Total transfer | 1,660,935 | 1,608,813 | 631,277 | 617,636 |
The glue drops 90.5% raw (57,617 to 5,495 bytes) and 83.4% gzip (16,358 to 2,717); the .wasm is unchanged (glue-only change). The render gate passes against the closure-minified module, confirming closure preserved the public runtime surface. --config=wasm (dev) is left un-minified for fast iteration.
ThinLTO folds and inlines across translation units. Measured on the stripped -Os binaries with bash tools/binary_size.sh:
| Target | non-LTO | ThinLTO | delta |
|---|---|---|---|
| svg_parser_tool | 2,527,384 | 2,317,000 | -8.3% |
| donner-svg | 4,182,688 | 3,829,632 | -8.4% |
ThinLTO lands as --config=binary-size-lto (with macos-/linux- variants) layered on the size configs. It stays OFF the attribution --config=binary-size because ThinLTO debug info points at transient .thinlto.o object files that dsymutil and bloaty cannot open, which would blank out the per-compile-unit report. tools/binary_size.sh therefore reports both: the non-LTO build with attribution, and the ThinLTO headline shipped sizes. Covered by the existing native test suites (ThinLTO is a behavior-preserving optimizer change).
Two other native levers were evaluated and not taken:
While adding the LTO reporting, two latent bugs in the macOS attribution path were fixed in tools/binary_size.sh: it now explicitly builds the unstripped svg_parser_tool (only .stripped was requested, so the debug binary it copies could be missing), and passes --remote_download_all so the per-.o debug-map inputs dsymutil needs are materialized locally instead of left unfetched in the cache (which had made the compile-unit breakdown come back empty on this remote-cache host).
All measured on an internal build host (macOS arm64) with bash tools/binary_size.sh.
Native, stripped arm64 Mach-O. "Shipped" is the ThinLTO (--config=binary-size-lto) build:
| Target | Baseline | Shipped (LTO) | Saved | Reduction |
|---|---|---|---|---|
| svg_parser_tool | 2,527,384 | 2,317,000 | 210,384 | 8.3% |
| donner-svg | 4,182,688 | 3,829,632 | 353,056 | 8.4% |
WebAssembly transfer (tiny_skia donner_wasm), baseline --config=wasm (fastbuild -O0) vs shipped --config=wasm-size (-Oz + emmalloc + closure + -sFILESYSTEM=0):
| Artifact | Baseline raw | Shipped raw | Baseline gzip | Shipped gzip |
|---|---|---|---|---|
| .wasm | 5,688,030 | 1,603,318 | 1,184,620 | 614,919 |
| .js | 154,601 | 5,495 | 41,847 | 2,717 |
| Total | 5,842,631 | 1,608,813 | 1,226,467 | 617,636 |
Wasm total transfer: raw down 72.5% (5.84 MiB to 1.53 MiB), gzip down 49.6% (1.23 MiB to 617,636 bytes). The gzip transfer number is the one a browser actually pays. The glue alone dropped 96.4% raw and 93.5% gzip across the two waves.
The pass stops here for the levers available on this host, and the reason is concrete per surface:
Everything past this point is either an expensive source refactor (ranked below) or a deliberate feature removal (operator decision, surfaced not taken).
Done:
Remaining, ranked (not taken this pass):
Deliberate feature-tier tradeoffs, surfaced NOT taken (operator decisions):