Skip to content

Write the figure. Let it size itself.

Hand-written SVG scripts spend most of their lines on arithmetic — measuring text by eye, guessing box heights, threading a running y through every call. svg-plus takes that over. Blocks measure themselves, the canvas is cut to fit them, colours are named rather than spelled, and arrows find the boxes they connect after the layout has run.

from svg_plus import Doc, Row, Text, band, card

doc = Doc(700).add(
    band(
        "Foundation layers",
        Row(
            card("Networks", "Fibre, mobile, exchange points.", accent="green", key="net"),
            card("Silicon", "From design to foundry.", accent="blue", key="si"),
        ),
    ),
    Text("A justified paragraph, broken by total fit.", align="justify"),
)
doc.connect("net", "si", color="red")
doc.save("figure.svg")

No height is declared anywhere. Add a sentence and the card grows, the band grows, the canvas grows, and the arrow moves.


Four things you stop doing

1. Computing heights

This is the same figure — two columns of cards whose height follows their text — before and after. The original had to measure the text twice: once to size the canvas, once to place each card.

def fig_institutions() -> None:
    w, xs = colonnes(2, x0=MARGE, largeur_utile=L - 2 * MARGE, gouttiere=12)

    def hauteur(texte):
        return 46 + len(couper(texte, w - 32, 10.5)) * 13.5

    colonnes_h = [sum(hauteur(c[3]) + 10 for c in INSTITUTIONS[k])
                  for k in INSTITUTIONS]
    s = Svg(int(32 + max(colonnes_h)) + 4)          # canvas sized by hand
    for x, colonne in zip(xs, ("Public", "Privé")):
        s.txt(x + 2, 20, colonne.upper(), 11, ACCENT, "700", espace="1.1")
        y = 32
        for titre, statut, couleur, texte in INSTITUTIONS[colonne]:
            h = hauteur(texte)                       # measured again
            ...
            y += h + 10                              # and carried along
def build() -> Doc:
    return figure().add(
        Row(
            *(
                Stack(
                    eyebrow(column),
                    *(institution(*entry) for entry in INSTITUTIONS[column]),
                    gap=10.0,
                )
                for column in INSTITUTIONS
            ),
            gap=12.0,
        )
    )

Across the eight figures of that book, porting them cut the running-y arithmetic from 17 sites to 4 — and the four that remain are inside custom blocks that genuinely draw something.

2. Eyeballing how wide text is

Widths come from the font file itself, read through Pillow, which shapes with HarfBuzz. An approximate advance model was tried and rejected: the error compounds word by word and reopens the very gaps justification had just closed.

That is what makes box heights right, not only justified lines. And it catches things you would otherwise ship: porting four hand-written marketing posters found a headline that ran 118 units off the page, which nobody had noticed because nothing was measuring it.

Paragraphs are then broken with the Knuth-Plass total-fit algorithm — every possible breaking of the whole paragraph is scored, and the cheapest wins — instead of filling each line greedily and letting the next one pay:

Measure Greedy, loosest gap Total fit, loosest gap
145 units 11.1 4.6
165 units 17.4 6.9
195 units 18.3 3.6
250 units 5.7 2.6

The difference lands exactly where figures live: in narrow columns. Those numbers are not typed in — examples/manual/line_breaking.py breaks the same paragraph both ways and charts the result, so the figure cannot drift away from the code.

3. Spelling out colours

Blocks name a role; the theme resolves it. Swap the theme and the whole figure restyles, because nothing in the block tree mentions a colour.

NIGHT = replace(DAY, ink="#ffffff", muted="#8b94a7",
                surface="#151d33", rule="#232b40", page="#0b1020")

build(DAY).save("light.svg")
build(NIGHT).save("dark.svg")   # same tree, same code

An unknown name raises KeyError rather than quietly drawing something grey.

4. Routing arrows

Name two blocks and connect them. The endpoints resolve after the layout has run, so the arrow follows the boxes — and the router picks its axis from where they actually stand, not from where their centres happen to be.

doc.connect("api", "database")

examples/manual/pipeline.py is drawn entirely that way: seven arrows, none of them positioned by hand.


And the things a poster needs anyway

Layout and typography are the core. These are the rest of what a figure turns out to need.

  • Emphasis inside a sentence

    Text(["grew ", Span("sixfold", weight=700), " since 2017"])
    

    Each piece is measured in its own style, so the line still justifies exactly — and a full stop stays welded to the bold word before it.

  • Logos, embedded

    Image("logo.svg", height=34.0)
    

    SVG, PNG, JPEG, GIF, WebP — inlined as a data URI, so the figure stays one self-contained file that survives being emailed. Give a height; the width follows from the image.

  • Pages that hold their size

    Doc(1920, height=1080).add(
        title, body, Spacer(grow=True), footer,
    )
    

    A slide or a poster is a declared page. Growable spacers absorb the slack, so a footer sits on the bottom edge whatever the content above it does.

  • Vertical centring

    Frame(big_number, height=180, valign="middle")
    

    Without a sandwich of spacers.

And three properties that come from being a Python script rather than a document:

  • Deterministic. The same input gives byte-identical SVG. Figures diff cleanly in review, and a regenerated figure that has not changed shows as no change.
  • Translatable. The same poster in two languages lays itself out differently because the text measures itself. Nothing has to be nudged back into place — examples/hop3/ has both.
  • Accessible and searchable. The output is real <text>, not a raster: selectable, searchable, and screen-readable. doc.describe("Market share", "…") gives the figure a <title> and <desc>.

What it is for

  • Architecture diagrams

    Boxes that size themselves to their text, joined by arrows nobody routed.

  • Infographics

    Bar charts, callouts, timelines, status colours that survive being printed in black and white.

  • Posters

    A page of a declared size, with a footer pinned to the bottom edge and everything above it flowing.

  • Book figures

    A fixed measure so text bodies match from figure to figure, and justification tight enough to print.


Where it sits

svg-plus is for figures that need both box layout and real typography. Either one alone is well served elsewhere: Mermaid and Graphviz lay out graphs you do not control; matplotlib plots data; TikZ and Typst typeset beautifully in their own toolchain; svgwrite hands you primitives and the arithmetic.

Because a figure here is a Python script, it can be reviewed in a pull request, regenerated when the data changes, translated without being nudged back into place, and computed rather than transcribed.

Read the full comparison →


What it costs

Library size ~870 statements across eight modules; readable in an afternoon
Public API 33 names
Runtime dependencies svglib and pillow (PNG additionally wants rlPyCairo)
Output Plain SVG — no JavaScript, no runtime, selectable text, small files
Also writes PDF and PNG, through svglib
Tested 127 tests, 96 % coverage, five checkers clean

Get going


Honest limits

  • Bold italic is not supported. A theme carries three faces — body, bold, italic — and bold wins when both are asked for. Pass a fourth with Text(face=…).
  • No automatic graph layout. You place the boxes; svg-plus routes the arrow between them. For a graph whose shape you do not control, use graphviz.
  • PNG export needs a raster backend. uv add "svg-plus[png]"; .svg and .pdf need nothing extra.
  • Hyphenation is a hook, not a dictionary. Pass any callable that splits a word; pyphen plugs in as a one-liner.
  • The built-in metrics are approximate. They work anywhere with no font files, but can run several percent away from the face a browser resolves. Load the real file for work you intend to print.
  • Text can overflow. An unwrapped line, or a single word wider than its column, runs on rather than being clipped or ellipsised. Loud beats silent.
  • One figure per file. No multi-page flow, no page breaks. A deck is a loop over figures, not a document.