svganim 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.
- svganim-0.1.0/LICENSE +21 -0
- svganim-0.1.0/PKG-INFO +134 -0
- svganim-0.1.0/README.md +110 -0
- svganim-0.1.0/pyproject.toml +65 -0
- svganim-0.1.0/pyproject.toml.orig +54 -0
- svganim-0.1.0/src/svganim/__init__.py +5 -0
- svganim-0.1.0/src/svganim/_core.py +360 -0
- svganim-0.1.0/src/svganim/py.typed +0 -0
svganim-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleix Alcacer Sales
|
|
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.
|
svganim-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: svganim
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn a matplotlib figure and an update function into a single self-contained animated SVG. No GIFs, no JavaScript.
|
|
5
|
+
Keywords: matplotlib,svg,animation,smil,data-visualization
|
|
6
|
+
Author: Aleix Alcacer Sales
|
|
7
|
+
Author-email: Aleix Alcacer Sales <aalcacer@uji.es>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: matplotlib>=3.10.9
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Documentation, https://svganim.readthedocs.io
|
|
22
|
+
Project-URL: Repository, https://github.com/aleixalcacer/svganim
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
<div align="center">
|
|
26
|
+
<picture>
|
|
27
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/aleixalcacer/svganim/main/assets/logo-horizontal-dark.svg">
|
|
28
|
+
<img src="https://raw.githubusercontent.com/aleixalcacer/svganim/main/assets/logo-horizontal.svg" alt="svganim logo" width="360">
|
|
29
|
+
</picture>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div align="center">
|
|
33
|
+
<a href="https://svganim.readthedocs.io"><img src="https://readthedocs.org/projects/svganim/badge/?version=latest" alt="Documentation status"></a>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
Turn a matplotlib figure and a per-frame update function into one
|
|
37
|
+
self-contained, looping, animated SVG.
|
|
38
|
+
|
|
39
|
+
<div align="center">
|
|
40
|
+
<img src="https://raw.githubusercontent.com/aleixalcacer/svganim/main/assets/sorting.svg" alt="Bubble sort: sixteen bars changing height and colour">
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
## Why svganim
|
|
44
|
+
|
|
45
|
+
- **A real image.** One SVG file that works in an `<img>` tag and in GitHub
|
|
46
|
+
READMEs. No JavaScript, no HTML page, no player.
|
|
47
|
+
- **Vector.** Sharp at any size, unlike a GIF.
|
|
48
|
+
- **Only what changes is animated.** Axes, labels and static data are written
|
|
49
|
+
once, so files stay small. For the same 60-frame figure we got 91 KiB, against
|
|
50
|
+
1.1 MiB from matplotlib's `HTMLWriter` with SVG frames.
|
|
51
|
+
- **Reproducible.** Same code and matplotlib version, same bytes.
|
|
52
|
+
|
|
53
|
+
What you give up: playback controls, and changes to the number of elements or
|
|
54
|
+
the text between frames (see [Limitations](#limitations)). If you need those,
|
|
55
|
+
use `HTMLWriter` or `to_jshtml`.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install svganim
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import matplotlib.pyplot as plt
|
|
67
|
+
import numpy as np
|
|
68
|
+
from svganim import anim_to_svg
|
|
69
|
+
|
|
70
|
+
fig, ax = plt.subplots()
|
|
71
|
+
x = np.linspace(0, 2 * np.pi, 200)
|
|
72
|
+
(line,) = ax.plot(x, np.sin(x))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def update(i):
|
|
76
|
+
line.set_ydata(np.sin(x + i / 10))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
anim_to_svg(fig, update, n_frames=60, fps=20, hold=1.0, path="wave.svg")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<img src="wave.svg" alt="A moving sine wave">
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`update` must change artists that already exist (`set_data`, `set_offsets`,
|
|
87
|
+
`set_color`, ...), not create or remove them. A line whose data grows is fine,
|
|
88
|
+
so draw trails and curves with `set_data` on a single line.
|
|
89
|
+
|
|
90
|
+
### `anim_to_svg(fig, update, n_frames, fps=20, hold=1.0, path=None, *, precision=3, interpolate=False)`
|
|
91
|
+
|
|
92
|
+
| Argument | Description |
|
|
93
|
+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
94
|
+
| `fig` | The matplotlib figure to render. |
|
|
95
|
+
| `update` | Called as `update(i)` before frame `i` is rendered. |
|
|
96
|
+
| `n_frames` | Number of frames. |
|
|
97
|
+
| `fps` | Frames per second. |
|
|
98
|
+
| `hold` | Seconds to hold the last frame before looping. |
|
|
99
|
+
| `path` | If given, the SVG is also written to this file. |
|
|
100
|
+
| `precision` | Decimals kept in coordinates. Lower means smaller files. |
|
|
101
|
+
| `interpolate` | If `True`, shapes glide and colours fade between frames instead of switching. See the [k-means example](https://svganim.readthedocs.io/en/latest/examples/kmeans.html). |
|
|
102
|
+
|
|
103
|
+
Returns the SVG as a string. Raises `ValueError` if the figure breaks a rule
|
|
104
|
+
below; the message names the element that changed.
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
Each frame is rendered to SVG and the first one becomes the base document. Later
|
|
109
|
+
frames are compared with it, and every attribute that changes gets a
|
|
110
|
+
[SMIL](https://developer.mozilla.org/docs/Web/SVG/SMIL) animation. Elements that
|
|
111
|
+
never change are left untouched.
|
|
112
|
+
|
|
113
|
+
## Limitations
|
|
114
|
+
|
|
115
|
+
- The number of SVG elements must be the same in every frame: no new artists,
|
|
116
|
+
no changing text, no `imshow`.
|
|
117
|
+
- There are no playback controls, only a loop.
|
|
118
|
+
|
|
119
|
+
## Examples and docs
|
|
120
|
+
|
|
121
|
+
The documentation has a gallery of notebooks that explain each example, plus an
|
|
122
|
+
API reference built from the docstrings. It also shows how animations display
|
|
123
|
+
in Jupyter and Quarto.
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv run pytest
|
|
129
|
+
uv run sphinx-build -W docs docs/_build/html
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
svganim-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/aleixalcacer/svganim/main/assets/logo-horizontal-dark.svg">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/aleixalcacer/svganim/main/assets/logo-horizontal.svg" alt="svganim logo" width="360">
|
|
5
|
+
</picture>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div align="center">
|
|
9
|
+
<a href="https://svganim.readthedocs.io"><img src="https://readthedocs.org/projects/svganim/badge/?version=latest" alt="Documentation status"></a>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
Turn a matplotlib figure and a per-frame update function into one
|
|
13
|
+
self-contained, looping, animated SVG.
|
|
14
|
+
|
|
15
|
+
<div align="center">
|
|
16
|
+
<img src="https://raw.githubusercontent.com/aleixalcacer/svganim/main/assets/sorting.svg" alt="Bubble sort: sixteen bars changing height and colour">
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
## Why svganim
|
|
20
|
+
|
|
21
|
+
- **A real image.** One SVG file that works in an `<img>` tag and in GitHub
|
|
22
|
+
READMEs. No JavaScript, no HTML page, no player.
|
|
23
|
+
- **Vector.** Sharp at any size, unlike a GIF.
|
|
24
|
+
- **Only what changes is animated.** Axes, labels and static data are written
|
|
25
|
+
once, so files stay small. For the same 60-frame figure we got 91 KiB, against
|
|
26
|
+
1.1 MiB from matplotlib's `HTMLWriter` with SVG frames.
|
|
27
|
+
- **Reproducible.** Same code and matplotlib version, same bytes.
|
|
28
|
+
|
|
29
|
+
What you give up: playback controls, and changes to the number of elements or
|
|
30
|
+
the text between frames (see [Limitations](#limitations)). If you need those,
|
|
31
|
+
use `HTMLWriter` or `to_jshtml`.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install svganim
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import matplotlib.pyplot as plt
|
|
43
|
+
import numpy as np
|
|
44
|
+
from svganim import anim_to_svg
|
|
45
|
+
|
|
46
|
+
fig, ax = plt.subplots()
|
|
47
|
+
x = np.linspace(0, 2 * np.pi, 200)
|
|
48
|
+
(line,) = ax.plot(x, np.sin(x))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def update(i):
|
|
52
|
+
line.set_ydata(np.sin(x + i / 10))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
anim_to_svg(fig, update, n_frames=60, fps=20, hold=1.0, path="wave.svg")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```html
|
|
59
|
+
<img src="wave.svg" alt="A moving sine wave">
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`update` must change artists that already exist (`set_data`, `set_offsets`,
|
|
63
|
+
`set_color`, ...), not create or remove them. A line whose data grows is fine,
|
|
64
|
+
so draw trails and curves with `set_data` on a single line.
|
|
65
|
+
|
|
66
|
+
### `anim_to_svg(fig, update, n_frames, fps=20, hold=1.0, path=None, *, precision=3, interpolate=False)`
|
|
67
|
+
|
|
68
|
+
| Argument | Description |
|
|
69
|
+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| `fig` | The matplotlib figure to render. |
|
|
71
|
+
| `update` | Called as `update(i)` before frame `i` is rendered. |
|
|
72
|
+
| `n_frames` | Number of frames. |
|
|
73
|
+
| `fps` | Frames per second. |
|
|
74
|
+
| `hold` | Seconds to hold the last frame before looping. |
|
|
75
|
+
| `path` | If given, the SVG is also written to this file. |
|
|
76
|
+
| `precision` | Decimals kept in coordinates. Lower means smaller files. |
|
|
77
|
+
| `interpolate` | If `True`, shapes glide and colours fade between frames instead of switching. See the [k-means example](https://svganim.readthedocs.io/en/latest/examples/kmeans.html). |
|
|
78
|
+
|
|
79
|
+
Returns the SVG as a string. Raises `ValueError` if the figure breaks a rule
|
|
80
|
+
below; the message names the element that changed.
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
Each frame is rendered to SVG and the first one becomes the base document. Later
|
|
85
|
+
frames are compared with it, and every attribute that changes gets a
|
|
86
|
+
[SMIL](https://developer.mozilla.org/docs/Web/SVG/SMIL) animation. Elements that
|
|
87
|
+
never change are left untouched.
|
|
88
|
+
|
|
89
|
+
## Limitations
|
|
90
|
+
|
|
91
|
+
- The number of SVG elements must be the same in every frame: no new artists,
|
|
92
|
+
no changing text, no `imshow`.
|
|
93
|
+
- There are no playback controls, only a loop.
|
|
94
|
+
|
|
95
|
+
## Examples and docs
|
|
96
|
+
|
|
97
|
+
The documentation has a gallery of notebooks that explain each example, plus an
|
|
98
|
+
API reference built from the docstrings. It also shows how animations display
|
|
99
|
+
in Jupyter and Quarto.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
uv run pytest
|
|
105
|
+
uv run sphinx-build -W docs docs/_build/html
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "svganim"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Turn a matplotlib figure and an update function into a single self-contained animated SVG. No GIFs, no JavaScript."
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
keywords = [
|
|
8
|
+
"matplotlib",
|
|
9
|
+
"svg",
|
|
10
|
+
"animation",
|
|
11
|
+
"smil",
|
|
12
|
+
"data-visualization",
|
|
13
|
+
]
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
requires-python = ">=3.10"
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
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
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["matplotlib>=3.10.9"]
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "Aleix Alcacer Sales"
|
|
31
|
+
email = "aalcacer@uji.es"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Documentation = "https://svganim.readthedocs.io"
|
|
35
|
+
Repository = "https://github.com/aleixalcacer/svganim"
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = [
|
|
42
|
+
"E",
|
|
43
|
+
"F",
|
|
44
|
+
"I",
|
|
45
|
+
"B",
|
|
46
|
+
"UP",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.9.0,<0.10.0"]
|
|
51
|
+
build-backend = "uv_build"
|
|
52
|
+
|
|
53
|
+
[dependency-groups]
|
|
54
|
+
dev = [
|
|
55
|
+
"pytest>=9.1.1",
|
|
56
|
+
"ruff>=0.16.10",
|
|
57
|
+
]
|
|
58
|
+
docs = [
|
|
59
|
+
"furo>=2025.12.19",
|
|
60
|
+
"ipykernel>=7.3.0",
|
|
61
|
+
"myst-nb>=1.4.0",
|
|
62
|
+
"sphinx>=8.1.3",
|
|
63
|
+
"sphinx-autobuild>=2024.10.3",
|
|
64
|
+
"sphinx-design>=0.6.1",
|
|
65
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "svganim"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Turn a matplotlib figure and an update function into a single self-contained animated SVG. No GIFs, no JavaScript."
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
keywords = ["matplotlib", "svg", "animation", "smil", "data-visualization"]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Aleix Alcacer Sales", email = "aalcacer@uji.es" }
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Science/Research",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"matplotlib>=3.10.9",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Documentation = "https://svganim.readthedocs.io"
|
|
30
|
+
Repository = "https://github.com/aleixalcacer/svganim"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.9.0,<0.10.0"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=9.1.1",
|
|
45
|
+
"ruff>=0.16.10",
|
|
46
|
+
]
|
|
47
|
+
docs = [
|
|
48
|
+
"furo>=2025.12.19",
|
|
49
|
+
"ipykernel>=7.3.0",
|
|
50
|
+
"myst-nb>=1.4.0",
|
|
51
|
+
"sphinx>=8.1.3",
|
|
52
|
+
"sphinx-autobuild>=2024.10.3",
|
|
53
|
+
"sphinx-design>=0.6.1",
|
|
54
|
+
]
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from xml.etree import ElementTree as ET
|
|
9
|
+
|
|
10
|
+
import matplotlib as mpl
|
|
11
|
+
from matplotlib.figure import Figure
|
|
12
|
+
|
|
13
|
+
SVG = "http://www.w3.org/2000/svg"
|
|
14
|
+
XLINK = "http://www.w3.org/1999/xlink"
|
|
15
|
+
ET.register_namespace("", SVG)
|
|
16
|
+
ET.register_namespace("xlink", XLINK)
|
|
17
|
+
|
|
18
|
+
_DEFS = f"{{{SVG}}}defs"
|
|
19
|
+
_IMAGE = f"{{{SVG}}}image"
|
|
20
|
+
_METADATA = f"{{{SVG}}}metadata"
|
|
21
|
+
|
|
22
|
+
# Attributes whose numbers are rounded to `precision` decimals.
|
|
23
|
+
_NUMERIC = {"d", "transform", "x", "y", "width", "height", "points"}
|
|
24
|
+
_NUMBER = re.compile(r"-?\d+(?:\.\d+)?(?:e[-+]?\d+)?", re.IGNORECASE)
|
|
25
|
+
_TRANSFORM = re.compile(r"^(translate|scale|rotate|skewX|skewY)\(([^)]*)\)$")
|
|
26
|
+
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
27
|
+
|
|
28
|
+
# Attributes whose values SMIL can interpolate numerically.
|
|
29
|
+
_INTERPOLABLE = {
|
|
30
|
+
"d", "points", "transform", "x", "y", "x1", "y1", "x2", "y2",
|
|
31
|
+
"cx", "cy", "r", "rx", "ry", "width", "height", "stroke-width",
|
|
32
|
+
"opacity", "fill-opacity", "stroke-opacity",
|
|
33
|
+
} # fmt: skip
|
|
34
|
+
_COLOR = {"fill", "stroke"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SvgAnimation(str):
|
|
38
|
+
"""The SVG text, which Jupyter and Quarto display as an animated image."""
|
|
39
|
+
|
|
40
|
+
__slots__ = ()
|
|
41
|
+
|
|
42
|
+
def _repr_html_(self) -> str:
|
|
43
|
+
data = base64.b64encode(self.encode("utf-8")).decode("ascii")
|
|
44
|
+
return f'<img src="data:image/svg+xml;base64,{data}">'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def anim_to_svg(
|
|
48
|
+
fig: Figure,
|
|
49
|
+
update: Callable[[int], object],
|
|
50
|
+
n_frames: int,
|
|
51
|
+
fps: float = 20,
|
|
52
|
+
hold: float = 1.0,
|
|
53
|
+
path: str | Path | None = None,
|
|
54
|
+
*,
|
|
55
|
+
precision: int = 3,
|
|
56
|
+
interpolate: bool = False,
|
|
57
|
+
) -> SvgAnimation:
|
|
58
|
+
"""Render ``fig`` as a looping SVG animation and return it as a string.
|
|
59
|
+
|
|
60
|
+
The result is a :class:`SvgAnimation`, a :class:`str` subclass, so it can be
|
|
61
|
+
written or embedded as is. As the last expression of a Jupyter or Quarto
|
|
62
|
+
cell it is displayed as an animated image.
|
|
63
|
+
|
|
64
|
+
``update(i)`` is called before frame ``i`` is rendered. Frame 0 becomes the
|
|
65
|
+
base document; every attribute that differs in later frames gets a SMIL
|
|
66
|
+
animation, so static parts cost nothing.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
fig : matplotlib.figure.Figure
|
|
71
|
+
The figure to render.
|
|
72
|
+
update : callable
|
|
73
|
+
Called as ``update(i)`` before frame ``i`` is rendered. It should modify
|
|
74
|
+
existing artists (``set_data``, ``set_offsets``, ...) and not create or
|
|
75
|
+
remove any. Its return value is ignored.
|
|
76
|
+
n_frames : int
|
|
77
|
+
Number of frames.
|
|
78
|
+
fps : float, default 20
|
|
79
|
+
Frames per second.
|
|
80
|
+
hold : float, default 1.0
|
|
81
|
+
Seconds to hold the last frame before the animation loops.
|
|
82
|
+
path : str or pathlib.Path, optional
|
|
83
|
+
If given, the SVG is also written to this file.
|
|
84
|
+
precision : int, default 3
|
|
85
|
+
Decimals kept in coordinates. Lower values give smaller files.
|
|
86
|
+
interpolate : bool, default False
|
|
87
|
+
By default the SVG switches between the rendered frames. With ``True``,
|
|
88
|
+
numeric attributes (paths, positions, colours, opacities) are instead
|
|
89
|
+
interpolated linearly between frames, so shapes glide and colours fade
|
|
90
|
+
from one state to the next, which suits animations that show discrete
|
|
91
|
+
states (algorithm iterations, for example). Matplotlib's path
|
|
92
|
+
simplification is turned off so that paths keep a constant vertex
|
|
93
|
+
count, which makes the file larger. Attributes that cannot be
|
|
94
|
+
interpolated, such as a path whose vertex count changes, keep switching
|
|
95
|
+
stepwise.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
SvgAnimation
|
|
100
|
+
The animated SVG document, as a ``str`` subclass.
|
|
101
|
+
|
|
102
|
+
Raises
|
|
103
|
+
------
|
|
104
|
+
ValueError
|
|
105
|
+
If an argument is out of range, if the figure contains raster images
|
|
106
|
+
(``imshow``, ``rasterized=True``), if the number or order of SVG elements
|
|
107
|
+
changes between frames (the message names the element), or if a
|
|
108
|
+
transform cannot be animated.
|
|
109
|
+
|
|
110
|
+
Examples
|
|
111
|
+
--------
|
|
112
|
+
>>> anim_to_svg(fig, update, n_frames=60, fps=20, path="wave.svg") # doctest: +SKIP
|
|
113
|
+
"""
|
|
114
|
+
if n_frames < 1:
|
|
115
|
+
raise ValueError("n_frames must be at least 1")
|
|
116
|
+
if fps <= 0 or hold < 0:
|
|
117
|
+
raise ValueError("fps must be positive and hold non-negative")
|
|
118
|
+
|
|
119
|
+
update(0)
|
|
120
|
+
simplify = not interpolate
|
|
121
|
+
base = _render(fig, precision, simplify)
|
|
122
|
+
walked = list(_walk(base))
|
|
123
|
+
nodes = [el for el, _ in walked]
|
|
124
|
+
base_props = [_props(el) for el in nodes]
|
|
125
|
+
defs = _Defs(base)
|
|
126
|
+
|
|
127
|
+
# Each later frame is compared against the base and then discarded, so only
|
|
128
|
+
# the values that change are kept: {(element index, attribute): {frame: value}}.
|
|
129
|
+
changes: dict[tuple[int, str], dict[int, str]] = {}
|
|
130
|
+
for i in range(1, n_frames):
|
|
131
|
+
update(i)
|
|
132
|
+
root = _render(fig, precision, simplify)
|
|
133
|
+
defs.merge(root)
|
|
134
|
+
_record_changes(changes, i, list(_walk(root)), walked, base_props)
|
|
135
|
+
|
|
136
|
+
duration = n_frames / fps + hold
|
|
137
|
+
times = [i / fps / duration for i in range(n_frames)]
|
|
138
|
+
for (idx, name), changed in sorted(changes.items()):
|
|
139
|
+
initial = base_props[idx][name]
|
|
140
|
+
values = [changed.get(i, initial) for i in range(n_frames)]
|
|
141
|
+
try:
|
|
142
|
+
animation = _animation(name, values, times, duration, interpolate)
|
|
143
|
+
except ValueError as err:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"{err} (<{_tag(nodes[idx])}> in {walked[idx][1]!r})"
|
|
146
|
+
) from None
|
|
147
|
+
nodes[idx].append(animation)
|
|
148
|
+
|
|
149
|
+
svg = ET.tostring(base, encoding="unicode")
|
|
150
|
+
if path is not None:
|
|
151
|
+
Path(path).write_text(svg, encoding="utf-8")
|
|
152
|
+
return SvgAnimation(svg)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _render(fig: Figure, precision: int, simplify: bool) -> ET.Element:
|
|
156
|
+
"""Render ``fig`` to a normalized SVG tree (deterministic, vector only)."""
|
|
157
|
+
# A fixed hash salt makes generated ids (clip paths, markers) deterministic.
|
|
158
|
+
# Simplification changes the vertex count from frame to frame, which would
|
|
159
|
+
# make paths impossible to interpolate, so it is off when interpolating.
|
|
160
|
+
rc = {"svg.hashsalt": "svganim", "svg.fonttype": "path", "path.simplify": simplify}
|
|
161
|
+
with mpl.rc_context(rc):
|
|
162
|
+
buf = io.BytesIO()
|
|
163
|
+
fig.savefig(buf, format="svg", metadata={"Date": None})
|
|
164
|
+
root = ET.fromstring(buf.getvalue())
|
|
165
|
+
if next(root.iter(_IMAGE), None) is not None:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"the figure contains raster images (imshow, rasterized artists, ...); "
|
|
168
|
+
"svganim only produces vector output. Use pcolormesh instead of imshow "
|
|
169
|
+
"and remove rasterized=True"
|
|
170
|
+
)
|
|
171
|
+
for child in root.findall(_METADATA):
|
|
172
|
+
root.remove(child)
|
|
173
|
+
for el in root.iter():
|
|
174
|
+
for key in _NUMERIC & el.attrib.keys():
|
|
175
|
+
el.set(key, _round(el.get(key, ""), precision))
|
|
176
|
+
return root
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _round(value: str, precision: int) -> str:
|
|
180
|
+
def repl(m: re.Match[str]) -> str:
|
|
181
|
+
text = f"{float(m.group()):.{precision}f}".rstrip("0").rstrip(".")
|
|
182
|
+
return "0" if text in ("", "-0") else text
|
|
183
|
+
|
|
184
|
+
return _NUMBER.sub(repl, value)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _tag(el: ET.Element) -> str:
|
|
188
|
+
return el.tag.rpartition("}")[2]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _walk(el: ET.Element, label: str = "") -> Iterator[tuple[ET.Element, str]]:
|
|
192
|
+
"""Yield ``(element, label)`` for ``el`` and its descendants, skipping defs.
|
|
193
|
+
|
|
194
|
+
The label is the id of the closest element that has one (matplotlib names
|
|
195
|
+
its artists, e.g. ``line2d_3``), used to say where something went wrong.
|
|
196
|
+
"""
|
|
197
|
+
if el.tag == _DEFS:
|
|
198
|
+
return
|
|
199
|
+
label = el.get("id", label)
|
|
200
|
+
yield el, label
|
|
201
|
+
for child in el:
|
|
202
|
+
yield from _walk(child, label)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class _Defs:
|
|
206
|
+
"""Collects definitions that only appear in later frames into the base."""
|
|
207
|
+
|
|
208
|
+
def __init__(self, base: ET.Element) -> None:
|
|
209
|
+
target = base.find(_DEFS)
|
|
210
|
+
if target is None:
|
|
211
|
+
target = ET.Element(_DEFS)
|
|
212
|
+
base.insert(0, target)
|
|
213
|
+
self.target = target
|
|
214
|
+
self.known = {el.get("id") for el in base.iter() if el.get("id")}
|
|
215
|
+
|
|
216
|
+
def merge(self, root: ET.Element) -> None:
|
|
217
|
+
for defs in root.iter(_DEFS):
|
|
218
|
+
for child in defs:
|
|
219
|
+
ident = child.get("id")
|
|
220
|
+
if ident and ident not in self.known:
|
|
221
|
+
self.known.add(ident)
|
|
222
|
+
self.target.append(child)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _props(el: ET.Element) -> dict[str, str]:
|
|
226
|
+
"""Attributes and inline-style properties of ``el`` in one flat dict."""
|
|
227
|
+
props = {k: v for k, v in el.attrib.items() if k not in ("id", "style")}
|
|
228
|
+
for decl in el.get("style", "").split(";"):
|
|
229
|
+
key, _, val = decl.partition(":")
|
|
230
|
+
if val:
|
|
231
|
+
props[key.strip()] = val.strip()
|
|
232
|
+
return props
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _record_changes(
|
|
236
|
+
changes: dict[tuple[int, str], dict[int, str]],
|
|
237
|
+
frame: int,
|
|
238
|
+
walked: list[tuple[ET.Element, str]],
|
|
239
|
+
base_walked: list[tuple[ET.Element, str]],
|
|
240
|
+
base_props: list[dict[str, str]],
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Store, for frame ``frame``, every value that differs from the base."""
|
|
243
|
+
if len(walked) != len(base_walked) or any(
|
|
244
|
+
el.tag != ref.tag
|
|
245
|
+
for (el, _), (ref, _) in zip(walked, base_walked, strict=False)
|
|
246
|
+
):
|
|
247
|
+
raise _structure_error(frame, walked, base_walked)
|
|
248
|
+
for idx, ((el, label), _) in enumerate(zip(walked, base_walked, strict=True)):
|
|
249
|
+
props = _props(el)
|
|
250
|
+
if props == base_props[idx]:
|
|
251
|
+
continue
|
|
252
|
+
if props.keys() != base_props[idx].keys():
|
|
253
|
+
name = min(props.keys() ^ base_props[idx].keys())
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"attribute {name!r} of <{_tag(el)}> in {label!r} "
|
|
256
|
+
f"is missing in some frames"
|
|
257
|
+
)
|
|
258
|
+
for name, value in props.items():
|
|
259
|
+
if value != base_props[idx][name]:
|
|
260
|
+
changes.setdefault((idx, name), {})[frame] = value
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _structure_error(
|
|
264
|
+
frame: int,
|
|
265
|
+
walked: list[tuple[ET.Element, str]],
|
|
266
|
+
base_walked: list[tuple[ET.Element, str]],
|
|
267
|
+
) -> ValueError:
|
|
268
|
+
"""Build an error that points at the first element that differs."""
|
|
269
|
+
head = f"the SVG element structure changes between frames (frame {frame})"
|
|
270
|
+
for (el, label), (ref, ref_label) in zip(walked, base_walked, strict=False):
|
|
271
|
+
if el.tag != ref.tag or el.get("id") != ref.get("id"):
|
|
272
|
+
return ValueError(
|
|
273
|
+
f"{head}: found <{_tag(el)}> in {label!r} "
|
|
274
|
+
f"where the base has <{_tag(ref)}> in {ref_label!r}"
|
|
275
|
+
)
|
|
276
|
+
shorter = min(len(walked), len(base_walked))
|
|
277
|
+
if len(walked) > len(base_walked):
|
|
278
|
+
el, label = walked[shorter]
|
|
279
|
+
return ValueError(f"{head}: extra <{_tag(el)}> in {label!r} not in the base")
|
|
280
|
+
ref, label = base_walked[shorter]
|
|
281
|
+
return ValueError(f"{head}: <{_tag(ref)}> in {label!r} is missing")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _animation(
|
|
285
|
+
name: str,
|
|
286
|
+
values: list[str],
|
|
287
|
+
times: list[float],
|
|
288
|
+
duration: float,
|
|
289
|
+
interpolate: bool = False,
|
|
290
|
+
) -> ET.Element:
|
|
291
|
+
"""Build the SMIL animation of attribute ``name`` over all frames."""
|
|
292
|
+
tag, shown, kind = "animate", values, None
|
|
293
|
+
if name == "transform":
|
|
294
|
+
# <animate> cannot target transform; <animateTransform> needs a type.
|
|
295
|
+
tag = "animateTransform"
|
|
296
|
+
kind, shown = _transform_values(values)
|
|
297
|
+
|
|
298
|
+
last = len(shown) - 1
|
|
299
|
+
if interpolate and _can_interpolate(name, shown):
|
|
300
|
+
# Drop the middle of every run of equal values: interpolating across it
|
|
301
|
+
# changes nothing, so the animation is unchanged and the file smaller.
|
|
302
|
+
keep = [
|
|
303
|
+
i
|
|
304
|
+
for i in range(len(shown))
|
|
305
|
+
if i in (0, last) or not shown[i - 1] == shown[i] == shown[i + 1]
|
|
306
|
+
]
|
|
307
|
+
mode = "linear"
|
|
308
|
+
else:
|
|
309
|
+
# Switch between the frames where the value changes.
|
|
310
|
+
keep = [i for i, v in enumerate(shown) if i == 0 or v != shown[i - 1]]
|
|
311
|
+
mode = "discrete"
|
|
312
|
+
key_times = [times[i] for i in keep]
|
|
313
|
+
out = [shown[i] for i in keep]
|
|
314
|
+
if mode == "linear":
|
|
315
|
+
# Linear keyTimes must end at 1: repeat the last value to hold it.
|
|
316
|
+
key_times.append(1.0)
|
|
317
|
+
out.append(out[-1])
|
|
318
|
+
attrs = {
|
|
319
|
+
# ElementTree spells namespaced attributes {uri}name; SMIL wants prefix:name.
|
|
320
|
+
"attributeName": name.replace(f"{{{XLINK}}}", "xlink:"),
|
|
321
|
+
"values": ";".join(out),
|
|
322
|
+
"keyTimes": ";".join(f"{t:.6g}" for t in key_times),
|
|
323
|
+
"calcMode": mode,
|
|
324
|
+
"dur": f"{duration:.6g}s",
|
|
325
|
+
"repeatCount": "indefinite",
|
|
326
|
+
}
|
|
327
|
+
if kind is not None:
|
|
328
|
+
attrs["type"] = kind
|
|
329
|
+
return ET.Element(f"{{{SVG}}}{tag}", attrs)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _transform_values(values: list[str]) -> tuple[str, list[str]]:
|
|
333
|
+
"""Split single-function transforms into their common type and arguments."""
|
|
334
|
+
kinds, args = set(), []
|
|
335
|
+
for value in values:
|
|
336
|
+
m = _TRANSFORM.match(value.strip())
|
|
337
|
+
if m is None:
|
|
338
|
+
raise ValueError(f"cannot animate compound transform {value!r}")
|
|
339
|
+
kinds.add(m.group(1))
|
|
340
|
+
args.append(" ".join(m.group(2).replace(",", " ").split()))
|
|
341
|
+
if len(kinds) != 1:
|
|
342
|
+
raise ValueError("the transform type changes between frames")
|
|
343
|
+
return kinds.pop(), args
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _can_interpolate(name: str, values: list[str]) -> bool:
|
|
347
|
+
"""Whether SMIL can interpolate ``values`` of attribute ``name``.
|
|
348
|
+
|
|
349
|
+
The attribute must be numeric (not a url or ``none``) and every value must
|
|
350
|
+
have the same text around its numbers, which is not the case for paths with
|
|
351
|
+
a different number of vertices.
|
|
352
|
+
"""
|
|
353
|
+
if name in _COLOR:
|
|
354
|
+
return all(_HEX_COLOR.match(v) for v in values)
|
|
355
|
+
if name not in _INTERPOLABLE:
|
|
356
|
+
return False
|
|
357
|
+
template = _NUMBER.sub("#", values[0])
|
|
358
|
+
if "#" not in template or re.search("[Aa]", template): # no numbers, or arcs
|
|
359
|
+
return False
|
|
360
|
+
return all(_NUMBER.sub("#", v) == template for v in values)
|
|
File without changes
|