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