Why write one at all
Every SVG viewer I had on Windows was either a browser or a 40 MB Electron app that took two seconds to show a 12 KB icon. The pieces of a vector graphics stack are all well understood - XML in, shapes in the middle, coverage out - so I wrote the whole thing: 16 files, 4 280 lines of C11, linking nothing but gdi32, comdlg32, shell32 and user32. Built with one gcc line under MinGW-w64.
The interesting part is not that it works. It is which four decisions make it fast.
1. Parse in place, allocate in an arena
xml.c never copies a string. The file is read once into a buffer, and the DOM holds pointers and lengths into that buffer, with entity handling done by rewriting in place. Nodes and attributes come from a bump allocator (arena.h), so parsing allocates in a handful of large blocks and frees in one instruction. Element id lookups - which use, href gradients and clip-path need constantly - go through a hash map, not a tree walk. There is also a custom float parser, because strtod is locale-aware and surprisingly slow when you call it a few million times on path data.
2. Compile the DOM into a flat shape list
svg.c turns the tree into a flat array of shapes: geometry, paint, transform, clip. All path commands, absolute and relative, compact arc flags; linear and radial gradients with href inheritance, gradientUnits, gradientTransform and spreadMethod; clipPath and mask (as a geometric clip); use/symbol expansion; <style> CSS with tag, .class, #id, tag.class and .a.b selectors; 148 named colours; viewBox with preserveAspectRatio; and lengths in px, pt, pc, mm, cm, in, em, ex and %.
Text is the part people expect to be hard. It isn't, if you refuse to write a font engine: glyph outlines come from the installed system fonts through GDI's GetGlyphOutline, and then they are ordinary paths. Which means text is filled, stroked, clipped, transformed and anti-aliased by exactly the same code as a rectangle, and supports tspan nesting, text-anchor, letter-spacing and font substitution for free.
3. The rasterizer: exact area, no supersampling
This is the heart of it. raster.c uses a font-rs style signed-coverage accumulator: for each edge you add its exact area contribution to a per-pixel accumulation buffer, then a prefix sum along each row turns accumulated deltas into coverage. Two consequences matter:
- The cost of an edge is proportional to the rows it spans, not to the area it covers and not to a sample count. There is no 4x or 16x supersampling anywhere.
- The anti-aliasing is analytically exact, so it does not get worse on near-horizontal edges the way sampling does.
Fills use the accumulated winding number for nonzero and even-odd. Strokes are built as unions of consistently oriented quads plus joins and caps, then filled nonzero - so stroking needs no separate rasterisation path at all. Gradients are evaluated through 256-entry premultiplied lookup tables.
4. Parallelise both halves, keep the pool alive
Two phases, both across all cores on a persistent thread pool that is never torn down between frames:
- Prepare - flatten curves, stroke, dash - runs over chunks of shapes claimed through an atomic counter, so uneven shape complexity self-balances.
- Rasterize splits the image into horizontal row bands, one band per worker. The accumulator is per-band, so there is nothing to lock.
On top of that, only shapes that intersect the viewport are prepared at all, and the view is a plain affine matrix, so pan and zoom are just a re-render with different numbers.
The numbers
On an 8-thread laptop, a stress file of 20 000 paths and 1.9 M edges renders at 1600x1600 in about 60 ms. Ordinary icons and illustrations: 1 to 3 ms. The title bar shows parse time, render time, shape and edge counts and the thread count live, and T toggles single-threaded mode, so every claim here is one keypress from being checked. There is also a headless mode - svgviewer.exe file.svg -o out.png -n 10 - that writes the PNG plus a timings file, which is what I actually benchmark with.
What is deliberately missing
No image, no filters, no patterns, no textPath curvature, and masks are approximated by their geometry rather than by luminance. Filters in particular would double the size of the project and are not what a viewer is for. The app icon, incidentally, is generated by the viewer itself from icon.svg.
What I'd do differently
I would write the headless PNG path and the timing report on day one instead of near the end. Every performance decision above was settled by measurement, and for the first stretch I was measuring by watching a title bar - which is fine for eyeballing, useless for comparing two builds. The second thing: the persistent thread pool should have existed from the first parallel commit. Spawning threads per frame hid the real cost of the prepare phase behind scheduler noise for longer than I would like to admit.