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/image.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Embedding raster images and external SVGs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import struct
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
|
|
11
|
+
from .core import Element
|
|
12
|
+
from .geom import Affine, BBox
|
|
13
|
+
from .svgdoc import Node, RenderContext
|
|
14
|
+
|
|
15
|
+
__all__ = ["Image", "image_size", "SVGFile"]
|
|
16
|
+
|
|
17
|
+
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
18
|
+
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
|
19
|
+
".svg": "image/svg+xml", ".avif": "image/avif"}
|
|
20
|
+
|
|
21
|
+
_SVG_NS = "http://www.w3.org/2000/svg"
|
|
22
|
+
_XLINK_NS = "http://www.w3.org/1999/xlink"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def image_size(data: bytes, path: str = "") -> tuple:
|
|
26
|
+
"""Sniff ``(width, height)`` from raw image bytes. ``(None, None)`` if unknown."""
|
|
27
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
28
|
+
w, h = struct.unpack(">II", data[16:24])
|
|
29
|
+
return float(w), float(h)
|
|
30
|
+
if data[:2] == b"\xff\xd8": # JPEG
|
|
31
|
+
i = 2
|
|
32
|
+
n = len(data)
|
|
33
|
+
while i < n - 9:
|
|
34
|
+
if data[i] != 0xFF:
|
|
35
|
+
i += 1
|
|
36
|
+
continue
|
|
37
|
+
marker = data[i + 1]
|
|
38
|
+
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
|
39
|
+
i += 2
|
|
40
|
+
continue
|
|
41
|
+
seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]
|
|
42
|
+
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
|
|
43
|
+
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
|
44
|
+
h, w = struct.unpack(">HH", data[i + 5:i + 9])
|
|
45
|
+
return float(w), float(h)
|
|
46
|
+
i += 2 + seg_len
|
|
47
|
+
return None, None
|
|
48
|
+
if data[:6] in (b"GIF87a", b"GIF89a"):
|
|
49
|
+
w, h = struct.unpack("<HH", data[6:10])
|
|
50
|
+
return float(w), float(h)
|
|
51
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
52
|
+
chunk = data[12:16]
|
|
53
|
+
if chunk == b"VP8X":
|
|
54
|
+
w = int.from_bytes(data[24:27], "little") + 1
|
|
55
|
+
h = int.from_bytes(data[27:30], "little") + 1
|
|
56
|
+
return float(w), float(h)
|
|
57
|
+
if chunk == b"VP8 ":
|
|
58
|
+
w = struct.unpack("<H", data[26:28])[0] & 0x3FFF
|
|
59
|
+
h = struct.unpack("<H", data[28:30])[0] & 0x3FFF
|
|
60
|
+
return float(w), float(h)
|
|
61
|
+
if chunk == b"VP8L":
|
|
62
|
+
b = data[21:25]
|
|
63
|
+
bits = int.from_bytes(b, "little")
|
|
64
|
+
return float((bits & 0x3FFF) + 1), float(((bits >> 14) & 0x3FFF) + 1)
|
|
65
|
+
return None, None
|
|
66
|
+
if data[:2] == b"BM":
|
|
67
|
+
w, h = struct.unpack("<ii", data[18:26])
|
|
68
|
+
return float(abs(w)), float(abs(h))
|
|
69
|
+
head = data[:4096].decode("utf-8", "ignore")
|
|
70
|
+
if "<svg" in head or path.lower().endswith(".svg"):
|
|
71
|
+
return _svg_size(data.decode("utf-8", "ignore"))
|
|
72
|
+
return None, None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_LEN_RE = re.compile(r"^\s*(-?[\d.]+)\s*([a-z%]*)\s*$", re.I)
|
|
76
|
+
_UNITS = {"": 1.0, "px": 1.0, "pt": 96 / 72, "pc": 16.0, "mm": 96 / 25.4,
|
|
77
|
+
"cm": 96 / 2.54, "in": 96.0}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _length(value, default=None):
|
|
81
|
+
if value is None:
|
|
82
|
+
return default
|
|
83
|
+
m = _LEN_RE.match(str(value))
|
|
84
|
+
if not m:
|
|
85
|
+
return default
|
|
86
|
+
unit = m.group(2).lower()
|
|
87
|
+
if unit == "%":
|
|
88
|
+
return default
|
|
89
|
+
return float(m.group(1)) * _UNITS.get(unit, 1.0)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _svg_size(text: str) -> tuple:
|
|
93
|
+
try:
|
|
94
|
+
root = ET.fromstring(text)
|
|
95
|
+
except ET.ParseError:
|
|
96
|
+
return None, None
|
|
97
|
+
w = _length(root.get("width"))
|
|
98
|
+
h = _length(root.get("height"))
|
|
99
|
+
vb = root.get("viewBox")
|
|
100
|
+
if vb:
|
|
101
|
+
parts = [float(x) for x in re.split(r"[,\s]+", vb.strip()) if x]
|
|
102
|
+
if len(parts) == 4:
|
|
103
|
+
if w is None:
|
|
104
|
+
w = parts[2]
|
|
105
|
+
if h is None:
|
|
106
|
+
h = parts[3]
|
|
107
|
+
return w, h
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Image(Element):
|
|
111
|
+
"""Place a PNG/JPEG/GIF/WebP or SVG file into the figure.
|
|
112
|
+
|
|
113
|
+
Raster images are base64-embedded so the exported SVG is self-contained.
|
|
114
|
+
SVG files are inlined as vectors by default (``inline=True``), with all
|
|
115
|
+
internal ids rewritten so several copies never collide.
|
|
116
|
+
|
|
117
|
+
>>> Image("logo.svg", w=80)
|
|
118
|
+
>>> Image("plot.png", w=200).right_of(box, gap=20)
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
role = "image"
|
|
122
|
+
|
|
123
|
+
def __init__(self, source, x: float = 0.0, y: float = 0.0, w: float = None,
|
|
124
|
+
h: float = None, *, inline: bool = True,
|
|
125
|
+
fit: str = "contain", mime: str = None, **kw):
|
|
126
|
+
self.source = source
|
|
127
|
+
self.inline = inline
|
|
128
|
+
self.fit = fit
|
|
129
|
+
self._mime = mime
|
|
130
|
+
self._data: bytes | None = None
|
|
131
|
+
self._text: str | None = None
|
|
132
|
+
self._natural = (None, None)
|
|
133
|
+
self._load()
|
|
134
|
+
super().__init__(x, y, w, h, **kw)
|
|
135
|
+
|
|
136
|
+
# -- loading ---------------------------------------------------------
|
|
137
|
+
def _load(self) -> None:
|
|
138
|
+
src = self.source
|
|
139
|
+
if isinstance(src, bytes):
|
|
140
|
+
self._data = src
|
|
141
|
+
self._mime = self._mime or "image/png"
|
|
142
|
+
elif isinstance(src, str) and src.startswith("data:"):
|
|
143
|
+
header, _, payload = src.partition(",")
|
|
144
|
+
self._mime = self._mime or header[5:].split(";")[0]
|
|
145
|
+
try:
|
|
146
|
+
self._data = base64.b64decode(payload)
|
|
147
|
+
except Exception:
|
|
148
|
+
self._data = payload.encode()
|
|
149
|
+
elif isinstance(src, str) and src.lstrip().startswith("<svg"):
|
|
150
|
+
self._text = src
|
|
151
|
+
self._data = src.encode()
|
|
152
|
+
self._mime = "image/svg+xml"
|
|
153
|
+
else:
|
|
154
|
+
path = os.fspath(src)
|
|
155
|
+
if not os.path.isfile(path):
|
|
156
|
+
raise FileNotFoundError(f"image not found: {path}")
|
|
157
|
+
with open(path, "rb") as fh:
|
|
158
|
+
self._data = fh.read()
|
|
159
|
+
ext = os.path.splitext(path)[1].lower()
|
|
160
|
+
self._mime = self._mime or _MIME.get(ext, "application/octet-stream")
|
|
161
|
+
if self._mime == "image/svg+xml" and self._text is None:
|
|
162
|
+
self._text = self._data.decode("utf-8", "ignore")
|
|
163
|
+
self._natural = image_size(self._data, str(self.source)
|
|
164
|
+
if isinstance(self.source, str) else "")
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def natural_size(self) -> tuple:
|
|
168
|
+
return self._natural
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def is_svg(self) -> bool:
|
|
172
|
+
return self._mime == "image/svg+xml"
|
|
173
|
+
|
|
174
|
+
def _measure(self) -> None:
|
|
175
|
+
nw, nh = self._natural
|
|
176
|
+
nw = nw or 100.0
|
|
177
|
+
nh = nh or 100.0
|
|
178
|
+
if self._explicit_w and self._explicit_h:
|
|
179
|
+
return
|
|
180
|
+
if self._explicit_w:
|
|
181
|
+
self._h = self._w * nh / nw
|
|
182
|
+
elif self._explicit_h:
|
|
183
|
+
self._w = self._h * nw / nh
|
|
184
|
+
else:
|
|
185
|
+
self._w, self._h = nw, nh
|
|
186
|
+
|
|
187
|
+
def data_uri(self) -> str:
|
|
188
|
+
b64 = base64.b64encode(self._data).decode("ascii")
|
|
189
|
+
return f"data:{self._mime};base64,{b64}"
|
|
190
|
+
|
|
191
|
+
# -- rendering -------------------------------------------------------
|
|
192
|
+
def _render_content(self, ctx: RenderContext):
|
|
193
|
+
self._ensure()
|
|
194
|
+
bb = self.local_bbox
|
|
195
|
+
if self.is_svg and self.inline:
|
|
196
|
+
node = self._inline_svg(ctx, bb)
|
|
197
|
+
if node is not None:
|
|
198
|
+
return node
|
|
199
|
+
par = {"contain": "xMidYMid meet", "cover": "xMidYMid slice",
|
|
200
|
+
"fill": "none", "stretch": "none"}.get(self.fit, "xMidYMid meet")
|
|
201
|
+
attrs = {"x": bb.x, "y": bb.y, "width": bb.w, "height": bb.h,
|
|
202
|
+
"preserveAspectRatio": par, "href": self.data_uri()}
|
|
203
|
+
op = self.prop("image_opacity", None)
|
|
204
|
+
if op is not None:
|
|
205
|
+
attrs["opacity"] = op
|
|
206
|
+
return Node("image", **attrs)
|
|
207
|
+
|
|
208
|
+
def _inline_svg(self, ctx: RenderContext, bb: BBox):
|
|
209
|
+
try:
|
|
210
|
+
root = ET.fromstring(self._text)
|
|
211
|
+
except ET.ParseError:
|
|
212
|
+
ctx.warn(f"could not parse SVG {self.source!r}; embedding as image")
|
|
213
|
+
return None
|
|
214
|
+
nw, nh = self._natural
|
|
215
|
+
vb = root.get("viewBox")
|
|
216
|
+
if vb:
|
|
217
|
+
parts = [float(x) for x in re.split(r"[,\s]+", vb.strip()) if x]
|
|
218
|
+
vx, vy, vw, vh = parts if len(parts) == 4 else (0, 0, nw or bb.w,
|
|
219
|
+
nh or bb.h)
|
|
220
|
+
else:
|
|
221
|
+
vx, vy, vw, vh = 0.0, 0.0, nw or bb.w, nh or bb.h
|
|
222
|
+
if not vw or not vh:
|
|
223
|
+
return None
|
|
224
|
+
scale = min(bb.w / vw, bb.h / vh) if self.fit == "contain" else \
|
|
225
|
+
max(bb.w / vw, bb.h / vh) if self.fit == "cover" else None
|
|
226
|
+
if scale is None:
|
|
227
|
+
sx, sy = bb.w / vw, bb.h / vh
|
|
228
|
+
else:
|
|
229
|
+
sx = sy = scale
|
|
230
|
+
tx = bb.x + (bb.w - vw * sx) / 2.0 - vx * sx
|
|
231
|
+
ty = bb.y + (bb.h - vh * sy) / 2.0 - vy * sy
|
|
232
|
+
prefix = ctx.uid("svg") + "_"
|
|
233
|
+
ids = {el.get("id") for el in root.iter() if el.get("id")}
|
|
234
|
+
body = "".join(_serialize(child, ids, prefix)
|
|
235
|
+
for child in root
|
|
236
|
+
if _localname(child.tag) not in ("title", "desc",
|
|
237
|
+
"metadata"))
|
|
238
|
+
m = Affine(sx, 0, 0, sy, tx, ty)
|
|
239
|
+
g = Node("g", raw=body)
|
|
240
|
+
if not m.is_identity:
|
|
241
|
+
g.attrs["transform"] = m.to_svg()
|
|
242
|
+
if self.fit == "cover" and (vw * sx > bb.w + 1e-6
|
|
243
|
+
or vh * sy > bb.h + 1e-6):
|
|
244
|
+
# "cover" scales past the box on one axis; clip like <image> does
|
|
245
|
+
clip = Node("clipPath").add(
|
|
246
|
+
Node("rect", x=bb.x, y=bb.y, width=bb.w, height=bb.h))
|
|
247
|
+
wrapper = Node("g", clip_path=f"url(#{ctx.add_def(clip)})")
|
|
248
|
+
wrapper.add(g)
|
|
249
|
+
return wrapper
|
|
250
|
+
return g
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _localname(tag) -> str:
|
|
254
|
+
return tag.split("}")[-1] if isinstance(tag, str) else str(tag)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
_URL_RE = re.compile(r"url\(\s*['\"]?#([^)'\"]+)['\"]?\s*\)")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _serialize(el, ids: set, prefix: str) -> str:
|
|
261
|
+
"""Re-emit an XML subtree, namespacing ids so copies never collide."""
|
|
262
|
+
tag = _localname(el.tag)
|
|
263
|
+
parts = [f"<{tag}"]
|
|
264
|
+
for key, value in el.attrib.items():
|
|
265
|
+
k = _localname(key)
|
|
266
|
+
if key.startswith(f"{{{_XLINK_NS}}}"):
|
|
267
|
+
k = "href"
|
|
268
|
+
if k in ("id",) and value in ids:
|
|
269
|
+
value = prefix + value
|
|
270
|
+
elif k == "href" and value.startswith("#") and value[1:] in ids:
|
|
271
|
+
value = "#" + prefix + value[1:]
|
|
272
|
+
elif "url(#" in str(value):
|
|
273
|
+
value = _URL_RE.sub(
|
|
274
|
+
lambda m: f"url(#{prefix + m.group(1) if m.group(1) in ids else m.group(1)})",
|
|
275
|
+
value)
|
|
276
|
+
if k.startswith("xmlns"):
|
|
277
|
+
continue
|
|
278
|
+
parts.append(f' {k}="{_xesc(value)}"')
|
|
279
|
+
children = list(el)
|
|
280
|
+
text = (el.text or "")
|
|
281
|
+
if not children and not text.strip():
|
|
282
|
+
parts.append("/>")
|
|
283
|
+
return "".join(parts)
|
|
284
|
+
parts.append(">")
|
|
285
|
+
if tag == "style":
|
|
286
|
+
parts.append(_URL_RE.sub(
|
|
287
|
+
lambda m: f"url(#{prefix + m.group(1) if m.group(1) in ids else m.group(1)})",
|
|
288
|
+
text))
|
|
289
|
+
else:
|
|
290
|
+
parts.append(_xesc_text(text))
|
|
291
|
+
for child in children:
|
|
292
|
+
parts.append(_serialize(child, ids, prefix))
|
|
293
|
+
if child.tail:
|
|
294
|
+
parts.append(_xesc_text(child.tail))
|
|
295
|
+
parts.append(f"</{tag}>")
|
|
296
|
+
return "".join(parts)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _xesc(v) -> str:
|
|
300
|
+
return (str(v).replace("&", "&").replace("<", "<")
|
|
301
|
+
.replace(">", ">").replace('"', """))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _xesc_text(v) -> str:
|
|
305
|
+
return str(v).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
SVGFile = Image
|