predictygraph 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.
Files changed (44) hide show
  1. predictygraph/__init__.py +108 -0
  2. predictygraph/__main__.py +7 -0
  3. predictygraph/anim.py +91 -0
  4. predictygraph/cli.py +62 -0
  5. predictygraph/default.css +16 -0
  6. predictygraph/fonts/Carlito-Bold.ttf +0 -0
  7. predictygraph/fonts/Carlito-Regular.ttf +0 -0
  8. predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  9. predictygraph/fonts/DejaVuSans.ttf +0 -0
  10. predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  11. predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  12. predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  13. predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  14. predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  15. predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  16. predictygraph/fonts.py +167 -0
  17. predictygraph/ir.py +62 -0
  18. predictygraph/layout.py +534 -0
  19. predictygraph/model.py +98 -0
  20. predictygraph/parser.py +310 -0
  21. predictygraph/render/__init__.py +17 -0
  22. predictygraph/render/apng.py +43 -0
  23. predictygraph/render/svg.py +121 -0
  24. predictygraph/route.py +453 -0
  25. predictygraph/safe.py +41 -0
  26. predictygraph/scene.py +349 -0
  27. predictygraph/style.py +204 -0
  28. predictygraph/text.py +80 -0
  29. predictygraph/theme.py +107 -0
  30. predictygraph/themes/catppuccin-frappe.toml +27 -0
  31. predictygraph/themes/catppuccin-latte.toml +27 -0
  32. predictygraph/themes/catppuccin-macchiato.toml +27 -0
  33. predictygraph/themes/catppuccin-mocha.toml +27 -0
  34. predictygraph/themes/default.toml +22 -0
  35. predictygraph/themes/dracula.toml +25 -0
  36. predictygraph/themes/github-light.toml +25 -0
  37. predictygraph/themes/gruvbox-dark.toml +24 -0
  38. predictygraph/themes/nord.toml +25 -0
  39. predictygraph/themes/solarized-dark.toml +15 -0
  40. predictygraph/themes/solarized-light.toml +24 -0
  41. predictygraph-0.1.0.dist-info/METADATA +60 -0
  42. predictygraph-0.1.0.dist-info/RECORD +44 -0
  43. predictygraph-0.1.0.dist-info/WHEEL +4 -0
  44. predictygraph-0.1.0.dist-info/entry_points.txt +6 -0
predictygraph/route.py ADDED
@@ -0,0 +1,453 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Edge routing (format-independent): ports, lanes, bus levels and octilinear paths.
6
+
7
+ The router works on the laid-out AST and decides *where* every edge goes. It reports
8
+ `needs` back to the layout when a channel between two ranks is too narrow for the lanes
9
+ that have to pass through it; the pipeline then widens that gap and routes again.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass
15
+
16
+ from .layout import direct_child
17
+ from .model import Document, Edge, Group
18
+ from .style import px
19
+
20
+ LANE = 9.0 # distance between parallel lanes
21
+ CLEAR = 12.0 # min distance between a horizontal run and the boxes above/below it
22
+ RECT_SHAPES = {"box", "square", "round", "pill", "cylinder", "note", "image", "none"}
23
+
24
+
25
+ def lca(a, b, root):
26
+ aa = [a] + a.ancestors()
27
+ bb = set(id(x) for x in [b] + b.ancestors())
28
+ for x in aa:
29
+ if id(x) in bb and isinstance(x, Group):
30
+ return x
31
+ return root
32
+
33
+
34
+ def flow_dir(g: Group) -> str:
35
+ if g.attrs.get("layout", "layered") in ("layered",):
36
+ return g.attrs.get("dir", "TB").upper()
37
+ if g.attrs.get("layout") == "row":
38
+ return "LR"
39
+ if g.attrs.get("layout") == "column":
40
+ return "TB"
41
+ return "auto"
42
+
43
+
44
+ def boundary_point(n, tx, ty, shape):
45
+ """Intersection of ray centre->(tx,ty) with node outline."""
46
+ cx, cy = n.cx, n.cy
47
+ dx, dy = tx - cx, ty - cy
48
+ if dx == 0 and dy == 0:
49
+ return cx, cy
50
+ hw, hh = n.w / 2, n.h / 2
51
+ if shape in ("circle", "ellipse"):
52
+ t = 1 / math.sqrt((dx / hw) ** 2 + (dy / hh) ** 2)
53
+ elif shape == "diamond":
54
+ t = 1 / (abs(dx) / hw + abs(dy) / hh)
55
+ else:
56
+ t = min(hw / abs(dx) if dx else math.inf, hh / abs(dy) if dy else math.inf)
57
+ return cx + dx * t, cy + dy * t
58
+
59
+
60
+ def port(n, side):
61
+ return {"top": (n.cx, n.ay), "bottom": (n.cx, n.ay + n.h),
62
+ "left": (n.ax, n.cy), "right": (n.ax + n.w, n.cy)}[side]
63
+
64
+
65
+ @dataclass
66
+ class Diagonal:
67
+ """When may a bend/ortho jog be a slant instead of a right-angle step?"""
68
+ mode: str = "auto"
69
+ max: float = 40.0
70
+ angle: float = 45.0
71
+
72
+ @classmethod
73
+ def from_style(cls, st: dict, route: str) -> "Diagonal":
74
+ dg = str(st.get("diagonal", "40" if route == "bend" else "0")).strip()
75
+ fixed = {"none": 0.0, "always": 1e9}
76
+ return cls("legacy" if dg == "legacy" else "auto",
77
+ fixed[dg] if dg in fixed else px(dg, 40.0),
78
+ min(80.0, max(10.0, px(st.get("diagonal-angle"), 45.0))))
79
+
80
+
81
+ def octilinear(p0, p1, axis, mid, stub, diag: Diagonal):
82
+ """Route between two ports with horizontal/vertical runs and at most one diagonal.
83
+
84
+ main = distance along the flow axis, cross = sideways offset to make up.
85
+ * cross ~ 0 -> one straight line
86
+ * cross <= diag.max and the slant fits between the stubs -> stub, slant, stub
87
+ * otherwise -> stub, perpendicular run, stub (Z shape)
88
+ The jog is centred on `mid` (lane / channel level) if given.
89
+ """
90
+ (x0, y0), (x1, y1) = p0, p1
91
+ v = axis == "v"
92
+ a0, a1 = (y0, y1) if v else (x0, x1) # main axis
93
+ c0, c1 = (x0, x1) if v else (y0, y1) # cross axis
94
+ main, cross = a1 - a0, c1 - c0
95
+ sgn = 1.0 if main >= 0 else -1.0
96
+
97
+ def P(a, c):
98
+ return (c, a) if v else (a, c)
99
+
100
+ if abs(cross) < 0.5:
101
+ return [p0, p1]
102
+ angle = diag.angle
103
+ stub = min(stub, abs(main) / 2)
104
+ if mid is not None: # a lane/channel level wins over the default stub length
105
+ stub = max(2.0, min(stub, abs(mid - a0), abs(a1 - mid)))
106
+ room = abs(main) - 2 * stub
107
+ if diag.max >= 1e9 and room > 1: # diagonal=always: steepen until the slant fits
108
+ angle = max(angle, math.degrees(math.atan2(abs(cross), room)))
109
+ dlen = abs(cross) / math.tan(math.radians(angle)) # main-axis length the slant uses
110
+ centre = mid if mid is not None else (a0 + a1) / 2
111
+ if abs(cross) <= diag.max and dlen <= room:
112
+ if sgn > 0:
113
+ s = max(a0 + stub, min(centre - dlen / 2, a1 - (stub + dlen)))
114
+ else:
115
+ s = min(a0 - stub, max(centre + dlen / 2, a1 + (stub + dlen)))
116
+ return [p0, P(s, c0), P(s + sgn * dlen, c1), p1]
117
+ if sgn > 0:
118
+ m = max(a0 + stub, min(centre, a1 - stub))
119
+ else:
120
+ m = min(a0 - stub, max(centre, a1 + stub))
121
+ return [p0, P(m, c0), P(m, c1), p1]
122
+
123
+
124
+ def rounded(pts, r):
125
+ """Polyline -> path segments with rounded corners (quadratic)."""
126
+ if len(pts) < 3:
127
+ return [("M", *pts[0])] + [("L", *p) for p in pts[1:]]
128
+ segs = [("M", *pts[0])]
129
+ for p0, p1, p2 in zip(pts, pts[1:], pts[2:]):
130
+ v1 = (p1[0] - p0[0], p1[1] - p0[1])
131
+ v2 = (p2[0] - p1[0], p2[1] - p1[1])
132
+ l1, l2 = math.hypot(*v1), math.hypot(*v2)
133
+ if l1 == 0 or l2 == 0:
134
+ continue
135
+ rr = min(r, l1 / 2, l2 / 2)
136
+ a = (p1[0] - v1[0] / l1 * rr, p1[1] - v1[1] / l1 * rr)
137
+ b = (p1[0] + v2[0] / l2 * rr, p1[1] + v2[1] / l2 * rr)
138
+ segs += [("L", *a), ("Q", *p1, *b)]
139
+ segs.append(("L", *pts[-1]))
140
+ return segs
141
+
142
+
143
+ class Router:
144
+ def __init__(self, doc: Document):
145
+ self.doc = doc
146
+ self.offset: dict[str, float] = {}
147
+ self.port_shift: dict[tuple[str, int], float] = {}
148
+ self.bend_level: dict[str, float] = {}
149
+ self.channel: dict[str, tuple[float, float]] = {}
150
+ self.gapkey: dict[str, tuple[str, int]] = {}
151
+ self.needs: dict[tuple[str, int], float] = {}
152
+
153
+ def route(self) -> "Router":
154
+ self._parallel_offsets()
155
+ self._spread_ports()
156
+ self._shared_bends()
157
+ self._separate_channels()
158
+ return self
159
+
160
+ def level(self, e: Edge) -> float | None:
161
+ """Where the sideways jog of `e` sits on the flow axis."""
162
+ if e.id in self.bend_level:
163
+ return self.bend_level[e.id]
164
+ ch = self.channel.get(e.id)
165
+ return (ch[0] + ch[1]) / 2 if ch else None
166
+
167
+ # ------------------------------------------------------------ passes
168
+ def _parallel_offsets(self):
169
+ pairs: dict[tuple[str, str], list[Edge]] = {}
170
+ for e in self.doc.edges:
171
+ pairs.setdefault(tuple(sorted((e.src, e.dst))), []).append(e)
172
+ for es in pairs.values():
173
+ n = len(es)
174
+ for i, e in enumerate(es):
175
+ k = (i - (n - 1) / 2) * 12
176
+ # opposite-direction edges get mirrored sign so they stay apart
177
+ self.offset[e.id] = k if e.src <= e.dst else -k
178
+
179
+ def _spread_ports(self):
180
+ """Several curved/bent edges on one side of a node: spread them symmetrically
181
+ around the side's middle (ordered by where they go)."""
182
+ sides: dict[tuple[str, str], list] = {}
183
+ for e in self.doc.edges:
184
+ if e.attrs.get("exit") or e.attrs.get("enter") or self.offset.get(e.id):
185
+ continue
186
+ pts, axis = self.geometry(e)
187
+ if axis not in ("v", "h"):
188
+ continue
189
+ a, b = self.doc.items[e.src], self.doc.items[e.dst]
190
+ if flow_dir(lca(a, b, self.doc.root)) == "auto" or e.style.get("route", "curve") in ("straight", "ortho"):
191
+ continue # ortho fan-in/out merges into a bus instead
192
+ i = 0 if axis == "v" else 1
193
+ for end, node, here, there in ((0, a, pts[0], pts[1] if len(pts) > 2 else pts[-1]),
194
+ (1, b, pts[-1], pts[-2] if len(pts) > 2 else pts[0])):
195
+ side = ("bottom" if there[1] > here[1] else "top") if axis == "v" else \
196
+ ("right" if there[0] > here[0] else "left")
197
+ sides.setdefault((node.id, side), []).append((there[i], e.id, end))
198
+ for (nid, side), lst in sides.items():
199
+ if len(lst) < 2:
200
+ continue
201
+ n = self.doc.items[nid]
202
+ if n.attrs.get("shape", "box") in ("diamond", "circle", "ellipse") and not isinstance(n, Group):
203
+ continue
204
+ span = n.w if side in ("top", "bottom") else n.h
205
+ step = min(18.0, (span - 12) / (len(lst) - 1)) if span > 12 else 0.0
206
+ lst.sort()
207
+ for k, (_, eid, end) in enumerate(lst):
208
+ self.port_shift[(eid, end)] = (k - (len(lst) - 1) / 2) * step
209
+
210
+ def _shared_bends(self):
211
+ """Ortho fan-in/fan-out: edges sharing a target (or source) turn at one common
212
+ level, so they merge into a single bus line (class-diagram style)."""
213
+ geo = {e.id: self.geometry(e) for e in self.doc.edges if e.style.get("route", "curve") == "ortho"}
214
+ for key in ("dst", "src"):
215
+ buckets: dict[str, list] = {}
216
+ for e in self.doc.edges:
217
+ if e.id in geo and geo[e.id][1] in ("v", "h") and len(geo[e.id][0]) == 2:
218
+ buckets.setdefault(getattr(e, key), []).append(e)
219
+ for es in buckets.values():
220
+ if len(es) < 2 or any(e.id in self.bend_level for e in es):
221
+ continue
222
+ axis = geo[es[0].id][1]
223
+ i = 1 if axis == "v" else 0
224
+ hub = [geo[e.id][0][-1 if key == "dst" else 0][i] for e in es]
225
+ far = [geo[e.id][0][0 if key == "dst" else -1][i] for e in es]
226
+ if len({round(h) for h in hub}) != 1 or len({geo[e.id][1] for e in es}) != 1:
227
+ continue
228
+ chans = {self.channel.get(e.id) for e in es}
229
+ if len(chans) == 1 and None not in chans:
230
+ lo, hi = chans.pop()
231
+ level = (lo + hi) / 2
232
+ else:
233
+ h = hub[0]
234
+ nearest = min(far, key=lambda f: abs(f - h))
235
+ level = h + (nearest - h) / 2
236
+ for e in es:
237
+ self.bend_level[e.id] = level
238
+
239
+ def _separate_channels(self):
240
+ """Distinct edges whose sideways runs land on the same level and overlap get their
241
+ own lanes. Edges sharing a source or target *and* the same look stay merged (bus).
242
+ Channels too narrow for their lanes are reported in `self.needs`."""
243
+ runs = []
244
+ for e in self.doc.edges:
245
+ if e.style.get("route", "curve") not in ("bend", "ortho") or e.attrs.get("exit") or e.attrs.get("enter"):
246
+ continue
247
+ pts, axis = self.geometry(e)
248
+ if axis not in ("v", "h") or len(pts) != 2:
249
+ continue
250
+ (x0, y0), (x1, y1) = pts
251
+ v = axis == "v"
252
+ a0, a1 = (y0, y1) if v else (x0, x1)
253
+ c0, c1 = (x0, x1) if v else (y0, y1)
254
+ if abs(c1 - c0) < 0.5:
255
+ continue
256
+ lvl = self.level(e)
257
+ runs.append({"e": e, "axis": axis, "level": (a0 + a1) / 2 if lvl is None else lvl,
258
+ "lo": min(c0, c1), "hi": max(c0, c1), "c0": c0, "c1": c1, "a0": a0, "a1": a1})
259
+ lanes_at: dict[tuple[str, int], int] = {}
260
+ for r in runs:
261
+ gk = self.gapkey.get(r["e"].id)
262
+ if gk:
263
+ lanes_at[gk] = max(lanes_at.get(gk, 0), 1)
264
+
265
+ def look(ed):
266
+ return (ed.body, ed.style.get("stroke-dasharray"), ed.style.get("stroke"), tuple(sorted(ed.classes)))
267
+
268
+ runs.sort(key=lambda r: (r["axis"], r["level"]))
269
+ used: set[int] = set()
270
+ for i, r in enumerate(runs):
271
+ if i in used:
272
+ continue
273
+ cl = [i]
274
+ for j in range(i + 1, len(runs)):
275
+ s = runs[j]
276
+ if j in used or s["axis"] != r["axis"] or abs(s["level"] - r["level"]) > LANE:
277
+ continue
278
+ if any(min(s["hi"], runs[k]["hi"]) - max(s["lo"], runs[k]["lo"]) > 1 for k in cl):
279
+ cl.append(j)
280
+ if len(cl) < 2:
281
+ continue
282
+ lanes: list[list[int]] = []
283
+ for k in cl:
284
+ ek = runs[k]["e"]
285
+ for ln in lanes:
286
+ if any((runs[m]["e"].src == ek.src or runs[m]["e"].dst == ek.dst)
287
+ and look(runs[m]["e"]) == look(ek) for m in ln):
288
+ ln.append(k)
289
+ break
290
+ else:
291
+ lanes.append([k])
292
+ if len(lanes) < 2:
293
+ continue
294
+ used.update(cl)
295
+ # order lanes so runs don't cross: the run that starts further along its
296
+ # travel direction turns later (further from the source rank)
297
+ lanes.sort(key=lambda ln: sum(runs[m]["c0"] - runs[m]["c1"] for m in ln) / len(ln))
298
+ base = sum(runs[m]["level"] for m in cl) / len(cl)
299
+ n = len(lanes)
300
+ for idx, ln in enumerate(lanes):
301
+ lvl = base + (idx - (n - 1) / 2) * LANE
302
+ for m in ln:
303
+ rr = runs[m]
304
+ eid = rr["e"].id
305
+ ch = self.channel.get(eid)
306
+ if ch and ch[1] - ch[0] >= 2 * CLEAR:
307
+ lvl_c = min(max(lvl, ch[0] + CLEAR), ch[1] - CLEAR)
308
+ else:
309
+ lo_a, hi_a = sorted((rr["a0"], rr["a1"]))
310
+ lvl_c = min(max(lvl, lo_a + 4), hi_a - 4)
311
+ self.bend_level[eid] = lvl_c
312
+ gk = self.gapkey.get(eid)
313
+ if gk:
314
+ lanes_at[gk] = max(lanes_at[gk], n)
315
+ # report channels that are too narrow for the lanes they carry
316
+ for e in self.doc.edges:
317
+ gk = self.gapkey.get(e.id)
318
+ if not gk or gk not in lanes_at:
319
+ continue
320
+ lo, hi = self.channel[e.id]
321
+ need = (lanes_at[gk] - 1) * LANE + 2 * CLEAR - (hi - lo)
322
+ if need > 0.5:
323
+ self.needs[gk] = max(self.needs.get(gk, 0.0), need)
324
+
325
+ # ------------------------------------------------------------ geometry
326
+ def _ports(self, a, b, vert):
327
+ if vert:
328
+ down = b.cy > a.cy
329
+ return port(a, "bottom" if down else "top"), port(b, "top" if down else "bottom")
330
+ right = b.cx > a.cx
331
+ return port(a, "right" if right else "left"), port(b, "left" if right else "right")
332
+
333
+ def _record_channel(self, e, owner, da, db, vert):
334
+ """Free band between the two outer boxes on the flow axis (where runs belong)."""
335
+ if vert:
336
+ lo, hi = (da.ay + da.h, db.ay) if db.ay >= da.ay + da.h else (db.ay + db.h, da.ay)
337
+ else:
338
+ lo, hi = (da.ax + da.w, db.ax) if db.ax >= da.ax + da.w else (db.ax + db.w, da.ax)
339
+ self.channel[e.id] = (lo, hi)
340
+ ri = getattr(getattr(owner, "layout_obj", None), "rank_index", {})
341
+ if da.id in ri and db.id in ri and abs(ri[da.id] - ri[db.id]) == 1:
342
+ self.gapkey[e.id] = (owner.id, min(ri[da.id], ri[db.id]))
343
+
344
+ def geometry(self, e: Edge):
345
+ """-> (points, axis). axis: v | h (flow axis), L (explicit L port) or free."""
346
+ a, b = self.doc.items[e.src], self.doc.items[e.dst]
347
+ owner = lca(a, b, self.doc.root)
348
+ da, db = direct_child(a, owner), direct_child(b, owner)
349
+ d = flow_dir(owner)
350
+ route = e.style.get("route", "curve")
351
+ wps = list(e.waypoints)
352
+ off = self.offset.get(e.id, 0.0)
353
+ ex, en = e.attrs.get("exit"), e.attrs.get("enter")
354
+ if ex or en:
355
+ ex = ex or ("right" if b.cx > a.cx else "left")
356
+ en = en or ("left" if b.cx > a.cx else "right")
357
+ p0, p1 = port(a, ex), port(b, en)
358
+ v0, v1 = ex in ("top", "bottom"), en in ("top", "bottom")
359
+ if v0 != v1: # L-shape: one corner
360
+ corner = (p0[0], p1[1]) if v0 else (p1[0], p0[1])
361
+ return [p0, corner, p1], "L"
362
+ return [p0] + wps + [p1], ("v" if v0 else "h")
363
+ if d in ("TB", "BT", "LR", "RL") and route != "straight":
364
+ vert = d in ("TB", "BT")
365
+ if vert:
366
+ sep = da.ay + da.h <= db.ay or db.ay + db.h <= da.ay
367
+ else:
368
+ sep = da.ax + da.w <= db.ax or db.ax + db.w <= da.ax
369
+ if not sep:
370
+ vert = not vert # same rank: connect sideways
371
+ elif not wps:
372
+ self._record_channel(e, owner, da, db, vert)
373
+ p0, p1 = self._ports(a, b, vert)
374
+ s0, s1 = self.port_shift.get((e.id, 0), 0.0), self.port_shift.get((e.id, 1), 0.0)
375
+ if vert:
376
+ p0, p1 = (p0[0] + s0, p0[1]), (p1[0] + s1, p1[1])
377
+ else:
378
+ p0, p1 = (p0[0], p0[1] + s0), (p1[0], p1[1] + s1)
379
+ if s0 or s1:
380
+ return [p0] + wps + [p1], ("v" if vert else "h")
381
+ if off:
382
+ if vert:
383
+ p0, p1 = (p0[0] + off, p0[1]), (p1[0] + off, p1[1])
384
+ else:
385
+ p0, p1 = (p0[0], p0[1] + off), (p1[0], p1[1] + off)
386
+ # boxes overlap on the cross axis: one straight line, no jog / double trace
387
+ boxy = all(x.attrs.get("shape", "box") in RECT_SHAPES or isinstance(x, Group) for x in (a, b))
388
+ if not wps and not off and boxy:
389
+ i = 0 if vert else 1
390
+ lo = max(a.ax if vert else a.ay, b.ax if vert else b.ay)
391
+ hi = min((a.ax + a.w) if vert else (a.ay + a.h), (b.ax + b.w) if vert else (b.ay + b.h))
392
+ if abs(p0[i] - p1[i]) > 0.5 and hi - lo >= 16:
393
+ m = min(max((p0[i] + p1[i]) / 2, lo + 8), hi - 8)
394
+ p0 = (m, p0[1]) if vert else (p0[0], m)
395
+ p1 = (m, p1[1]) if vert else (p1[0], m)
396
+ return [p0] + wps + [p1], ("v" if vert else "h")
397
+ # free layouts: shortest centre-to-centre, clipped to outline
398
+ tgt0 = wps[0] if wps else (b.cx, b.cy)
399
+ tgt1 = wps[-1] if wps else (a.cx, a.cy)
400
+ p0 = boundary_point(a, *tgt0, a.attrs.get("shape", "box"))
401
+ p1 = boundary_point(b, *tgt1, b.attrs.get("shape", "box"))
402
+ return [p0] + wps + [p1], ("v" if abs(p1[1] - p0[1]) >= abs(p1[0] - p0[0]) else "h")
403
+
404
+ # ------------------------------------------------------------ paths
405
+ def polyline(self, pts, axis, route, radius, stub, mid, diag: Diagonal):
406
+ """Corner points of the drawn path (curves: just the anchor points)."""
407
+ if axis == "L" or route == "straight" or len(pts) < 2:
408
+ return list(pts)
409
+ if route in ("bend", "ortho") and len(pts) == 2 and diag.mode != "legacy":
410
+ return octilinear(pts[0], pts[-1], axis, mid, stub if stub is not None else radius * 1.5, diag)
411
+ if route == "bend":
412
+ (x0, y0), (x1, y1) = pts[0], pts[-1]
413
+ span = abs(y1 - y0) if axis == "v" else abs(x1 - x0)
414
+ k = min(stub if stub is not None else radius * 1.5, span / 2)
415
+ sy = math.copysign(k, (y1 - y0) or 1)
416
+ sx = math.copysign(k, (x1 - x0) or 1)
417
+ inner = list(pts[1:-1])
418
+ if axis == "v":
419
+ return [pts[0], (x0, y0 + sy)] + inner + [(x1, y1 - sy), pts[-1]]
420
+ return [pts[0], (x0 + sx, y0)] + inner + [(x1 - sx, y1), pts[-1]]
421
+ if route == "ortho":
422
+ seg = [pts[0]]
423
+ for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
424
+ if axis == "v":
425
+ my = mid if mid is not None and len(pts) == 2 else (y0 + y1) / 2
426
+ seg += [(x0, my), (x1, my), (x1, y1)]
427
+ else:
428
+ mx = mid if mid is not None and len(pts) == 2 else (x0 + x1) / 2
429
+ seg += [(mx, y0), (mx, y1), (x1, y1)]
430
+ clean = [seg[0]]
431
+ for p in seg[1:]:
432
+ if abs(p[0] - clean[-1][0]) > 0.05 or abs(p[1] - clean[-1][1]) > 0.05:
433
+ clean.append(p)
434
+ return clean
435
+ return list(pts)
436
+
437
+ def path_segs(self, pts, axis, route, radius, stub, mid, diag: Diagonal):
438
+ if axis == "L":
439
+ return rounded(list(pts), max(radius, 8))
440
+ if route == "straight" or len(pts) < 2:
441
+ return rounded(list(pts), 0)
442
+ if route in ("bend", "ortho"):
443
+ return rounded(self.polyline(pts, axis, route, radius, stub, mid, diag), radius)
444
+ # curve: cubic beziers with tangents along the flow axis
445
+ segs = [("M", *pts[0])]
446
+ for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
447
+ if axis == "v":
448
+ k = (y1 - y0) / 2
449
+ segs.append(("C", x0, y0 + k, x1, y1 - k, x1, y1))
450
+ else:
451
+ k = (x1 - x0) / 2
452
+ segs.append(("C", x0 + k, y0, x1 - k, y1, x1, y1))
453
+ return segs
predictygraph/safe.py ADDED
@@ -0,0 +1,41 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Input hardening shared by the parser, styles, text and animation code."""
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ NUM = r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
12
+ NUM_RE = re.compile(NUM)
13
+ MAX_NUM = 1e6
14
+ MAX_EDGES = 10_000
15
+ MAX_VAR_LEN = 10_000
16
+
17
+
18
+ class UnsafePathError(ValueError):
19
+ pass
20
+
21
+
22
+ def number(v, default: float = 0.0, lo: float = -MAX_NUM, hi: float = MAX_NUM) -> float:
23
+ """Leading CSS-ish number of v, clamped to [lo, hi]; default when absent or not finite."""
24
+ m = re.match(rf"\s*({NUM})", str(v)) if v is not None else None
25
+ if not m:
26
+ return default
27
+ n = float(m.group(1))
28
+ return default if n != n else min(hi, max(lo, n)) # NaN check
29
+
30
+
31
+ def numbers(v) -> list[float]:
32
+ return [min(MAX_NUM, max(-MAX_NUM, float(x))) for x in NUM_RE.findall(v or "")]
33
+
34
+
35
+ def safe_path(base_dir: str, rel: str, allow_outside: bool = False) -> Path:
36
+ """Resolve rel against base_dir; refuse anything that escapes base_dir unless allowed."""
37
+ base = Path(base_dir).resolve()
38
+ p = (base / rel).resolve()
39
+ if not allow_outside and not p.is_relative_to(base):
40
+ raise UnsafePathError(f"path {rel!r} is outside {str(base)!r} (use --allow-outside)")
41
+ return p