Skip to content

Drawing and output

When the layout is not enough

Blocks cover boxes, columns and text. They do not cover a spine down a timeline, a legend of swatches, a numbered badge, or a square whose area encodes a quantity. For those, Draw reserves height and hands you the canvas:

from svg_plus import Draw, Rect

def paint(canvas, box):
    canvas.rect(Rect(box.x, box.y, 12, 12), fill="green", stroke="green", radius=2.5)
    canvas.text(box.x + 18, box.y + 10, "thin", canvas.theme.style(11.0))

legend = Draw(14.0, paint)

box is the rectangle the layout gave you, in figure coordinates. canvas.theme is there when you need a style or a colour. A Draw can also take width= to claim a fixed column in a row, or grow=True to absorb a stack's slack.

Canvas primitives

Everything on a canvas is one of these:

canvas.rect(box, fill="surface", stroke="rule", radius=6.0, stroke_width=1.0)
canvas.text(x, y, "label", style, anchor="start")     # anchor: start|middle|end
canvas.line(start, end, stroke="rule", stroke_width=1.0, dash="4 3")
canvas.arrow(start, end, stroke="muted", stroke_width=1.4)
canvas.circle(cx, cy, radius, fill="accent", stroke="none")

canvas.text takes a TextStyle, which you get from the theme:

style = canvas.theme.style(10.5, weight=700, fill="accent", tracking=1.1)
canvas.text(box.cx, box.cy, "SUMMARY", style, "middle")

y is the baseline, not the top. style.width(text) tells you how wide a string will be, which is how you lay out a legend or right-align a value.

Arrow markers are created once per colour, so a figure with forty arrows carries two marker definitions rather than forty.

Connectors

Names turn into arrows. Mark the blocks you want to join, then connect them — the endpoints are resolved after the layout has run:

doc = Doc(600).add(
    Row(card("A", "…", key="a"), card("B", "…", key="b")),
    card("C", "…", key="c"),
)
doc.connect("a", "b")
doc.connect("a", "c", color="red")

How a connector is routed

The axis comes from where the boxes actually stand, not from the offset between their centres: a narrow box above a wide one is below it even though their centres are far apart across.

  • Boxes that share a span get a straight arrow down the middle of what they share, so a fan-out from one box to three below it comes out as three parallel arrows rather than three diagonals.
  • Boxes that line up on neither axis get a square-cornered route with rounded bends, turning in the gutter between them. Diagonals cutting across a figure are most of what makes a hand-laid diagram look untidy.

Labels and arrowheads

doc.connect("gateway", "db", label="reads", heads="<->")

A label is knocked out of the line it sits on, at the midpoint of the straight run. heads takes -> (the default), <-, <-> or --.

A short hop between two adjacent boxes has no room for a label — the gutter is narrower than the word. Put the labelled edges on the long runs, or widen the gap.

Connecting a name that was never placed raises KeyError. examples/manual/pipeline.py and examples/manual/architecture.py are drawn entirely this way.

Detours

An edge running back across several tiers has nothing useful to say on its way, and every box it crosses is noise. Send it round the outside instead:

doc.connect("prometheus", "predictor", label="historical data", via="left")

via takes left, right, top or bottom. The connector leaves that side of both boxes, runs along a lane just outside them, and comes back in — so a feedback loop reads as a loop. The lane is kept on the page, so give the figure enough pad for one to live in.

Connectors do not steer around obstacles by themselves

Without via, a route is computed from its two endpoints alone, so an edge that skips a tier can cross a box between them. via is the manual answer; automatic obstacle avoidance is parked alongside automatic layout.

Arrowheads and short gaps

Heads are a fixed size in figure units, so every connector in a figure carries the same one whatever its stroke width. An arrow normally stops a little short of the box it points at — but between two boxes that nearly touch it gives that clearance up rather than shrink to a bare head with no shaft. You can put two blocks 8 units apart and still get an arrow that looks like one.

Overlays

Everything above is in the flow: blocks follow one another. An overlay is not — it is placed against a block that has already landed, on top of whatever is underneath:

doc.overlay(badge("rate limited"), near="gateway", at="top-left")

at takes any of top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right. The overlay is centred on that point of the box it names, and takes that box's width unless it declares one of its own with width=.

This is how a badge sits on a card, a stamp lands in a corner, or a note is pinned beside a box. Overlays are drawn after the connectors, so they sit above everything.

Reading the layout back

canvas = doc.build()
canvas.rects["a"]        # Rect(x=…, y=…, w=…, h=…)
canvas.width, canvas.height

build() runs the whole pipeline and gives you the canvas. Useful in tests, and for anything you want to draw on top afterwards.

Writing files

doc.save("figure.svg")   # written directly, no dependencies
doc.save("figure.pdf")   # parsed by svglib, rendered by reportlab
doc.save("figure.png")   # same, plus a raster backend
doc.svg()                # the markup as a string

save() creates parent directories, and raises ValueError on any other suffix rather than guessing.

PNG needs an extra

renderPM needs its own raster backend. uv add "svg-plus[png]" pulls in rlPyCairo.

What survives the PDF and PNG path

svglib supports neither word-spacing nor textLength, which is exactly why justified lines are written as one <text> per word — the spacing is baked into coordinates and survives. Font resolution is svglib's own; a family that is not registered there falls back to Helvetica, so check a PDF before trusting it for print.

Naming the figure

doc.describe("European cloud market share", "Two bars, 2017 against 2024.")

This emits <title> and <desc> as the first children of the SVG: the figure's accessible name, which screen readers announce and browsers show as a tooltip. Worth doing for anything that will be published — the text inside the figure is already real, selectable text, and this gives the whole thing a label.

Fixed pages

A figure sizes itself. A poster is a page of a declared size:

Doc(842, height=1191, theme=THEME, pad=64)

With a fixed height, the root stack receives the full page and any grow=True child absorbs the slack — which is how a footer stays on the bottom edge. If the content is taller than the page it simply overflows; measure it and adjust, or let the page grow:

from svg_plus import Stack

root = Stack(*doc.blocks, gap=doc.theme.gap)
content = root.measure(doc.width - 2 * doc.pad, doc.theme)
assert content <= doc.height - 2 * doc.pad

The example suite asserts exactly that for all four posters.