9  Architecture

9.1 Why the renderer doesn’t know what a Z-spider is

typograph is a neutral diagram renderer: layout, style resolution, clipping, bounds, curve geometry. It has no dependency on any theme — including typograph-zx, which is a normal consumer of typograph’s public API instead: it depends on the generic contracts in node.typ, edge.typ, shape.typ, and theme.typ; nothing in the other direction. Nothing from a theme package is applied to a diagram unless a document explicitly binds it with theme:.

The test for whether something belongs in the renderer or in a theme is simple: would a non-ZX diagram language need it? A layout/style/geometry concept — “nodes have shapes,” “edges clip to silhouettes,” “styles resolve in layers” — belongs in the renderer. A specific answer to that concept — “a Z-spider is a green circle” — belongs in a theme. This is also why box and gate live in typograph itself rather than in a theme package, despite looking, at first glance, like they’d want domain-specific styling: a labelled rectangle and a port-capable node with legs are generically useful shapes for any diagram language, so they get small, functional, deliberately neutral default styling at the constructor level — enough that a document with no theme at all still gets a visible, usable box — while remaining exactly as themeable as any theme-defined kind, through the ordinary node-presets mechanism (see Theming). “Lives in the engine” and “has a specific appearance” are independent questions; conflating them is the most common way to misread this package.

9.2 The diagram item protocol

Every value a document builds inside a diagram({ .. }) body — a node, an edge, a piece of placed content — is represented the same way: a length-one array containing one tagged dictionary, (type: "node", ..) / (type: "edge", ..) / (type: "content", ..). utility.typ defines the tag predicates (is-node, is-edge, is-content) that the rest of the package uses to dispatch on an item without caring which constructor produced it.

Two things about this shape are load-bearing, not incidental:

  • Arrays, not bare dictionaries. A Typst code block joins its statements’ values with +. Arrays concatenate under +; bare dictionaries merge their keys instead of collecting separate items. Wrapping every item in a length-one array is what makes { z(0,0); x(1,0) } “just work” as item collection, with no explicit push/accumulator anywhere in a document — see Core Concepts for the document-facing version of this.
  • A type tag, not a Typst-native distinction. Nodes, edges, and content are structurally similar dictionaries; the tag is what lets is-node/is-edge/is-content stay a single field comparison instead of duck-typing on which keys happen to be present.

group() (in content.typ) is the clearest consumer of this protocol: it pattern-matches on item type to decide what an affine transform means for that item — moving a node’s (x, y), transforming an edge’s waypoints and deferred endpoints, translating placed content — entirely through is-node/is-edge/is-content, with no knowledge of which constructor produced any given item.

9.3 The coordinate flip, and where it happens exactly once

Diagram coordinates use the mathematical convention (y up); Typst’s own layout primitives use the screen convention (y down). Node positions and resolved edge paths use diagram-space; their conversion to on-page positions is centralized in geometry.typ’s to-screen(p, unit):

#let to-screen(p, unit) = (p.at(0) * unit, -p.at(1) * unit)

World positions pass through this function before placement. Shape builders are the important exception: their points, label offsets, and decorative part translations are already node-local screen-space lengths. Port projection converts those local lengths back to diagram-space offsets before the edge path is rendered. Keep this boundary explicit in custom builders; applying the world-space y-flip to their points a second time mirrors them.

This is also the source of the one recurring “gotcha” documented throughout the guide: shape-builder angles (style.rotate) are Typst’s screen convention (+90deg visually clockwise) because builders return outlines that feed straight into Typst’s native circle/rect/polygon elements, while group()’s rotate: and edge direction angles stay in diagram-space math convention, because they operate on coordinates before to-screen runs. Both conventions are internally consistent; they just answer to different layers, which is why a node whose shape should visually track a group(rotate: angle) needs style: (rotate: -angle) on that node — see Fragments and Equations.

9.4 Contextual lengths at the layout boundary

Constructors and style merging preserve font-relative inputs without needing layout context. node-outline() and the diagram’s edge preparation normalize known geometry fields to absolute lengths before zoom and numeric geometry. Part-local overrides normalize before inheriting the scaled base style; unknown custom style fields remain untouched. Percentage components retain their native reference semantics.

Length validation rejects definitely invalid signs at construction time. Mixed-sign values such as 1em - 12pt need the surrounding font size before their sign is known, so the resolved style is validated again during layout. Already-absolute helpers remain context-free; avoid unconditionally calling .to-absolute() on their inputs, since even 1pt.to-absolute() requests context in a non-contextual helper.

9.5 Deferred positions

position.typ holds the shared point/axis protocol, constructor argument parsing, offsets, and affine expression transforms. Captured nodes are flattened into local tables; their positions refer to integer indices rather than recursively embedding the same ancestors in both axes. Public values remain ordinary immutable data, without layout callbacks or mutable handles. A pair projecting the same point shares one capture-table import. Consecutive offsets are combined into one displacement, keeping their expression depth constant without changing the explicit-point clipping semantics.

position-layout.typ merges these tables into diagram-local identities, prepares outlines without coordinates, and resolves an iterative dependency graph with one vertex per node axis. Named references resolve against the whole diagram; direct captures emit their source nodes. Missing names/ports and cycles produce diagnostics before any drawing is emitted. Identity is based on symbolic source values, not coincident resolved coordinates. Capture normalization already interns nodes, so outline preparation preserves that table’s order and indices rather than deduplicating it again.

Group transforms rebase projections into the fragment’s local frame around transformed anchors, then apply its affine transform. Port candidates use the same final outline and group rotation as edge endpoints, which matters when a non-square graphic remains upright. Outline preparation remains independent of position, and drawing/clipping/bounds use the final numeric positions. Existing numeric-only layouts skip the coordinate graph.