Architecture
How the library is put together — the pipeline, the layer stack, and the two ideas the whole design turns on.
Sauvignon is one XML → XML transform: a high-level relational description lowered to low-level, absolutely-positioned geometry. Everything below is read from the source at build time, and the layer, layout and chart diagrams are drawn by Sauvignon from the package's own import graph.
The pipeline
Three stages, two intermediate representations. Each stage only knows about the representation on either side of it.
<diagram padding="14">
<row gap="14" align="center">
<parallelogram id="src">.svgn</parallelogram>
<rect id="parse">parse</rect>
<hexagon id="model">model</hexagon>
<rect id="layout">layout</rect>
<hexagon id="scene">scene</hexagon>
<rect id="render">render</rect>
<parallelogram id="svg">.svg</parallelogram>
</row>
<connect from="src" to="parse"/>
<connect from="parse" to="model"/>
<connect from="model" to="layout"/>
<connect from="layout" to="scene"/>
<connect from="scene" to="render"/>
<connect from="render" to="svg"/>
</diagram>
The hexagons are the two IRs, and they are the reason the ends stay independent:
- model —
Node,Container,Connect,Align,Distribute. What the author said, with no geometry in it at all. It knows nothing about XML, so a different front-end could produce it. - scene — resolved, absolutely-positioned primitives, in draw order: panels, edges, shapes, labels. It knows nothing about SVG, so a different backend could consume it.
The layer stack
The package is a strict one-way dependency stack. A lower layer never imports a higher one, which is what lets you read it bottom-up.
Each layer earns its place:
- foundation — domain-agnostic utilities: geometry and text metrics, SVG/CSS emit helpers, the constraint solver, the error type. Imports nothing from the layers above it.
- vocabulary — what tags mean: the mark types, the registry that resolves a tag, and the Grammar-of-Graphics scales.
- ir — the two representations above.
- engine — the transforms:
layout/,chart/, andrender. - frontend — the XML surface: the parser and component expansion.
- root — the public API, the pipeline function, and the CLI.
That invariant is not a comment; it is checked on every build by walking the
package's relative imports (the house rule is that internal imports are always
relative; see the counter at the top of this page). If someone makes
foundation import engine, the build says so.
The relational core
This is the idea the whole engine turns on. Positions are not assigned by walking the tree — they are solved.
The layout tree desugars into soft difference constraints: each child's
position expressed as an offset from its container. <align> and <distribute>
compile into hard constraints referencing elements by id. Both go into one
pool, and hard wins.
<diagram padding="14">
<row gap="50" align="start">
<column gap="10">
<rect id="p">soft: in a column</rect>
<rect id="q">soft: below it</rect>
</column>
<column gap="10">
<rect id="r">another column</rect>
<rect id="s">and its child</rect>
</column>
</row>
<align of="q r" edge="center-y"/>
</diagram>
Because both kinds of constraint live in one pool, an explicit relation overrides structural placement rather than fighting it. That is what allows a node to be stacked, aligned and connected at once, and what allows aligning two elements in different parents — something a strict layout tree cannot express at all.
The solver itself is deliberately small: a weighted union-find over 1-D
difference constraints, pos[a] − pos[b] = offset, with x and y solved
independently. Variables in one component have fixed relative positions.
Inside the layout engine
Layout runs in five phases: measure (bottom-up intrinsic sizes) → build (desugar the tree into constraints) → solve → frame (shift to the origin, size the canvas) → emit (produce scene primitives).
The package mirrors that, with one deliberate compromise:
Only direct dependencies are drawn. resolve does import base, but that
edge is already implied by resolve → measure → tracks → base, so it is left
out — the standard way to draw a dependency graph, and what stops arrows from
running through the boxes in between.
base holds the leaf helpers, tracks the grid track/area/line math, connect
the edge routing, and resolve the driver. The compromise is measure: the
measure pass and grid track sizing are mutually recursive — measuring a grid
requires measuring its children, which may themselves be grids — so splitting
them into separate modules would create an import cycle. They stay in one module
so the package graph stays a DAG.
Charts are computed, not solved
A chart's geometry is not expressible as difference constraints — axes, lines
and areas need real coordinates. So charts take an escape hatch: each one
becomes a Figure, an opaque box that composes its own primitives in local
coordinates and is placed by the solver as a single unit.
The chart engine reads data, not XML. The front end freezes a chart's subtree
into a ChartElement — a frozen record with the four members the engine
actually used — so nothing under engine/ imports an XML parser and no XML node
survives into a Figure's deferred draw. That split also fixes a staging bug:
reading every field eagerly, before the Figure is built, means a mistyped
numeric column is a parse() error rather than something that surfaces from
layout().
Opaque, but — for <plot> and <facet> — not rigid, and not pre-composed. Such
a Figure holds a render(w, h) callback rather than a finished list of
primitives, and that callback is invoked
after layout has resolved the box's size — so a chart takes part in grow and
stretch like anything else and reflows into whatever cell it lands in. Give a
plot grow="true" in a wide row and it is the bars that get wider, not a
picture that gets scaled. Only an author-pinned width or height makes a Figure
genuinely rigid.
This is why a bar chart and an architecture diagram can share one engine, and
why <facet> works: facets are just Figures laid out by the ordinary relational
engine, so small multiples cost no new machinery.
Modules are grouped by how far below the top of the graph they sit — each band
is one step further from the tags nothing imports, so the bottom row imports
nothing else in the package. spec — the frozen chart element the front
end hands over — is the one true leaf, which is what you would hope for a type
everything reads and nothing reasons about. data and color sit just above it
as the shared helpers; cartesian and polar are the two coordinate frames;
every remaining module is a single chart tag. One tag leans on another:
timeline draws its periods as a chronology draws its stretches, with the
chronology's callouts, so it sits a band above chronology. The Grammar of Graphics shows
through here — polar is not a chart type, it is a projection, which is why
pie = bars + polar.
(This one is drawn as bands rather than as a dependency graph like layout/
above. With fourteen modules and thirty-seven edges the arrows cross into a
hairball — a small, honest demonstration of the ceiling described at the end of
this page.)
The count went down by one without the edge count moving, which is how the
attribute table turned out to be in the wrong place. It sat in chart/ and
nothing in chart/ imported it — only the front end did, to refuse what an
element does not read. It describes the language's elements rather than the
chart engine's, so it lives beside the tag registry now: vocabulary/registry
says which tags exist, vocabulary/attrs says what each of them takes.
Extensibility
Two things are data rather than code:
- Marks. A polygon is defined by vertices in the unit box, and both its
rendering and its connector clipping derive from that one outline. Adding a
shape is a call to
register_mark, not a parser change. - Tags. The registry holds what an element means — mark, combinator, or
relation — and marks are the pluggable category:
register_markis all it takes to add one, and the registry rejects a name the parser would resolve first, so a new mark can never be silently unreachable. One table, two readers:<define>'s shadow check reads the sameRESERVED_TAGS.
It is not true that the parser has no hardcoded tag list. The six chart tags
and the five plot layers are dispatched by an if tag == … chain in
parse.py, and only marks are genuinely data-driven. Making chart tags
registrable the way marks are is the honest next step here.
Appearance is the third: it is CSS. The renderer emits semantic classes and
a palette of custom properties, so restyling is overriding variables, and dark
mode falls out of prefers-color-scheme for free.
The one real ceiling
Worth stating plainly, since it bounds what the design can grow into. The difference-constraint solver handles equalities on independent axes. It cannot express:
- inequalities or optimisation — no "at least 20px apart", no force-directed or otherwise optimised auto-layout;
- coupled axes — nothing like "on a circle", because x and y are solved separately.
That ceiling is why there is no automatic tree or graph layout. It is also
contained: it lives in foundation/solve and the constraint-building in
engine/layout/resolve, so a stronger solver could be swapped in without
touching the front-end, the IRs, or the backend. Chart coordinate systems are
not blocked by it, because they are a render-time projection rather than a
constraint.