Skip to content

Writing a block

The contract

A block is two methods:

from svg_plus import Block

class Callout(Block):
    def measure(self, width: float, theme: Theme) -> float:
        """How tall am I at this width?"""

    def render(self, canvas: Canvas, box: Rect) -> None:
        """Draw into this rectangle."""

Four rules, and they are the whole contract:

  1. measure has no side effects. It is called more than once — a container measures to compute its own height, then again to place children. It must return the same answer both times.
  2. render draws only inside box. The layout guarantees nothing about what is outside it.
  3. box.h may exceed what you measured. A Row stretches every child to the tallest. Fill it, ignore it, or centre in it — but do not assume it equals your measured height.
  4. Delegate with draw(), not render(). draw() records the rectangle for named blocks and then calls render(). Calling a child's render() directly silently breaks connectors.

When not to write one

Most of the time you want a function returning existing blocks:

def callout(text: str) -> Block:
    return Frame(
        Text(text, size=11.0, align="justify"),
        fill="surface", stroke="rule", stroke_width=0.8, pad=13.0,
    )

That is all card(), band() and eyebrow() are. Reach for a real class only when you need to draw something the primitives cannot express and measure it.

For a one-off drawing with a height you already know, Draw is lighter still:

Draw(14.0, paint)                 # reserve 14 units, paint into them
Draw(0.0, paint, width=24.0)      # a fixed 24-unit column in a Row
Draw(0.0, paint, grow=True)       # absorb a Stack's slack

Worked example: a bullet

A dot and a label, with the dot on a fixed indent and the label wrapping in what is left.

class Bullet(Block):
    indent = 24.0

    def __init__(self, label: str, *, size: float, color: str = "accent") -> None:
        super().__init__()
        self.body = Text(label, size=size)
        self.size = size
        self.color = color

    def measure(self, width: float, theme: Theme) -> float:
        return self.body.measure(width - self.indent, theme)

    def render(self, canvas: Canvas, box: Rect) -> None:
        canvas.circle(box.x + 5.0, box.y + self.size * 0.66, 5.0, fill=self.color)
        self.body.draw(canvas, Rect(box.x + self.indent, box.y, box.w - self.indent, box.h))

Note that measure narrows the width by the same indent render uses. Getting those two out of step is the single most common bug in a custom block — the text is measured at one width and drawn at another, and it overflows.

Worked example: a timeline

Drawn decoration around ordinary blocks. The spine and the dots are canvas calls; the dates and bodies stay Text, so they still break and measure themselves.

class Timeline(Block):
    spine = 142.0
    gutter = 20.0
    step = 18.0

    def __init__(self, entries: list[tuple[str, str]]) -> None:
        super().__init__()
        self.dates = [Text(d, size=12.0, weight=700, align="right", wrap=False)
                      for d, _ in entries]
        self.bodies = [Text(b, size=11.5, align="justify") for _, b in entries]

    def _body_width(self, width: float) -> float:
        return width - self.spine - self.gutter

    def measure(self, width: float, theme: Theme) -> float:
        heights = [b.measure(self._body_width(width), theme) for b in self.bodies]
        return sum(heights) + self.step * (len(heights) - 1)

    def render(self, canvas: Canvas, box: Rect) -> None:
        width = self._body_width(box.w)
        heights = [b.measure(width, canvas.theme) for b in self.bodies]
        x = box.x + self.spine
        canvas.line((x, box.y + 6), (x, box.bottom - heights[-1] + 6), stroke_width=2.0)
        y = box.y
        for date, body, height in zip(self.dates, self.bodies, heights, strict=True):
            date.draw(canvas, Rect(box.x, y, self.spine - 20, 0))
            canvas.circle(x, y + 6, 5.0, fill="page", stroke="blue")
            body.draw(canvas, Rect(x + self.gutter, y, width, height))
            y += height + self.step

The _body_width helper exists so measure and render cannot disagree. Factor that out every time.

Pitfalls

Do not mutate children in render. It makes a second render observe different state than the first, and measuring is allowed to happen between the two. Decide colours and sizes in __init__.

Do not cache on self across renders. The same block may be drawn into two documents with different themes. Cache on the arguments — line breaking is memoised on (text, style, measure) for exactly this reason.

super().__init__() matters. Block is a dataclass carrying key, grow and width; skipping it leaves those unset and the block breaks inside a Row.

Prefer a dataclass for leaf blocks. Most built-in blocks are @dataclass, which keeps the constructor honest and readable:

@dataclass
class Sparkline(Block):
    values: Sequence[float]
    color: str = "accent"
    height: float = 24.0

    def measure(self, _width: float, _theme: Theme) -> float:
        return self.height

Use _width/_theme when you genuinely ignore them — the base declares those two parameters positional-only for exactly this.

Testing a block

Render it in isolation and read the elements back:

def paint(block, box):
    canvas = Canvas(box.right, box.bottom, DEFAULT)
    block.draw(canvas, box)
    return [ET.fromstring(part) for part in canvas.parts]

def test_bullet_indents_its_label():
    elements = paint(Bullet("hello", size=12.0), Rect(0, 0, 200, 20))
    label = next(e for e in elements if e.tag == "text")
    assert float(label.get("x")) == pytest.approx(24.0)

Assert the geometry, not the calls. If a block can overflow, assert that it does not — that is how the Bars label bug was caught, and the poster page-fit checks.