figkit 0.1.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.
- figkit/__init__.py +114 -0
- figkit/audit.py +611 -0
- figkit/colors.py +304 -0
- figkit/component.py +110 -0
- figkit/components.py +727 -0
- figkit/connectors.py +689 -0
- figkit/core.py +1021 -0
- figkit/export.py +301 -0
- figkit/figure.py +312 -0
- figkit/fonts.py +462 -0
- figkit/frame.py +376 -0
- figkit/geom.py +497 -0
- figkit/image.py +308 -0
- figkit/layout.py +529 -0
- figkit/mathtext.py +320 -0
- figkit/paint.py +136 -0
- figkit/py.typed +0 -0
- figkit/shapes.py +764 -0
- figkit/style.py +502 -0
- figkit/svgdoc.py +133 -0
- figkit/svgpath.py +470 -0
- figkit/text.py +625 -0
- figkit/themes.py +163 -0
- figkit-0.1.0.dist-info/METADATA +277 -0
- figkit-0.1.0.dist-info/RECORD +28 -0
- figkit-0.1.0.dist-info/WHEEL +5 -0
- figkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- figkit-0.1.0.dist-info/top_level.txt +1 -0
figkit/export.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Exporting figures to SVG, PNG, PDF and HTML.
|
|
2
|
+
|
|
3
|
+
SVG is written natively. PNG/PDF go through cairosvg when it is installed,
|
|
4
|
+
and otherwise fall back to whichever converter is on ``PATH``
|
|
5
|
+
(``rsvg-convert``, ``resvg``, ``inkscape``, ``chromium``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import warnings
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
__all__ = ["save_figure", "to_png", "to_pdf", "to_svg", "to_html",
|
|
17
|
+
"available_backends", "ExportError"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExportError(RuntimeError):
|
|
21
|
+
"""Raised when a figure cannot be converted to the requested format."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_RASTER_FORMATS = {"png", "jpg", "jpeg", "webp"}
|
|
25
|
+
_VECTOR_FORMATS = {"svg", "pdf", "ps", "eps"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def available_backends() -> dict:
|
|
29
|
+
"""Which conversion backends this machine can use."""
|
|
30
|
+
out = {"cairosvg": False, "rsvg-convert": False, "resvg": False,
|
|
31
|
+
"inkscape": False, "chromium": False}
|
|
32
|
+
try:
|
|
33
|
+
import cairosvg # noqa: F401 (import is the availability check)
|
|
34
|
+
out["cairosvg"] = True
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
for name in ("rsvg-convert", "resvg", "inkscape"):
|
|
38
|
+
out[name] = bool(shutil.which(name))
|
|
39
|
+
out["chromium"] = bool(shutil.which("chromium") or shutil.which("chrome")
|
|
40
|
+
or shutil.which("google-chrome")
|
|
41
|
+
or shutil.which("chromium-browser"))
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ext(path) -> str:
|
|
46
|
+
return os.path.splitext(str(path))[1].lstrip(".").lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_figure(fig, path, *, scale: float = None, dpi: float = None,
|
|
50
|
+
format: str = None, **kw) -> str:
|
|
51
|
+
"""Save ``fig`` to ``path``; the format comes from the extension."""
|
|
52
|
+
path = os.fspath(path)
|
|
53
|
+
fmt = (format or _ext(path) or "svg").lower()
|
|
54
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
55
|
+
if parent:
|
|
56
|
+
os.makedirs(parent, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
if fmt == "svg":
|
|
59
|
+
svg = fig.to_svg(**kw)
|
|
60
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
61
|
+
fh.write(svg)
|
|
62
|
+
return os.path.abspath(path)
|
|
63
|
+
if fmt in ("html", "htm"):
|
|
64
|
+
html_kw = {k: v for k, v in kw.items()
|
|
65
|
+
if k in ("title", "background", "text_as_paths",
|
|
66
|
+
"embed_fonts", "pretty")}
|
|
67
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
68
|
+
fh.write(fig.to_html(**html_kw))
|
|
69
|
+
return os.path.abspath(path)
|
|
70
|
+
if fmt in _RASTER_FORMATS:
|
|
71
|
+
data = to_png(fig, None, scale=scale if scale is not None else 2.0,
|
|
72
|
+
dpi=dpi, fmt=fmt, **kw)
|
|
73
|
+
with open(path, "wb") as fh:
|
|
74
|
+
fh.write(data)
|
|
75
|
+
return os.path.abspath(path)
|
|
76
|
+
if fmt in _VECTOR_FORMATS:
|
|
77
|
+
data = to_pdf(fig, None, fmt=fmt, **kw)
|
|
78
|
+
with open(path, "wb") as fh:
|
|
79
|
+
fh.write(data)
|
|
80
|
+
return os.path.abspath(path)
|
|
81
|
+
raise ExportError(
|
|
82
|
+
f"unknown output format {fmt!r}; use svg, png, pdf or html")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def to_svg(fig, path=None, **kw):
|
|
86
|
+
svg = fig.to_svg(**kw)
|
|
87
|
+
if path is None:
|
|
88
|
+
return svg
|
|
89
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
90
|
+
fh.write(svg)
|
|
91
|
+
return os.path.abspath(path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def to_html(fig, path=None, **kw):
|
|
95
|
+
html = fig.to_html(**kw)
|
|
96
|
+
if path is None:
|
|
97
|
+
return html
|
|
98
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
99
|
+
fh.write(html)
|
|
100
|
+
return os.path.abspath(path)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _warn_if_text_not_outlined(fig, svg: str, svg_kw: dict) -> None:
|
|
104
|
+
"""Raster/PDF export outlines text so the output cannot depend on the
|
|
105
|
+
renderer's fonts. If no outline font could be found we fall back to real
|
|
106
|
+
``<text>``, which quietly reintroduces that dependency — so say so."""
|
|
107
|
+
if not svg_kw.get("text_as_paths") or "<text" not in svg:
|
|
108
|
+
return
|
|
109
|
+
warnings.warn(
|
|
110
|
+
"figkit: no outline font was available, so text was exported as "
|
|
111
|
+
"<text> rather than paths. The rendered output will use whatever font "
|
|
112
|
+
"the converter picks, which may not match the measured layout. "
|
|
113
|
+
"Install the font you asked for, or register one with "
|
|
114
|
+
"figkit.register_font().", stacklevel=3)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _split_kw(kw: dict) -> tuple:
|
|
118
|
+
"""Separate SVG-rendering options from converter options."""
|
|
119
|
+
svg_keys = ("pretty", "text_as_paths", "embed_fonts", "standalone")
|
|
120
|
+
svg_kw = {k: v for k, v in kw.items() if k in svg_keys}
|
|
121
|
+
rest = {k: v for k, v in kw.items() if k not in svg_keys}
|
|
122
|
+
return svg_kw, rest
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def to_png(fig, path=None, *, scale: float = 2.0, dpi: float = None,
|
|
126
|
+
background=None, fmt: str = "png", **kw):
|
|
127
|
+
"""Rasterise a figure. Returns bytes when ``path`` is ``None``."""
|
|
128
|
+
svg_kw, _ = _split_kw(kw)
|
|
129
|
+
# Outlining text removes any dependency on the converter's font stack.
|
|
130
|
+
svg_kw.setdefault("text_as_paths", True)
|
|
131
|
+
svg = fig.to_svg(**svg_kw)
|
|
132
|
+
_warn_if_text_not_outlined(fig, svg, svg_kw)
|
|
133
|
+
vb = fig.viewbox()
|
|
134
|
+
scale = float(scale if scale is not None else 2.0)
|
|
135
|
+
if dpi:
|
|
136
|
+
scale = float(dpi) / 96.0
|
|
137
|
+
width = max(1, int(round(vb.w * fig.scale * scale)))
|
|
138
|
+
height = max(1, int(round(vb.h * fig.scale * scale)))
|
|
139
|
+
data = _rasterize(svg, width, height, background, fmt)
|
|
140
|
+
if path is None:
|
|
141
|
+
return data
|
|
142
|
+
with open(path, "wb") as fh:
|
|
143
|
+
fh.write(data)
|
|
144
|
+
return os.path.abspath(path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def to_pdf(fig, path=None, *, background=None, fmt: str = "pdf", **kw):
|
|
148
|
+
"""Export to PDF (or PS/EPS). Returns bytes when ``path`` is ``None``."""
|
|
149
|
+
svg_kw, _ = _split_kw(kw)
|
|
150
|
+
svg_kw.setdefault("text_as_paths", True)
|
|
151
|
+
svg = fig.to_svg(**svg_kw)
|
|
152
|
+
_warn_if_text_not_outlined(fig, svg, svg_kw)
|
|
153
|
+
data = _vectorize(svg, fmt, background)
|
|
154
|
+
if path is None:
|
|
155
|
+
return data
|
|
156
|
+
with open(path, "wb") as fh:
|
|
157
|
+
fh.write(data)
|
|
158
|
+
return os.path.abspath(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------
|
|
162
|
+
# Backends
|
|
163
|
+
# --------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _cairosvg(svg: str, fmt: str, width=None, height=None, background=None):
|
|
166
|
+
"""Convert via cairosvg, or return None so the next backend gets a turn.
|
|
167
|
+
|
|
168
|
+
Every step is guarded, not just the import: cairosvg raises OSError when
|
|
169
|
+
its native Cairo library is missing, and it can raise that on import *or*
|
|
170
|
+
on first attribute access. A backend in the fallback chain must never be
|
|
171
|
+
able to end the chain.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
import cairosvg
|
|
175
|
+
|
|
176
|
+
fn = {"png": cairosvg.svg2png, "pdf": cairosvg.svg2pdf,
|
|
177
|
+
"ps": cairosvg.svg2ps, "eps": cairosvg.svg2ps}.get(fmt)
|
|
178
|
+
except Exception:
|
|
179
|
+
return None
|
|
180
|
+
if fn is None:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
if "<filter" in svg:
|
|
184
|
+
# cairosvg ignores filter primitives outright, so a shadow that shows
|
|
185
|
+
# up in the SVG would silently vanish here. Say so rather than ship
|
|
186
|
+
# two files that disagree.
|
|
187
|
+
warnings.warn(
|
|
188
|
+
"figkit: this figure uses an SVG filter (e.g. shadow=...), which "
|
|
189
|
+
"the cairosvg backend ignores — the rasterised output will differ "
|
|
190
|
+
"from the SVG. Drop the filter, or render with rsvg-convert / "
|
|
191
|
+
"resvg / chromium.", stacklevel=3)
|
|
192
|
+
|
|
193
|
+
kwargs = {"bytestring": svg.encode("utf-8")}
|
|
194
|
+
if width:
|
|
195
|
+
kwargs["output_width"] = width
|
|
196
|
+
if height:
|
|
197
|
+
kwargs["output_height"] = height
|
|
198
|
+
if background:
|
|
199
|
+
kwargs["background_color"] = background
|
|
200
|
+
try:
|
|
201
|
+
return fn(**kwargs)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
warnings.warn(f"figkit: the cairosvg backend failed ({exc}); "
|
|
204
|
+
f"trying the next one", stacklevel=3)
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _run_cli(cmd: list, svg: str, out_suffix: str) -> bytes | None:
|
|
209
|
+
with tempfile.TemporaryDirectory(prefix="figkit-export-") as tmp:
|
|
210
|
+
src = os.path.join(tmp, "figure.svg")
|
|
211
|
+
dst = os.path.join(tmp, "figure" + out_suffix)
|
|
212
|
+
with open(src, "w", encoding="utf-8") as fh:
|
|
213
|
+
fh.write(svg)
|
|
214
|
+
real = [c.format(src=src, dst=dst) for c in cmd]
|
|
215
|
+
try:
|
|
216
|
+
proc = subprocess.run(real, capture_output=True, timeout=180)
|
|
217
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
218
|
+
return None
|
|
219
|
+
if proc.returncode != 0 or not os.path.exists(dst):
|
|
220
|
+
return None
|
|
221
|
+
with open(dst, "rb") as fh:
|
|
222
|
+
return fh.read()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _rasterize(svg: str, width: int, height: int, background, fmt: str) -> bytes:
|
|
226
|
+
data = _cairosvg(svg, "png", width, height, background)
|
|
227
|
+
if data:
|
|
228
|
+
return data
|
|
229
|
+
exe = shutil.which("rsvg-convert")
|
|
230
|
+
if exe:
|
|
231
|
+
cmd = [exe, "-w", str(width), "-h", str(height), "-o", "{dst}", "{src}"]
|
|
232
|
+
if background:
|
|
233
|
+
cmd[1:1] = ["-b", str(background)]
|
|
234
|
+
data = _run_cli(cmd, svg, ".png")
|
|
235
|
+
if data:
|
|
236
|
+
return data
|
|
237
|
+
exe = shutil.which("resvg")
|
|
238
|
+
if exe:
|
|
239
|
+
data = _run_cli([exe, "--width", str(width), "--height", str(height),
|
|
240
|
+
"{src}", "{dst}"], svg, ".png")
|
|
241
|
+
if data:
|
|
242
|
+
return data
|
|
243
|
+
exe = shutil.which("inkscape")
|
|
244
|
+
if exe:
|
|
245
|
+
data = _run_cli([exe, "{src}", "--export-type=png",
|
|
246
|
+
f"--export-width={width}", f"--export-height={height}",
|
|
247
|
+
"--export-filename={dst}"], svg, ".png")
|
|
248
|
+
if data:
|
|
249
|
+
return data
|
|
250
|
+
exe = _chromium()
|
|
251
|
+
if exe:
|
|
252
|
+
data = _run_cli([exe, "--headless", "--disable-gpu", "--no-sandbox",
|
|
253
|
+
f"--window-size={width},{height}",
|
|
254
|
+
"--default-background-color=00000000",
|
|
255
|
+
"--screenshot={dst}", "{src}"], svg, ".png")
|
|
256
|
+
if data:
|
|
257
|
+
return data
|
|
258
|
+
raise ExportError(
|
|
259
|
+
"no PNG backend available. Install one of:\n"
|
|
260
|
+
" pip install 'figkit[export]' (cairosvg — recommended)\n"
|
|
261
|
+
" apt install librsvg2-bin (rsvg-convert)\n"
|
|
262
|
+
" cargo install resvg (resvg)\n"
|
|
263
|
+
"Or export SVG and convert it yourself.")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _vectorize(svg: str, fmt: str, background) -> bytes:
|
|
267
|
+
data = _cairosvg(svg, fmt, background=background)
|
|
268
|
+
if data:
|
|
269
|
+
return data
|
|
270
|
+
suffix = "." + fmt
|
|
271
|
+
exe = shutil.which("rsvg-convert")
|
|
272
|
+
if exe:
|
|
273
|
+
data = _run_cli([exe, "-f", fmt, "-o", "{dst}", "{src}"], svg, suffix)
|
|
274
|
+
if data:
|
|
275
|
+
return data
|
|
276
|
+
exe = shutil.which("inkscape")
|
|
277
|
+
if exe:
|
|
278
|
+
data = _run_cli([exe, "{src}", f"--export-type={fmt}",
|
|
279
|
+
"--export-filename={dst}"], svg, suffix)
|
|
280
|
+
if data:
|
|
281
|
+
return data
|
|
282
|
+
exe = _chromium()
|
|
283
|
+
if exe and fmt == "pdf":
|
|
284
|
+
data = _run_cli([exe, "--headless", "--disable-gpu", "--no-sandbox",
|
|
285
|
+
"--print-to-pdf={dst}", "--no-pdf-header-footer",
|
|
286
|
+
"{src}"], svg, ".pdf")
|
|
287
|
+
if data:
|
|
288
|
+
return data
|
|
289
|
+
raise ExportError(
|
|
290
|
+
f"no {fmt.upper()} backend available. Install one of:\n"
|
|
291
|
+
" pip install 'figkit[export]' (cairosvg — recommended)\n"
|
|
292
|
+
" apt install librsvg2-bin (rsvg-convert)\n"
|
|
293
|
+
" apt install inkscape")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _chromium() -> str | None:
|
|
297
|
+
for name in ("chromium", "chromium-browser", "google-chrome", "chrome"):
|
|
298
|
+
exe = shutil.which(name)
|
|
299
|
+
if exe:
|
|
300
|
+
return exe
|
|
301
|
+
return None
|
figkit/figure.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""The :class:`Figure` root: collects elements, sizes the canvas, exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
from .core import Group, pop_container, push_container
|
|
9
|
+
from .geom import Affine, BBox, _expand_spec, to_point
|
|
10
|
+
from .style import DEFAULT_THEME, Theme, use_theme
|
|
11
|
+
from .svgdoc import Node, RenderContext
|
|
12
|
+
|
|
13
|
+
__all__ = ["Figure"]
|
|
14
|
+
|
|
15
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
16
|
+
XLINK_NS = "http://www.w3.org/1999/xlink"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Figure(Group):
|
|
20
|
+
"""The drawing surface.
|
|
21
|
+
|
|
22
|
+
Use it as a context manager and every element you create is added
|
|
23
|
+
automatically::
|
|
24
|
+
|
|
25
|
+
with Figure(theme=my_theme, pad=24) as fig:
|
|
26
|
+
a = Box("input")
|
|
27
|
+
b = Box("model").right_of(a, gap=40)
|
|
28
|
+
arrow(a.e, b.w)
|
|
29
|
+
fig.save("figure.svg")
|
|
30
|
+
|
|
31
|
+
By default the canvas is sized to fit its contents plus ``pad``. Pass
|
|
32
|
+
``w``/``h`` to pin it instead.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
role = "figure"
|
|
36
|
+
|
|
37
|
+
def __init__(self, w: float = None, h: float = None, *, pad=24,
|
|
38
|
+
background=None, theme: Theme = None, style=None,
|
|
39
|
+
title: str = None, description: str = None,
|
|
40
|
+
viewbox: BBox = None, origin=None, scale: float = 1.0,
|
|
41
|
+
**props):
|
|
42
|
+
self._fixed_w = w
|
|
43
|
+
self._fixed_h = h
|
|
44
|
+
self.pad = _expand_spec(pad)
|
|
45
|
+
self.background = background
|
|
46
|
+
self.title = title
|
|
47
|
+
self.description = description
|
|
48
|
+
self._viewbox = viewbox
|
|
49
|
+
self._origin = origin
|
|
50
|
+
self.scale = float(scale)
|
|
51
|
+
self._theme_token = None
|
|
52
|
+
self._container_token = None
|
|
53
|
+
super().__init__(style=style, theme=theme or DEFAULT_THEME, add=False,
|
|
54
|
+
**props)
|
|
55
|
+
self.parent = None
|
|
56
|
+
|
|
57
|
+
# -- context manager -------------------------------------------------
|
|
58
|
+
def __enter__(self) -> "Figure":
|
|
59
|
+
self._container_token = push_container(self)
|
|
60
|
+
self._theme_token = use_theme(self._theme or DEFAULT_THEME)
|
|
61
|
+
self._theme_token.__enter__()
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def __exit__(self, *exc):
|
|
65
|
+
if self._theme_token is not None:
|
|
66
|
+
self._theme_token.__exit__(*exc)
|
|
67
|
+
self._theme_token = None
|
|
68
|
+
pop_container(self._container_token)
|
|
69
|
+
self._container_token = None
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def child_matrix(self) -> Affine:
|
|
73
|
+
return Affine.IDENTITY
|
|
74
|
+
|
|
75
|
+
def parent_matrix(self) -> Affine:
|
|
76
|
+
return Affine.IDENTITY
|
|
77
|
+
|
|
78
|
+
# -- canvas geometry -------------------------------------------------
|
|
79
|
+
def content_bbox(self) -> BBox:
|
|
80
|
+
boxes = [c.ink_bbox for c in self._children if c.visible]
|
|
81
|
+
bb = BBox.union_all(boxes)
|
|
82
|
+
return bb if bb is not None else BBox(0, 0, 0, 0)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def fixed_size(self) -> bool:
|
|
86
|
+
"""True when both dimensions were pinned, i.e. this is a canvas."""
|
|
87
|
+
return self._fixed_w is not None and self._fixed_h is not None
|
|
88
|
+
|
|
89
|
+
def viewbox(self) -> BBox:
|
|
90
|
+
"""The rectangle of world space that ends up in the output.
|
|
91
|
+
|
|
92
|
+
Auto-sized figures wrap their content plus ``pad``. Giving both ``w``
|
|
93
|
+
and ``h`` makes the figure a fixed canvas, whose origin is ``(0, 0)``
|
|
94
|
+
so that coordinates mean what they say — pass ``origin=`` to move it,
|
|
95
|
+
or ``origin="content"`` for the auto-sized behaviour.
|
|
96
|
+
"""
|
|
97
|
+
if self._viewbox is not None:
|
|
98
|
+
return self._viewbox
|
|
99
|
+
content = self.content_bbox()
|
|
100
|
+
top, right, bottom, left = self.pad
|
|
101
|
+
bb = content.expand(0, top=top, right=right, bottom=bottom, left=left)
|
|
102
|
+
if self._fixed_w is not None:
|
|
103
|
+
bb = BBox(bb.x, bb.y, float(self._fixed_w), bb.h)
|
|
104
|
+
if self._fixed_h is not None:
|
|
105
|
+
bb = BBox(bb.x, bb.y, bb.w, float(self._fixed_h))
|
|
106
|
+
anchor = self._origin
|
|
107
|
+
if anchor is None and self.fixed_size:
|
|
108
|
+
anchor = (0.0, 0.0) # a pinned canvas starts at the origin
|
|
109
|
+
if anchor is not None and anchor != "content":
|
|
110
|
+
p = to_point(anchor)
|
|
111
|
+
bb = BBox(p.x, p.y, bb.w, bb.h)
|
|
112
|
+
if bb.w <= 0:
|
|
113
|
+
bb = BBox(bb.x, bb.y, 1.0, bb.h)
|
|
114
|
+
if bb.h <= 0:
|
|
115
|
+
bb = BBox(bb.x, bb.y, bb.w, 1.0)
|
|
116
|
+
return bb
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def size(self):
|
|
120
|
+
vb = self.viewbox()
|
|
121
|
+
from .geom import Point
|
|
122
|
+
return Point(vb.w * self.scale, vb.h * self.scale)
|
|
123
|
+
|
|
124
|
+
def set_viewbox(self, x, y=None, w=None, h=None) -> "Figure":
|
|
125
|
+
"""Pin the visible rectangle explicitly."""
|
|
126
|
+
self._viewbox = x if isinstance(x, BBox) else BBox(x, y, w, h)
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def fit_contents(self, pad=None) -> "Figure":
|
|
130
|
+
"""Drop any pinned viewbox or fixed size and go back to auto-sizing."""
|
|
131
|
+
self._viewbox = None
|
|
132
|
+
self._fixed_w = self._fixed_h = None
|
|
133
|
+
self._origin = None
|
|
134
|
+
if pad is not None:
|
|
135
|
+
self.pad = _expand_spec(pad)
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
# -- rendering -------------------------------------------------------
|
|
139
|
+
def render_context(self, **opts) -> RenderContext:
|
|
140
|
+
return RenderContext(theme=self._theme or DEFAULT_THEME, **opts)
|
|
141
|
+
|
|
142
|
+
def to_svg(self, *, pretty: bool = True, text_as_paths: bool = False,
|
|
143
|
+
embed_fonts: bool = False, standalone: bool = True,
|
|
144
|
+
scale: float = None, width: float = None,
|
|
145
|
+
height: float = None) -> str:
|
|
146
|
+
"""Render to an SVG document string."""
|
|
147
|
+
ctx = self.render_context(text_as_paths=text_as_paths,
|
|
148
|
+
embed_fonts=embed_fonts, pretty=pretty)
|
|
149
|
+
vb = self.viewbox()
|
|
150
|
+
body_nodes = []
|
|
151
|
+
kids = sorted([c for c in self._children if c.visible],
|
|
152
|
+
key=lambda c: (getattr(c, "z", 0) or 0))
|
|
153
|
+
for child in kids:
|
|
154
|
+
node = child.render(ctx)
|
|
155
|
+
if node is not None:
|
|
156
|
+
body_nodes.append(node)
|
|
157
|
+
|
|
158
|
+
sc = self.scale if scale is None else float(scale)
|
|
159
|
+
out_w = width if width is not None else vb.w * sc
|
|
160
|
+
out_h = height if height is not None else vb.h * sc
|
|
161
|
+
|
|
162
|
+
svg = Node("svg", xmlns=SVG_NS, xmlns__xlink=XLINK_NS,
|
|
163
|
+
width=_len(out_w), height=_len(out_h),
|
|
164
|
+
viewBox=f"{_n(vb.x)} {_n(vb.y)} {_n(vb.w)} {_n(vb.h)}")
|
|
165
|
+
if self.title:
|
|
166
|
+
svg.add(Node("title", text=self.title))
|
|
167
|
+
if self.description:
|
|
168
|
+
svg.add(Node("desc", text=self.description))
|
|
169
|
+
|
|
170
|
+
if embed_fonts:
|
|
171
|
+
css = _font_face_css(ctx)
|
|
172
|
+
if css:
|
|
173
|
+
svg.add(Node("style", raw=css, type="text/css"))
|
|
174
|
+
if ctx.defs:
|
|
175
|
+
defs = Node("defs")
|
|
176
|
+
defs.add(*ctx.defs)
|
|
177
|
+
svg.add(defs)
|
|
178
|
+
bg = self.background if self.background is not None else \
|
|
179
|
+
self.prop("background", None)
|
|
180
|
+
if bg and str(bg).lower() != "none":
|
|
181
|
+
svg.add(Node("rect", x=vb.x, y=vb.y, width=vb.w, height=vb.h,
|
|
182
|
+
fill=bg))
|
|
183
|
+
svg.add(*body_nodes)
|
|
184
|
+
|
|
185
|
+
for message in ctx.warnings:
|
|
186
|
+
warnings.warn(f"figkit: {message}", stacklevel=2)
|
|
187
|
+
head = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' \
|
|
188
|
+
if standalone else ""
|
|
189
|
+
return head + svg.render(0, pretty)
|
|
190
|
+
|
|
191
|
+
def _repr_svg_(self) -> str:
|
|
192
|
+
"""Rich display in Jupyter."""
|
|
193
|
+
return self.to_svg(standalone=False)
|
|
194
|
+
|
|
195
|
+
def to_html(self, *, title: str = None, embed: bool = True,
|
|
196
|
+
background: str = "#ffffff", **svg_kw) -> str:
|
|
197
|
+
"""A standalone HTML page wrapping the SVG."""
|
|
198
|
+
svg = self.to_svg(standalone=False, **svg_kw)
|
|
199
|
+
heading = title or self.title or "figure"
|
|
200
|
+
return f"""<!doctype html>
|
|
201
|
+
<html lang="en">
|
|
202
|
+
<head>
|
|
203
|
+
<meta charset="utf-8">
|
|
204
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
205
|
+
<title>{_esc(heading)}</title>
|
|
206
|
+
<style>
|
|
207
|
+
:root {{ color-scheme: light dark; }}
|
|
208
|
+
body {{ margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
209
|
+
background: {background}; padding: 24px; box-sizing: border-box;
|
|
210
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }}
|
|
211
|
+
.figkit-wrap {{ max-width: 100%; }}
|
|
212
|
+
.figkit-wrap svg {{ max-width: 100%; height: auto; display: block; }}
|
|
213
|
+
</style>
|
|
214
|
+
</head>
|
|
215
|
+
<body>
|
|
216
|
+
<div class="figkit-wrap">
|
|
217
|
+
{svg}
|
|
218
|
+
</div>
|
|
219
|
+
</body>
|
|
220
|
+
</html>
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
# -- saving ----------------------------------------------------------
|
|
224
|
+
def save(self, path, *, scale: float = None, dpi: float = None,
|
|
225
|
+
format: str = None, **kw) -> str:
|
|
226
|
+
"""Write the figure to ``.svg``, ``.png``, ``.pdf`` or ``.html``.
|
|
227
|
+
|
|
228
|
+
>>> fig.save("out.svg")
|
|
229
|
+
>>> fig.save("out.png", scale=2) # 2x pixel density
|
|
230
|
+
>>> fig.save("out.pdf")
|
|
231
|
+
"""
|
|
232
|
+
from .export import save_figure
|
|
233
|
+
return save_figure(self, path, scale=scale, dpi=dpi, format=format, **kw)
|
|
234
|
+
|
|
235
|
+
def to_png(self, path=None, *, scale: float = 2.0, **kw):
|
|
236
|
+
from .export import to_png
|
|
237
|
+
return to_png(self, path, scale=scale, **kw)
|
|
238
|
+
|
|
239
|
+
def to_pdf(self, path=None, **kw):
|
|
240
|
+
from .export import to_pdf
|
|
241
|
+
return to_pdf(self, path, **kw)
|
|
242
|
+
|
|
243
|
+
def audit(self, **options):
|
|
244
|
+
"""Check the figure for the mistakes you would otherwise spot by eye.
|
|
245
|
+
|
|
246
|
+
Returns a :class:`~figkit.audit.Report` that is falsy when nothing
|
|
247
|
+
looks wrong, so ``print(fig.audit())`` is usually all you need::
|
|
248
|
+
|
|
249
|
+
with Figure() as fig:
|
|
250
|
+
...
|
|
251
|
+
print(fig.audit())
|
|
252
|
+
|
|
253
|
+
See :func:`figkit.audit.audit` for the individual checks and how to
|
|
254
|
+
switch them off.
|
|
255
|
+
"""
|
|
256
|
+
from .audit import audit as run_audit
|
|
257
|
+
return run_audit(self, **options)
|
|
258
|
+
|
|
259
|
+
def show(self, path=None) -> str:
|
|
260
|
+
"""Write an HTML preview and return its path (handy while iterating)."""
|
|
261
|
+
path = path or "figure.html"
|
|
262
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
263
|
+
fh.write(self.to_html())
|
|
264
|
+
return os.path.abspath(path)
|
|
265
|
+
|
|
266
|
+
def __repr__(self) -> str:
|
|
267
|
+
vb = self.viewbox()
|
|
268
|
+
return (f"<Figure {len(self._children)} elements "
|
|
269
|
+
f"{vb.w:.4g}x{vb.h:.4g}>")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _n(v: float) -> str:
|
|
273
|
+
from .svgpath import fmt
|
|
274
|
+
return fmt(v)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _len(v: float) -> str:
|
|
278
|
+
from .svgpath import fmt
|
|
279
|
+
return fmt(v)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _esc(s: str) -> str:
|
|
283
|
+
return (str(s).replace("&", "&").replace("<", "<")
|
|
284
|
+
.replace(">", ">"))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _font_face_css(ctx: RenderContext) -> str:
|
|
288
|
+
"""Base64 ``@font-face`` rules for every font actually used."""
|
|
289
|
+
import base64
|
|
290
|
+
|
|
291
|
+
from .fonts import get_font
|
|
292
|
+
|
|
293
|
+
rules = []
|
|
294
|
+
seen = set()
|
|
295
|
+
for family, weight, style in sorted(ctx.fonts_used, key=lambda t: str(t)):
|
|
296
|
+
font = get_font(family, weight, style)
|
|
297
|
+
if not font.available or font.path in seen:
|
|
298
|
+
continue
|
|
299
|
+
data = font.font_data()
|
|
300
|
+
if not data or len(data) > 6_000_000:
|
|
301
|
+
continue
|
|
302
|
+
seen.add(font.path)
|
|
303
|
+
ext = os.path.splitext(font.path)[1].lower()
|
|
304
|
+
fmt_name = {".otf": "opentype", ".ttf": "truetype",
|
|
305
|
+
".woff": "woff", ".woff2": "woff2"}.get(ext, "truetype")
|
|
306
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
307
|
+
primary = str(family).split(",")[0].strip().strip("'\"")
|
|
308
|
+
rules.append(
|
|
309
|
+
f"@font-face{{font-family:'{primary}';"
|
|
310
|
+
f"font-weight:{font.weight};font-style:{font.style};"
|
|
311
|
+
f"src:url(data:font/{fmt_name};base64,{b64}) format('{fmt_name}');}}")
|
|
312
|
+
return "\n".join(rules)
|