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/mathtext.py ADDED
@@ -0,0 +1,320 @@
1
+ """LaTeX / math rendering to SVG paths.
2
+
3
+ Two backends:
4
+
5
+ ``mathtext`` (default)
6
+ Uses matplotlib's built-in TeX-subset renderer. No LaTeX install needed,
7
+ produces real vector outlines, handles the vast majority of inline math
8
+ used in figures (``$F_{\\mathcal{M}}$``, ``\\sum``, ``\\frac``, ...).
9
+
10
+ ``latex``
11
+ Shells out to a real ``latex`` + ``dvisvgm`` toolchain, so anything your
12
+ TeX distribution can typeset works (``\\begin{aligned}``, custom packages
13
+ via ``preamble=``). Requires those binaries on ``PATH``.
14
+
15
+ Both return glyph outlines, so exported figures never depend on fonts being
16
+ installed on the viewer's machine.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import functools
22
+ import os
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ import tempfile
27
+ import xml.etree.ElementTree as ET
28
+ from dataclasses import dataclass, field
29
+
30
+ __all__ = ["MathRender", "render_math", "math_available", "latex_available",
31
+ "set_math_fontset", "set_latex_preamble", "MathError"]
32
+
33
+
34
+ class MathError(RuntimeError):
35
+ """Raised when a math expression cannot be typeset."""
36
+
37
+
38
+ @dataclass
39
+ class MathRender:
40
+ """Vector result of typesetting a math expression.
41
+
42
+ Coordinates are in px with the *baseline at y = 0* and y growing downward,
43
+ so the outline can be dropped straight into an SVG at a baseline point.
44
+ """
45
+
46
+ d: str = ""
47
+ width: float = 0.0
48
+ ascent: float = 0.0 # above baseline, positive
49
+ descent: float = 0.0 # below baseline, positive
50
+ x_offset: float = 0.0
51
+ size: float = 12.0
52
+ backend: str = "mathtext"
53
+ fill_rule: str = "nonzero"
54
+ extra: dict = field(default_factory=dict)
55
+
56
+ @property
57
+ def height(self) -> float:
58
+ return self.ascent + self.descent
59
+
60
+ @property
61
+ def empty(self) -> bool:
62
+ return not self.d
63
+
64
+
65
+ _FONTSET = ["cm"] # cm | dejavusans | dejavuserif | stix | stixsans
66
+ _PREAMBLE = [r"\usepackage{amsmath}\usepackage{amssymb}\usepackage{amsfonts}"]
67
+
68
+
69
+ def set_math_fontset(name: str) -> None:
70
+ """Set the matplotlib mathtext font set (``cm``, ``stix``, ``dejavusans``...)."""
71
+ _FONTSET[0] = name
72
+ render_math.cache_clear()
73
+
74
+
75
+ def set_latex_preamble(preamble: str) -> None:
76
+ """Set the LaTeX preamble used by the ``latex`` backend."""
77
+ _PREAMBLE[0] = preamble
78
+ render_math.cache_clear()
79
+
80
+
81
+ def math_available() -> bool:
82
+ """True when the built-in ``mathtext`` backend can be used."""
83
+ try:
84
+ import matplotlib # noqa: F401 (import is the availability check)
85
+ return True
86
+ except ImportError:
87
+ return False
88
+
89
+
90
+ def latex_available() -> bool:
91
+ """True when a real ``latex`` + ``dvisvgm`` toolchain is on PATH."""
92
+ return bool(shutil.which("latex") and shutil.which("dvisvgm"))
93
+
94
+
95
+ # --------------------------------------------------------------------------
96
+ # matplotlib mathtext backend
97
+ # --------------------------------------------------------------------------
98
+
99
+ _MPL_CODES = {1: "M", 2: "L", 3: "Q", 4: "C", 79: "Z"}
100
+ _MPL_NARGS = {1: 1, 2: 1, 3: 2, 4: 3, 79: 1}
101
+
102
+
103
+ def _fmt(v: float) -> str:
104
+ s = f"{v:.3f}"
105
+ if "." in s:
106
+ s = s.rstrip("0").rstrip(".")
107
+ return s or "0"
108
+
109
+
110
+ def _render_mathtext(expr: str, size: float) -> MathRender:
111
+ try:
112
+ import matplotlib
113
+ from matplotlib.textpath import TextPath
114
+ from matplotlib.font_manager import FontProperties
115
+ except ImportError as exc: # pragma: no cover - depends on install extras
116
+ raise MathError(
117
+ "math rendering needs matplotlib: pip install 'figkit[latex]'"
118
+ ) from exc
119
+
120
+ matplotlib.rcParams["mathtext.fontset"] = _FONTSET[0]
121
+ body = expr if expr.startswith("$") else f"${expr}$"
122
+ try:
123
+ tp = TextPath((0, 0), body, size=size, prop=FontProperties(size=size))
124
+ except Exception as exc:
125
+ raise MathError(f"could not typeset {expr!r}: {exc}") from exc
126
+
127
+ verts, codes = tp.vertices, tp.codes
128
+ if codes is None or len(verts) == 0:
129
+ return MathRender(size=size, backend="mathtext")
130
+
131
+ parts = []
132
+ i = 0
133
+ n = len(codes)
134
+ while i < n:
135
+ code = int(codes[i])
136
+ nargs = _MPL_NARGS.get(code, 1)
137
+ letter = _MPL_CODES.get(code)
138
+ if letter is None:
139
+ i += nargs
140
+ continue
141
+ if letter == "Z":
142
+ parts.append("Z")
143
+ else:
144
+ pts = verts[i:i + nargs]
145
+ coords = " ".join(f"{_fmt(float(p[0]))} {_fmt(-float(p[1]))}"
146
+ for p in pts)
147
+ parts.append(f"{letter}{coords}")
148
+ i += nargs
149
+ d = "".join(parts)
150
+
151
+ bb = tp.get_extents()
152
+ return MathRender(
153
+ d=d,
154
+ width=float(bb.x1),
155
+ ascent=max(0.0, float(bb.y1)),
156
+ descent=max(0.0, -float(bb.y0)),
157
+ x_offset=0.0,
158
+ size=size,
159
+ backend="mathtext",
160
+ )
161
+
162
+
163
+ # --------------------------------------------------------------------------
164
+ # real LaTeX backend (latex + dvisvgm)
165
+ # --------------------------------------------------------------------------
166
+
167
+ _TEX_DOC = r"""\documentclass[12pt]{article}
168
+ %(preamble)s
169
+ \pagestyle{empty}
170
+ \setlength{\parindent}{0pt}
171
+ \begin{document}
172
+ %(body)s
173
+ \end{document}
174
+ """
175
+
176
+ _UNIT = {"pt": 1.0, "px": 1.0, "": 1.0, "mm": 72.0 / 25.4, "cm": 72.0 / 2.54,
177
+ "in": 72.0, "pc": 12.0, "ex": 6.0, "em": 12.0}
178
+
179
+
180
+ def _len_pt(value: str) -> float:
181
+ m = re.match(r"^\s*(-?[\d.]+)\s*([a-z%]*)\s*$", str(value or "0"))
182
+ if not m:
183
+ return 0.0
184
+ return float(m.group(1)) * _UNIT.get(m.group(2), 1.0)
185
+
186
+
187
+ def _render_latex(expr: str, size: float, display: bool = False) -> MathRender:
188
+ if not latex_available():
189
+ raise MathError(
190
+ "the 'latex' backend needs `latex` and `dvisvgm` on PATH; "
191
+ "use backend='mathtext' (the default) instead"
192
+ )
193
+ body = expr.strip()
194
+ if body.startswith("$") and body.endswith("$"):
195
+ body = body[1:-1]
196
+ if not re.match(r"^\s*\\begin\{", body) and "$" not in body:
197
+ body = (f"\\[{body}\\]" if display else f"${body}$")
198
+ tex = _TEX_DOC % {"preamble": _PREAMBLE[0], "body": body}
199
+
200
+ with tempfile.TemporaryDirectory(prefix="figkit-tex-") as tmp:
201
+ tex_path = os.path.join(tmp, "fig.tex")
202
+ with open(tex_path, "w", encoding="utf-8") as fh:
203
+ fh.write(tex)
204
+ try:
205
+ proc = subprocess.run(
206
+ ["latex", "-interaction=nonstopmode", "-halt-on-error", "fig.tex"],
207
+ cwd=tmp, capture_output=True, text=True, timeout=60)
208
+ except subprocess.TimeoutExpired as exc:
209
+ raise MathError("latex timed out") from exc
210
+ dvi = os.path.join(tmp, "fig.dvi")
211
+ if not os.path.exists(dvi):
212
+ log = (proc.stdout or "") + (proc.stderr or "")
213
+ tail = "\n".join(log.strip().splitlines()[-15:])
214
+ raise MathError(f"latex failed for {expr!r}:\n{tail}")
215
+ try:
216
+ subprocess.run(
217
+ ["dvisvgm", "--no-fonts", "--exact-bbox", "--scale=1",
218
+ "-o", "fig.svg", "fig.dvi"],
219
+ cwd=tmp, capture_output=True, text=True, timeout=60, check=True)
220
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
221
+ raise MathError(f"dvisvgm failed for {expr!r}: {exc}") from exc
222
+ with open(os.path.join(tmp, "fig.svg"), "r", encoding="utf-8") as fh:
223
+ svg_text = fh.read()
224
+
225
+ d, w, h = _flatten_dvisvgm(svg_text)
226
+ if h <= 0:
227
+ return MathRender(size=size, backend="latex")
228
+ # LaTeX body is set at 12pt; scale so the result matches the requested size.
229
+ scale = size / 12.0
230
+ from .svgpath import scale_path_data, path_bbox
231
+ d = scale_path_data(d, scale)
232
+ x0, y0, x1, y1 = path_bbox(d)
233
+ # dvisvgm puts the baseline of the first line at y = 0.
234
+ return MathRender(d=d, width=max(0.0, x1), ascent=max(0.0, -y0),
235
+ descent=max(0.0, y1), x_offset=x0, size=size,
236
+ backend="latex")
237
+
238
+
239
+ def _flatten_dvisvgm(svg_text: str) -> tuple:
240
+ """Resolve ``<use>`` references from dvisvgm output into one path."""
241
+ ns = "{http://www.w3.org/2000/svg}"
242
+ root = ET.fromstring(svg_text)
243
+ defs: dict = {}
244
+ for path in root.iter(f"{ns}path"):
245
+ pid = path.get("id")
246
+ if pid:
247
+ defs[pid] = path.get("d", "")
248
+
249
+ from .svgpath import translate_path_data, scale_path_data
250
+
251
+ parts = []
252
+
253
+ def walk(node, tx: float, ty: float, sc: float):
254
+ tag = node.tag.replace(ns, "")
255
+ t = node.get("transform")
256
+ if t:
257
+ for m in re.finditer(r"(translate|scale|matrix)\(([^)]*)\)", t):
258
+ kind, args = m.group(1), [float(x) for x in
259
+ re.split(r"[,\s]+", m.group(2).strip())
260
+ if x]
261
+ if kind == "translate":
262
+ tx += args[0] * sc
263
+ ty += (args[1] if len(args) > 1 else 0.0) * sc
264
+ elif kind == "scale":
265
+ sc *= args[0]
266
+ elif kind == "matrix" and len(args) == 6:
267
+ sc *= args[0]
268
+ tx += args[4]
269
+ ty += args[5]
270
+ if tag == "use":
271
+ href = (node.get("{http://www.w3.org/1999/xlink}href")
272
+ or node.get("href") or "")
273
+ ref = defs.get(href.lstrip("#"))
274
+ if ref:
275
+ x = float(node.get("x", 0) or 0) * sc + tx
276
+ y = float(node.get("y", 0) or 0) * sc + ty
277
+ d = scale_path_data(ref, sc) if sc != 1.0 else ref
278
+ parts.append(translate_path_data(d, x, y))
279
+ elif tag == "path" and node.get("id") is None:
280
+ d = scale_path_data(node.get("d", ""), sc) if sc != 1.0 else node.get("d", "")
281
+ parts.append(translate_path_data(d, tx, ty))
282
+ elif tag == "rect":
283
+ x = float(node.get("x", 0) or 0) * sc + tx
284
+ y = float(node.get("y", 0) or 0) * sc + ty
285
+ w = float(node.get("width", 0) or 0) * sc
286
+ h = float(node.get("height", 0) or 0) * sc
287
+ parts.append(f"M{x} {y}h{w}v{h}h{-w}Z")
288
+ for child in node:
289
+ walk(child, tx, ty, sc)
290
+
291
+ for child in root:
292
+ if child.tag == f"{ns}defs":
293
+ continue
294
+ walk(child, 0.0, 0.0, 1.0)
295
+
296
+ d = "".join(parts)
297
+ from .svgpath import path_bbox
298
+ x0, y0, x1, y1 = path_bbox(d) if d else (0, 0, 0, 0)
299
+ return d, x1 - x0, y1 - y0
300
+
301
+
302
+ # --------------------------------------------------------------------------
303
+ # public entry point
304
+ # --------------------------------------------------------------------------
305
+
306
+ @functools.lru_cache(maxsize=2048)
307
+ def render_math(expr: str, size: float = 12.0, backend: str = "auto",
308
+ display: bool = False) -> MathRender:
309
+ """Typeset ``expr`` and return outlines with the baseline at ``y = 0``."""
310
+ expr = expr.strip()
311
+ if not expr:
312
+ return MathRender(size=size)
313
+ backend = (backend or "auto").lower()
314
+ if backend == "auto":
315
+ backend = "mathtext"
316
+ if backend == "latex":
317
+ return _render_latex(expr, size, display=display)
318
+ if backend == "mathtext":
319
+ return _render_mathtext(expr, size)
320
+ raise MathError(f"unknown math backend {backend!r}")
figkit/paint.py ADDED
@@ -0,0 +1,136 @@
1
+ """Turning resolved style properties into SVG paint attributes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .colors import parse_color, to_hex
6
+ from .style import normalize_dash
7
+ from .svgdoc import Node, RenderContext
8
+
9
+ __all__ = ["paint_attrs", "shadow_filter", "gradient_paint"]
10
+
11
+
12
+ def gradient_paint(spec, ctx: RenderContext, bbox=None) -> str:
13
+ """Register a gradient definition and return a ``url(#id)`` reference.
14
+
15
+ ``spec`` is a dict like ``{"type": "linear", "stops": ["#fff", "#000"],
16
+ "angle": 90}`` or ``{"type": "radial", "stops": [(0, "#fff"), (1, "#000")]}``.
17
+ """
18
+ kind = str(spec.get("type", "linear")).lower()
19
+ stops = spec.get("stops") or ["#ffffff", "#000000"]
20
+ norm = []
21
+ for i, st in enumerate(stops):
22
+ if isinstance(st, (tuple, list)) and len(st) >= 2:
23
+ offset, color = float(st[0]), st[1]
24
+ opacity = st[2] if len(st) > 2 else None
25
+ else:
26
+ offset = i / max(1, len(stops) - 1)
27
+ color, opacity = st, None
28
+ norm.append((offset, color, opacity))
29
+
30
+ if kind.startswith("rad"):
31
+ node = Node("radialGradient",
32
+ cx=spec.get("cx", 0.5), cy=spec.get("cy", 0.5),
33
+ r=spec.get("r", 0.5))
34
+ else:
35
+ import math
36
+ angle = float(spec.get("angle", 0.0))
37
+ a = math.radians(angle)
38
+ dx, dy = math.cos(a) / 2.0, math.sin(a) / 2.0
39
+ node = Node("linearGradient",
40
+ x1=0.5 - dx, y1=0.5 - dy, x2=0.5 + dx, y2=0.5 + dy)
41
+ for offset, color, opacity in norm:
42
+ node.add(Node("stop", offset=round(offset, 4), stop_color=to_hex(color),
43
+ stop_opacity=opacity))
44
+ return f"url(#{ctx.add_def(node)})"
45
+
46
+
47
+ def shadow_filter(spec, ctx: RenderContext) -> str:
48
+ """Register a drop-shadow filter and return a ``url(#id)`` reference."""
49
+ if spec is True:
50
+ spec = {}
51
+ if not isinstance(spec, dict):
52
+ spec = {}
53
+ dx = spec.get("dx", 0)
54
+ dy = spec.get("dy", 2)
55
+ blur = spec.get("blur", 4)
56
+ color = to_hex(spec.get("color", "#000000"), keep_alpha=False)
57
+ opacity = spec.get("opacity", 0.18)
58
+ node = Node("filter", x="-40%", y="-40%", width="180%", height="180%",
59
+ color_interpolation_filters="sRGB")
60
+ node.add(Node("feDropShadow", dx=dx, dy=dy, stdDeviation=blur,
61
+ flood_color=color, flood_opacity=opacity))
62
+ key = f"shadow:{dx}:{dy}:{blur}:{color}:{opacity}"
63
+ return f"url(#{ctx.add_def(node, key)})"
64
+
65
+
66
+ def _color(value, ctx: RenderContext, bbox=None):
67
+ """Resolve a paint value to ``(paint, opacity_or_None)``."""
68
+ if value is None:
69
+ return None, None
70
+ if isinstance(value, dict):
71
+ return gradient_paint(value, ctx, bbox), None
72
+ if isinstance(value, (tuple, list)):
73
+ value = to_hex(value)
74
+ if isinstance(value, str):
75
+ s = value.strip()
76
+ if s.startswith("url(") or s.startswith("var(") or s.lower() == "none":
77
+ return s, None
78
+ # Split any alpha out into a separate attribute: 8-digit hex and
79
+ # rgba()/hsla() are not universally supported by SVG rasterisers.
80
+ try:
81
+ parsed = parse_color(s)
82
+ except ValueError:
83
+ return s, None
84
+ if parsed is None:
85
+ return "none", None
86
+ r, g, b, a = parsed
87
+ if a < 1.0:
88
+ return f"#{r:02x}{g:02x}{b:02x}", round(a, 4)
89
+ if len(s) > 7 or not s.startswith("#"):
90
+ return f"#{r:02x}{g:02x}{b:02x}", None
91
+ return s, None
92
+ return value, None
93
+
94
+
95
+ def paint_attrs(el, ctx: RenderContext, *, fill: bool = True,
96
+ stroke: bool = True, bbox=None) -> dict:
97
+ """Resolve fill/stroke/dash/etc. for an element into SVG attributes."""
98
+ attrs: dict = {}
99
+ if fill:
100
+ f, f_alpha = _color(el.prop("fill", None), ctx, bbox)
101
+ attrs["fill"] = "none" if f is None else f
102
+ fo = el.prop("fill_opacity", None)
103
+ fo = f_alpha if fo is None else (
104
+ float(fo) * f_alpha if f_alpha is not None else float(fo))
105
+ if fo is not None:
106
+ attrs["fill-opacity"] = round(float(fo), 4)
107
+ if stroke:
108
+ s, s_alpha = _color(el.prop("stroke", None), ctx, bbox)
109
+ sw = el.prop("stroke_width", None)
110
+ if s in (None, "none") or not sw:
111
+ attrs["stroke"] = "none"
112
+ else:
113
+ attrs["stroke"] = s
114
+ attrs["stroke-width"] = sw
115
+ if s_alpha is not None:
116
+ attrs["stroke-opacity"] = s_alpha
117
+ dash = normalize_dash(el.prop("stroke_dash", None))
118
+ if dash:
119
+ attrs["stroke-dasharray"] = dash
120
+ off = el.prop("stroke_dashoffset", None)
121
+ if off:
122
+ attrs["stroke-dashoffset"] = off
123
+ cap = el.prop("stroke_linecap", None)
124
+ if cap and cap != "butt":
125
+ attrs["stroke-linecap"] = cap
126
+ join = el.prop("stroke_linejoin", None)
127
+ if join and join != "miter":
128
+ attrs["stroke-linejoin"] = join
129
+ so = el.prop("stroke_opacity", None)
130
+ if so is not None:
131
+ attrs["stroke-opacity"] = (round(float(so) * s_alpha, 4)
132
+ if s_alpha is not None else so)
133
+ shadow = el.prop("shadow", None)
134
+ if shadow:
135
+ attrs["filter"] = shadow_filter(shadow, ctx)
136
+ return attrs
figkit/py.typed ADDED
File without changes