Skip to main content

svg-plus

Write SVG figures — architecture diagrams, infographics, posters — as content rather than as coordinates.

Documentation · Tutorial · How to · User guide · Reference

Hand-written SVG scripts spend most of their lines on arithmetic: measuring text by eye, guessing box heights, and threading a running y through every call. svg-plus takes that over. Blocks measure themselves, the figure sizes itself to what it contains, and paragraphs are broken with the Knuth-Plass total-fit algorithm against the real metrics of the font you are actually going to render with.

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

doc = Doc(700).add(
    band(
        "Couches de fondation",
        Row(
            card("Réseaux et connectivité", "La fibre, le mobile, les points d'échange.", accent="green", key="net"),
            card("Chaîne du silicium", "De la conception à la fonderie.", accent="blue", key="si"),
        ),
    ),
    Text("Un paragraphe justifié, coupé au total-fit.", align="justify"),
)
doc.connect("net", "si", color="red")
doc.save("figure.svg")

Nobody declared a height. Add a sentence and the card grows, the band grows, the canvas grows.

Install

uv add svg-plus          # or: pip install svg-plus
uv add "svg-plus[png]"   # PNG export additionally needs a raster backend

Python 3.12+. .svg is written directly; .pdf and .png go through svglib.

Fonts, and why they matter here

Justification places each word itself, so a measurement that is off by a percent shows up as ragged spacing. Two sources of metrics are available:

from svg_plus import Theme, find_font, load_font

Theme()  # built-in Helvetica metrics: no font file, works anywhere, approximate

regular = find_font("SourceSansPro-Regular")     # searches the usual font directories
Theme(
    body=load_font(regular),
    bold=load_font(find_font("SourceSansPro-Semibold"), weight=700),
    italic=load_font(find_font("SourceSansPro-It"), italic=True),
)

The built-in metrics come from reportlab's Adobe Helvetica tables, which can run several percent away from the face a browser actually resolves for font-family: Helvetica. That is fine for a draft. For work you intend to print, load the file: load_font reads it through Pillow, which shapes with HarfBuzz and therefore measures what the renderer will draw.

Line breaking

break_paragraph implements Knuth-Plass total-fit: it scores every possible breaking of the whole paragraph and keeps the one with the lowest demerits, instead of filling each line greedily and letting the next one pay. In practice that means no line stretched to compensate for the one above it, and no single word stranded on the last line.

from svg_plus import DEFAULT, break_paragraph

for line in break_paragraph("…", DEFAULT.style(11.0), measure=260.0):
    line.words, line.gaps, line.natural_width, line.last

Hyphenation is a hook rather than a built-in dictionary — pass any callable that splits a word:

import pyphen
splitter = pyphen.Pyphen(lang="fr_FR")
Text(body, align="justify", hyphenate=lambda word: splitter.inserted(word).split("-"))

The blocks

Block What it does
Text A paragraph. align="justify" places each word; anything else sets the line whole. Pass a sequence with Spans to emphasise one word.
Stack(*children, gap=…) Top to bottom; each child as tall as it asked to be.
Row(*children, weights=…) Side by side; every child stretched to the tallest.
Frame(child, …) A padded panel — the card, the band, the callout.
Bars(items) A labelled bar chart.
Image(source, height=…) A picture, embedded as a data URI.
Spacer, Rule Fixed height, with or without a line.
Draw(height, paint) The escape hatch: reserve height, then draw on the canvas yourself.

Any block may take width= to claim a fixed column in a Row — a badge, a bullet, an icon — while the rest share what is left; or grow=True to absorb a Stack's slack, which is how a poster keeps its footer on the bottom edge:

Doc(842, height=1191).add(masthead, body, Spacer(grow=True), footer)

A figure with no height sizes itself to its content; a poster is a page of a given size and says so.

card(), band(), eyebrow(), heading() and grid() are compositions of those, not new machinery. Colours are named, not spelled: fill="surface", accent="red", or a literal #rrggbb. Swap the Theme and the figure restyles.

For anything the layout cannot express, name a block with key= and it records where it landed; doc.connect("a", "b") then draws an arrow between the two, picking the facing edges from where the boxes ended up. examples/manual/pipeline.py is built entirely that way — seven arrows, none of them routed by hand. Draw hands you the canvas with the box it reserved for anything else.

Examples

Three families, one script per figure, each runnable on its own:

examples/eurostack/   book.py + eight figures from a book — layered diagrams,
                      a timeline, a bar chart, nested boxes, an area comparison
examples/hop3/        brand.py + four marketing posters, two light and two dark,
                      at A4 and social sizes
examples/manual/      sheet.py + two figures documenting svg-plus itself: the
                      build pipeline, and total-fit measured against greedy
python examples/eurostack/tbb.py     # one figure
cd examples && make hop3             # one family
make figures                         # all fourteen, from the repo root

The settings module of each family holds what it shares — fonts, palette, page format, and for hop3 the chrome every poster repeats (masthead, rules, bullets, call to action). Everything else lives with the figure that uses it. Each figure module exposes build() -> Doc and an OUTPUT filename, which is all the test suite needs to build them all.

The first two families were hand-written SVG before, and porting them found real bugs in the originals: one poster headline overran the page by 118 units, which nobody had noticed because nothing was measuring it. The manual figures compute what they show — line_breaking.py breaks the same paragraph both ways and charts the result, so the figure cannot drift away from the code.

Extending

A block is two methods — how tall are you at this width, and draw yourself into this box:

class Callout(Block):
    def measure(self, width, theme): ...   # -> height
    def render(self, canvas, box): ...     # canvas.rect / .text / .line / .arrow

Timeline in examples/eurostack/frise_eurostack.py and Bullet in examples/hop3/brand.py are both written that way: they draw a spine or a dot themselves and hand the rest to ordinary Text blocks.

Development

make test         # pytest
make lint         # ruff, ty, pyrefly, mypy
make format
make figures      # rebuild every example figure
make docs         # build the site into docs/site/
make docs-serve   # live preview

The documentation lives in docs/src/ and is built with Zensical; docs/zensical.toml configures it.

Release files for svg-plus 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for svg-plus 0.1.0
File Size Uploaded
svg_plus-0.1.0.tar.gz 25.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for svg-plus 0.1.0
File Interpreter ABI Platform
svg_plus-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 54.8 kB

Release files / svg_plus-0.1.0.tar.gz

Download URL svg_plus-0.1.0.tar.gz
Size 25.4 kB
Tags Source
SHA-256 checksum
How to use checksums
13d79b45ec9dbf600f9316b99d57d52eab8826499707eedc238b8236aa38c3c3
BLAKE2b-256 checksum
How to use checksums
fc1c68ecd07ce509cc6c35faa3a7c7c367de54c6332f5e09a4cf53fc76fc0bda
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / svg_plus-0.1.0-py3-none-any.whl

Download URL svg_plus-0.1.0-py3-none-any.whl
Size 29.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d44b074a877167601ec2b468330a89d97734c7b8749e2ff4eaf6d9369c45e1c2
BLAKE2b-256 checksum
How to use checksums
171c9c6e24039987ded46d9e6693baab761c6d1066199aa7664bf2e6d5c8f982
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page