Skip to content

Fonts and themes

Why fonts matter more here than usual

Justified text places each word individually, at coordinates svg-plus computes. If the width it computed is off by a percent, the line reaches the margin a percent early or late, and every gap in it is wrong. Measurement is not a detail of this library; it is the thing the library rests on.

There are two sources of metrics.

Theme()   # or builtin_font("Helvetica", weight=700)

reportlab's tables for the standard PDF families — Helvetica, Times, Courier. No font file needed, works on any machine including CI. Approximate: they can run several percent away from the face a browser resolves for font-family: Helvetica, which shows up as loose justification.

load_font(find_font("SourceSansPro-Regular"))

Read through Pillow, which shapes with HarfBuzz when available — so the width is what the renderer will actually draw. This is what you want for anything you intend to print.

Loading a family

from svg_plus import Theme, find_font, load_font

FAMILY = "Source Sans Pro, Helvetica Neue, Helvetica, sans-serif"

THEME = Theme(
    body=load_font(find_font("SourceSansPro-Regular"), family=FAMILY),
    bold=load_font(find_font("SourceSansPro-Bold"), family=FAMILY, weight=700),
    italic=load_font(find_font("SourceSansPro-It"), family=FAMILY, italic=True),
)

find_font takes a filename stem and searches the usual directories on macOS, Linux and Windows, raising FileNotFoundError with the list it looked in. family= is the string written into the SVG, so you can measure with one specific file and still name a whole fallback stack for the renderer.

Fall back gracefully when the font may be missing:

def theme() -> Theme:
    try:
        regular = find_font("SourceSansPro-Regular")
    except FileNotFoundError:
        return Theme()          # built-in metrics; approximate but portable
    return Theme(body=load_font(regular, family=FAMILY), ...)

Collections

Helvetica Neue and Menlo ship several faces in one .ttc. index= picks one:

neue = find_font("HelveticaNeue")
load_font(neue, weight=700, index=1)    # 0 regular, 1 bold, 2 italic

A face the theme does not carry

A theme has three faces. When you need a fourth — a monospace terminal block, a display face for one headline — pass it directly:

MONO = load_font(find_font("Menlo"), family="Menlo, monospace")

Text("# apt install postgresql", size=14.0, face=MONO, fill="#ffffff")

face overrides the theme entirely; weight and italic then only describe what that face already is, for the SVG attributes.

No bold italic

A theme carries body, bold and italic. Ask for both bold and italic and bold wins. If you need a bold-italic face, pass it with face=.

The theme

Theme(
    body=, bold=, italic=,   # the three faces
    size=11.0,                   # default text size
    leading=1.38,                # multiple of size
    ink="#1a1a1a",               # body text
    muted="#5f5f5f",             # secondary text
    accent="#1f4e5f",            # titles, emphasis
    surface="#f4f3f0",           # panel grounds
    rule="#e0ded9",              # borders and hairlines
    page="#ffffff",              # the canvas itself
    radius=5.0, pad=14.0, gap=12.0,
)

The theme is frozen. To vary one thing, build a new one — dataclasses.replace(THEME, page="#0b1020") works.

Naming colours

Blocks take colour names, not spellings:

Frame(child, fill="surface", stroke="rule")   # palette roles
Text("locked", fill="red")                     # status colours
Frame(child, fill="#f4e0dc")                   # a literal, when you mean it

Theme.color() resolves in this order: a # literal or "none" passes through; then the six palette roles; then the five status colours red, amber, green, blue, grey. Anything else raises KeyError — a typo is a crash, not a silently grey box.

This is what makes a figure restylable. examples/hop3/ defines a light theme and a dark one with the same role names, and the posters are written once.

Status colours and their tints

The status colours are meant as redundant reinforcement of what the text already says — a figure has to survive being printed in black and white. For the pale ground that goes with a status stroke, tint() mixes toward white:

from svg_plus import tint

Frame(child, fill=tint(THEME.color("red")), stroke="red")

A worked example

from dataclasses import replace
from svg_plus import DEFAULT, Doc, Text, card

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

def build(theme):
    return Doc(600, theme=theme).add(
        card("Networks", "Fibre, mobile and exchange points.", accent="green"),
        Text("Colours are named, so this tree does not know which theme it is in."),
    )

build(DAY).save("light.svg")
build(NIGHT).save("dark.svg")

The same block tree, two themes, two figures. Nothing in the tree mentions a colour: