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:
measurehas 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.renderdraws only insidebox. The layout guarantees nothing about what is outside it.box.hmay exceed what you measured. ARowstretches every child to the tallest. Fill it, ignore it, or centre in it — but do not assume it equals your measured height.- Delegate with
draw(), notrender().draw()records the rectangle for named blocks and then callsrender(). Calling a child'srender()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.