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/svgpath.py ADDED
@@ -0,0 +1,470 @@
1
+ """A small, dependency-free SVG path toolkit.
2
+
3
+ Enough to parse, transform, measure and flatten path data — which is what we
4
+ need for accurate bounding boxes, arrow geometry and embedding external SVGs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+
12
+ __all__ = [
13
+ "parse_path", "abs_segments", "path_bbox", "transform_path_data",
14
+ "translate_path_data", "scale_path_data", "flatten_path", "path_length",
15
+ "point_at", "fmt", "path_from_points", "rounded_polyline",
16
+ ]
17
+
18
+ _TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|(-?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)")
19
+
20
+ _ARGS = {"M": 2, "L": 2, "H": 1, "V": 1, "C": 6, "S": 4, "Q": 4, "T": 2,
21
+ "A": 7, "Z": 0}
22
+
23
+
24
+ def fmt(v) -> str:
25
+ """Compact number formatting for path/attribute output."""
26
+ if isinstance(v, str):
27
+ return v
28
+ v = float(v)
29
+ if v != v or v in (float("inf"), float("-inf")):
30
+ return "0"
31
+ if abs(v - round(v)) < 5e-5:
32
+ return str(int(round(v)))
33
+ s = f"{v:.4f}".rstrip("0").rstrip(".")
34
+ return s or "0"
35
+
36
+
37
+ def parse_path(d: str) -> list:
38
+ """Tokenise path data into ``[(command, [numbers...]), ...]``.
39
+
40
+ Repeated coordinate sets are expanded into separate commands, and an
41
+ implicit ``L``/``l`` after ``M``/``m`` is made explicit.
42
+ """
43
+ if not d:
44
+ return []
45
+ out = []
46
+ cmd = None
47
+ nums: list = []
48
+ for m in _TOKEN_RE.finditer(d):
49
+ if m.group(1):
50
+ if cmd is not None:
51
+ out.extend(_emit(cmd, nums))
52
+ cmd = m.group(1)
53
+ nums = []
54
+ else:
55
+ nums.append(float(m.group(2)))
56
+ if cmd is not None:
57
+ out.extend(_emit(cmd, nums))
58
+ return out
59
+
60
+
61
+ def _emit(cmd: str, nums: list) -> list:
62
+ n = _ARGS[cmd.upper()]
63
+ if n == 0:
64
+ return [(cmd, [])]
65
+ if not nums:
66
+ return []
67
+ out = []
68
+ first = True
69
+ for i in range(0, len(nums) - n + 1, n):
70
+ c = cmd
71
+ if not first and cmd in "Mm":
72
+ c = "L" if cmd == "M" else "l"
73
+ out.append((c, nums[i:i + n]))
74
+ first = False
75
+ return out
76
+
77
+
78
+ def abs_segments(d: str) -> list:
79
+ """Normalise to absolute segments.
80
+
81
+ Yields ``("M", x, y)``, ``("L", x, y)``, ``("C", x1,y1,x2,y2,x,y)``,
82
+ ``("Q", x1,y1,x,y)`` and ``("Z",)``. Arcs are converted to cubics.
83
+ """
84
+ segs = []
85
+ cx = cy = 0.0 # current point
86
+ sx = sy = 0.0 # subpath start
87
+ prev_c2 = None # previous cubic control point (for S)
88
+ prev_q1 = None # previous quadratic control point (for T)
89
+ for cmd, a in parse_path(d):
90
+ up = cmd.upper()
91
+ rel = cmd.islower()
92
+ if up == "M":
93
+ x, y = (cx + a[0], cy + a[1]) if rel else (a[0], a[1])
94
+ segs.append(("M", x, y))
95
+ cx, cy = sx, sy = x, y
96
+ prev_c2 = prev_q1 = None
97
+ elif up == "L":
98
+ x, y = (cx + a[0], cy + a[1]) if rel else (a[0], a[1])
99
+ segs.append(("L", x, y))
100
+ cx, cy = x, y
101
+ prev_c2 = prev_q1 = None
102
+ elif up == "H":
103
+ x = cx + a[0] if rel else a[0]
104
+ segs.append(("L", x, cy))
105
+ cx = x
106
+ prev_c2 = prev_q1 = None
107
+ elif up == "V":
108
+ y = cy + a[0] if rel else a[0]
109
+ segs.append(("L", cx, y))
110
+ cy = y
111
+ prev_c2 = prev_q1 = None
112
+ elif up == "C":
113
+ pts = [(cx + a[i] if rel else a[i], cy + a[i + 1] if rel else a[i + 1])
114
+ for i in (0, 2, 4)]
115
+ segs.append(("C", pts[0][0], pts[0][1], pts[1][0], pts[1][1],
116
+ pts[2][0], pts[2][1]))
117
+ prev_c2 = pts[1]
118
+ cx, cy = pts[2]
119
+ prev_q1 = None
120
+ elif up == "S":
121
+ c1 = (2 * cx - prev_c2[0], 2 * cy - prev_c2[1]) if prev_c2 else (cx, cy)
122
+ pts = [(cx + a[i] if rel else a[i], cy + a[i + 1] if rel else a[i + 1])
123
+ for i in (0, 2)]
124
+ segs.append(("C", c1[0], c1[1], pts[0][0], pts[0][1],
125
+ pts[1][0], pts[1][1]))
126
+ prev_c2 = pts[0]
127
+ cx, cy = pts[1]
128
+ prev_q1 = None
129
+ elif up == "Q":
130
+ pts = [(cx + a[i] if rel else a[i], cy + a[i + 1] if rel else a[i + 1])
131
+ for i in (0, 2)]
132
+ segs.append(("Q", pts[0][0], pts[0][1], pts[1][0], pts[1][1]))
133
+ prev_q1 = pts[0]
134
+ cx, cy = pts[1]
135
+ prev_c2 = None
136
+ elif up == "T":
137
+ q1 = (2 * cx - prev_q1[0], 2 * cy - prev_q1[1]) if prev_q1 else (cx, cy)
138
+ x, y = (cx + a[0], cy + a[1]) if rel else (a[0], a[1])
139
+ segs.append(("Q", q1[0], q1[1], x, y))
140
+ prev_q1 = q1
141
+ cx, cy = x, y
142
+ prev_c2 = None
143
+ elif up == "A":
144
+ rx, ry, rot, laf, sf = a[0], a[1], a[2], a[3], a[4]
145
+ x, y = (cx + a[5], cy + a[6]) if rel else (a[5], a[6])
146
+ segs.extend(_arc_to_cubics(cx, cy, rx, ry, rot, laf, sf, x, y))
147
+ cx, cy = x, y
148
+ prev_c2 = prev_q1 = None
149
+ elif up == "Z":
150
+ segs.append(("Z",))
151
+ cx, cy = sx, sy
152
+ prev_c2 = prev_q1 = None
153
+ return segs
154
+
155
+
156
+ def _arc_to_cubics(x1, y1, rx, ry, rot_deg, large_arc, sweep, x2, y2) -> list:
157
+ """Convert an SVG elliptical arc into a series of cubic segments."""
158
+ if rx == 0 or ry == 0 or (x1 == x2 and y1 == y2):
159
+ return [("L", x2, y2)]
160
+ rx, ry = abs(rx), abs(ry)
161
+ phi = math.radians(rot_deg)
162
+ cosp, sinp = math.cos(phi), math.sin(phi)
163
+ dx2, dy2 = (x1 - x2) / 2.0, (y1 - y2) / 2.0
164
+ x1p = cosp * dx2 + sinp * dy2
165
+ y1p = -sinp * dx2 + cosp * dy2
166
+ lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
167
+ if lam > 1:
168
+ s = math.sqrt(lam)
169
+ rx, ry = rx * s, ry * s
170
+ num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
171
+ den = rx * rx * y1p * y1p + ry * ry * x1p * x1p
172
+ factor = math.sqrt(max(0.0, num / den)) if den else 0.0
173
+ if bool(large_arc) == bool(sweep):
174
+ factor = -factor
175
+ cxp = factor * rx * y1p / ry
176
+ cyp = -factor * ry * x1p / rx
177
+ cx = cosp * cxp - sinp * cyp + (x1 + x2) / 2.0
178
+ cy = sinp * cxp + cosp * cyp + (y1 + y2) / 2.0
179
+
180
+ def angle(ux, uy, vx, vy):
181
+ dot = ux * vx + uy * vy
182
+ n = math.hypot(ux, uy) * math.hypot(vx, vy)
183
+ if n == 0:
184
+ return 0.0
185
+ a = math.acos(max(-1.0, min(1.0, dot / n)))
186
+ return -a if (ux * vy - uy * vx) < 0 else a
187
+
188
+ theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
189
+ dtheta = angle((x1p - cxp) / rx, (y1p - cyp) / ry,
190
+ (-x1p - cxp) / rx, (-y1p - cyp) / ry)
191
+ if not sweep and dtheta > 0:
192
+ dtheta -= 2 * math.pi
193
+ elif sweep and dtheta < 0:
194
+ dtheta += 2 * math.pi
195
+
196
+ n_segs = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2))))
197
+ delta = dtheta / n_segs
198
+ t = 4.0 / 3.0 * math.tan(delta / 4.0)
199
+ out = []
200
+ th = theta1
201
+ px = x1
202
+ py = y1
203
+ for _ in range(n_segs):
204
+ cos1, sin1 = math.cos(th), math.sin(th)
205
+ th2 = th + delta
206
+ cos2, sin2 = math.cos(th2), math.sin(th2)
207
+ ex = cx + rx * cosp * cos2 - ry * sinp * sin2
208
+ ey = cy + rx * sinp * cos2 + ry * cosp * sin2
209
+ d1x = -rx * cosp * sin1 - ry * sinp * cos1
210
+ d1y = -rx * sinp * sin1 + ry * cosp * cos1
211
+ d2x = -rx * cosp * sin2 - ry * sinp * cos2
212
+ d2y = -rx * sinp * sin2 + ry * cosp * cos2
213
+ out.append(("C", px + t * d1x, py + t * d1y,
214
+ ex - t * d2x, ey - t * d2y, ex, ey))
215
+ px, py, th = ex, ey, th2
216
+ return out
217
+
218
+
219
+ # --------------------------------------------------------------------------
220
+ # Bounding box (exact, using Bezier extrema)
221
+ # --------------------------------------------------------------------------
222
+
223
+ def _bez_extrema(p0, p1, p2, p3=None) -> list:
224
+ """Parameter values where a Bezier component reaches an extremum."""
225
+ ts = []
226
+ if p3 is None: # quadratic
227
+ den = p0 - 2 * p1 + p2
228
+ if abs(den) > 1e-12:
229
+ t = (p0 - p1) / den
230
+ if 0 < t < 1:
231
+ ts.append(t)
232
+ return ts
233
+ a = -p0 + 3 * p1 - 3 * p2 + p3
234
+ b = 2 * (p0 - 2 * p1 + p2)
235
+ c = p1 - p0
236
+ if abs(a) < 1e-12:
237
+ if abs(b) > 1e-12:
238
+ t = -c / b
239
+ if 0 < t < 1:
240
+ ts.append(t)
241
+ return ts
242
+ disc = b * b - 4 * a * c
243
+ if disc < 0:
244
+ return ts
245
+ sq = math.sqrt(disc)
246
+ for t in ((-b + sq) / (2 * a), (-b - sq) / (2 * a)):
247
+ if 0 < t < 1:
248
+ ts.append(t)
249
+ return ts
250
+
251
+
252
+ def _bez_at(t, p0, p1, p2, p3=None) -> float:
253
+ if p3 is None:
254
+ mt = 1 - t
255
+ return mt * mt * p0 + 2 * mt * t * p1 + t * t * p2
256
+ mt = 1 - t
257
+ return (mt ** 3 * p0 + 3 * mt * mt * t * p1
258
+ + 3 * mt * t * t * p2 + t ** 3 * p3)
259
+
260
+
261
+ def path_bbox(d: str) -> tuple:
262
+ """Exact ``(x0, y0, x1, y1)`` bounds of path data (ignoring stroke width)."""
263
+ xs: list = []
264
+ ys: list = []
265
+ cx = cy = 0.0
266
+ sx = sy = 0.0
267
+ started = False
268
+ for seg in abs_segments(d):
269
+ k = seg[0]
270
+ if k == "M":
271
+ cx, cy = seg[1], seg[2]
272
+ sx, sy = cx, cy
273
+ xs.append(cx)
274
+ ys.append(cy)
275
+ started = True
276
+ elif k == "L":
277
+ xs.extend((cx, seg[1]))
278
+ ys.extend((cy, seg[2]))
279
+ cx, cy = seg[1], seg[2]
280
+ elif k == "C":
281
+ x1, y1, x2, y2, x, y = seg[1:]
282
+ xs.extend((cx, x))
283
+ ys.extend((cy, y))
284
+ for t in _bez_extrema(cx, x1, x2, x):
285
+ xs.append(_bez_at(t, cx, x1, x2, x))
286
+ for t in _bez_extrema(cy, y1, y2, y):
287
+ ys.append(_bez_at(t, cy, y1, y2, y))
288
+ cx, cy = x, y
289
+ elif k == "Q":
290
+ x1, y1, x, y = seg[1:]
291
+ xs.extend((cx, x))
292
+ ys.extend((cy, y))
293
+ for t in _bez_extrema(cx, x1, x):
294
+ xs.append(_bez_at(t, cx, x1, x))
295
+ for t in _bez_extrema(cy, y1, y):
296
+ ys.append(_bez_at(t, cy, y1, y))
297
+ cx, cy = x, y
298
+ elif k == "Z":
299
+ cx, cy = sx, sy
300
+ if not started or not xs:
301
+ return (0.0, 0.0, 0.0, 0.0)
302
+ return (min(xs), min(ys), max(xs), max(ys))
303
+
304
+
305
+ # --------------------------------------------------------------------------
306
+ # Transforms
307
+ # --------------------------------------------------------------------------
308
+
309
+ def transform_path_data(d: str, affine) -> str:
310
+ """Apply an :class:`~figkit.geom.Affine` to path data, returning new data."""
311
+ if not d:
312
+ return ""
313
+ ap = affine.apply
314
+ out = []
315
+ for seg in abs_segments(d):
316
+ k = seg[0]
317
+ if k == "Z":
318
+ out.append("Z")
319
+ continue
320
+ coords = seg[1:]
321
+ pts = [ap((coords[i], coords[i + 1])) for i in range(0, len(coords), 2)]
322
+ body = " ".join(f"{fmt(p.x)} {fmt(p.y)}" for p in pts)
323
+ out.append(f"{k}{body}")
324
+ return "".join(out)
325
+
326
+
327
+ def translate_path_data(d: str, dx: float, dy: float) -> str:
328
+ from .geom import Affine
329
+ if dx == 0 and dy == 0:
330
+ return d
331
+ return transform_path_data(d, Affine.translate(dx, dy))
332
+
333
+
334
+ def scale_path_data(d: str, sx: float, sy: float = None) -> str:
335
+ from .geom import Affine
336
+ sy = sx if sy is None else sy
337
+ if sx == 1 and sy == 1:
338
+ return d
339
+ return transform_path_data(d, Affine.scale(sx, sy))
340
+
341
+
342
+ # --------------------------------------------------------------------------
343
+ # Flattening / measuring
344
+ # --------------------------------------------------------------------------
345
+
346
+ def flatten_path(d: str, steps: int = 16) -> list:
347
+ """Approximate path data as a list of polylines (lists of ``(x, y)``)."""
348
+ polys: list = []
349
+ cur: list = []
350
+ cx = cy = sx = sy = 0.0
351
+ for seg in abs_segments(d):
352
+ k = seg[0]
353
+ if k == "M":
354
+ if len(cur) > 1:
355
+ polys.append(cur)
356
+ cx, cy = seg[1], seg[2]
357
+ sx, sy = cx, cy
358
+ cur = [(cx, cy)]
359
+ elif k == "L":
360
+ cx, cy = seg[1], seg[2]
361
+ cur.append((cx, cy))
362
+ elif k == "C":
363
+ x1, y1, x2, y2, x, y = seg[1:]
364
+ for i in range(1, steps + 1):
365
+ t = i / steps
366
+ cur.append((_bez_at(t, cx, x1, x2, x), _bez_at(t, cy, y1, y2, y)))
367
+ cx, cy = x, y
368
+ elif k == "Q":
369
+ x1, y1, x, y = seg[1:]
370
+ for i in range(1, steps + 1):
371
+ t = i / steps
372
+ cur.append((_bez_at(t, cx, x1, x), _bez_at(t, cy, y1, y)))
373
+ cx, cy = x, y
374
+ elif k == "Z":
375
+ cur.append((sx, sy))
376
+ polys.append(cur)
377
+ cx, cy = sx, sy
378
+ cur = [(cx, cy)]
379
+ if len(cur) > 1:
380
+ polys.append(cur)
381
+ return polys
382
+
383
+
384
+ def path_length(d: str, steps: int = 24) -> float:
385
+ total = 0.0
386
+ for poly in flatten_path(d, steps):
387
+ for i in range(len(poly) - 1):
388
+ total += math.hypot(poly[i + 1][0] - poly[i][0],
389
+ poly[i + 1][1] - poly[i][1])
390
+ return total
391
+
392
+
393
+ def point_at(d: str, t: float, steps: int = 24) -> tuple:
394
+ """``(point, unit_direction)`` at fraction ``t`` along the whole path."""
395
+ from .geom import point_along
396
+ pts: list = []
397
+ for poly in flatten_path(d, steps):
398
+ pts.extend(poly)
399
+ if not pts:
400
+ from .geom import Point
401
+ return Point(0, 0), Point(1, 0)
402
+ return point_along(pts, t)
403
+
404
+
405
+ # --------------------------------------------------------------------------
406
+ # Building path data
407
+ # --------------------------------------------------------------------------
408
+
409
+ def path_from_points(points, close: bool = False) -> str:
410
+ from .geom import to_point
411
+ pts = [to_point(p) for p in points]
412
+ if not pts:
413
+ return ""
414
+ parts = [f"M{fmt(pts[0].x)} {fmt(pts[0].y)}"]
415
+ parts += [f"L{fmt(p.x)} {fmt(p.y)}" for p in pts[1:]]
416
+ if close:
417
+ parts.append("Z")
418
+ return "".join(parts)
419
+
420
+
421
+ def rounded_polyline(points, radius: float = 0.0, close: bool = False) -> str:
422
+ """Polyline with rounded corners — the workhorse for elbow connectors."""
423
+ from .geom import to_point
424
+ pts = [to_point(p) for p in points]
425
+ # drop consecutive duplicates
426
+ clean = [pts[0]] if pts else []
427
+ for p in pts[1:]:
428
+ if abs(p.x - clean[-1].x) > 1e-9 or abs(p.y - clean[-1].y) > 1e-9:
429
+ clean.append(p)
430
+ pts = clean
431
+ if len(pts) < 2:
432
+ return path_from_points(pts, close)
433
+ if radius <= 0:
434
+ return path_from_points(pts, close)
435
+
436
+ parts = []
437
+ n = len(pts)
438
+ idxs = range(n) if close else range(1, n - 1)
439
+ if not close:
440
+ parts.append(f"M{fmt(pts[0].x)} {fmt(pts[0].y)}")
441
+ else:
442
+ first_prev, first_cur = pts[-1], pts[0]
443
+ v_in = (first_cur - first_prev)
444
+ start = first_cur - v_in.normalized() * min(radius, v_in.length / 2)
445
+ parts.append(f"M{fmt(start.x)} {fmt(start.y)}")
446
+
447
+ for i in idxs:
448
+ prev_p = pts[(i - 1) % n]
449
+ cur = pts[i]
450
+ nxt = pts[(i + 1) % n]
451
+ v_in = cur - prev_p
452
+ v_out = nxt - cur
453
+ len_in, len_out = v_in.length, v_out.length
454
+ if len_in < 1e-9 or len_out < 1e-9:
455
+ continue
456
+ u_in, u_out = v_in.normalized(), v_out.normalized()
457
+ # collinear corner: nothing to round
458
+ if abs(u_in.x * u_out.y - u_in.y * u_out.x) < 1e-6 and \
459
+ (u_in.x * u_out.x + u_in.y * u_out.y) > 0:
460
+ continue
461
+ r = min(radius, len_in / 2.0, len_out / 2.0)
462
+ a = cur - v_in.normalized() * r
463
+ b = cur + v_out.normalized() * r
464
+ parts.append(f"L{fmt(a.x)} {fmt(a.y)}")
465
+ parts.append(f"Q{fmt(cur.x)} {fmt(cur.y)} {fmt(b.x)} {fmt(b.y)}")
466
+ if close:
467
+ parts.append("Z")
468
+ else:
469
+ parts.append(f"L{fmt(pts[-1].x)} {fmt(pts[-1].y)}")
470
+ return "".join(parts)