How it works¶
Two decisions¶
Everything else follows from these.
A block states its height before it knows its position. That is what removes the running y from figure scripts, and it is why measure(width, theme) takes a width and returns a number, with no side effects.
Widths come from the font. Justification places words at computed coordinates, so an approximate advance model is not an optimisation — it is a bug that compounds word by word. Measurement reads the font file (Pillow, HarfBuzz-shaped) or reportlab's tables, and nothing downstream second-guesses it.
The three passes¶
Block tree ──┐
├─► measure(width) ─► arrange ─► render ─► Canvas ─► .svg / .pdf / .png
Theme ───────┘
- Measure.
Doc.build()wraps your blocks in a rootStackand asks its height at the working width. Containers recurse;Textbreaks its paragraph and multiplies by the leading. - Arrange. The canvas is cut — to the measured height, or to the declared one for a poster — and each block is handed a
Rect. Named blocks record theirs on the way past. - Render. Blocks emit SVG strings into
canvas.parts. Queued connectors resolve last, when every rectangle is known.
Measuring twice is normal: a Stack measures its children to compute its own height, then again to place them. Line breaking is memoised on (text, style, measure), so the second pass is a dictionary lookup.
The modules¶
| Module | Holds | Depends on |
|---|---|---|
fonts |
Font, TextStyle, loaders |
Pillow, reportlab |
text |
Knuth-Plass breaking, Line |
fonts |
theme |
Theme, palette, tint |
fonts |
canvas |
Rect, Canvas, SVG emission |
theme |
blocks |
Block and every block |
canvas, text |
doc |
Doc, file output |
blocks, svglib |
The dependency graph is a line. fonts and text are pure: given the same inputs they return the same values, touch no globals and do no I/O — the functional core. All I/O is in Doc.save and the font loaders, at the edges.
The line breaker¶
text.py is a direct implementation of Knuth and Plass. The paragraph becomes a stream of three item types:
- Box — a word, with its measured width.
- Glue — a space, with how far it may stretch and shrink (TeX's ½ and ⅓ of a space).
- Penalty — an optional hyphen, or a forced break.
Feasible breakpoints are found by dynamic programming over a list of active nodes. Each candidate line gets an adjustment ratio (how far its glue must move), which becomes a badness, which becomes demerits — plus charges for breaking at a hyphen, for two hyphenated lines in a row, and for adjacent lines in very different spacing classes. The cheapest chain wins.
Three details are worth knowing before touching it:
The closing sequence. A paragraph ends with a forbidden break, then infinitely stretchable glue, then a forced break. That is what lets the last line be short without being scored as terrible. Infinite stretch is a finite sentinel (INFINITY = 10_000), so the ratio is clamped to zero explicitly when it appears — otherwise the last line's spacing comes out a fraction wide.
The second pass. In narrow columns, often no breaking is feasible within tolerance. The paragraph records that it had to force a break, and the whole thing is re-broken accepting any amount of stretch. A loose line beats text running past the measure. This fires constantly at card width.
Ragged text may not shrink. Only justified text can squeeze its spaces. The breaker is told which it is building, and builds glue with zero shrink for ragged text — otherwise ragged lines come out a fraction past the measure.
Layout details¶
Half-leading. A line's baseline sits at (leading - (ascent + descent)) / 2 + ascent from the top of its slot, so text is optically centred in the space it was given rather than jammed against the top.
Row stretches, Stack does not. Every child of a Row gets the height of the tallest, which is what aligns a row of cards. A Stack gives each child exactly what it measured, unless it has slack and something asked to grow.
Fixed columns are subtracted first. Row removes the width= children from the available space, then divides the remainder by the weights of the rest.
Connector routing picks its axis from span overlap, not centre offset, and runs the arrow down the middle of the shared span. Centre offset is wrong whenever the two boxes differ in width — a bug that survived until the first figure actually used connectors.
Emission¶
Canvas accumulates strings. There is no DOM, no element tree, no builder — a figure is a list of <rect>, <text>, <line> and <circle> fragments joined at the end. Numbers are formatted to two decimals with trailing zeros stripped; text is escaped; font families go through quoteattr.
Arrow markers are collected in a dict keyed by colour, so they are defined once each in <defs>.
Testing strategy¶
The SVG is the product, so tests parse it and assert on geometry rather than on calls. The properties worth pinning:
- No line exceeds the width it will be drawn at, in either alignment.
- Non-final justified lines land exactly on the measure; last lines keep natural spacing.
- Total fit is never looser than greedy, swept across measures where they agree on the line count.
- Nothing is drawn outside the canvas, across all fourteen example figures.
- Posters are exactly the page size they declare, and their content fits.