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 +23 -0
- figworks/_export.py +34 -0
- figworks/collection.py +206 -0
- figworks/core/__init__.py +7 -0
- figworks/core/display.py +38 -0
- figworks/core/document.py +376 -0
- figworks/core/element.py +387 -0
- figworks/core/selectors.py +108 -0
- figworks/core/units.py +58 -0
- figworks/elements/__init__.py +7 -0
- figworks/elements/arrows.py +59 -0
- figworks/elements/shapes.py +91 -0
- figworks/elements/text.py +48 -0
- figworks/figure/__init__.py +8 -0
- figworks/figure/anchors.py +17 -0
- figworks/figure/figure.py +347 -0
- figworks/figure/grid.py +123 -0
- figworks/figure/layout.py +109 -0
- figworks/figure/panel.py +142 -0
- figworks/fonts.py +191 -0
- figworks/matplotlib.py +319 -0
- figworks/py.typed +0 -0
- figworks/style.py +306 -0
- figworks/styles/aps.md +45 -0
- figworks/styles/ieee.md +46 -0
- figworks/styles/nature.md +50 -0
- figworks/styles/paper.md +46 -0
- figworks/styles/presentation.md +20 -0
- figworks-0.8.0.dist-info/METADATA +431 -0
- figworks-0.8.0.dist-info/RECORD +33 -0
- figworks-0.8.0.dist-info/WHEEL +5 -0
- figworks-0.8.0.dist-info/licenses/LICENSE +21 -0
- figworks-0.8.0.dist-info/top_level.txt +1 -0
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}")
|
figworks/core/display.py
ADDED
|
@@ -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
|