codefig 0.17.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.
- codefig/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
codefig/icon.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Icon and Image nodes (v0.4.0).
|
|
2
|
+
|
|
3
|
+
``Icon("gpu", size=16, color=…)`` draws a bundled Tabler icon (see :mod:`codefig.icons`)
|
|
4
|
+
as inline vector paths; ``Icon.from_svg(path)`` loads any SVG file. ``Image(path,
|
|
5
|
+
width=…)`` embeds a PNG / JPG / SVG (base64) with its aspect ratio preserved,
|
|
6
|
+
``fit="contain" | "cover"``, rounded corners and an optional border.
|
|
7
|
+
|
|
8
|
+
Both are *hard* obstacles (edges route around them, check_overlaps() flags crossings).
|
|
9
|
+
Neither takes coordinates: they measure themselves and sit in Row / Col / Box like any node.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import re
|
|
16
|
+
import xml.etree.ElementTree as ET
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, List, Optional, Sequence, Tuple, Union
|
|
19
|
+
|
|
20
|
+
from . import icons as _icons
|
|
21
|
+
from .node import Node
|
|
22
|
+
|
|
23
|
+
_SVG_NS = "{http://www.w3.org/2000/svg}"
|
|
24
|
+
_PRESENTATION = ("fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Icon(Node):
|
|
28
|
+
"""A small vector pictogram.
|
|
29
|
+
|
|
30
|
+
name: bundled icon (``codefig.icons.list()``) or alias (frozen → snowflake,
|
|
31
|
+
trainable → flame, …). size: height in px (default ≈ 1.4 × theme font size).
|
|
32
|
+
color: stroke / fill colour. filled: Tabler's filled variant. stroke_width: line
|
|
33
|
+
weight in icon units (Tabler default 2 on a 24 grid). bg: optional disc / rounded
|
|
34
|
+
square behind the glyph (``bg_shape="circle" | "rounded"``).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, name: Optional[str] = None, *, size: Optional[float] = None,
|
|
38
|
+
color: Optional[str] = None, filled: bool = False,
|
|
39
|
+
stroke_width: Optional[float] = None, bg: Optional[str] = None,
|
|
40
|
+
bg_shape: str = "circle", bg_pad: float = 0.22, opacity: float = 1.0,
|
|
41
|
+
_spec: Optional[dict] = None, **kwargs: Any) -> None:
|
|
42
|
+
super().__init__(**kwargs)
|
|
43
|
+
if _spec is None:
|
|
44
|
+
if name is None:
|
|
45
|
+
raise ValueError("Icon needs a name (codefig.icons.list()) or use Icon.from_svg()")
|
|
46
|
+
_spec = _icons.get(name, filled=filled)
|
|
47
|
+
if bg_shape not in ("circle", "rounded", "square"):
|
|
48
|
+
raise ValueError("bg_shape must be circle|rounded|square")
|
|
49
|
+
self.name = name
|
|
50
|
+
self.spec = _spec
|
|
51
|
+
self.size = None if size is None else float(size)
|
|
52
|
+
self.color = color
|
|
53
|
+
self.filled = filled
|
|
54
|
+
self.stroke_width = stroke_width
|
|
55
|
+
self.bg = bg
|
|
56
|
+
self.bg_shape = bg_shape
|
|
57
|
+
self.bg_pad = float(bg_pad)
|
|
58
|
+
self.opacity = float(opacity)
|
|
59
|
+
|
|
60
|
+
# ---- construction -------------------------------------------------------
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_svg(cls, path: Union[str, Path], *, size: Optional[float] = None,
|
|
63
|
+
color: Optional[str] = None, **kwargs: Any) -> "Icon":
|
|
64
|
+
"""Load any SVG file. With ``color=`` every non-``none`` stroke / fill is
|
|
65
|
+
recoloured (monochrome icon); without it the file's own colours are kept."""
|
|
66
|
+
root = ET.fromstring(Path(path).read_text(encoding="utf-8"))
|
|
67
|
+
vb = root.get("viewBox")
|
|
68
|
+
if vb:
|
|
69
|
+
box = tuple(float(v) for v in re.split(r"[\s,]+", vb.strip()))
|
|
70
|
+
else:
|
|
71
|
+
w = float(re.sub(r"[^\d.]", "", root.get("width", "24")) or 24)
|
|
72
|
+
h = float(re.sub(r"[^\d.]", "", root.get("height", "24")) or 24)
|
|
73
|
+
box = (0.0, 0.0, w, h)
|
|
74
|
+
inherited = {k: root.get(k) for k in _PRESENTATION if root.get(k) is not None}
|
|
75
|
+
elems: List[str] = []
|
|
76
|
+
for child in root:
|
|
77
|
+
tag = child.tag.replace(_SVG_NS, "")
|
|
78
|
+
if tag in ("title", "desc", "metadata", "style"):
|
|
79
|
+
continue
|
|
80
|
+
for el in child.iter():
|
|
81
|
+
el.tag = el.tag.replace(_SVG_NS, "")
|
|
82
|
+
if color is not None:
|
|
83
|
+
for a in ("fill", "stroke"):
|
|
84
|
+
v = el.get(a)
|
|
85
|
+
if v is not None and v != "none":
|
|
86
|
+
el.set(a, color)
|
|
87
|
+
elems.append(ET.tostring(child, encoding="unicode").replace(' xmlns="http://www.w3.org/2000/svg"', ""))
|
|
88
|
+
if color is not None:
|
|
89
|
+
inherited = {k: (color if k in ("fill", "stroke") and v not in ("none",) else v)
|
|
90
|
+
for k, v in inherited.items()}
|
|
91
|
+
spec = {"elements": elems, "viewbox": box, "mode": "native", "attrs": inherited}
|
|
92
|
+
return cls(None, size=size, color=color, _spec=spec, **kwargs)
|
|
93
|
+
|
|
94
|
+
# ---- layout -------------------------------------------------------------
|
|
95
|
+
def _size(self) -> float:
|
|
96
|
+
return self.size if self.size is not None else round(self.theme.font_size * 1.4, 1)
|
|
97
|
+
|
|
98
|
+
def _measure(self) -> Tuple[float, float]:
|
|
99
|
+
s = self._size()
|
|
100
|
+
_x, _y, vw, vh = self.spec["viewbox"]
|
|
101
|
+
w = s * vw / vh if vh else s
|
|
102
|
+
if self.bg:
|
|
103
|
+
p = 2 * self.bg_pad * s
|
|
104
|
+
return w + p, s + p
|
|
105
|
+
return w, s
|
|
106
|
+
|
|
107
|
+
def _color(self) -> str:
|
|
108
|
+
return self.color or self.theme.stroke
|
|
109
|
+
|
|
110
|
+
# ---- drawing ------------------------------------------------------------
|
|
111
|
+
def paint(self, buf: List[str], x: float, y: float, w: float, h: float,
|
|
112
|
+
color: Optional[str] = None) -> None:
|
|
113
|
+
"""Draw the glyph fitted (contain, centred) into the rectangle (x, y, w, h)."""
|
|
114
|
+
vx, vy, vw, vh = self.spec["viewbox"]
|
|
115
|
+
s = min(w / vw, h / vh)
|
|
116
|
+
dx = x + (w - vw * s) / 2 - vx * s
|
|
117
|
+
dy = y + (h - vh * s) / 2 - vy * s
|
|
118
|
+
col = color or self._color()
|
|
119
|
+
mode = self.spec.get("mode", "stroke")
|
|
120
|
+
op = f' opacity="{self.opacity:.3f}"' if self.opacity < 0.999 else ""
|
|
121
|
+
if mode == "stroke":
|
|
122
|
+
sw = 2.0 if self.stroke_width is None else float(self.stroke_width)
|
|
123
|
+
attrs = (f'fill="none" stroke="{col}" stroke-width="{sw:g}" '
|
|
124
|
+
f'stroke-linecap="round" stroke-linejoin="round"')
|
|
125
|
+
elif mode == "fill":
|
|
126
|
+
attrs = f'fill="{col}" stroke="none"'
|
|
127
|
+
else:
|
|
128
|
+
a = dict(self.spec.get("attrs", {}))
|
|
129
|
+
if self.stroke_width is not None:
|
|
130
|
+
a["stroke-width"] = f"{float(self.stroke_width):g}"
|
|
131
|
+
a = {k: (col if v == "currentColor" else v) for k, v in a.items()}
|
|
132
|
+
attrs = " ".join(f'{k}="{v}"' for k, v in a.items())
|
|
133
|
+
body = "".join(e.replace("currentColor", col) for e in self.spec["elements"])
|
|
134
|
+
buf.append(f'<g data-cf-icon="1" transform="translate({dx:.2f},{dy:.2f}) scale({s:.4f})" {attrs}{op}>'
|
|
135
|
+
f"{body}</g>")
|
|
136
|
+
|
|
137
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
138
|
+
from .render_svg import draw_circle, draw_rect
|
|
139
|
+
|
|
140
|
+
g = self.geom
|
|
141
|
+
x, y, w, h = g.x + ox, g.y + oy, g.w, g.h
|
|
142
|
+
if self.bg:
|
|
143
|
+
if self.bg_shape == "circle":
|
|
144
|
+
draw_circle(buf, x + w / 2, y + h / 2, min(w, h) / 2, fill=self.bg)
|
|
145
|
+
else:
|
|
146
|
+
draw_rect(buf, x, y, w, h, fill=self.bg, stroke="none", stroke_width=0,
|
|
147
|
+
radius=min(w, h) * 0.22 if self.bg_shape == "rounded" else 0)
|
|
148
|
+
p = self.bg_pad * self._size()
|
|
149
|
+
x, y, w, h = x + p, y + p, w - 2 * p, h - 2 * p
|
|
150
|
+
self.paint(buf, x, y, w, h)
|
|
151
|
+
|
|
152
|
+
def legend_key(self, label: str):
|
|
153
|
+
from .legend import Key
|
|
154
|
+
|
|
155
|
+
return Key(label, kind="icon", icon=self)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def as_icon(v: Any, *, size: Optional[float] = None, color: Optional[str] = None,
|
|
159
|
+
theme: Any = None) -> Optional[Icon]:
|
|
160
|
+
"""``"gpu"`` / Icon / None → Icon (used by Box(icon=…), badges, legends)."""
|
|
161
|
+
if v is None or v is False:
|
|
162
|
+
return None
|
|
163
|
+
if isinstance(v, Icon):
|
|
164
|
+
return v
|
|
165
|
+
if isinstance(v, str):
|
|
166
|
+
return Icon(v, size=size, color=color, theme=theme)
|
|
167
|
+
raise TypeError(f"icon must be a name or Icon, got {type(v).__name__}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------- Image
|
|
171
|
+
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
172
|
+
".webp": "image/webp", ".svg": "image/svg+xml"}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _svg_size(data: bytes) -> Tuple[float, float]:
|
|
176
|
+
root = ET.fromstring(data.decode("utf-8"))
|
|
177
|
+
vb = root.get("viewBox")
|
|
178
|
+
if vb:
|
|
179
|
+
p = [float(v) for v in re.split(r"[\s,]+", vb.strip())]
|
|
180
|
+
return p[2], p[3]
|
|
181
|
+
|
|
182
|
+
def num(s: Optional[str]) -> float:
|
|
183
|
+
m = re.match(r"[\d.]+", s or "")
|
|
184
|
+
return float(m.group(0)) if m else 100.0
|
|
185
|
+
return num(root.get("width")), num(root.get("height"))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Image(Node):
|
|
189
|
+
"""Embedded raster / SVG picture (base64 in the SVG output).
|
|
190
|
+
|
|
191
|
+
Give ``width=`` or ``height=`` (the other follows the aspect ratio) or both with
|
|
192
|
+
``fit="contain"`` (letterbox inside the box) / ``"cover"`` (fill and crop).
|
|
193
|
+
``radius=`` rounds the corners, ``border=True`` / a colour draws a frame.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, path: Union[str, Path], *, width: Optional[float] = None,
|
|
197
|
+
height: Optional[float] = None, fit: str = "contain", radius: float = 0.0,
|
|
198
|
+
border: Any = None, border_color: Optional[str] = None,
|
|
199
|
+
border_width: Optional[float] = None, opacity: float = 1.0, **kwargs: Any) -> None:
|
|
200
|
+
if fit not in ("contain", "cover", "stretch"):
|
|
201
|
+
raise ValueError("fit must be contain|cover|stretch")
|
|
202
|
+
frame = border
|
|
203
|
+
if isinstance(border, str) and border.startswith("#"):
|
|
204
|
+
border_color, frame = border, "solid"
|
|
205
|
+
elif border is True:
|
|
206
|
+
frame = "solid"
|
|
207
|
+
elif border is False:
|
|
208
|
+
frame = None
|
|
209
|
+
super().__init__(border=frame, **kwargs)
|
|
210
|
+
self.path = Path(path)
|
|
211
|
+
data = self.path.read_bytes()
|
|
212
|
+
ext = self.path.suffix.lower()
|
|
213
|
+
if ext not in _MIME:
|
|
214
|
+
raise ValueError(f"unsupported image type {ext!r}; use PNG / JPG / GIF / WEBP / SVG")
|
|
215
|
+
self.mime = _MIME[ext]
|
|
216
|
+
if ext == ".svg":
|
|
217
|
+
self.px_w, self.px_h = _svg_size(data)
|
|
218
|
+
else:
|
|
219
|
+
from PIL import Image as _PIL
|
|
220
|
+
|
|
221
|
+
with _PIL.open(self.path) as im:
|
|
222
|
+
self.px_w, self.px_h = float(im.width), float(im.height)
|
|
223
|
+
self._b64 = base64.b64encode(data).decode("ascii")
|
|
224
|
+
self.width = width
|
|
225
|
+
self.height = height
|
|
226
|
+
self.fit = fit
|
|
227
|
+
self.radius = float(radius)
|
|
228
|
+
self.border_color = border_color
|
|
229
|
+
self._border_width = None if border_width is None else float(border_width)
|
|
230
|
+
self.opacity = float(opacity)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def ratio(self) -> float:
|
|
234
|
+
return self.px_w / self.px_h if self.px_h else 1.0
|
|
235
|
+
|
|
236
|
+
def _measure(self) -> Tuple[float, float]:
|
|
237
|
+
if self.width is not None and self.height is not None:
|
|
238
|
+
return float(self.width), float(self.height)
|
|
239
|
+
if self.width is not None:
|
|
240
|
+
return float(self.width), float(self.width) / self.ratio
|
|
241
|
+
if self.height is not None:
|
|
242
|
+
return float(self.height) * self.ratio, float(self.height)
|
|
243
|
+
w = min(self.px_w, 160.0)
|
|
244
|
+
return w, w / self.ratio
|
|
245
|
+
|
|
246
|
+
_seq = 0
|
|
247
|
+
|
|
248
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
249
|
+
from .render_svg import draw_rect
|
|
250
|
+
|
|
251
|
+
g = self.geom
|
|
252
|
+
x, y, w, h = g.x + ox, g.y + oy, g.w, g.h
|
|
253
|
+
par = {"contain": "xMidYMid meet", "cover": "xMidYMid slice", "stretch": "none"}[self.fit]
|
|
254
|
+
Image._seq += 1
|
|
255
|
+
clip = ""
|
|
256
|
+
if self.radius > 0 or self.fit == "cover":
|
|
257
|
+
cid = f"cf-img-{Image._seq}"
|
|
258
|
+
buf.append(f'<defs><clipPath id="{cid}"><rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" '
|
|
259
|
+
f'height="{h:.2f}" rx="{self.radius:.2f}" ry="{self.radius:.2f}"/></clipPath></defs>')
|
|
260
|
+
clip = f' clip-path="url(#{cid})"'
|
|
261
|
+
op = f' opacity="{self.opacity:.3f}"' if self.opacity < 0.999 else ""
|
|
262
|
+
buf.append(f'<image x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" height="{h:.2f}" '
|
|
263
|
+
f'preserveAspectRatio="{par}"{clip}{op} '
|
|
264
|
+
f'xlink:href="data:{self.mime};base64,{self._b64}" />')
|
|
265
|
+
if self.border not in (None, "none"):
|
|
266
|
+
if self.fit == "contain": # frame the drawn picture, not the letterbox
|
|
267
|
+
s = min(w / self.px_w, h / self.px_h)
|
|
268
|
+
iw, ih = self.px_w * s, self.px_h * s
|
|
269
|
+
x, y, w, h = x + (w - iw) / 2, y + (h - ih) / 2, iw, ih
|
|
270
|
+
draw_rect(buf, x, y, w, h, fill="none", stroke=self.border_color or self.theme.stroke,
|
|
271
|
+
stroke_width=self.border_width, radius=self.radius,
|
|
272
|
+
dasharray=self._dash(self.border_width))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _image_border_width(self) -> float:
|
|
276
|
+
"""Frame width: explicit ``border_width=`` or the theme's default stroke (1.0 classic)."""
|
|
277
|
+
if self._border_width is not None:
|
|
278
|
+
return self._border_width
|
|
279
|
+
return self.theme.SW(1.0)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _set_image_border_width(self, v) -> None:
|
|
283
|
+
self._border_width = None if v is None else float(v)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
Image.border_width = property(_image_border_width, _set_image_border_width)
|
|
287
|
+
|
|
288
|
+
__all__ = ["Icon", "Image", "as_icon"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020-2026 Paweł Kuna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Bundled icon set: a curated subset of Tabler Icons (MIT, https://tabler.io/icons).
|
|
2
|
+
|
|
3
|
+
from codefig import icons, Icon
|
|
4
|
+
icons.list() # sorted names
|
|
5
|
+
icons.search("cloud") # names containing a substring
|
|
6
|
+
Icon("gpu", size=18, color="#2e6fd0")
|
|
7
|
+
Box("Encoder", icon="snowflake")
|
|
8
|
+
|
|
9
|
+
Icons are 24×24 stroke drawings stored as inline SVG elements, so they recolour with
|
|
10
|
+
``color=`` and stay vector in SVG / PDF output. ``filled=True`` uses Tabler's filled
|
|
11
|
+
variant where one exists (``icons.list(filled=True)``). The Tabler licence text ships
|
|
12
|
+
next to the data as ``LICENSE-tabler-icons.txt`` (see README "Credits").
|
|
13
|
+
Register your own drawings with :func:`register` or load a file with ``Icon.from_svg``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
_DIR = Path(__file__).resolve().parent
|
|
23
|
+
_DATA: Optional[dict] = None
|
|
24
|
+
_EXTRA: Dict[str, dict] = {}
|
|
25
|
+
|
|
26
|
+
# common synonyms → bundled names
|
|
27
|
+
ALIASES = {
|
|
28
|
+
"frozen": "snowflake", "trainable": "flame", "fire": "flame", "llm": "brain",
|
|
29
|
+
"model": "brain", "agent": "robot", "bot": "robot", "chat": "message", "doc": "file-text",
|
|
30
|
+
"document": "file-text", "storage": "database", "db": "database", "people": "users",
|
|
31
|
+
"person": "user", "gear": "settings", "lightning": "bolt", "image": "photo",
|
|
32
|
+
"picture": "photo", "web": "world", "internet": "world", "cache": "stack-2",
|
|
33
|
+
"memory": "device-sd-card", "disk": "device-floppy", "tick": "check", "cross": "x",
|
|
34
|
+
"close": "x", "idea": "bulb", "magic": "wand", "time": "clock", "tree": "binary-tree",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _data() -> dict:
|
|
39
|
+
global _DATA
|
|
40
|
+
if _DATA is None:
|
|
41
|
+
_DATA = json.loads((_DIR / "tabler.json").read_text(encoding="utf-8"))
|
|
42
|
+
return _DATA
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list(*, filled: bool = False) -> List[str]: # noqa: A001 - public API name
|
|
46
|
+
"""Sorted names of the bundled (and registered) icons."""
|
|
47
|
+
d = _data()
|
|
48
|
+
names = set(d["filled" if filled else "outline"])
|
|
49
|
+
names |= {n for n, e in _EXTRA.items() if (not filled or e.get("mode") == "fill")}
|
|
50
|
+
return sorted(names)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def search(text: str) -> List[str]:
|
|
54
|
+
"""Bundled icon names containing ``text`` (aliases included)."""
|
|
55
|
+
t = text.lower()
|
|
56
|
+
hits = {n for n in list() if t in n}
|
|
57
|
+
hits |= {v for k, v in ALIASES.items() if t in k}
|
|
58
|
+
return sorted(hits)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_name(name: str) -> str:
|
|
62
|
+
n = name.strip().lower().replace("_", "-").replace(" ", "-")
|
|
63
|
+
if n in _EXTRA or n in _data()["outline"]:
|
|
64
|
+
return n
|
|
65
|
+
if n in ALIASES:
|
|
66
|
+
return ALIASES[n]
|
|
67
|
+
raise KeyError(
|
|
68
|
+
f"unknown icon {name!r}; see codefig.icons.list() / icons.search(), "
|
|
69
|
+
f"or use Icon.from_svg(path) / icons.register()")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get(name: str, *, filled: bool = False) -> dict:
|
|
73
|
+
"""Icon spec: ``{"elements": [...], "viewbox": (x, y, w, h), "mode": "stroke"|"fill"|"native"}``."""
|
|
74
|
+
n = resolve_name(name)
|
|
75
|
+
if n in _EXTRA:
|
|
76
|
+
return _EXTRA[n]
|
|
77
|
+
d = _data()
|
|
78
|
+
vb = float(d.get("viewbox", 24))
|
|
79
|
+
if filled:
|
|
80
|
+
if n not in d["filled"]:
|
|
81
|
+
raise KeyError(f"icon {n!r} has no filled variant; filled ones: icons.list(filled=True)")
|
|
82
|
+
return {"elements": d["filled"][n], "viewbox": (0.0, 0.0, vb, vb), "mode": "fill"}
|
|
83
|
+
return {"elements": d["outline"][n], "viewbox": (0.0, 0.0, vb, vb), "mode": "stroke"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register(name: str, elements: Sequence[str], *, viewbox=(0, 0, 24, 24), mode: str = "stroke") -> None:
|
|
87
|
+
"""Add a custom icon from raw SVG elements (``mode``: stroke | fill | native)."""
|
|
88
|
+
if mode not in ("stroke", "fill", "native"):
|
|
89
|
+
raise ValueError("mode must be stroke|fill|native")
|
|
90
|
+
_EXTRA[name.lower()] = {"elements": [str(e) for e in elements],
|
|
91
|
+
"viewbox": tuple(float(v) for v in viewbox), "mode": mode}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def info() -> dict:
|
|
95
|
+
d = _data()
|
|
96
|
+
return {"source": d["source"], "version": d["version"], "license": d["license"],
|
|
97
|
+
"count": len(d["outline"]), "filled": len(d["filled"]),
|
|
98
|
+
"license_file": str(_DIR / "LICENSE-tabler-icons.txt")}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["ALIASES", "get", "info", "list", "register", "resolve_name", "search"]
|