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/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
|