weasysvg 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. weasysvg-0.1.0/.gitignore +52 -0
  2. weasysvg-0.1.0/CHANGELOG.md +46 -0
  3. weasysvg-0.1.0/LICENSE +21 -0
  4. weasysvg-0.1.0/PKG-INFO +209 -0
  5. weasysvg-0.1.0/README.md +169 -0
  6. weasysvg-0.1.0/pyproject.toml +113 -0
  7. weasysvg-0.1.0/src/weasysvg/__init__.py +100 -0
  8. weasysvg-0.1.0/src/weasysvg/_core.py +450 -0
  9. weasysvg-0.1.0/src/weasysvg/conditional.py +169 -0
  10. weasysvg-0.1.0/src/weasysvg/fetch.py +48 -0
  11. weasysvg-0.1.0/src/weasysvg/filters.py +1234 -0
  12. weasysvg-0.1.0/src/weasysvg/fonts.py +150 -0
  13. weasysvg-0.1.0/src/weasysvg/foreign_object.py +433 -0
  14. weasysvg-0.1.0/src/weasysvg/glyphs.py +200 -0
  15. weasysvg-0.1.0/src/weasysvg/ink.py +235 -0
  16. weasysvg-0.1.0/src/weasysvg/markers.py +122 -0
  17. weasysvg-0.1.0/src/weasysvg/paint.py +564 -0
  18. weasysvg-0.1.0/src/weasysvg/pathdata.py +357 -0
  19. weasysvg-0.1.0/src/weasysvg/py.typed +0 -0
  20. weasysvg-0.1.0/src/weasysvg/text.py +740 -0
  21. weasysvg-0.1.0/src/weasysvg/textpath.py +167 -0
  22. weasysvg-0.1.0/src/weasysvg/turbulence.py +185 -0
  23. weasysvg-0.1.0/src/weasysvg/vertical.py +194 -0
  24. weasysvg-0.1.0/tests/conftest.py +5 -0
  25. weasysvg-0.1.0/tests/helpers.py +140 -0
  26. weasysvg-0.1.0/tests/test_conditional.py +218 -0
  27. weasysvg-0.1.0/tests/test_fetch.py +54 -0
  28. weasysvg-0.1.0/tests/test_filters.py +991 -0
  29. weasysvg-0.1.0/tests/test_fonts.py +84 -0
  30. weasysvg-0.1.0/tests/test_foreign_object.py +468 -0
  31. weasysvg-0.1.0/tests/test_install.py +156 -0
  32. weasysvg-0.1.0/tests/test_markers.py +167 -0
  33. weasysvg-0.1.0/tests/test_paint.py +706 -0
  34. weasysvg-0.1.0/tests/test_text.py +1042 -0
@@ -0,0 +1,52 @@
1
+ # IDE
2
+ .idea/
3
+ .vscode/
4
+ *.swp
5
+ *.swo
6
+
7
+ # macOS
8
+ .DS_Store
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.py[cod]
13
+ *$py.class
14
+ *.so
15
+ .Python
16
+ build/
17
+ develop-eggs/
18
+ dist/
19
+ downloads/
20
+ eggs/
21
+ .eggs/
22
+ lib/
23
+ lib64/
24
+ parts/
25
+ sdist/
26
+ var/
27
+ wheels/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+
32
+ # Virtual environments
33
+ .venv/
34
+ venv/
35
+ ENV/
36
+
37
+ # Testing
38
+ .pytest_cache/
39
+ .coverage
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+
44
+ # Type checking
45
+ .mypy_cache/
46
+
47
+ # Ruff
48
+ .ruff_cache/
49
+ coverage.xml
50
+
51
+ # Visual regression bench output
52
+ tools/visual/out/
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-21
11
+
12
+ ### Added
13
+
14
+ - `weasysvg.install()` patches WeasyPrint 70.x to render SVG close to Chromium: `<foreignObject>` with
15
+ the list markers of Chromium and the white space of empty inline boxes collapsed as there,
16
+ conditional processing, vector filter chains (blur, exact for a plain rect, color matrices and
17
+ transfer functions included), masks set before the path, pattern and gradient fixes, `mix-blend-mode`, unclipped
18
+ opacity groups, marker orientation, text baselines from font metrics, text-anchor per text chunk, filters, masks and clips on text, circles of the right length, dots of zero-length dashes in place in PDFium, negative dash offsets of odd dash arrays, decorations placed as in Chromium with underlines that skip the ink of the glyphs (laid out whole or character by character), font-size
19
+ keywords, relative font sizes inherited as computed, `ex` from the x-height of the font, and Chromium font selection. No SVG content is rasterized: the PDF stays vector.
20
+ - `feTurbulence`, drawn as an image of the noise, and `feComposite` arithmetic.
21
+ - `systemLanguage` reads the `lang` of the document where the image sits, else `install(language=...)`,
22
+ `en` by default: a German document shows the German branch of a `<switch>`.
23
+ - Arabic laid out one character at a time, on a path or down a column, takes its joining forms and runs
24
+ right to left, numbers in it read left to right.
25
+ - Upright CJK in vertical text hangs from the vertical origin of its font (`VORG`, `vmtx`), as in Chromium
26
+ and resvg, and `rotate` turns it about that point.
27
+ - Python 3.10 to 3.14 and PyPy 3.11, the versions WeasyPrint 70 supports.
28
+ - Releases on PyPI (`pip install weasysvg`), with publish attestations, and on GitHub, with signed
29
+ build provenance of the same files.
30
+ - Upstream tracking for WeasyPrint upgrades (UPGRADING.md): probes of the gaps weasysvg covers, a
31
+ snapshot of the internals it hooks, a bench baseline, and a weekly run against the newest WeasyPrint.
32
+ - A visual regression corpus of 115 SVGs with Chromium and resvg references, and a bench that
33
+ compares weasysvg with Chromium.
34
+ - `corpus/COMPARISON.md` and the interactive `corpus/comparison.html` on GitHub Pages: every corpus SVG
35
+ in Chromium, resvg, WeasyPrint alone and with weasysvg, each panel at 2x as lossless WebP, sharp when zoomed.
36
+ - Each corpus SVG names its reference (Chromium, resvg, or both). The verdicts say
37
+ whether a rendering is better than it, matches it, comes close or differs, and what differs, in a
38
+ table with an icon per status. The comparison page shows the SVG in your own browser too, and a swipe
39
+ between any two renderers.
40
+ - `make review`: the renderings of each corpus SVG compared region by region at twice the resolution of the
41
+ references, with sheets of the cells that differ, to judge the verdicts at full size.
42
+ - The visual image has Trebuchet MS, the first font of Mermaid, as Safari has it: Mermaid labels fit their
43
+ boxes in the references.
44
+ - Chromium prints the references with the Accept-Language list of a desktop Chrome in English
45
+ (`en-US,en`): `systemLanguage` matches English as there.
46
+ - `corpus/fonts`: the fonts the resvg tests ask for. The references, weasysvg, resvg and the browser panel of the comparison page all use them.
weasysvg-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergey Grigoriev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.5
2
+ Name: weasysvg
3
+ Version: 0.1.0
4
+ Summary: Native SVG rendering for WeasyPrint, close to Chromium: foreignObject, filters, masks, blending and text.
5
+ Project-URL: Homepage, https://github.com/grigoriev/weasysvg
6
+ Project-URL: Repository, https://github.com/grigoriev/weasysvg
7
+ Project-URL: Issues, https://github.com/grigoriev/weasysvg/issues
8
+ Author-email: Sergey Grigoriev <grigoriev@fastmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: Implementation :: CPython
22
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
23
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
24
+ Classifier: Topic :: Printing
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: fonttools>=4.59.2
28
+ Requires-Dist: weasyprint<71,>=70
29
+ Provides-Extra: dev
30
+ Requires-Dist: mypy==2.4.0; extra == 'dev'
31
+ Requires-Dist: pypdfium2==5.14.0; extra == 'dev'
32
+ Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
33
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
34
+ Requires-Dist: ruff==0.17.1; extra == 'dev'
35
+ Provides-Extra: visual
36
+ Requires-Dist: numpy==2.5.4; (python_version >= '3.12') and extra == 'visual'
37
+ Requires-Dist: playwright==1.64.0; extra == 'visual'
38
+ Requires-Dist: resvg-py==0.5.0; extra == 'visual'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # weasysvg
42
+
43
+ [![CI](https://github.com/grigoriev/weasysvg/actions/workflows/ci.yml/badge.svg)](https://github.com/grigoriev/weasysvg/actions/workflows/ci.yml)
44
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/grigoriev/weasysvg/badge)](https://scorecard.dev/viewer/?uri=github.com/grigoriev/weasysvg)
45
+ [![Release](https://img.shields.io/github/v/release/grigoriev/weasysvg)](https://github.com/grigoriev/weasysvg/releases)
46
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
47
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
48
+ [![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)
49
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/grigoriev/weasysvg/blob/main/LICENSE)
50
+
51
+ [![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)
52
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=grigoriev_weasysvg&metric=coverage)](https://sonarcloud.io/summary/new_code?id=grigoriev_weasysvg)
53
+
54
+ A plugin for [WeasyPrint](https://weasyprint.org) 70.x that renders SVG natively, close to Chromium.
55
+
56
+ ## Why?
57
+
58
+ WeasyPrint draws SVG as vector graphics, but it lacks `<foreignObject>` and has gaps in filters, masks,
59
+ blending and text. Diagram tools depend on these: draw.io and Mermaid put their labels in
60
+ `<foreignObject>`, D2 masks its edge labels, and many tools draw shadows with filters. Services that
61
+ turn HTML into PDF therefore rasterize SVG in a headless Chromium first.
62
+
63
+ weasysvg closes these gaps inside WeasyPrint. The SVG stays vector, text stays selectable, and the
64
+ service needs no browser.
65
+
66
+ ## Installation
67
+
68
+ weasysvg runs on CPython 3.10 to 3.14 and on PyPy 3.11, as WeasyPrint 70 does. On PyPy a C compiler
69
+ must be present: zopfli, which WeasyPrint needs, has no PyPy wheel and builds from source.
70
+
71
+ Install it from PyPI:
72
+
73
+ ```sh
74
+ pip install weasysvg
75
+ ```
76
+
77
+ weasysvg depends on WeasyPrint only. Every SVG feature it adds stays vector in the PDF, blur included.
78
+ The one exception is the noise of `feTurbulence`: it has no vector form, so it is an image, computed in
79
+ Python.
80
+
81
+ WeasyPrint needs Pango, see its
82
+ [installation guide](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html).
83
+
84
+ ### Verify
85
+
86
+ The build of each release has signed provenance on GitHub, and the GitHub release carries its bundle.
87
+ PyPI and the GitHub release hold the same files, so verify a file from either with
88
+ `gh attestation verify <file> --repo grigoriev/weasysvg`. On PyPI each file also has a publish
89
+ attestation (PEP 740): it ties the upload to `release.yml` of this repository.
90
+
91
+ ## Usage
92
+
93
+ Call `install()` once, before the first rendering:
94
+
95
+ ```python
96
+ import weasyprint
97
+ import weasysvg
98
+
99
+ weasysvg.install()
100
+ weasyprint.HTML("report.html").write_pdf("report.pdf")
101
+ ```
102
+
103
+ `install()` patches WeasyPrint in place, for the whole process. It is idempotent, and it returns the
104
+ names of the installed features. Each feature can be turned off:
105
+
106
+ ```python
107
+ weasysvg.install(filters=False, fonts=False)
108
+ ```
109
+
110
+ | Argument | Default | Effect |
111
+ |---|---|---|
112
+ | `conditional_processing` | `True` | `<switch>`, `requiredExtensions`, `systemLanguage` |
113
+ | `foreign_object` | `True` | `<foreignObject>` content laid out by WeasyPrint |
114
+ | `filters` | `True` | filter chains, `drop-shadow()` and `blur()` |
115
+ | `paint` | `True` | gradients, patterns and masks |
116
+ | `blend` | `True` | `mix-blend-mode` (needs `paint`) |
117
+ | `text` | `True` | text baselines, decorations, font-size keywords |
118
+ | `fonts` | `True` | font family selection as Chromium does it |
119
+ | `fetch_cache` | `True` | one fetch per URL and SVG |
120
+ | `markers` | `True` | marker orientation as in Chromium |
121
+ | `language` | `"en"` | the language `systemLanguage` reads where the document has no `lang` |
122
+
123
+ weasysvg targets WeasyPrint 70.x and warns on any other major version: it patches internals that
124
+ change between releases.
125
+
126
+ ## Features
127
+
128
+ | Area | What weasysvg adds |
129
+ |---|---|
130
+ | `<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. |
131
+ | 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`. |
132
+ | 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. |
133
+ | 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. |
134
+ | 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. |
135
+ | 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. |
136
+ | 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. |
137
+ | 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. |
138
+ | Markers | `orient="auto"` follows the tangents of lines, curves and arcs, and a mid marker bisects its vertex with the angle wrap of Chromium. |
139
+ | Presentation attributes | An empty attribute such as `fill=""` is ignored, as in browsers. |
140
+
141
+ ## How it compares to Chromium
142
+
143
+ 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,
144
+ Mermaid, PlantUML, Graphviz, D2 and BPMN diagrams, real-world files from Wikimedia Commons, and tests of
145
+ the resvg suite. Every SVG has a reference: Chromium, resvg, or both where they agree (69 SVGs). For the resvg tests the expected
146
+ image of the resvg suite decides between Chromium and resvg where they differ. `make review` compares the
147
+ renderings at twice the resolution of the panels, in an 8 x 8 grid, and a look at each cell that differs
148
+ gives the verdict:
149
+
150
+ | Verdict | WeasyPrint 70 | WeasyPrint 70 with weasysvg |
151
+ |---|---|---|
152
+ | ⬆️ better than the reference | 0 | 1 |
153
+ | ✅ matches the reference | 39 | 110 |
154
+ | 🟡 close to the reference | 22 | 4 |
155
+ | ❌ differs from the reference | 54 | 0 |
156
+
157
+ Of the 35 diagrams from draw.io, kroki and mermaid.ink, 34 match the reference and 1 is close.
158
+ 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)
159
+ shows every SVG in Chromium, your own browser, resvg, WeasyPrint alone and WeasyPrint with weasysvg, in a row,
160
+ with the reference marked and a swipe between any two of them, as GitHub compares images;
161
+ [`corpus/COMPARISON.md`](https://github.com/grigoriev/weasysvg/blob/main/corpus/COMPARISON.md) holds the same in the repository.
162
+
163
+ ## Known gaps
164
+
165
+ - On a `textPath` and in vertical text each character is laid out alone. Arabic takes its joining forms,
166
+ but loses the forms and ligatures some fonts pick from the whole word (Amiri): such a word runs longer.
167
+ - On a `textPath` and in vertical text, joining and right-to-left order stop at the edge of a `<tspan>`: an
168
+ Arabic word split by one falls apart there.
169
+ - An underline or overline skips no ink under a turned character (`rotate`) or on vertical text.
170
+ - Text with several `y` or `dy` values draws no decoration line.
171
+ - A plain rect is blurred exactly. Any other shape is blurred in levels by the distance to its nearest
172
+ edge, which knows nothing of the far edge: a filled shape thinner than about two deviations comes out
173
+ darker and sharper than in Chromium, and corners come out mitered inside the shape.
174
+ - Lighting, morphology, convolution and displacement have no vector form: the element draws
175
+ unfiltered, with a warning.
176
+ - `feComposite` arithmetic needs one input to be a flood of the whole filter region, or both inputs to
177
+ be the same. A translucent result composites over the value of the formula on nothing.
178
+ - Except on a plain rect, a blur with two different deviations uses their mean.
179
+
180
+ ## Development
181
+
182
+ Tests and the bench run in Docker, since WeasyPrint needs Pango. See [CONTRIBUTING.md](https://github.com/grigoriev/weasysvg/blob/main/CONTRIBUTING.md).
183
+
184
+ ```sh
185
+ make lint # ruff and mypy, on the host
186
+ make test # pytest with coverage, in Docker
187
+ make visual # the visual bench, report in tools/visual/out/report.html
188
+ make comparison # corpus/COMPARISON.md, the renderings of every SVG side by side
189
+ make upstream # probes of the WeasyPrint gaps and of the hooked internals
190
+ ```
191
+
192
+ weasysvg supports the latest WeasyPrint major only. [UPGRADING.md](https://github.com/grigoriev/weasysvg/blob/main/UPGRADING.md) describes how a new one
193
+ is taken: which patches it makes redundant, which internals changed, what renders worse.
194
+
195
+ ## Contributing
196
+
197
+ 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).
198
+
199
+ ## Disclaimer
200
+
201
+ This software is provided "as is", without warranty of any kind, as the LICENSE states. Use
202
+ it at your own risk. Sergey Grigoriev is not liable for damage from its use, as far as the law
203
+ allows. It is published free of charge, outside of any commercial offering, with no
204
+ obligation to support it. Security reports are welcome, see [SECURITY.md](https://github.com/grigoriev/weasysvg/blob/main/SECURITY.md).
205
+
206
+ ## License
207
+
208
+ MIT, see [LICENSE](https://github.com/grigoriev/weasysvg/blob/main/LICENSE). The test corpus keeps the licenses of its sources, see
209
+ [corpus/SOURCES.md](https://github.com/grigoriev/weasysvg/blob/main/corpus/SOURCES.md).
@@ -0,0 +1,169 @@
1
+ # weasysvg
2
+
3
+ [![CI](https://github.com/grigoriev/weasysvg/actions/workflows/ci.yml/badge.svg)](https://github.com/grigoriev/weasysvg/actions/workflows/ci.yml)
4
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/grigoriev/weasysvg/badge)](https://scorecard.dev/viewer/?uri=github.com/grigoriev/weasysvg)
5
+ [![Release](https://img.shields.io/github/v/release/grigoriev/weasysvg)](https://github.com/grigoriev/weasysvg/releases)
6
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
7
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
8
+ [![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)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/grigoriev/weasysvg/blob/main/LICENSE)
10
+
11
+ [![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)
12
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=grigoriev_weasysvg&metric=coverage)](https://sonarcloud.io/summary/new_code?id=grigoriev_weasysvg)
13
+
14
+ A plugin for [WeasyPrint](https://weasyprint.org) 70.x that renders SVG natively, close to Chromium.
15
+
16
+ ## Why?
17
+
18
+ WeasyPrint draws SVG as vector graphics, but it lacks `<foreignObject>` and has gaps in filters, masks,
19
+ blending and text. Diagram tools depend on these: draw.io and Mermaid put their labels in
20
+ `<foreignObject>`, D2 masks its edge labels, and many tools draw shadows with filters. Services that
21
+ turn HTML into PDF therefore rasterize SVG in a headless Chromium first.
22
+
23
+ weasysvg closes these gaps inside WeasyPrint. The SVG stays vector, text stays selectable, and the
24
+ service needs no browser.
25
+
26
+ ## Installation
27
+
28
+ weasysvg runs on CPython 3.10 to 3.14 and on PyPy 3.11, as WeasyPrint 70 does. On PyPy a C compiler
29
+ must be present: zopfli, which WeasyPrint needs, has no PyPy wheel and builds from source.
30
+
31
+ Install it from PyPI:
32
+
33
+ ```sh
34
+ pip install weasysvg
35
+ ```
36
+
37
+ weasysvg depends on WeasyPrint only. Every SVG feature it adds stays vector in the PDF, blur included.
38
+ The one exception is the noise of `feTurbulence`: it has no vector form, so it is an image, computed in
39
+ Python.
40
+
41
+ WeasyPrint needs Pango, see its
42
+ [installation guide](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html).
43
+
44
+ ### Verify
45
+
46
+ The build of each release has signed provenance on GitHub, and the GitHub release carries its bundle.
47
+ PyPI and the GitHub release hold the same files, so verify a file from either with
48
+ `gh attestation verify <file> --repo grigoriev/weasysvg`. On PyPI each file also has a publish
49
+ attestation (PEP 740): it ties the upload to `release.yml` of this repository.
50
+
51
+ ## Usage
52
+
53
+ Call `install()` once, before the first rendering:
54
+
55
+ ```python
56
+ import weasyprint
57
+ import weasysvg
58
+
59
+ weasysvg.install()
60
+ weasyprint.HTML("report.html").write_pdf("report.pdf")
61
+ ```
62
+
63
+ `install()` patches WeasyPrint in place, for the whole process. It is idempotent, and it returns the
64
+ names of the installed features. Each feature can be turned off:
65
+
66
+ ```python
67
+ weasysvg.install(filters=False, fonts=False)
68
+ ```
69
+
70
+ | Argument | Default | Effect |
71
+ |---|---|---|
72
+ | `conditional_processing` | `True` | `<switch>`, `requiredExtensions`, `systemLanguage` |
73
+ | `foreign_object` | `True` | `<foreignObject>` content laid out by WeasyPrint |
74
+ | `filters` | `True` | filter chains, `drop-shadow()` and `blur()` |
75
+ | `paint` | `True` | gradients, patterns and masks |
76
+ | `blend` | `True` | `mix-blend-mode` (needs `paint`) |
77
+ | `text` | `True` | text baselines, decorations, font-size keywords |
78
+ | `fonts` | `True` | font family selection as Chromium does it |
79
+ | `fetch_cache` | `True` | one fetch per URL and SVG |
80
+ | `markers` | `True` | marker orientation as in Chromium |
81
+ | `language` | `"en"` | the language `systemLanguage` reads where the document has no `lang` |
82
+
83
+ weasysvg targets WeasyPrint 70.x and warns on any other major version: it patches internals that
84
+ change between releases.
85
+
86
+ ## Features
87
+
88
+ | Area | What weasysvg adds |
89
+ |---|---|
90
+ | `<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. |
91
+ | 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`. |
92
+ | 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. |
93
+ | 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. |
94
+ | 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. |
95
+ | 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. |
96
+ | 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. |
97
+ | 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. |
98
+ | Markers | `orient="auto"` follows the tangents of lines, curves and arcs, and a mid marker bisects its vertex with the angle wrap of Chromium. |
99
+ | Presentation attributes | An empty attribute such as `fill=""` is ignored, as in browsers. |
100
+
101
+ ## How it compares to Chromium
102
+
103
+ 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,
104
+ Mermaid, PlantUML, Graphviz, D2 and BPMN diagrams, real-world files from Wikimedia Commons, and tests of
105
+ the resvg suite. Every SVG has a reference: Chromium, resvg, or both where they agree (69 SVGs). For the resvg tests the expected
106
+ image of the resvg suite decides between Chromium and resvg where they differ. `make review` compares the
107
+ renderings at twice the resolution of the panels, in an 8 x 8 grid, and a look at each cell that differs
108
+ gives the verdict:
109
+
110
+ | Verdict | WeasyPrint 70 | WeasyPrint 70 with weasysvg |
111
+ |---|---|---|
112
+ | ⬆️ better than the reference | 0 | 1 |
113
+ | ✅ matches the reference | 39 | 110 |
114
+ | 🟡 close to the reference | 22 | 4 |
115
+ | ❌ differs from the reference | 54 | 0 |
116
+
117
+ Of the 35 diagrams from draw.io, kroki and mermaid.ink, 34 match the reference and 1 is close.
118
+ 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)
119
+ shows every SVG in Chromium, your own browser, resvg, WeasyPrint alone and WeasyPrint with weasysvg, in a row,
120
+ with the reference marked and a swipe between any two of them, as GitHub compares images;
121
+ [`corpus/COMPARISON.md`](https://github.com/grigoriev/weasysvg/blob/main/corpus/COMPARISON.md) holds the same in the repository.
122
+
123
+ ## Known gaps
124
+
125
+ - On a `textPath` and in vertical text each character is laid out alone. Arabic takes its joining forms,
126
+ but loses the forms and ligatures some fonts pick from the whole word (Amiri): such a word runs longer.
127
+ - On a `textPath` and in vertical text, joining and right-to-left order stop at the edge of a `<tspan>`: an
128
+ Arabic word split by one falls apart there.
129
+ - An underline or overline skips no ink under a turned character (`rotate`) or on vertical text.
130
+ - Text with several `y` or `dy` values draws no decoration line.
131
+ - A plain rect is blurred exactly. Any other shape is blurred in levels by the distance to its nearest
132
+ edge, which knows nothing of the far edge: a filled shape thinner than about two deviations comes out
133
+ darker and sharper than in Chromium, and corners come out mitered inside the shape.
134
+ - Lighting, morphology, convolution and displacement have no vector form: the element draws
135
+ unfiltered, with a warning.
136
+ - `feComposite` arithmetic needs one input to be a flood of the whole filter region, or both inputs to
137
+ be the same. A translucent result composites over the value of the formula on nothing.
138
+ - Except on a plain rect, a blur with two different deviations uses their mean.
139
+
140
+ ## Development
141
+
142
+ Tests and the bench run in Docker, since WeasyPrint needs Pango. See [CONTRIBUTING.md](https://github.com/grigoriev/weasysvg/blob/main/CONTRIBUTING.md).
143
+
144
+ ```sh
145
+ make lint # ruff and mypy, on the host
146
+ make test # pytest with coverage, in Docker
147
+ make visual # the visual bench, report in tools/visual/out/report.html
148
+ make comparison # corpus/COMPARISON.md, the renderings of every SVG side by side
149
+ make upstream # probes of the WeasyPrint gaps and of the hooked internals
150
+ ```
151
+
152
+ weasysvg supports the latest WeasyPrint major only. [UPGRADING.md](https://github.com/grigoriev/weasysvg/blob/main/UPGRADING.md) describes how a new one
153
+ is taken: which patches it makes redundant, which internals changed, what renders worse.
154
+
155
+ ## Contributing
156
+
157
+ 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).
158
+
159
+ ## Disclaimer
160
+
161
+ This software is provided "as is", without warranty of any kind, as the LICENSE states. Use
162
+ it at your own risk. Sergey Grigoriev is not liable for damage from its use, as far as the law
163
+ allows. It is published free of charge, outside of any commercial offering, with no
164
+ obligation to support it. Security reports are welcome, see [SECURITY.md](https://github.com/grigoriev/weasysvg/blob/main/SECURITY.md).
165
+
166
+ ## License
167
+
168
+ MIT, see [LICENSE](https://github.com/grigoriev/weasysvg/blob/main/LICENSE). The test corpus keeps the licenses of its sources, see
169
+ [corpus/SOURCES.md](https://github.com/grigoriev/weasysvg/blob/main/corpus/SOURCES.md).
@@ -0,0 +1,113 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "weasysvg"
7
+ version = "0.1.0"
8
+ description = "Native SVG rendering for WeasyPrint, close to Chromium: foreignObject, filters, masks, blending and text."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [
12
+ { name = "Sergey Grigoriev", email = "grigoriev@fastmail.com" }
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Programming Language :: Python :: Implementation :: CPython",
26
+ "Programming Language :: Python :: Implementation :: PyPy",
27
+ "Topic :: Multimedia :: Graphics :: Graphics Conversion",
28
+ "Topic :: Printing",
29
+ "Typing :: Typed",
30
+ ]
31
+ requires-python = ">=3.10"
32
+ dependencies = [
33
+ # ink.py reads glyph outlines; WeasyPrint 70 requires it already
34
+ "fonttools>=4.59.2",
35
+ "weasyprint>=70,<71",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest==9.1.1",
41
+ "pytest-cov==7.1.0",
42
+ "mypy==2.4.0",
43
+ "ruff==0.17.1",
44
+ "pypdfium2==5.14.0",
45
+ ]
46
+ # The visual regression bench in tools/visual, run in Docker
47
+ visual = [
48
+ "numpy==2.5.4; python_version >= '3.12'",
49
+ "playwright==1.64.0",
50
+ "resvg-py==0.5.0",
51
+ ]
52
+
53
+ [project.urls]
54
+ Homepage = "https://github.com/grigoriev/weasysvg"
55
+ Repository = "https://github.com/grigoriev/weasysvg"
56
+ Issues = "https://github.com/grigoriev/weasysvg/issues"
57
+
58
+ [tool.hatch.build.targets.wheel]
59
+ packages = ["src/weasysvg"]
60
+
61
+ [tool.hatch.build.targets.sdist]
62
+ include = ["src/weasysvg", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
63
+
64
+ [tool.pytest.ini_options]
65
+ testpaths = ["tests"]
66
+
67
+ [tool.ruff]
68
+ line-length = 240
69
+ target-version = "py310"
70
+
71
+ [tool.ruff.lint]
72
+ select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
73
+ ignore = []
74
+
75
+ [tool.ruff.lint.isort]
76
+ known-first-party = ["weasysvg"]
77
+
78
+ [tool.uv]
79
+ required-version = ">=0.5.0"
80
+
81
+ [tool.coverage.run]
82
+ source = ["weasysvg"]
83
+ branch = true
84
+
85
+ [tool.coverage.report]
86
+ fail_under = 90
87
+ show_missing = true
88
+ exclude_lines = [
89
+ "pragma: no cover",
90
+ "if TYPE_CHECKING:",
91
+ "if __name__ == .__main__.:",
92
+ ]
93
+
94
+ [tool.mypy]
95
+ python_version = "3.10"
96
+ strict = true
97
+ warn_return_any = true
98
+ warn_unused_configs = true
99
+ disallow_untyped_defs = true
100
+ disallow_incomplete_defs = true
101
+ check_untyped_defs = true
102
+ disallow_untyped_decorators = true
103
+ no_implicit_optional = true
104
+ warn_redundant_casts = true
105
+ warn_unused_ignores = true
106
+ warn_no_return = true
107
+ follow_imports = "normal"
108
+ show_error_codes = true
109
+
110
+ # WeasyPrint ships no type information
111
+ [[tool.mypy.overrides]]
112
+ module = ["weasyprint.*", "weasyprint", "pypdfium2.*", "numpy.*", "fontTools.*"]
113
+ ignore_missing_imports = true