Metadata-Version: 2.3
Name: svg-plus
Version: 0.1.0
Summary: Write SVG figures — diagrams, infographics, posters — as content, not coordinates
Author: Stefane Fermigier
Author-email: Stefane Fermigier <sf@abilian.com>
Requires-Dist: svglib>=2.1.0
Requires-Dist: pillow>=10.0
Requires-Dist: rlpycairo>=0.3 ; extra == 'png'
Requires-Python: >=3.12
Provides-Extra: png
Description-Content-Type: text/markdown

# svg-plus

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

**[Documentation](https://sfermigier.github.io/svg-plus/)** · [Tutorial](https://sfermigier.github.io/svg-plus/tutorial/) · [How to](https://sfermigier.github.io/svg-plus/how-to/) · [User guide](https://sfermigier.github.io/svg-plus/guide/) · [Reference](https://sfermigier.github.io/svg-plus/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.

```python
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

```console
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](https://pypi.org/project/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:

```python
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.

```python
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:

```python
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 `Span`s 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:

```python
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
```

```console
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:

```python
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

```console
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](https://zensical.org); `docs/zensical.toml` configures it.
