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)
| File | Size | Uploaded | |
|---|---|---|---|
| svg_plus-0.1.0.tar.gz | 25.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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}
|