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/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("&", "&amp;").replace("<", "&lt;")
284
+ .replace(">", "&gt;"))
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)