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/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("&", "&amp;").replace("<", "&lt;")
301
+ .replace(">", "&gt;").replace('"', "&quot;"))
302
+
303
+
304
+ def _xesc_text(v) -> str:
305
+ return str(v).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
306
+
307
+
308
+ SVGFile = Image