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
@@ -0,0 +1,534 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Hierarchical layout: each container is laid out independently (inside-out)
6
+ with its own balancing style, then treated as a single box by its parent."""
7
+ from __future__ import annotations
8
+
9
+ from collections import defaultdict
10
+
11
+ from .model import Document, Group, Item, Node
12
+ from .style import box4, px
13
+ from .text import load_image, measure
14
+
15
+
16
+ # ---------------------------------------------------------------- sizing
17
+ def size_node(n: Node, doc: Document) -> None:
18
+ st = n.style
19
+ fs = px(st.get("font-size"), 14)
20
+ bold = st.get("font-weight", "normal") in ("bold", "600", "700", "800", "900")
21
+ label = n.label if n.label is not None else n.id
22
+ n.text_w, n.text_h, n.text_lines = measure(label, st.get("font-family", "sans-serif"), fs, bold) if label else (0.0, 0.0, [])
23
+ pt, pr, pb, pl = box4(st.get("padding"), 8)
24
+ shape = n.attrs.get("shape")
25
+ if "image" in n.attrs:
26
+ img = load_image(n.attrs["image"], doc.base_dir, doc.allow_outside)
27
+ iw, ih = img["w"], img["h"]
28
+ if "img-width" in n.attrs or "img-height" in n.attrs:
29
+ tw, th = max(0.0, px(n.attrs.get("img-width"), 0)), max(0.0, px(n.attrs.get("img-height"), 0))
30
+ if tw and th:
31
+ iw, ih = tw, th
32
+ elif tw:
33
+ iw, ih = tw, ih * tw / iw
34
+ else:
35
+ iw, ih = iw * th / ih, th
36
+ else:
37
+ s = min(1.0, 96 / max(iw, ih))
38
+ iw, ih = iw * s, ih * s
39
+ img["dw"], img["dh"] = iw, ih
40
+ n.image = img
41
+ shape = shape or "image"
42
+ gap = 4 if n.text_lines else 0
43
+ if shape == "image":
44
+ n.w = max(iw, n.text_w)
45
+ n.h = ih + gap + n.text_h
46
+ else:
47
+ n.w = max(iw, n.text_w) + pl + pr
48
+ n.h = ih + gap + n.text_h + pt + pb
49
+ else:
50
+ shape = shape or "box"
51
+ n.w = n.text_w + pl + pr
52
+ n.h = n.text_h + pt + pb
53
+ if shape in ("circle", "square"):
54
+ d = max(n.text_w, n.text_h) + max(pl, pt) * 1.2 if n.text_lines else 16.0
55
+ n.w = n.h = d
56
+ elif shape in ("ellipse",):
57
+ n.w, n.h = n.w * 1.25, n.h * 1.3
58
+ elif shape == "diamond":
59
+ n.w, n.h = n.w * 1.6, n.h * 1.7
60
+ elif shape == "hexagon":
61
+ n.w += n.h * 0.6
62
+ elif shape == "cylinder":
63
+ n.h += 12
64
+ n.attrs["shape"] = shape
65
+ explicit = (px(n.attrs.get("width"), 0), px(n.attrs.get("height"), 0))
66
+ mins = (0.0, 0.0) if shape in ("circle", "square") and not n.text_lines else \
67
+ (px(st.get("min-width"), 0), px(st.get("min-height"), 0))
68
+ n.w, n.h = max(n.w, mins[0], explicit[0]), max(n.h, mins[1], explicit[1])
69
+ if shape in ("circle", "square"):
70
+ n.w = n.h = max(n.w, n.h)
71
+
72
+
73
+ # ---------------------------------------------------------------- helpers
74
+ def direct_child(item: Item, g: Group) -> Item | None:
75
+ """Ancestor-or-self of `item` that is a direct child of g."""
76
+ cur = item
77
+ while cur is not None and cur.parent is not g:
78
+ cur = cur.parent
79
+ return cur
80
+
81
+
82
+ def local_edges(g: Group, doc: Document):
83
+ """Edges projected onto g's direct children: list of (src_child, dst_child, edge)."""
84
+ out = []
85
+ for e in doc.edges:
86
+ if e.attrs.get("constraint") == "false":
87
+ continue
88
+ a = direct_child(doc.items[e.src], g)
89
+ b = direct_child(doc.items[e.dst], g)
90
+ if a is not None and b is not None and a is not b:
91
+ out.append((a, b, e))
92
+ return out
93
+
94
+
95
+ def opt(g: Group, key: str, default: str) -> str:
96
+ return g.attrs.get(key, default)
97
+
98
+
99
+ # ---------------------------------------------------------------- layouts
100
+ class Layout:
101
+ def __init__(self, g: Group, doc: Document):
102
+ self.g, self.doc = g, doc
103
+ self.kids = g.children
104
+
105
+ def run(self) -> tuple[float, float]:
106
+ raise NotImplementedError
107
+
108
+
109
+ class LineLayout(Layout):
110
+ """row / column: single line with alignment."""
111
+
112
+ def run(self):
113
+ horizontal = opt(self.g, "layout", "row") == "row"
114
+ gap = px(opt(self.g, "gap", "24"))
115
+ align = opt(self.g, "align", "center")
116
+ cross = max((k.h if horizontal else k.w) for k in self.kids)
117
+ pos = 0.0
118
+ for k in self.kids:
119
+ size_c = k.h if horizontal else k.w
120
+ off = {"start": 0, "end": cross - size_c}.get(align, (cross - size_c) / 2)
121
+ if horizontal:
122
+ k.x, k.y = pos, off
123
+ pos += k.w + gap
124
+ else:
125
+ k.x, k.y = off, pos
126
+ pos += k.h + gap
127
+ pos -= gap
128
+ return (pos, cross) if horizontal else (cross, pos)
129
+
130
+
131
+ class GridLayout(Layout):
132
+ def run(self):
133
+ n = len(self.kids)
134
+ if "rows" in self.g.attrs and "cols" not in self.g.attrs:
135
+ rows = int(self.g.attrs["rows"])
136
+ cols = -(-n // rows)
137
+ else:
138
+ cols = int(opt(self.g, "cols", str(max(1, round(n ** 0.5)))))
139
+ gap = px(opt(self.g, "gap", "24"))
140
+ align = opt(self.g, "align", "center")
141
+ slots, taken, auto = {}, set(), []
142
+ for k in self.kids:
143
+ if "row" in k.attrs and "col" in k.attrs:
144
+ rc = (int(k.attrs["row"]), int(k.attrs["col"]))
145
+ slots[k.id] = rc
146
+ taken.add(rc)
147
+ else:
148
+ auto.append(k)
149
+ i = 0
150
+ for k in auto:
151
+ while (i // cols, i % cols) in taken:
152
+ i += 1
153
+ slots[k.id] = (i // cols, i % cols)
154
+ i += 1
155
+ nrows = max(r for r, _ in slots.values()) + 1
156
+ ncols = max(c for _, c in slots.values()) + 1
157
+ if opt(self.g, "uniform", "true") == "true":
158
+ cw = [max(k.w for k in self.kids)] * ncols
159
+ rh = [max(k.h for k in self.kids)] * nrows
160
+ else:
161
+ cw, rh = [0.0] * ncols, [0.0] * nrows
162
+ for k in self.kids:
163
+ r, c = slots[k.id]
164
+ cw[c], rh[r] = max(cw[c], k.w), max(rh[r], k.h)
165
+ xs = [sum(cw[:c]) + gap * c for c in range(ncols)]
166
+ ys = [sum(rh[:r]) + gap * r for r in range(nrows)]
167
+ for k in self.kids:
168
+ r, c = slots[k.id]
169
+ fx = {"start": 0, "end": cw[c] - k.w}.get(align, (cw[c] - k.w) / 2)
170
+ fy = (rh[r] - k.h) / 2
171
+ k.x, k.y = xs[c] + fx, ys[r] + fy
172
+ return xs[-1] + cw[-1], ys[-1] + rh[-1]
173
+
174
+
175
+ class FixedLayout(Layout):
176
+ def run(self):
177
+ for k in self.kids:
178
+ x, y = (k.attrs.get("at", "0,0").split(",") + ["0"])[:2]
179
+ k.x, k.y = float(x), float(y)
180
+ return max(k.x + k.w for k in self.kids), max(k.y + k.h for k in self.kids)
181
+
182
+
183
+ class RadialLayout(Layout):
184
+ def run(self):
185
+ import math
186
+ cid = opt(self.g, "center", self.kids[0].id)
187
+ center = next((k for k in self.kids if k.id == cid), self.kids[0])
188
+ ring = [k for k in self.kids if k is not center]
189
+ biggest = max((max(k.w, k.h) for k in ring), default=0)
190
+ circumference_need = sum(max(k.w, k.h) + 20 for k in ring)
191
+ r_auto = max(circumference_need / (2 * math.pi),
192
+ max(center.w, center.h) / 2 + max((min(k.w, k.h) for k in ring), default=0) / 2 + 36)
193
+ start = math.radians(px(opt(self.g, "start-angle", "-90"), -90))
194
+ angles = [start + 2 * math.pi * i / max(1, len(ring)) for i in range(len(ring))]
195
+
196
+ def support(k, a): # half-extent of a box along direction a
197
+ return abs(math.cos(a)) * k.w / 2 + abs(math.sin(a)) * k.h / 2
198
+ if ring:
199
+ r_auto = max(r_auto, max(support(center, a) + support(k, a) + 24 for k, a in zip(ring, angles)))
200
+ r = px(opt(self.g, "radius", "auto"), 0) or r_auto
201
+ pts = []
202
+ for k, a in zip(ring, angles):
203
+ pts.append((k, r * math.cos(a), r * math.sin(a)))
204
+ pts.append((center, 0.0, 0.0))
205
+ minx = min(px_ - k.w / 2 for k, px_, _ in pts)
206
+ miny = min(py - k.h / 2 for k, _, py in pts)
207
+ for k, cx, cy in pts:
208
+ k.x, k.y = cx - k.w / 2 - minx, cy - k.h / 2 - miny
209
+ return (max(k.x + k.w for k, _, _ in pts), max(k.y + k.h for k, _, _ in pts))
210
+
211
+
212
+ class LayeredLayout(Layout):
213
+ """Sugiyama-style: ranking, (optional) crossing reduction, balancing."""
214
+
215
+ def run(self):
216
+ g = self.g
217
+ d = opt(g, "dir", "TB").upper()
218
+ horizontal = d in ("LR", "RL")
219
+ rank_gap = px(opt(g, "rank-gap", "56"))
220
+ node_gap = px(opt(g, "node-gap", "28"))
221
+ balance = opt(g, "balance", "median")
222
+ order_mode = opt(g, "order", "auto")
223
+ kids = self.kids
224
+ idx = {k.id: i for i, k in enumerate(kids)}
225
+ edges = [(a.id, b.id, float(e.attrs.get("weight", 1))) for a, b, e in local_edges(g, self.doc)]
226
+ # where inside a child box does the real endpoint sit (cross axis, rel. to centre)?
227
+ # lets a node line up with the actual node inside a group, not the group centre
228
+ self.portoff = {}
229
+ for a, b, e in local_edges(g, self.doc):
230
+ self.portoff[(a.id, b.id)] = self._inner_off(self.doc.items[e.src], a, horizontal)
231
+ self.portoff[(b.id, a.id)] = self._inner_off(self.doc.items[e.dst], b, horizontal)
232
+
233
+ # 1. break cycles with DFS in source order
234
+ succ = defaultdict(list)
235
+ for a, b, w in edges:
236
+ succ[a].append((b, w))
237
+ state, dag = {}, []
238
+
239
+ def dfs(u):
240
+ state[u] = 1
241
+ for v, w in succ[u]:
242
+ if state.get(v) == 1:
243
+ dag.append((v, u, w)) # reverse back edge
244
+ else:
245
+ dag.append((u, v, w))
246
+ if v not in state:
247
+ dfs(v)
248
+ state[u] = 2
249
+
250
+ for k in kids:
251
+ if k.id not in state:
252
+ dfs(k.id)
253
+ dag = list(dict.fromkeys(dag))
254
+
255
+ # 2. longest-path ranking, honour rank= pins
256
+ rank = {k.id: int(k.attrs["rank"]) for k in kids if "rank" in k.attrs}
257
+ preds = defaultdict(list)
258
+ for a, b, _ in dag:
259
+ preds[b].append(a)
260
+ def get_rank(u, seen=()):
261
+ if u in rank:
262
+ return rank[u]
263
+ r = 0
264
+ for p in preds[u]:
265
+ if p not in seen:
266
+ r = max(r, get_rank(p, seen + (u,)) + 1)
267
+ rank[u] = r
268
+ return r
269
+ for k in kids:
270
+ get_rank(k.id)
271
+ # pull sources down next to their successors (avoids long first edges)
272
+ succ_dag = defaultdict(list)
273
+ for a, b, _ in dag:
274
+ succ_dag[a].append(b)
275
+ for k in kids:
276
+ if not preds[k.id] and succ_dag[k.id] and "rank" not in k.attrs:
277
+ rank[k.id] = min(rank[s] for s in succ_dag[k.id]) - 1
278
+
279
+ # 3. virtual nodes for long edges so they bend around ranks
280
+ layers: dict[int, list[str]] = defaultdict(list)
281
+ for k in kids:
282
+ layers[rank[k.id]].append(k.id)
283
+ size = {k.id: (k.w, k.h) for k in kids}
284
+ adj = [] # (upper, lower, weight)
285
+ vcount = 0
286
+ self.virtual_chains = {}
287
+ for a, b, w in dag:
288
+ ra, rb = rank[a], rank[b]
289
+ if rb - ra <= 1:
290
+ adj.append((a, b, w))
291
+ continue
292
+ prev = a
293
+ chain = []
294
+ for r in range(ra + 1, rb):
295
+ vid = f"__v{vcount}"
296
+ vcount += 1
297
+ layers[r].append(vid)
298
+ size[vid] = (0.0, 0.0)
299
+ rank[vid] = r
300
+ adj.append((prev, vid, w))
301
+ if prev == a:
302
+ self.portoff[(a, vid)] = self.portoff.get((a, b), self.portoff.get((a, b), 0.0))
303
+ chain.append(vid)
304
+ prev = vid
305
+ adj.append((prev, b, w))
306
+ self.portoff[(b, prev)] = self.portoff.get((b, a), 0.0)
307
+ self.virtual_chains[(a, b)] = chain
308
+ lo, hi = min(layers), max(layers)
309
+ order = [layers.get(r, []) for r in range(lo, hi + 1)]
310
+ up, down = defaultdict(list), defaultdict(list)
311
+ for a, b, w in adj:
312
+ down[a].append((b, w))
313
+ up[b].append((a, w))
314
+
315
+ # 4. crossing reduction (barycenter sweeps) unless order=source
316
+ if order_mode != "source":
317
+ def pos_of(layer):
318
+ return {u: i for i, u in enumerate(layer)}
319
+ def crossings(ordr):
320
+ c = 0
321
+ for i in range(len(ordr) - 1):
322
+ p2 = pos_of(ordr[i + 1])
323
+ es = [(j, p2[v]) for j, u in enumerate(ordr[i]) for v, _ in down[u] if v in p2]
324
+ c += sum(1 for x in range(len(es)) for y in range(x + 1, len(es))
325
+ if (es[x][0] - es[y][0]) * (es[x][1] - es[y][1]) < 0)
326
+ return c
327
+ best, best_c = [list(l) for l in order], crossings(order)
328
+ for it in range(8):
329
+ rng = range(1, len(order)) if it % 2 == 0 else range(len(order) - 2, -1, -1)
330
+ nb = up if it % 2 == 0 else down
331
+ for i in rng:
332
+ ref = pos_of(order[i - 1] if it % 2 == 0 else order[i + 1])
333
+ def bary(u, i=i, ref=ref):
334
+ ns = [ref[v] for v, _ in nb[u] if v in ref]
335
+ return sum(ns) / len(ns) if ns else order[i].index(u)
336
+ order[i] = sorted(order[i], key=lambda u: (bary(u), idx.get(u, 1e9)))
337
+ c = crossings(order)
338
+ if c < best_c:
339
+ best, best_c = [list(l) for l in order], c
340
+ order = best
341
+
342
+ # 5. cross-axis coordinates
343
+ def csz(u):
344
+ w, h = size[u]
345
+ return h if horizontal else w
346
+ def msz(u):
347
+ w, h = size[u]
348
+ return w if horizontal else h
349
+ vgap = node_gap / 2
350
+ pos: dict[str, float] = {}
351
+ widths = []
352
+ for layer in order:
353
+ x = 0.0
354
+ for u in layer:
355
+ pos[u] = x + csz(u) / 2
356
+ x += csz(u) + (vgap if u.startswith("__v") else node_gap)
357
+ widths.append(x - node_gap if layer else 0)
358
+ maxw = max(widths) if widths else 0
359
+ if balance in ("center", "median"):
360
+ for layer, w in zip(order, widths):
361
+ shift = (maxw - w) / 2
362
+ for u in layer:
363
+ pos[u] += shift
364
+ elif balance == "end":
365
+ for layer, w in zip(order, widths):
366
+ for u in layer:
367
+ pos[u] += maxw - w
368
+ if balance == "median":
369
+ pos = self._median_pull(order, pos, up, down, csz, node_gap, vgap)
370
+
371
+ # 6. main-axis coordinates (router may ask for wider gaps: g.extra_gap[rank])
372
+ rank_thick = [max((msz(u) for u in layer), default=0) for layer in order]
373
+ extra = dict(getattr(g, "extra_gap", {}))
374
+ # labelled edges need a run long enough to carry the label beside the bus
375
+ for a, b, e in local_edges(g, self.doc):
376
+ if e.label and a.id in rank and b.id in rank:
377
+ lh = px(e.style.get("font-size"), 11) * 1.25 * (e.label.count("\n") + 1) + 10
378
+ r = min(rank[a.id], rank[b.id]) - lo
379
+ extra[r] = max(extra.get(r, 0.0), lh)
380
+ main = 0.0
381
+ main_pos = []
382
+ for ri, t in enumerate(rank_thick):
383
+ main_pos.append(main)
384
+ main += t + rank_gap + extra.get(ri, 0.0)
385
+ total_main = main - rank_gap - extra.get(len(rank_thick) - 1, 0.0)
386
+ self.rank_index = {u: ri for ri, layer in enumerate(order) for u in layer if not u.startswith("__v")}
387
+ minc = min((pos[u] - csz(u) / 2 for layer in order for u in layer), default=0)
388
+ self.virtual_pos = {}
389
+ for ri, layer in enumerate(order):
390
+ for u in layer:
391
+ c = pos[u] - minc
392
+ m = main_pos[ri] + rank_thick[ri] / 2
393
+ if d in ("BT", "RL"):
394
+ m = total_main - m
395
+ if u.startswith("__v"):
396
+ self.virtual_pos[u] = (m, c) if horizontal else (c, m)
397
+ continue
398
+ k = kids[idx[u]]
399
+ if horizontal:
400
+ k.x, k.y = m - k.w / 2, c - k.h / 2
401
+ else:
402
+ k.x, k.y = c - k.w / 2, m - k.h / 2
403
+ cross_total = max((pos[u] - minc + csz(u) / 2 for layer in order for u in layer), default=0)
404
+ self._store_waypoints(horizontal)
405
+ return (total_main, cross_total) if horizontal else (cross_total, total_main)
406
+
407
+ @staticmethod
408
+ def _inner_off(item, child, horizontal):
409
+ """Cross-axis offset of `item`'s centre from the centre of `child` (its ancestor-or-self)."""
410
+ if item is child:
411
+ return 0.0
412
+ c = (item.y + item.h / 2) if horizontal else (item.x + item.w / 2)
413
+ cur = item.parent
414
+ while cur is not None and cur is not child:
415
+ c += (cur.oy + cur.y) if horizontal else (cur.ox + cur.x)
416
+ cur = cur.parent
417
+ if cur is None:
418
+ return 0.0
419
+ c += child.oy if horizontal else child.ox
420
+ return c - ((child.h if horizontal else child.w) / 2)
421
+
422
+ def _median_pull(self, order, pos, up, down, csz, node_gap, vgap):
423
+ def gap(a, b):
424
+ return (vgap if a.startswith("__v") or b.startswith("__v") else node_gap)
425
+ for it in range(12):
426
+ for li in (range(len(order)) if it % 2 == 0 else range(len(order) - 1, -1, -1)):
427
+ layer = order[li]
428
+ want = {}
429
+ po = self.portoff
430
+ for u in layer:
431
+ ns = [(pos[v] + po.get((v, u), 0.0) - po.get((u, v), 0.0), w) for v, w in up[u] + down[u]]
432
+ if ns:
433
+ ns.sort()
434
+ tot = sum(w for _, w in ns)
435
+ acc, med = 0.0, ns[0][0]
436
+ for p, w in ns:
437
+ acc += w
438
+ if acc >= tot / 2:
439
+ med = p
440
+ break
441
+ if len(ns) % 2 == 0 and all(w == ns[0][1] for _, w in ns):
442
+ med = (ns[len(ns) // 2 - 1][0] + ns[len(ns) // 2][0]) / 2
443
+ want[u] = med
444
+ else:
445
+ want[u] = pos[u]
446
+ # place in order keeping min separation, then fix overlaps right-to-left
447
+ new = [want[u] for u in layer]
448
+ for i in range(1, len(layer)):
449
+ mn = new[i - 1] + (csz(layer[i - 1]) + csz(layer[i])) / 2 + gap(layer[i - 1], layer[i])
450
+ new[i] = max(new[i], mn)
451
+ # shift the packed block by the median residual (L1 optimum): when a parent
452
+ # has an odd number of children, the middle child lands exactly under it
453
+ if layer:
454
+ res = sorted(want[u] - n for n, u in zip(new, layer))
455
+ m = len(res) // 2
456
+ shift = res[m] if len(res) % 2 else (res[m - 1] + res[m]) / 2
457
+ new = [n + shift for n in new]
458
+ for u, n in zip(layer, new):
459
+ pos[u] = n
460
+ return pos
461
+
462
+ def _store_waypoints(self, horizontal):
463
+ self.g.attrs.setdefault("__dir__", "LR" if horizontal else "TB")
464
+ self.waypoints = {}
465
+ for (a, b), chain in self.virtual_chains.items():
466
+ self.waypoints[(a, b)] = [self.virtual_pos[v] for v in chain]
467
+
468
+
469
+ LAYOUTS = {"layered": LayeredLayout, "grid": GridLayout, "row": LineLayout,
470
+ "column": LineLayout, "radial": RadialLayout, "fixed": FixedLayout}
471
+
472
+
473
+ def layout_group(g: Group, doc: Document) -> None:
474
+ for c in g.children:
475
+ if isinstance(c, Group):
476
+ layout_group(c, doc)
477
+ else:
478
+ size_node(c, doc)
479
+ if "layout" not in g.attrs:
480
+ g.attrs["layout"] = "layered" if local_edges(g, doc) or g.kind == "graph" else "row"
481
+ pt, pr, pb, pl = box4(g.style.get("padding"), 14)
482
+ if not g.children:
483
+ cw, ch = 40.0, 20.0
484
+ lay = None
485
+ else:
486
+ lay = LAYOUTS[g.attrs["layout"]](g, doc)
487
+ cw, ch = lay.run()
488
+ g.label_h = 0.0
489
+ lw = 0.0
490
+ if g.label and g.kind == "group":
491
+ fs = px(g.style.get("font-size"), 12)
492
+ lw, g.label_h, _ = measure(g.label, g.style.get("font-family", "sans-serif"), fs,
493
+ g.style.get("font-weight", "normal") in ("bold", "700"))
494
+ g.label_h += 6
495
+ inner_w = max(cw, lw) + getattr(g, "extra_w", 0.0) # extra_w: room requested for the title
496
+ top = g.style.get("label-position", "top") != "bottom"
497
+ g.ox = pl + (inner_w - cw) / 2
498
+ g.oy = pt + (g.label_h if top else 0)
499
+ g.w = inner_w + pl + pr
500
+ g.h = ch + pt + pb + g.label_h
501
+ g.layout_obj = lay
502
+
503
+
504
+ def absolutize(g: Group, ox: float, oy: float) -> None:
505
+ g.ax, g.ay = ox, oy
506
+ for c in g.children:
507
+ c.ax, c.ay = ox + g.ox + c.x, oy + g.oy + c.y
508
+ if isinstance(c, Group):
509
+ absolutize(c, c.ax, c.ay)
510
+
511
+
512
+ def layout(doc: Document) -> None:
513
+ root = doc.root
514
+ for e in doc.edges: # layout may run again after the router widens gaps
515
+ if e.wp_owner is not None:
516
+ e.waypoints, e.wp_owner = [], None
517
+ layout_group(root, doc)
518
+ absolutize(root, 0, 0)
519
+ # translate virtual waypoints into absolute coords and attach to edges
520
+ for g in [root] + [i for i in doc.walk() if isinstance(i, Group)]:
521
+ lay = getattr(g, "layout_obj", None)
522
+ wps = getattr(lay, "waypoints", None)
523
+ if not wps:
524
+ continue
525
+ for e in doc.edges:
526
+ a = direct_child(doc.items[e.src], g)
527
+ b = direct_child(doc.items[e.dst], g)
528
+ if a is None or b is None:
529
+ continue
530
+ for key, rev in (((a.id, b.id), False), ((b.id, a.id), True)):
531
+ if key in wps and not e.waypoints:
532
+ pts = [(g.ax + g.ox + x, g.ay + g.oy + y) for x, y in wps[key]]
533
+ e.waypoints = pts[::-1] if rev else pts
534
+ e.wp_owner = g
predictygraph/model.py ADDED
@@ -0,0 +1,98 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Document model: graph, groups, nodes, edges."""
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass
12
+ class Item:
13
+ id: str
14
+ label: str | None = None
15
+ classes: list[str] = field(default_factory=list)
16
+ attrs: dict[str, str] = field(default_factory=dict)
17
+ parent: "Group | None" = None
18
+ style: dict[str, str] = field(default_factory=dict)
19
+ # layout results (relative to parent content origin, then absolute)
20
+ w: float = 0.0
21
+ h: float = 0.0
22
+ x: float = 0.0
23
+ y: float = 0.0
24
+ ax: float = 0.0
25
+ ay: float = 0.0
26
+ kind: str = "node"
27
+
28
+ @property
29
+ def cx(self) -> float:
30
+ return self.ax + self.w / 2
31
+
32
+ @property
33
+ def cy(self) -> float:
34
+ return self.ay + self.h / 2
35
+
36
+ def ancestors(self) -> list["Group"]:
37
+ out, p = [], self.parent
38
+ while p is not None:
39
+ out.append(p)
40
+ p = p.parent
41
+ return out
42
+
43
+
44
+ @dataclass
45
+ class Node(Item):
46
+ implicit: bool = False
47
+ image: dict | None = None
48
+ text_lines: list[str] = field(default_factory=list)
49
+ text_w: float = 0.0
50
+ text_h: float = 0.0
51
+
52
+
53
+ @dataclass
54
+ class Group(Item):
55
+ kind: str = "group"
56
+ children: list[Item] = field(default_factory=list)
57
+ defaults: dict[str, dict[str, str]] = field(default_factory=dict)
58
+ # content box offset inside the group box
59
+ ox: float = 0.0
60
+ oy: float = 0.0
61
+ label_h: float = 0.0
62
+
63
+
64
+ @dataclass
65
+ class Edge:
66
+ src: str
67
+ dst: str
68
+ arrow: str # none|end|start|both
69
+ body: str # solid|thick|dashed
70
+ label: str | None = None
71
+ classes: list[str] = field(default_factory=list)
72
+ attrs: dict[str, str] = field(default_factory=dict)
73
+ scope: Group | None = None
74
+ style: dict[str, str] = field(default_factory=dict)
75
+ label_style: dict[str, str] = field(default_factory=dict)
76
+ index: int = 0
77
+ waypoints: list[tuple[float, float]] = field(default_factory=list)
78
+ wp_owner: Group | None = None
79
+ dir_hint: str | None = None
80
+ kind: str = "edge"
81
+ id: str = ""
82
+
83
+
84
+ @dataclass
85
+ class Document:
86
+ root: Group
87
+ edges: list[Edge] = field(default_factory=list)
88
+ stylesheets: list[tuple[str, str]] = field(default_factory=list) # (css text, base dir)
89
+ items: dict[str, Item] = field(default_factory=dict)
90
+ base_dir: str = "."
91
+ allow_outside: bool = False # let image=/css "..." read files outside base_dir
92
+
93
+ def walk(self, g: Group | None = None):
94
+ g = g or self.root
95
+ for c in g.children:
96
+ yield c
97
+ if isinstance(c, Group):
98
+ yield from self.walk(c)