figworks 0.8.0__py3-none-any.whl

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.
figworks/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Public FigWorks API."""
2
+
3
+ from figworks.collection import FigureCollection
4
+ from figworks.core.display import display_svg
5
+ from figworks.figure import Anchor, Figure, Panel, Theme
6
+ from figworks.figure.layout import layout_svgs
7
+ from figworks.matplotlib import compose
8
+ from figworks.style import load_theme
9
+
10
+ __all__ = [
11
+ "Anchor",
12
+ "Figure",
13
+ "FigureCollection",
14
+ "Panel",
15
+ "Theme",
16
+ "__version__",
17
+ "compose",
18
+ "display_svg",
19
+ "layout_svgs",
20
+ "load_theme",
21
+ ]
22
+
23
+ __version__ = "0.8.0"
figworks/_export.py ADDED
@@ -0,0 +1,34 @@
1
+ """Internal SVG export helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import cairosvg
9
+ from cairosvg.surface import PDFSurface, cairo
10
+
11
+
12
+ class _ReproduciblePDF(PDFSurface):
13
+ """CairoSVG's PDF surface without the creation date cairo stamps by default.
14
+
15
+ The date makes every PDF differ from the last; an empty value omits it.
16
+ """
17
+
18
+ def _create_surface(self, width: float, height: float) -> tuple[Any, float, float]:
19
+ surface, width, height = super()._create_surface(width, height)
20
+ surface.set_metadata(cairo.PDF_METADATA_CREATE_DATE, "")
21
+ return surface, width, height
22
+
23
+
24
+ def svg_to_pdf(svg_string: str, path: str | Path) -> None:
25
+ _ReproduciblePDF.convert(bytestring=svg_string.encode("utf-8"), write_to=str(path))
26
+
27
+
28
+ def svg_to_png(svg_string: str, path: str | Path, dpi: int = 300) -> None:
29
+ # cairosvg's `dpi` only converts physical units, leaving px lengths at one
30
+ # pixel each. Resolving at the CSS 96 DPI and scaling rasterizes every unit
31
+ # at `dpi`.
32
+ cairosvg.svg2png(
33
+ bytestring=svg_string.encode("utf-8"), write_to=str(path), dpi=96, scale=dpi / 96
34
+ )
figworks/collection.py ADDED
@@ -0,0 +1,206 @@
1
+ """Regenerate a set of figures from one command.
2
+
3
+ A *collection* is one set of figures sharing a style, an output directory, and
4
+ a list of formats -- a thesis, a paper, a talk. Register each figure once, and
5
+ the collection builds, saves, and closes them:
6
+
7
+ HERE = Path(__file__).resolve().parent
8
+
9
+ THESIS = FigureCollection(outdir=HERE / "figures", style=HERE / "style.md")
10
+
11
+ @THESIS.figure("majorana_network")
12
+ def majorana_network() -> Figure:
13
+ ...
14
+
15
+ if __name__ == "__main__":
16
+ THESIS.main()
17
+
18
+ A builder returns either a FigWorks :class:`~figworks.Figure` or a Matplotlib
19
+ figure, and knows nothing about file paths. Both kinds are written as vector
20
+ files where the format allows, byte-identically from run to run, and only after
21
+ their fonts are checked. Each collection has its own registry, so a second
22
+ one -- slides with a larger theme, say -- can coexist without mixing figures.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ from collections.abc import Callable, Sequence
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ if TYPE_CHECKING:
37
+ from figworks.style import Theme
38
+
39
+ # A builder takes no arguments and returns the finished figure. The collection
40
+ # owns styling, saving, and closing, so builders stay free of file paths.
41
+ type FigureBuilder = Callable[[], Any]
42
+
43
+ # PDF for LaTeX, SVG for hand editing, PNG for slides and quick previews.
44
+ DEFAULT_FORMATS: tuple[str, ...] = ("pdf", "svg", "png")
45
+
46
+
47
+ def git_revision(cwd: Path) -> str:
48
+ """Short commit the figures were generated from, for provenance."""
49
+ git = shutil.which("git")
50
+ if git is None:
51
+ return "unknown"
52
+ try:
53
+ out = subprocess.run(
54
+ [git, "rev-parse", "--short", "HEAD"],
55
+ cwd=cwd,
56
+ capture_output=True,
57
+ text=True,
58
+ check=True,
59
+ )
60
+ except (OSError, subprocess.CalledProcessError):
61
+ return "unknown"
62
+ return out.stdout.strip() or "unknown"
63
+
64
+
65
+ @dataclass
66
+ class FigureCollection:
67
+ """One set of figures sharing a style, an output directory, and formats.
68
+
69
+ Args:
70
+ outdir: Where the files are written, one per figure and format.
71
+ style: The set's style: a built-in name, a path to a ``style.md``, or a
72
+ loaded :class:`~figworks.style.Theme`. Every Matplotlib figure is
73
+ built under the settings it generates.
74
+ formats: File formats to write, from ``svg``, ``pdf``, and ``png``.
75
+ dpi: Resolution of PNG output.
76
+ """
77
+
78
+ outdir: Path
79
+ style: str | Path | Theme = "paper"
80
+ formats: Sequence[str] = DEFAULT_FORMATS
81
+ dpi: int = 300
82
+ builders: dict[str, FigureBuilder] = field(default_factory=dict, init=False)
83
+
84
+ def __post_init__(self) -> None:
85
+ unknown = sorted(set(self.formats) - {"svg", "pdf", "png"})
86
+ if unknown:
87
+ raise ValueError(f"unsupported format(s): {', '.join(unknown)}")
88
+
89
+ def figure(self, name: str) -> Callable[[FigureBuilder], FigureBuilder]:
90
+ """Register a figure builder under its output file name, without suffix."""
91
+
92
+ def decorator(func: FigureBuilder) -> FigureBuilder:
93
+ if name in self.builders:
94
+ raise ValueError(f"Figure {name!r} is already registered.")
95
+ self.builders[name] = func
96
+ return func
97
+
98
+ return decorator
99
+
100
+ def apply_style(self) -> None:
101
+ """Set Matplotlib to this collection's style, from a clean slate.
102
+
103
+ Matplotlib's defaults first, so a run never inherits settings left by
104
+ an earlier import or notebook session, then the settings the style
105
+ generates. :meth:`build` restores the previous settings afterwards.
106
+ """
107
+ import matplotlib.pyplot as plt
108
+
109
+ from figworks.style import load_theme
110
+
111
+ plt.style.use("default")
112
+ plt.rcParams.update(load_theme(self.style).rc())
113
+
114
+ def save_figure(self, name: str, fig: Any, outdir: Path | None = None) -> list[Path]:
115
+ """Write one figure once per format; returns the paths written."""
116
+ from matplotlib.figure import Figure as MplFigure
117
+
118
+ from figworks.figure.figure import Figure
119
+ from figworks.matplotlib import save_figure
120
+
121
+ outdir = outdir or self.outdir
122
+ outdir.mkdir(parents=True, exist_ok=True)
123
+ written = []
124
+ for suffix in self.formats:
125
+ path = outdir / f"{name}.{suffix}"
126
+ if isinstance(fig, Figure):
127
+ fig.save(path, dpi=self.dpi)
128
+ elif isinstance(fig, MplFigure):
129
+ save_figure(fig, path, dpi=self.dpi)
130
+ else:
131
+ raise TypeError(
132
+ f"figure {name!r} returned {type(fig).__name__}; a builder must "
133
+ "return a FigWorks Figure or a Matplotlib figure"
134
+ )
135
+ written.append(path)
136
+ return written
137
+
138
+ def build(self, names: Sequence[str], outdir: Path | None = None) -> list[Path]:
139
+ """Build and save the named figures in the order given; returns the paths.
140
+
141
+ Matplotlib's settings are restored afterwards, so building from a
142
+ notebook leaves its style alone.
143
+ """
144
+ import matplotlib.pyplot as plt
145
+ from matplotlib.figure import Figure as MplFigure
146
+
147
+ written: list[Path] = []
148
+ with plt.rc_context():
149
+ self.apply_style()
150
+ for name in names:
151
+ fig = self.builders[name]()
152
+ try:
153
+ paths = self.save_figure(name, fig, outdir)
154
+ finally:
155
+ if isinstance(fig, MplFigure):
156
+ plt.close(fig)
157
+ # Plots placed into a FigWorks figure stay open otherwise.
158
+ plt.close("all")
159
+ written += paths
160
+ print(f" {name}: " + ", ".join(p.name for p in paths))
161
+ return written
162
+
163
+ def main(self, argv: Sequence[str] | None = None) -> None:
164
+ """Command-line entry point: ``--list``, ``--all``, or figure names."""
165
+ import matplotlib
166
+
167
+ # Headless here, not on import: a notebook importing FigWorks keeps its backend.
168
+ matplotlib.use("Agg")
169
+ prog = Path(sys.argv[0]).name
170
+ parser = argparse.ArgumentParser(prog=prog, description="Regenerate registered figures.")
171
+ parser.add_argument(
172
+ "names", nargs="*", help="figures to build (default: none; use --all or --list)"
173
+ )
174
+ parser.add_argument("--all", action="store_true", help="build every figure")
175
+ parser.add_argument("--list", action="store_true", help="list registered figures and exit")
176
+ parser.add_argument(
177
+ "--outdir",
178
+ type=Path,
179
+ default=self.outdir,
180
+ help=f"output directory (default: {self.outdir})",
181
+ )
182
+ args = parser.parse_args(argv)
183
+
184
+ if not self.builders:
185
+ parser.error("no figures are registered in this collection")
186
+
187
+ if args.list:
188
+ for name, func in sorted(self.builders.items()):
189
+ summary = (func.__doc__ or "").strip().splitlines()
190
+ print(f"{name:<24} {summary[0] if summary else ''}")
191
+ return
192
+
193
+ names = sorted(self.builders) if args.all else list(args.names)
194
+ if not names:
195
+ parser.error("no figures requested; pass names, --all, or --list")
196
+ unknown = [name for name in names if name not in self.builders]
197
+ if unknown:
198
+ parser.error(
199
+ f"unknown figure(s): {', '.join(unknown)}\n"
200
+ f"registered: {', '.join(sorted(self.builders))}"
201
+ )
202
+
203
+ revision = git_revision(Path.cwd())
204
+ print(f"Generating {len(names)} figure(s) at revision {revision}")
205
+ written = self.build(names, args.outdir)
206
+ print(f"Wrote {len(written)} file(s) to {args.outdir}")
@@ -0,0 +1,7 @@
1
+ """Core SVG document primitives."""
2
+
3
+ from figworks.core.document import SVGDocument
4
+ from figworks.core.selectors import Selection
5
+ from figworks.core.units import to_px
6
+
7
+ __all__ = ["SVGDocument", "Selection", "to_px"]
@@ -0,0 +1,38 @@
1
+ """Helpers for displaying SVG output in notebooks and terminals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+
7
+
8
+ def display_svg(source: object) -> None:
9
+ """Render an SVG as an inline image in a Jupyter notebook.
10
+
11
+ Accepts an SVG string, a matplotlib figure, or a :class:`figworks.Figure`.
12
+ Falls back gracefully when IPython is not available.
13
+ """
14
+ svg = _to_svg(source)
15
+ try:
16
+ from IPython.display import HTML, display
17
+ except ImportError:
18
+ print(svg)
19
+ return
20
+ data = base64.b64encode(svg.encode("utf-8")).decode("ascii")
21
+ display(HTML(f'<img src="data:image/svg+xml;base64,{data}" alt="FigWorks SVG"/>'))
22
+
23
+
24
+ def _to_svg(source: object) -> str:
25
+ from matplotlib.figure import Figure as MplFigure
26
+
27
+ if isinstance(source, str):
28
+ return source
29
+ if isinstance(source, MplFigure):
30
+ from figworks.matplotlib import mpl_to_svg
31
+
32
+ return mpl_to_svg(source)
33
+ to_string = getattr(getattr(source, "document", None), "to_string", None)
34
+ if callable(to_string):
35
+ svg = to_string()
36
+ if isinstance(svg, str):
37
+ return svg
38
+ raise TypeError("Expected an SVG string, a matplotlib Figure, or a FigWorks Figure")
@@ -0,0 +1,376 @@
1
+ """Internal SVG document engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import svg
9
+ from lxml import etree
10
+
11
+ from figworks.core.element import (
12
+ ALIGN,
13
+ accumulated_scale,
14
+ copy_element,
15
+ ensure_defs,
16
+ fit_transform,
17
+ local_name,
18
+ parse_svg,
19
+ resolve_id_collisions,
20
+ svg_intrinsic_size,
21
+ svg_kwargs,
22
+ svg_physical_size,
23
+ svg_to_lxml,
24
+ )
25
+ from figworks.core.selectors import Selection, select
26
+ from figworks.core.units import px_decimal, svg_length, to_px
27
+
28
+
29
+ class SVGDocument:
30
+ """Canonical lxml-backed SVG document."""
31
+
32
+ def __init__(self, width: str | int | float, height: str | int | float) -> None:
33
+ self._imports = 0
34
+ self.width = svg_length(width)
35
+ self.height = svg_length(height)
36
+ self.width_px = to_px(width)
37
+ self.height_px = to_px(height)
38
+ # The canvas is declared in physical units but every element is placed in
39
+ # px user units, so the root needs a viewBox to bind the two. Without it
40
+ # a renderer maps one user unit to one px at its own default 96 DPI while
41
+ # sizing the canvas from the physical attributes, and any export above
42
+ # 96 DPI leaves the content in a corner at the wrong scale.
43
+ self.root = svg_to_lxml(
44
+ svg.SVG(
45
+ viewBox=svg.ViewBoxSpec(0, 0, self.width_px, self.height_px),
46
+ extra={"version": "1.1"},
47
+ ),
48
+ raw={"width": self.width, "height": self.height},
49
+ )
50
+
51
+ def element(self, tag: str, text: str | None = None, **attrs: Any) -> etree._Element:
52
+ constructors = {
53
+ "circle": svg.Circle,
54
+ "ellipse": svg.Ellipse,
55
+ "g": svg.G,
56
+ "line": svg.Line,
57
+ "path": svg.Path,
58
+ "polyline": svg.Polyline,
59
+ "rect": svg.Rect,
60
+ "text": svg.Text,
61
+ }
62
+ try:
63
+ constructor = constructors[tag]
64
+ except KeyError as exc:
65
+ raise ValueError(f"Unsupported native SVG element: {tag!r}") from exc
66
+ return svg_to_lxml(constructor(text=text, **svg_kwargs(attrs)))
67
+
68
+ def append(
69
+ self,
70
+ element: etree._Element,
71
+ parent: etree._Element | None = None,
72
+ ) -> etree._Element:
73
+ # `parent or self.root` would silently retarget the root: an lxml
74
+ # element with no children is falsy, so an empty group is discarded.
75
+ (self.root if parent is None else parent).append(element)
76
+ return element
77
+
78
+ def group(
79
+ self,
80
+ id: str | None = None,
81
+ class_: str | None = None,
82
+ transform: str | None = None,
83
+ parent: etree._Element | None = None,
84
+ ) -> etree._Element:
85
+ group = svg_to_lxml(
86
+ svg.G(id=id, class_=None if class_ is None else [class_]),
87
+ raw={"transform": transform},
88
+ )
89
+ return self.append(group, parent)
90
+
91
+ def defs(self) -> etree._Element:
92
+ return ensure_defs(self.root)
93
+
94
+ def import_svg(
95
+ self,
96
+ svg_string: str,
97
+ id: str | None = None,
98
+ transform: str | None = None,
99
+ parent: etree._Element | None = None,
100
+ *,
101
+ namespace: str | None = None,
102
+ ) -> etree._Element:
103
+ """Import an SVG document's content as a group, keeping its ids unique.
104
+
105
+ Imported ids are kept unless one already exists in this document; a
106
+ colliding id is prefixed with ``namespace`` (default: ``id``) and its
107
+ references inside the content follow. See
108
+ :func:`~figworks.core.element.resolve_id_collisions`.
109
+ """
110
+ if id is not None and self.root.find(f".//*[@id='{id}']") is not None:
111
+ raise ValueError(f"Id {id!r} is already used in this figure")
112
+ imported_root = parse_svg(svg_string)
113
+ group = self.group(id=id, transform=transform, parent=parent)
114
+ self._imports += 1
115
+ prefix = namespace or id or f"import{self._imports}"
116
+ for duplicate in resolve_id_collisions(imported_root, self.root, prefix):
117
+ # Identical to a definition already in the document: share that one.
118
+ parent = duplicate.getparent()
119
+ if parent is not None:
120
+ parent.remove(duplicate)
121
+
122
+ for child in imported_root:
123
+ if local_name(child) == "defs":
124
+ self.defs().extend(copy_element(definition) for definition in child)
125
+ else:
126
+ group.append(copy_element(child))
127
+
128
+ return group
129
+
130
+ def placeholder(
131
+ self,
132
+ id: str,
133
+ x: float,
134
+ y: float,
135
+ w: float,
136
+ h: float,
137
+ label: str | None = None,
138
+ ) -> etree._Element:
139
+ """Create a rectangular placeholder that can be filled later by id.
140
+
141
+ The placeholder is drawn with a dashed outline so it is visible while
142
+ composing a figure.
143
+ """
144
+ rect = svg_to_lxml(
145
+ svg.Rect(
146
+ id=id,
147
+ class_=["figworks-placeholder"],
148
+ x=px_decimal(x),
149
+ y=px_decimal(y),
150
+ width=px_decimal(w),
151
+ height=px_decimal(h),
152
+ fill="none",
153
+ stroke="#999999",
154
+ stroke_width=px_decimal(0.5),
155
+ stroke_dasharray=[4, 3],
156
+ )
157
+ )
158
+ self.append(rect)
159
+ if label is not None:
160
+ self.append(
161
+ svg_to_lxml(
162
+ svg.Text(
163
+ text=label,
164
+ x=px_decimal(x + w / 2),
165
+ y=px_decimal(y + h / 2),
166
+ class_=["figworks-placeholder-label"],
167
+ extra={"data-figworks-placeholder": id},
168
+ text_anchor="middle",
169
+ dominant_baseline="middle",
170
+ )
171
+ )
172
+ )
173
+ return rect
174
+
175
+ def fill(
176
+ self,
177
+ id: str,
178
+ svg_string: str,
179
+ *,
180
+ preserve_aspect_ratio: bool = True,
181
+ ) -> etree._Element:
182
+ """Replace the placeholder (or any element) with imported SVG content.
183
+
184
+ The content is positioned and scaled to fit inside the target element's
185
+ bounding box, swapping vector SVG content in by id.
186
+
187
+ Returns the wrapping group that holds the imported content.
188
+ """
189
+ targets = self.select(f"#{id}")
190
+ if len(targets) == 0:
191
+ raise KeyError(f"No element with id {id!r} in document")
192
+ target = targets.nodes[0]
193
+ box = element_box(target)
194
+ for label in self.root.iter():
195
+ if label.get("data-figworks-placeholder") == id:
196
+ parent = label.getparent()
197
+ if parent is not None:
198
+ parent.remove(label)
199
+ return self._place_in_placeholder(target, box, svg_string, preserve_aspect_ratio)
200
+
201
+ def fill_plane(self, id: str, svg_string: str) -> etree._Element:
202
+ """Fill a unit-square group with SVG content, normalized to fit it.
203
+
204
+ The target is a group whose own transform maps content coordinates in
205
+ ``[0, 1]^2`` onto wherever it belongs -- typically an ``vecview`` scene
206
+ plane, whose transform sends the unit square onto a rectangle of a world
207
+ plane, so the content ends up lying *in* the scene rather than on top of
208
+ it. This method only normalizes: it leaves the target's transform alone
209
+ and appends the content beneath it.
210
+
211
+ The normalization is non-uniform by construction -- content of any size
212
+ is mapped onto the same unit square -- so the target rectangle must be
213
+ shaped to the content's aspect ratio or the content comes out stretched.
214
+ For an ``vecview`` plane that means ``|u_edge| / |v_edge|`` equal to the
215
+ content's ``width / height``.
216
+
217
+ Returns the wrapping group holding the imported content.
218
+ """
219
+ targets = self.select(f"#{id}")
220
+ if len(targets) == 0:
221
+ raise KeyError(f"No element with id {id!r} in document")
222
+ target = targets.nodes[0]
223
+
224
+ width, height, min_x, min_y = svg_intrinsic_size(svg_string)
225
+ if not width or not height:
226
+ raise ValueError(f"Content for {id!r} has no intrinsic size to normalize")
227
+
228
+ shift = f" translate({-min_x:g} {-min_y:g})" if (min_x or min_y) else ""
229
+ transform = f"scale({1 / width:g} {1 / height:g}){shift}"
230
+ return self.import_svg(svg_string, transform=transform, parent=target, namespace=id)
231
+
232
+ def fill_slot(self, id: str, svg_string: str) -> etree._Element:
233
+ """Fill an anchor group with SVG content at the content's own physical size.
234
+
235
+ The target is a group translated to an anchor point -- typically a
236
+ VecView ``Scene.slot`` -- whose ``data-align`` names the point of the
237
+ content's box that sits on the anchor (``center`` when absent). The
238
+ content keeps the size its document declares however the enclosing
239
+ scene was scaled to fit its panel: an 8 pt label stays 8 pt. The
240
+ anchor itself moves with the scene.
241
+
242
+ Returns the wrapping group holding the imported content.
243
+ """
244
+ targets = self.select(f"#{id}")
245
+ if len(targets) == 0:
246
+ raise KeyError(f"No element with id {id!r} in document")
247
+ target = targets.nodes[0]
248
+
249
+ align = target.get("data-align", "center")
250
+ if align not in ALIGN:
251
+ raise ValueError(
252
+ f"Slot {id!r} has unknown data-align {align!r}; expected one of {sorted(ALIGN)}"
253
+ )
254
+ view_w, view_h, min_x, min_y = svg_intrinsic_size(svg_string)
255
+ width, height = svg_physical_size(svg_string)
256
+ if not (view_w and view_h and width and height):
257
+ raise ValueError(f"Content for {id!r} has no intrinsic size to place")
258
+
259
+ fx, fy = ALIGN[align]
260
+ shift = f" translate({-min_x:g} {-min_y:g})" if (min_x or min_y) else ""
261
+ transform = (
262
+ f"scale({1 / accumulated_scale(target):g})"
263
+ f" translate({-fx * width:g} {-fy * height:g})"
264
+ f" scale({width / view_w:g} {height / view_h:g}){shift}"
265
+ )
266
+ return self.import_svg(svg_string, transform=transform, parent=target, namespace=id)
267
+
268
+ def place(
269
+ self,
270
+ svg_string: str,
271
+ x: float,
272
+ y: float,
273
+ width: float,
274
+ height: float,
275
+ *,
276
+ id: str | None = None,
277
+ preserve_aspect_ratio: bool = True,
278
+ parent: etree._Element | None = None,
279
+ align: str = "center",
280
+ ) -> etree._Element:
281
+ """Place SVG content directly in a layout box."""
282
+ transform = fit_transform(
283
+ (x, y, width, height),
284
+ svg_intrinsic_size(svg_string),
285
+ preserve_aspect_ratio=preserve_aspect_ratio,
286
+ align=align,
287
+ )
288
+ return self.import_svg(svg_string, id=id, transform=transform, parent=parent)
289
+
290
+ def _place_in_placeholder(
291
+ self,
292
+ target: etree._Element,
293
+ box: tuple[float, float, float, float],
294
+ svg_string: str,
295
+ preserve_aspect_ratio: bool = True,
296
+ ) -> etree._Element:
297
+ x, y, w, h = box
298
+ parent = target.getparent()
299
+ if parent is None:
300
+ parent = self.root
301
+ index = list(parent).index(target)
302
+ parent.remove(target)
303
+
304
+ transform = fit_transform(
305
+ (x, y, w, h),
306
+ svg_intrinsic_size(svg_string),
307
+ preserve_aspect_ratio=preserve_aspect_ratio,
308
+ )
309
+
310
+ group = self.import_svg(svg_string, namespace=target.get("id"))
311
+ if not preserve_aspect_ratio:
312
+ group.set("preserveAspectRatio", "none")
313
+ parent.insert(index, group)
314
+ group.set("transform", transform)
315
+ return group
316
+
317
+ def select(self, selector: str) -> Selection:
318
+ return select(self.root, selector)
319
+
320
+ def delete(self, selector: str) -> Selection:
321
+ return self.select(selector).delete()
322
+
323
+ def set_style(self, selector: str, **style: str | int | float) -> Selection:
324
+ return self.select(selector).set_style(**style)
325
+
326
+ def to_string(self) -> str:
327
+ return etree.tostring(
328
+ self.root,
329
+ encoding="unicode",
330
+ pretty_print=True,
331
+ xml_declaration=False,
332
+ )
333
+
334
+ def save(self, path: str | Path) -> None:
335
+ Path(path).write_text(self.to_string(), encoding="utf-8")
336
+
337
+
338
+ def extract_path_box(node: etree._Element) -> tuple[float, float, float, float]:
339
+ """Estimate a bounding box (x, y, w, h) from a node's path data."""
340
+ d = node.get("d", "") or node.get("points", "")
341
+ xs: list[float] = []
342
+ ys: list[float] = []
343
+ a1, a2 = xs, ys
344
+ for token in d.split():
345
+ try:
346
+ a1.append(float(token))
347
+ a1, a2 = a2, a1
348
+ except ValueError:
349
+ continue
350
+ if not xs or not ys:
351
+ return 0.0, 0.0, 0.0, 0.0
352
+ min_x, min_y = min(xs), min(ys)
353
+ return min_x, min_y, max(xs) - min_x, max(ys) - min_y
354
+
355
+
356
+ def element_box(node: etree._Element) -> tuple[float, float, float, float]:
357
+ """Return (x, y, width, height) for an element's layout box."""
358
+ name = etree.QName(node).localname
359
+ if name in {"image", "rect"}:
360
+ x = float(node.get("x", 0.0))
361
+ y = float(node.get("y", 0.0))
362
+ w = float(node.get("width", 0.0))
363
+ h = float(node.get("height", 0.0))
364
+ return x, y, w, h
365
+ if name in ("path", "polyline"):
366
+ return extract_path_box(node)
367
+ if name == "g":
368
+ boxes = [element_box(child) for child in node]
369
+ boxes = [box for box in boxes if box[2] and box[3]]
370
+ if boxes:
371
+ min_x = min(box[0] for box in boxes)
372
+ min_y = min(box[1] for box in boxes)
373
+ max_x = max(box[0] + box[2] for box in boxes)
374
+ max_y = max(box[1] + box[3] for box in boxes)
375
+ return min_x, min_y, max_x - min_x, max_y - min_y
376
+ return 0.0, 0.0, 0.0, 0.0