Metadata-Version: 2.5
Name: weasysvg
Version: 0.1.0
Summary: Native SVG rendering for WeasyPrint, close to Chromium: foreignObject, filters, masks, blending and text.
Project-URL: Homepage, https://github.com/grigoriev/weasysvg
Project-URL: Repository, https://github.com/grigoriev/weasysvg
Project-URL: Issues, https://github.com/grigoriev/weasysvg/issues
Author-email: Sergey Grigoriev <grigoriev@fastmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
Classifier: Topic :: Printing
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: fonttools>=4.59.2
Requires-Dist: weasyprint<71,>=70
Provides-Extra: dev
Requires-Dist: mypy==2.4.0; extra == 'dev'
Requires-Dist: pypdfium2==5.14.0; extra == 'dev'
Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
Requires-Dist: pytest==9.1.1; extra == 'dev'
Requires-Dist: ruff==0.17.1; extra == 'dev'
Provides-Extra: visual
Requires-Dist: numpy==2.5.4; (python_version >= '3.12') and extra == 'visual'
Requires-Dist: playwright==1.64.0; extra == 'visual'
Requires-Dist: resvg-py==0.5.0; extra == 'visual'
Description-Content-Type: text/markdown

# weasysvg

[![CI](https://github.com/grigoriev/weasysvg/actions/workflows/ci.yml/badge.svg)](https://github.com/grigoriev/weasysvg/actions/workflows/ci.yml)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/grigoriev/weasysvg/badge)](https://scorecard.dev/viewer/?uri=github.com/grigoriev/weasysvg)
[![Release](https://img.shields.io/github/v/release/grigoriev/weasysvg)](https://github.com/grigoriev/weasysvg/releases)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/grigoriev/weasysvg/blob/main/LICENSE)

[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=grigoriev_weasysvg&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=grigoriev_weasysvg)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=grigoriev_weasysvg&metric=coverage)](https://sonarcloud.io/summary/new_code?id=grigoriev_weasysvg)

A plugin for [WeasyPrint](https://weasyprint.org) 70.x that renders SVG natively, close to Chromium.

## Why?

WeasyPrint draws SVG as vector graphics, but it lacks `<foreignObject>` and has gaps in filters, masks,
blending and text. Diagram tools depend on these: draw.io and Mermaid put their labels in
`<foreignObject>`, D2 masks its edge labels, and many tools draw shadows with filters. Services that
turn HTML into PDF therefore rasterize SVG in a headless Chromium first.

weasysvg closes these gaps inside WeasyPrint. The SVG stays vector, text stays selectable, and the
service needs no browser.

## Installation

weasysvg runs on CPython 3.10 to 3.14 and on PyPy 3.11, as WeasyPrint 70 does. On PyPy a C compiler
must be present: zopfli, which WeasyPrint needs, has no PyPy wheel and builds from source.

Install it from PyPI:

```sh
pip install weasysvg
```

weasysvg depends on WeasyPrint only. Every SVG feature it adds stays vector in the PDF, blur included.
The one exception is the noise of `feTurbulence`: it has no vector form, so it is an image, computed in
Python.

WeasyPrint needs Pango, see its
[installation guide](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html).

### Verify

The build of each release has signed provenance on GitHub, and the GitHub release carries its bundle.
PyPI and the GitHub release hold the same files, so verify a file from either with
`gh attestation verify <file> --repo grigoriev/weasysvg`. On PyPI each file also has a publish
attestation (PEP 740): it ties the upload to `release.yml` of this repository.

## Usage

Call `install()` once, before the first rendering:

```python
import weasyprint
import weasysvg

weasysvg.install()
weasyprint.HTML("report.html").write_pdf("report.pdf")
```

`install()` patches WeasyPrint in place, for the whole process. It is idempotent, and it returns the
names of the installed features. Each feature can be turned off:

```python
weasysvg.install(filters=False, fonts=False)
```

| Argument | Default | Effect |
|---|---|---|
| `conditional_processing` | `True` | `<switch>`, `requiredExtensions`, `systemLanguage` |
| `foreign_object` | `True` | `<foreignObject>` content laid out by WeasyPrint |
| `filters` | `True` | filter chains, `drop-shadow()` and `blur()` |
| `paint` | `True` | gradients, patterns and masks |
| `blend` | `True` | `mix-blend-mode` (needs `paint`) |
| `text` | `True` | text baselines, decorations, font-size keywords |
| `fonts` | `True` | font family selection as Chromium does it |
| `fetch_cache` | `True` | one fetch per URL and SVG |
| `markers` | `True` | marker orientation as in Chromium |
| `language` | `"en"` | the language `systemLanguage` reads where the document has no `lang` |

weasysvg targets WeasyPrint 70.x and warns on any other major version: it patches internals that
change between releases.

## Features

| Area | What weasysvg adds |
|---|---|
| `<foreignObject>` | The XHTML inside is laid out as a WeasyPrint document and drawn in place. The SVG stylesheet still matches it. All objects of one SVG share one layout run. Tables taller than their rows grow their rows, as browsers do. Disc, circle and square list markers are the shapes Chromium paints, placed from the ascent of the font. |
| Conditional processing | `<switch>` draws its first matching child. `requiredExtensions` and `systemLanguage` hide elements, so draw.io no longer draws both the label and its fallback. XHTML counts as supported only with `foreign_object` on; without it the fallback wins. `systemLanguage` reads the `lang` of the element that holds the image (`<html lang="de">`), else the `language` of `install()`, and matches as Chromium does with the Accept-Language list of that language (`de-CH` reads `de-CH,de`). An SVG from CSS (`background-image`, `list-style-image`, `content: url()`) reads `language`. |
| Filters | `feGaussianBlur`, `feOffset`, `feFlood`, `feDropShadow`, `feMerge`, `feBlend` normal, `feComposite` over, in and arithmetic, `feColorMatrix`, `feComponentTransfer`, `primitiveUnits="objectBoundingBox"`, and the CSS `drop-shadow()` and `blur()` functions, all in vector. `feTurbulence` noise is an image. A blur is a soft mask: exact for a plain rect, as the product of two Gaussian profiles, and in steps through the profile for other shapes. A color matrix or transfer function recolors each fill, stroke and gradient stop. |
| Masks and clips | A mask is set before the path starts, so viewers apply it. `maskContentUnits`, the region of an `objectBoundingBox` mask, self-referencing masks, and `mask` on a `<mask>` ignored as in Chromium. A clip path with `clip-rule="evenodd"`, text, `<use>` or clipped children becomes a soft mask. |
| Gradients and patterns | Tiles start at the user space origin, `patternTransform` moves the tile origin too, patterns used twice, a gradient cell that covers the shape under any `gradientTransform`, colors mixed unpremultiplied as in Chromium, radial `r` and `fr` percentages, `href` stops used twice, a gradient cell that Quartz and Splash do not drop. |
| Blending and opacity | `mix-blend-mode` as an isolated group. Opacity groups are no longer clipped to the bounding box, which cut off shifted text. |
| Text | Vertical `writing-mode` (`tb`, `vertical-rl`): CJK upright from the vertical origin of the font (`VORG`, `vmtx`), other scripts sideways, `rotate` about the origin of each glyph as in resvg. `textPath` (SVG 2 `path` attribute, `href`, `startOffset`, `text-anchor`, `<tspan>` inside, decorations). A run with one position at most is laid out whole, so Arabic joins, bidi orders and kerning and ligatures apply; laid out one character at a time, on a path or down a column, Arabic in one text node still takes its joining forms and runs right to left; `letter-spacing` skips cursive scripts. `dominant-baseline` and `alignment-baseline` from real font metrics, `text-decoration` placed and sized as in Chromium, underlines and overlines skip the ink of the glyphs, font-size keywords, a relative font size inherited as computed and `ex` from the x-height of the font, nested `<text>` ignored, `text-anchor` per text chunk. A filter, mask or clip on text uses the box of the text as drawn. |
| Fonts | A CSS family list resolves to one family as Chromium on Linux does, with its metric-compatible replacements (Arial to Liberation Sans). A character the family lacks comes from the sans-serif list, as in Chromium. |
| Markers | `orient="auto"` follows the tangents of lines, curves and arcs, and a mid marker bisects its vertex with the angle wrap of Chromium. |
| Presentation attributes | An empty attribute such as `fill=""` is ignored, as in browsers. |

## How it compares to Chromium

The bench in [`tools/visual`](https://github.com/grigoriev/weasysvg/tree/main/tools/visual) renders a corpus of 115 SVGs. The corpus holds draw.io,
Mermaid, PlantUML, Graphviz, D2 and BPMN diagrams, real-world files from Wikimedia Commons, and tests of
the resvg suite. Every SVG has a reference: Chromium, resvg, or both where they agree (69 SVGs). For the resvg tests the expected
image of the resvg suite decides between Chromium and resvg where they differ. `make review` compares the
renderings at twice the resolution of the panels, in an 8 x 8 grid, and a look at each cell that differs
gives the verdict:

| Verdict | WeasyPrint 70 | WeasyPrint 70 with weasysvg |
|---|---|---|
| ⬆️ better than the reference | 0 | 1 |
| ✅ matches the reference | 39 | 110 |
| 🟡 close to the reference | 22 | 4 |
| ❌ differs from the reference | 54 | 0 |

Of the 35 diagrams from draw.io, kroki and mermaid.ink, 34 match the reference and 1 is close.
Each verdict says what differs. The verdicts are in [`corpus/verdicts.json`](https://github.com/grigoriev/weasysvg/blob/main/corpus/verdicts.json). The [comparison page](https://grigoriev.github.io/weasysvg/corpus/comparison.html)
shows every SVG in Chromium, your own browser, resvg, WeasyPrint alone and WeasyPrint with weasysvg, in a row,
with the reference marked and a swipe between any two of them, as GitHub compares images;
[`corpus/COMPARISON.md`](https://github.com/grigoriev/weasysvg/blob/main/corpus/COMPARISON.md) holds the same in the repository.

## Known gaps

- On a `textPath` and in vertical text each character is laid out alone. Arabic takes its joining forms,
  but loses the forms and ligatures some fonts pick from the whole word (Amiri): such a word runs longer.
- On a `textPath` and in vertical text, joining and right-to-left order stop at the edge of a `<tspan>`: an
  Arabic word split by one falls apart there.
- An underline or overline skips no ink under a turned character (`rotate`) or on vertical text.
- Text with several `y` or `dy` values draws no decoration line.
- A plain rect is blurred exactly. Any other shape is blurred in levels by the distance to its nearest
  edge, which knows nothing of the far edge: a filled shape thinner than about two deviations comes out
  darker and sharper than in Chromium, and corners come out mitered inside the shape.
- Lighting, morphology, convolution and displacement have no vector form: the element draws
  unfiltered, with a warning.
- `feComposite` arithmetic needs one input to be a flood of the whole filter region, or both inputs to
  be the same. A translucent result composites over the value of the formula on nothing.
- Except on a plain rect, a blur with two different deviations uses their mean.

## Development

Tests and the bench run in Docker, since WeasyPrint needs Pango. See [CONTRIBUTING.md](https://github.com/grigoriev/weasysvg/blob/main/CONTRIBUTING.md).

```sh
make lint     # ruff and mypy, on the host
make test     # pytest with coverage, in Docker
make visual   # the visual bench, report in tools/visual/out/report.html
make comparison # corpus/COMPARISON.md, the renderings of every SVG side by side
make upstream # probes of the WeasyPrint gaps and of the hooked internals
```

weasysvg supports the latest WeasyPrint major only. [UPGRADING.md](https://github.com/grigoriev/weasysvg/blob/main/UPGRADING.md) describes how a new one
is taken: which patches it makes redundant, which internals changed, what renders worse.

## Contributing

See [CONTRIBUTING.md](https://github.com/grigoriev/weasysvg/blob/main/CONTRIBUTING.md). Report vulnerabilities privately, see [SECURITY.md](https://github.com/grigoriev/weasysvg/blob/main/SECURITY.md).

## Disclaimer

This software is provided "as is", without warranty of any kind, as the LICENSE states. Use
it at your own risk. Sergey Grigoriev is not liable for damage from its use, as far as the law
allows. It is published free of charge, outside of any commercial offering, with no
obligation to support it. Security reports are welcome, see [SECURITY.md](https://github.com/grigoriev/weasysvg/blob/main/SECURITY.md).

## License

MIT, see [LICENSE](https://github.com/grigoriev/weasysvg/blob/main/LICENSE). The test corpus keeps the licenses of its sources, see
[corpus/SOURCES.md](https://github.com/grigoriev/weasysvg/blob/main/corpus/SOURCES.md).
