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 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
@@ -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,5 @@
1
+ """Turn a matplotlib figure and an update function into one animated SVG."""
2
+
3
+ from svganim._core import SvgAnimation, anim_to_svg
4
+
5
+ __all__ = ["SvgAnimation", "anim_to_svg"]
@@ -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