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/scene.py ADDED
@@ -0,0 +1,349 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """AST + layout + routing -> Scene IR.
6
+
7
+ Also owns the decisions that need the routed geometry of everything at once:
8
+ * group titles slide along their top edge to a span no edge crosses; if no span is
9
+ wide enough the title is lifted above the edges on a patch of the group's fill, so
10
+ the crossing line stops around the text (plain geometry: works in every renderer);
11
+ * edge labels sit on the longest straight run of their own edge.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import math
16
+
17
+ from .anim import BORDER_MODES, Track, border_tracks, dash_len, flow_tracks, seconds
18
+ from .ir import Elem, Scene
19
+ from .model import Document, Edge, Group, Node
20
+ from .route import Diagonal, Router, flow_dir, lca
21
+ from .style import box4, px
22
+ from .text import measure
23
+
24
+ TITLE_PAD = 6.0
25
+ ER_HEADS = ("one", "many", "zero-one", "zero-many", "one-many")
26
+
27
+
28
+ class SceneBuilder:
29
+ def __init__(self, doc: Document, router: Router):
30
+ self.doc, self.router = doc, router
31
+ self.edge_polys: dict[str, list[tuple[float, float]]] = {}
32
+ self.title_needs: dict[str, float] = {}
33
+
34
+ # ------------------------------------------------------------ public
35
+ def build(self) -> Scene:
36
+ root = self.doc.root
37
+ edges_layer = Elem("group", cls="pdg-edges")
38
+ edge_elems = [self.edge(e) for e in self.doc.edges]
39
+ self.title_layer = Elem("group", cls="pdg-titles")
40
+ groups_layer = Elem("group", cls="pdg-groups",
41
+ children=[self.group(c) for c in root.children if isinstance(c, Group)])
42
+ edges_layer.children = edge_elems
43
+ nodes_layer = Elem("group", cls="pdg-nodes",
44
+ children=[self.node(c) for c in self.doc.walk() if isinstance(c, Node)])
45
+ layers = [groups_layer, edges_layer, nodes_layer]
46
+ if self.title_layer.children:
47
+ layers.append(self.title_layer)
48
+ return Scene(root.w, root.h, root.style.get("background"), layers)
49
+
50
+ # ------------------------------------------------------------ text
51
+ @staticmethod
52
+ def text(lines, cx, top, fs, st, anchor="middle") -> list[Elem]:
53
+ lh = fs * 1.25
54
+ paint = {"fill": st.get("color", "#111827"), "font-family": st.get("font-family", "sans-serif"),
55
+ "font-size": fs, "font-weight": st.get("font-weight", "normal"),
56
+ "font-style": st.get("font-style", "normal")}
57
+ return [Elem("text", geom={"x": cx, "y": top + lh * i + fs * 0.95, "text": line, "anchor": anchor},
58
+ paint=dict(paint)) for i, line in enumerate(lines)]
59
+
60
+ # ------------------------------------------------------------ shapes
61
+ def shape(self, n, shape: str, st: dict) -> list[Elem]:
62
+ x, y, w, h = n.ax, n.ay, n.w, n.h
63
+ stroke = st.get("stroke", "none")
64
+ sw = px(st.get("stroke-width"), 1)
65
+ paint = {"fill": st.get("fill", "#fff"), "stroke": stroke, "stroke-width": sw}
66
+ if st.get("stroke-dasharray"):
67
+ paint["stroke-dasharray"] = st["stroke-dasharray"]
68
+ if st.get("opacity"):
69
+ paint["opacity"] = st["opacity"]
70
+ if st.get("shadow") == "soft":
71
+ paint["filter"] = "shadow"
72
+ r = px(st.get("border-radius"), 0)
73
+ if shape == "round":
74
+ r = max(r, 10)
75
+ if shape == "pill":
76
+ r = h / 2
77
+ if shape in ("box", "round", "pill", "note", "square"):
78
+ el = Elem("rect", geom={"x": x, "y": y, "w": w, "h": h, "rx": r}, paint=paint)
79
+ elif shape in ("circle", "ellipse"):
80
+ el = Elem("ellipse", geom={"cx": n.cx, "cy": n.cy, "rx": w / 2, "ry": h / 2}, paint=paint)
81
+ elif shape == "diamond":
82
+ el = Elem("polygon", geom={"points": [(n.cx, y), (x + w, n.cy), (n.cx, y + h), (x, n.cy)]}, paint=paint)
83
+ elif shape == "hexagon":
84
+ k = h / 2 * 0.6
85
+ el = Elem("polygon", geom={"points": [(x + k, y), (x + w - k, y), (x + w, n.cy), (x + w - k, y + h),
86
+ (x + k, y + h), (x, n.cy)]}, paint=paint)
87
+ elif shape == "cylinder":
88
+ e = 6.0
89
+ body = Elem("path", geom={"segs": [("M", x, y + e), ("A", w / 2, e, 0, 0, 1, x + w, y + e),
90
+ ("L", x + w, y + h - e), ("A", w / 2, e, 0, 0, 1, x, y + h - e),
91
+ ("Z",)]}, paint=paint)
92
+ lid = Elem("path", geom={"segs": [("M", x, y + e), ("A", w / 2, e, 0, 0, 0, x + w, y + e)]},
93
+ paint={"fill": "none", "stroke": stroke, "stroke-width": sw})
94
+ return [body, lid]
95
+ else:
96
+ return []
97
+ mode = st.get("border", "none")
98
+ if mode in BORDER_MODES and stroke != "none":
99
+ dur = seconds(st.get("border-speed"), 1.6)
100
+ dash = st.get("stroke-dasharray")
101
+ if mode == "march" and not dash:
102
+ dash = f"{max(4.0, sw * 4):g} {max(3.0, sw * 3):g}"
103
+ el.paint["stroke-dasharray"] = dash
104
+ if mode == "glow":
105
+ halo = Elem(el.kind, cls="pdg-border-glow", geom=dict(el.geom),
106
+ paint={"fill": "none", "stroke": stroke, "stroke-width": sw},
107
+ tracks=border_tracks("glow", sw, dash, dur))
108
+ return [halo, el]
109
+ el.tracks = border_tracks(mode, sw, dash, dur)
110
+ return [el]
111
+
112
+ def node(self, n: Node) -> Elem:
113
+ st = n.style
114
+ shape = n.attrs.get("shape", "box")
115
+ out = Elem("group", id=n.id, cls=" ".join(["pdg-node"] + n.classes), children=self.shape(n, shape, st))
116
+ fs = px(st.get("font-size"), 14)
117
+ if n.image:
118
+ img = n.image
119
+ ix = n.cx - img["dw"] / 2
120
+ gap = 4 if n.text_lines else 0
121
+ block = img["dh"] + gap + n.text_h
122
+ iy = n.ay + (n.h - block) / 2 if shape != "image" else n.ay
123
+ out.children.append(Elem("image", geom={"x": ix, "y": iy, "w": img["dw"], "h": img["dh"],
124
+ "href": img["href"]}))
125
+ out.children += self.text(n.text_lines, n.cx, iy + img["dh"] + gap, fs, st)
126
+ else:
127
+ out.children += self._node_text(n, st, fs)
128
+ return out
129
+
130
+ def _node_text(self, n, st, fs):
131
+ align = st.get("text-align", "center")
132
+ pt, pr, pb, pl = box4(st.get("padding"), 8)
133
+ top = n.cy - n.text_h / 2
134
+ if align == "left":
135
+ return self.text(n.text_lines, n.ax + pl, top, fs, st, "start")
136
+ if align == "right":
137
+ return self.text(n.text_lines, n.ax + n.w - pr, top, fs, st, "end")
138
+ return self.text(n.text_lines, n.cx, top, fs, st)
139
+
140
+ def group(self, g: Group) -> Elem:
141
+ st = g.style
142
+ out = Elem("group", id=g.id, cls=" ".join(["pdg-group"] + g.classes),
143
+ children=self.shape(g, g.attrs.get("shape", "box"), st))
144
+ if g.label:
145
+ fs = px(st.get("font-size"), 12)
146
+ pt, pr, pb, pl = box4(st.get("padding"), 14)
147
+ lines = g.label.split("\n")
148
+ bold = st.get("font-weight", "normal") in ("bold", "600", "700")
149
+ tw, th, _ = measure(g.label, st.get("font-family", "sans-serif"), fs, bold)
150
+ bottom = st.get("label-position", "top") == "bottom"
151
+ top = g.ay + g.h - pb - g.label_h + 6 if bottom else g.ay + pt
152
+ x0 = self._title_x(g, st, tw, pl, pr, top, th)
153
+ box = (x0 - TITLE_PAD, top - 2, x0 + tw + TITLE_PAD, top + th + 2)
154
+ texts = self.text(lines, x0, top, fs, st, "start")
155
+ xs = self._crossings(box[1], box[3], box[0], box[2])
156
+ if xs:
157
+ # ask the pipeline for a wider group: children are centred, so widening
158
+ # by d moves the crossing edges d/2 away from the left-aligned title
159
+ over = max(box[2] + 2 - min(xs), 0.0)
160
+ self.title_needs[g.id] = 2 * over + 4
161
+ if self._crossings(box[1], box[3], box[0], box[2]):
162
+ # nowhere free to slide to: lift the title above the edges on a patch of
163
+ # the group's own fill, so the line visibly stops around the text
164
+ fill = st.get("fill", "none")
165
+ if fill in ("none", "transparent"):
166
+ fill = self.doc.root.style.get("background", "#ffffff")
167
+ patch = Elem("rect", cls="pdg-title-bg",
168
+ geom={"x": box[0], "y": box[1], "w": box[2] - box[0], "h": box[3] - box[1], "rx": 3},
169
+ paint={"fill": fill})
170
+ self.title_layer.children.append(Elem("group", cls="pdg-group-title", children=[patch] + texts))
171
+ else:
172
+ out.children += texts
173
+ for c in g.children:
174
+ if isinstance(c, Group):
175
+ out.children.append(self.group(c))
176
+ return out
177
+
178
+ def _title_x(self, g, st, tw, pl, pr, top, th) -> float:
179
+ """Preferred x from text-align; slide along the title band if edges cross there."""
180
+ align = st.get("text-align", "left")
181
+ lo, hi = g.ax + pl, g.ax + g.w - pr - tw
182
+ pref = {"center": g.cx - tw / 2, "right": hi}.get(align, lo)
183
+ if hi <= lo:
184
+ return lo
185
+ blocked = sorted(self._crossings(top - 2, top + th + 2, g.ax, g.ax + g.w))
186
+ if not any(pref - TITLE_PAD < x < pref + tw + TITLE_PAD for x in blocked):
187
+ return pref
188
+ # candidate positions: just right / left of every crossing, and the band ends
189
+ cands = [lo, hi] + [x + TITLE_PAD + 2 for x in blocked] + [x - tw - TITLE_PAD - 2 for x in blocked]
190
+ ok = [c for c in cands if lo <= c <= hi and not any(c - TITLE_PAD < x < c + tw + TITLE_PAD for x in blocked)]
191
+ return min(ok, key=lambda c: abs(c - pref)) if ok else pref
192
+
193
+ def _crossings(self, y0, y1, xa, xb) -> list[float]:
194
+ xs = []
195
+ for poly in self.edge_polys.values():
196
+ for (ax, ay), (bx, by) in zip(poly, poly[1:]):
197
+ if ay == by or not (min(ay, by) <= y1 and max(ay, by) >= y0):
198
+ continue
199
+ for yy in (max(y0, min(ay, by)), min(y1, max(ay, by))):
200
+ t = (yy - ay) / (by - ay)
201
+ x = ax + (bx - ax) * t
202
+ if xa <= x <= xb:
203
+ xs.append(x)
204
+ return xs
205
+
206
+ # ------------------------------------------------------------ edges
207
+ def edge(self, e: Edge) -> Elem:
208
+ st = e.style
209
+ R = self.router
210
+ pts, axis = R.geometry(e)
211
+ route = st.get("route", "curve")
212
+ color = st.get("stroke", "#4b5563")
213
+ width = px(st.get("stroke-width"), 1.5)
214
+ if e.body == "thick" and "width" not in e.attrs:
215
+ width *= 2.5
216
+ dash = st.get("stroke-dasharray")
217
+ if e.body == "dashed" and not dash:
218
+ dash = f"{width * 3:.0f} {width * 2.5:.0f}"
219
+ arrow = e.attrs.get("arrow", e.arrow if e.arrow != "none" or "arrow" not in st else st["arrow"])
220
+ if "arrow" in st and "arrow" not in e.attrs and e.arrow == "end":
221
+ arrow = st["arrow"]
222
+ pts = list(pts)
223
+ hl = px(st.get("head-size"), min(18.0, 6.0 + width * 2.2))
224
+ free = route == "straight" or flow_dir(lca(self.doc.items[e.src], self.doc.items[e.dst],
225
+ self.doc.root)) == "auto"
226
+
227
+ def end_dir(tip, prev):
228
+ if free or axis == "L":
229
+ dx, dy = tip[0] - prev[0], tip[1] - prev[1]
230
+ elif axis == "v":
231
+ dx, dy = 0.0, tip[1] - prev[1]
232
+ else:
233
+ dx, dy = tip[0] - prev[0], 0.0
234
+ l = math.hypot(dx, dy) or 1.0
235
+ return dx / l, dy / l
236
+
237
+ heads = []
238
+ for end, nb, want, key in ((-1, -2, ("end", "both"), "head"), (0, 1, ("start", "both"), "head-start")):
239
+ if arrow not in want:
240
+ continue
241
+ kind = st.get(key, st.get("head", "triangle"))
242
+ tip = pts[end]
243
+ dv = end_dir(tip, pts[nb])
244
+ heads.append((kind, tip, dv))
245
+ if kind not in ER_HEADS: # stroke stops at the head's base; ER lines run to the box
246
+ pts[end] = (tip[0] - dv[0] * hl * 0.9, tip[1] - dv[1] * hl * 0.9)
247
+ stub = px(st["stub"]) if "stub" in st else None
248
+ if stub is not None and heads:
249
+ stub += hl
250
+ radius = px(st.get("radius"), 12 if route == "bend" else 6)
251
+ diag = Diagonal.from_style(st, route)
252
+ level = R.level(e)
253
+ segs = R.path_segs(pts, axis, route, radius, stub, level, diag)
254
+ self.edge_polys[e.id] = R.polyline(pts, axis, route, radius, stub, level, diag)
255
+
256
+ cls = " ".join(["pdg-edge"] + e.classes)
257
+ flow = st.get("flow", "none")
258
+ if flow == "auto":
259
+ flow = {"end": "forward", "start": "reverse", "both": "both"}.get(arrow, "forward")
260
+ flowing = flow in ("forward", "reverse", "both")
261
+ if flowing:
262
+ cls += f" pdg-flow pdg-flow-{flow}"
263
+ out = Elem("group", id=e.id, cls=cls)
264
+ base = {"fill": "none", "stroke": color, "stroke-linejoin": "round"}
265
+ if not flowing:
266
+ p = dict(base, **{"stroke-width": width, "stroke-linecap": "butt" if heads else "round"})
267
+ if dash:
268
+ p["stroke-dasharray"] = dash
269
+ out.children.append(Elem("path", cls="pdg-line", geom={"segs": segs}, paint=p))
270
+ else:
271
+ fdash = dash or f"0.1 {max(6.0, width * 4):g}"
272
+ fwidth = width if dash else max(width * 1.8, 2.5)
273
+ dur = seconds(st.get("flow-speed"), 1.2)
274
+ if not dash: # faint guide line under the moving dots
275
+ out.children.append(Elem("path", cls="pdg-line", geom={"segs": segs},
276
+ paint=dict(base, **{"stroke-width": max(0.75, width * 0.5),
277
+ "stroke-opacity": 0.3})))
278
+ dirs = ["forward", "reverse"] if flow == "both" else [flow]
279
+ for k, fd in enumerate(dirs):
280
+ tracks = flow_tracks(fdash, dur, fd == "reverse")
281
+ if flow == "both" and k == 1:
282
+ tracks = [t.shifted(dash_len(fdash) / 2) for t in tracks]
283
+ out.children.append(Elem("path", cls=f"pdg-line pdg-flow-{fd}", geom={"segs": segs},
284
+ paint=dict(base, **{"stroke-width": fwidth, "stroke-dasharray": fdash,
285
+ "stroke-linecap": "round"}),
286
+ tracks=tracks))
287
+ for kind, tip, dvec in heads:
288
+ out.children.append(self.head(kind, tip, dvec, hl, color, width,
289
+ self.doc.root.style.get("background", "#ffffff")))
290
+ if e.label:
291
+ out.children += self.edge_label(e, self.edge_polys[e.id])
292
+ return out
293
+
294
+ @staticmethod
295
+ def head(kind, tip, d, hl, color, width, bg="#ffffff") -> Elem:
296
+ """Arrowhead as plain geometry: tip sits exactly on the target outline.
297
+ ER heads (crow's foot): one, many, zero-one, zero-many, one-many."""
298
+ tx, ty = tip
299
+ ux, uy = d
300
+ nx, ny = -uy, ux
301
+ hw = max(hl * 0.42, width * 1.2)
302
+
303
+ def P(a, b): # a along -d from tip, b along normal
304
+ return (tx - ux * a + nx * b, ty - uy * a + ny * b)
305
+
306
+ line = {"fill": "none", "stroke": color, "stroke-width": max(1.2, width)}
307
+ bar = lambda a: Elem("polyline", geom={"points": [P(a, hw), P(a, -hw)]}, paint=line) # noqa: E731
308
+ crow = Elem("polyline", geom={"points": [P(0, hw), P(hl, 0), P(0, -hw)]}, paint=line)
309
+ ring = Elem("circle", geom={"cx": P(hl * 1.5, 0)[0], "cy": P(hl * 1.5, 0)[1], "r": hw * 0.6},
310
+ paint=dict(line, fill=bg))
311
+ er = {"one": [bar(hl * 0.5), bar(hl * 0.9)], "many": [crow], "zero-one": [bar(hl * 0.5), ring],
312
+ "zero-many": [crow, ring], "one-many": [crow, bar(hl * 1.3)]}
313
+ if kind in ER_HEADS:
314
+ return Elem("group", cls="pdg-head", children=er[kind])
315
+ shapes = {
316
+ "open": ("polyline", [P(hl, hw), P(0, 0), P(hl, -hw)], dict(line, **{"stroke-linejoin": "miter"})),
317
+ "diamond": ("polygon", [P(0, 0), P(hl / 2, hw), P(hl, 0), P(hl / 2, -hw)], {"fill": color}),
318
+ "bar": ("polyline", [P(0, hw), P(0, -hw)], dict(line, **{"stroke-width": max(2, width)})),
319
+ }
320
+ if kind == "dot":
321
+ c = P(hl / 2.2, 0)
322
+ return Elem("circle", cls="pdg-head", geom={"cx": c[0], "cy": c[1], "r": hl / 2.2}, paint={"fill": color})
323
+ tag, pts, paint = shapes.get(kind, ("polygon", [P(0, 0), P(hl, hw), P(hl, -hw)], {"fill": color}))
324
+ return Elem(tag, cls="pdg-head", geom={"points": pts}, paint=paint)
325
+
326
+ def edge_label(self, e, poly) -> list[Elem]:
327
+ st = e.label_style or e.style
328
+ fs = px(st.get("font-size"), 11)
329
+ w, h, lines = measure(e.label, st.get("font-family", "sans-serif"), fs)
330
+ # bent edges: the run into the target is the edge's own (a fan-out bus is shared),
331
+ # so labels go there when it fits; otherwise the longest run
332
+ segs = list(zip(poly, poly[1:]))
333
+ fits = len(segs) > 1 and math.dist(*segs[-1]) >= h + 4
334
+ best = segs[-1] if fits else max(segs, key=lambda s: math.dist(*s))
335
+ (ax, ay), (bx, by) = best
336
+ t = 0.5
337
+ lvl = self.router.bend_level.get(e.id) if any(
338
+ o is not e and o.src == e.src and self.router.bend_level.get(o.id) is not None for o in self.doc.edges) else None
339
+ if len(segs) == 1 and lvl is not None: # straight sibling of a fan-out: below the bus too
340
+ v = abs(bx - ax) < abs(by - ay)
341
+ a0, a1 = (ay, by) if v else (ax, bx)
342
+ if min(a0, a1) < lvl < max(a0, a1):
343
+ t = ((lvl + a1) / 2 - a0) / (a1 - a0)
344
+ mx, my = ax + (bx - ax) * t, ay + (by - ay) * t
345
+ bg = st.get("background", self.doc.root.style.get("background", "#ffffff"))
346
+ box = Elem("rect", cls="pdg-label-bg", geom={"x": mx - w / 2 - 3, "y": my - h / 2 - 1, "w": w + 6,
347
+ "h": h + 2, "rx": 3},
348
+ paint={"fill": bg, "fill-opacity": 0.9})
349
+ return [box] + self.text(lines, mx, my - h / 2, fs, st)
predictygraph/style.py ADDED
@@ -0,0 +1,204 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """CSS subset resolution with tinycss2 (no browser)."""
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+ from dataclasses import dataclass
11
+
12
+ import tinycss2
13
+
14
+ from .model import Document, Edge, Group, Item
15
+ from .safe import MAX_VAR_LEN, number
16
+
17
+ BUILTIN = (Path(__file__).parent / "default.css").read_text()
18
+
19
+ @dataclass
20
+ class Selector:
21
+ parts: list[tuple[str | None, str | None, list[str]]] # (type, id, classes), descendant chain
22
+
23
+ @property
24
+ def specificity(self):
25
+ a = sum(1 for _, i, _ in self.parts if i)
26
+ b = sum(len(c) for _, _, c in self.parts)
27
+ c = sum(1 for t, _, _ in self.parts if t)
28
+ return (a, b, c)
29
+
30
+
31
+ SIMPLE_RE = re.compile(r"([A-Za-z*][\w-]*)?((?:[#.][\w-]+)*)")
32
+
33
+
34
+ def parse_selector(text: str) -> Selector | None:
35
+ parts = []
36
+ for comp in text.split():
37
+ m = SIMPLE_RE.fullmatch(comp)
38
+ if not m:
39
+ return None
40
+ typ = m.group(1) if m.group(1) not in (None, "*") else None
41
+ ident, classes = None, []
42
+ for sigil, name in re.findall(r"([#.])([\w-]+)", m.group(2) or ""):
43
+ if sigil == "#":
44
+ ident = name
45
+ else:
46
+ classes.append(name)
47
+ parts.append((typ, ident, classes))
48
+ return Selector(parts) if parts else None
49
+
50
+
51
+ def match_simple(part, item) -> bool:
52
+ typ, ident, classes = part
53
+ if typ and typ != item.kind:
54
+ return False
55
+ if ident and ident != item.id:
56
+ return False
57
+ return all(c in item.classes for c in classes)
58
+
59
+
60
+ def matches(sel: Selector, item, ancestors: list) -> bool:
61
+ if not match_simple(sel.parts[-1], item):
62
+ return False
63
+ rest = sel.parts[:-1]
64
+ anc = list(ancestors)
65
+ for part in reversed(rest):
66
+ while anc and not match_simple(part, anc[0]):
67
+ anc.pop(0)
68
+ if not anc:
69
+ return False
70
+ anc.pop(0)
71
+ return True
72
+
73
+
74
+ class StyleSheet:
75
+ def __init__(self, target: str | None = None):
76
+ self.rules: list[tuple[Selector, dict[str, str], int]] = []
77
+ self.vars: dict[str, str] = {}
78
+ self._order = 0
79
+ self.target = target
80
+
81
+ def target_matches(self, prelude) -> bool:
82
+ """`@target macos, windows { ... }` applies when the active target is listed."""
83
+ names = {t.strip().lower() for t in tinycss2.serialize(prelude).split(",") if t.strip()}
84
+ return self.target is not None and self.target in names
85
+
86
+ def add(self, css: str):
87
+ self.add_rules(tinycss2.parse_stylesheet(css, skip_comments=True, skip_whitespace=True))
88
+
89
+ def add_rules(self, rules):
90
+ for rule in rules:
91
+ if rule.type == "at-rule" and rule.lower_at_keyword == "target":
92
+ if rule.content is not None and self.target_matches(rule.prelude):
93
+ self.add_rules(tinycss2.parse_rule_list(rule.content, skip_comments=True,
94
+ skip_whitespace=True))
95
+ continue
96
+ if rule.type != "qualified-rule":
97
+ continue
98
+ decls = {}
99
+ for d in tinycss2.parse_declaration_list(rule.content, skip_comments=True, skip_whitespace=True):
100
+ if d.type == "declaration":
101
+ decls[d.lower_name] = tinycss2.serialize(d.value).strip()
102
+ prelude = tinycss2.serialize(rule.prelude).strip()
103
+ for sel_text in prelude.split(","):
104
+ sel_text = sel_text.strip()
105
+ if sel_text in (":root", "html"):
106
+ self.vars.update({k: v for k, v in decls.items() if k.startswith("--")})
107
+ continue
108
+ sel = parse_selector(sel_text)
109
+ if sel:
110
+ self._order += 1
111
+ self.rules.append((sel, decls, self._order))
112
+
113
+ def compute(self, item, ancestors) -> dict[str, str]:
114
+ hits = [(sel.specificity, order, decls) for sel, decls, order in self.rules
115
+ if matches(sel, item, ancestors)]
116
+ out: dict[str, str] = {}
117
+ for _, _, decls in sorted(hits, key=lambda h: (h[0], h[1])):
118
+ out.update(decls)
119
+ return out
120
+
121
+ def resolve_vars(self, style: dict[str, str]) -> dict[str, str]:
122
+ local = {**self.vars, **{k: v for k, v in style.items() if k.startswith("--")}}
123
+ var_re = re.compile(r"var\(\s*(--[\w-]+)\s*(?:,((?:[^()]|\([^()]*\))*))?\)")
124
+
125
+ def sub(v, depth=0):
126
+ if depth > 8 or "var(" not in v or len(v) > MAX_VAR_LEN:
127
+ return v[:MAX_VAR_LEN]
128
+ def rep(m):
129
+ name, fb = m.group(1), m.group(2)
130
+ return local.get(name, fb.strip() if fb else "")
131
+ return sub(var_re.sub(rep, v), depth + 1)
132
+
133
+ return {k: sub(v) for k, v in style.items()}
134
+
135
+
136
+ BOX_KEYS = ("fill", "stroke", "color", "font-size", "stroke-width", "border-radius", "font-weight",
137
+ "stroke-dasharray", "padding", "border", "border-speed")
138
+ EDGE_KEYS = ("arrow", "head", "head-start", "head-size", "route", "radius", "stub", "flow", "flow-speed",
139
+ "diagonal", "diagonal-angle")
140
+ INHERITED_EDGE_KEYS = tuple(k for k in EDGE_KEYS if k not in ("flow", "head-start"))
141
+
142
+
143
+ @dataclass
144
+ class _Label:
145
+ """Pseudo item so `label` / `edge.x label` selectors match an edge's label."""
146
+ classes: list
147
+ id: str = ""
148
+ kind: str = "label"
149
+
150
+
151
+ def apply_styles(doc: Document, target: str | None = None) -> None:
152
+ sheet = StyleSheet(target)
153
+ sheet.add(BUILTIN)
154
+ for css, _ in doc.stylesheets:
155
+ sheet.add(css)
156
+ root = doc.root
157
+ root.style = sheet.resolve_vars(sheet.compute(root, []))
158
+
159
+ for item in doc.walk():
160
+ anc = item.ancestors() # nearest first; root last
161
+ parent = item.parent.style if item.parent else root.style
162
+ st = {k: parent[k] for k in ("font-family",) if k in parent}
163
+ st.update(sheet.compute(item, anc))
164
+ st.update({k: v for k, v in item.attrs.items() if k in BOX_KEYS})
165
+ item.style = sheet.resolve_vars(st)
166
+ ge = root.attrs
167
+ for e in doc.edges:
168
+ anc = [a for a in (([e.scope] + e.scope.ancestors()) if e.scope is not root else [root]) if a is not None]
169
+ st = {"font-family": root.style.get("font-family", "sans-serif")}
170
+ st.update(sheet.compute(e, anc))
171
+ if "edge-width" in ge:
172
+ st["stroke-width"] = ge["edge-width"]
173
+ groups = [ge] + [g.attrs for g in reversed(anc) if g is not root] # nearest group wins
174
+ for k in INHERITED_EDGE_KEYS:
175
+ for attrs in groups:
176
+ if k in attrs:
177
+ st[k] = attrs[k]
178
+ for k, v in e.attrs.items():
179
+ if k == "width":
180
+ st["stroke-width"] = v
181
+ elif k in EDGE_KEYS or k in ("stroke", "color", "stroke-dasharray", "font-size"):
182
+ st[k] = v
183
+ e.style = sheet.resolve_vars(st)
184
+ lab = sheet.compute(_Label(e.classes), [e] + anc)
185
+ e.label_style = sheet.resolve_vars({**{k: e.style[k] for k in ("font-family", "font-size", "color")
186
+ if k in e.style}, **lab})
187
+
188
+
189
+ def px(v: str | None, default: float = 0.0) -> float:
190
+ return number(v, default)
191
+
192
+
193
+ def box4(v: str | None, default: float = 0.0) -> tuple[float, float, float, float]:
194
+ """CSS shorthand -> (top, right, bottom, left)."""
195
+ if not v:
196
+ return (default,) * 4
197
+ vals = [px(x) for x in v.split()]
198
+ if len(vals) == 1:
199
+ return (vals[0],) * 4
200
+ if len(vals) == 2:
201
+ return (vals[0], vals[1], vals[0], vals[1])
202
+ if len(vals) == 3:
203
+ return (vals[0], vals[1], vals[2], vals[1])
204
+ return tuple(vals[:4])
predictygraph/text.py ADDED
@@ -0,0 +1,80 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Text measurement with real font files via Pillow, and image leaf loading.
6
+
7
+ Which font file is used depends on the active render target (see fonts.py). The target
8
+ is set for the duration of one build via ``use_target``.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import contextlib
14
+ import contextvars
15
+ import functools
16
+ import mimetypes
17
+ import re
18
+ from pathlib import Path
19
+
20
+ from PIL import Image, ImageFont
21
+
22
+ from .fonts import FontResolver
23
+ from .safe import NUM, number, safe_path
24
+
25
+ MAX_FONT = 1000.0
26
+ _resolver: contextvars.ContextVar[FontResolver] = contextvars.ContextVar("pdg_fonts", default=FontResolver())
27
+
28
+
29
+ @contextlib.contextmanager
30
+ def use_target(target: str | None):
31
+ token = _resolver.set(FontResolver(target or "host"))
32
+ try:
33
+ yield _resolver.get()
34
+ finally:
35
+ _resolver.reset(token)
36
+
37
+
38
+ @functools.lru_cache(maxsize=256)
39
+ def get_font(path: str | None, size: float):
40
+ size = min(MAX_FONT, max(1.0, size)) if size == size else 14.0
41
+ try:
42
+ if path:
43
+ return ImageFont.truetype(path, max(1, round(size)))
44
+ except OSError:
45
+ pass
46
+ return ImageFont.load_default(size=size)
47
+
48
+
49
+ def measure(text: str, family: str, size: float, bold: bool = False) -> tuple[float, float, list[str]]:
50
+ lines = text.split("\n") if text else []
51
+ if not lines:
52
+ return 0.0, 0.0, []
53
+ size = min(MAX_FONT, max(1.0, size)) if size == size else 14.0
54
+ fonts = [get_font(c.path, size) for c in _resolver.get().choose(family, bold)]
55
+ w = max(f.getlength(line) for f in fonts for line in lines)
56
+ line_h = size * 1.25
57
+ return w, line_h * len(lines), lines
58
+
59
+
60
+ def load_image(path: str, base_dir: str, allow_outside: bool = False) -> dict:
61
+ p = safe_path(base_dir, path, allow_outside)
62
+ data = p.read_bytes()
63
+ mime = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
64
+ if p.suffix.lower() == ".svg":
65
+ text = data.decode("utf-8", "replace")
66
+ w = h = None
67
+ m = re.search(rf'viewBox\s*=\s*"\s*{NUM}[\s,]+{NUM}[\s,]+({NUM})[\s,]+({NUM})', text)
68
+ if m:
69
+ w, h = number(m.group(1), 0, 0), number(m.group(2), 0, 0)
70
+ else:
71
+ mw = re.search(rf'<svg[^>]*\swidth="({NUM})', text)
72
+ mh = re.search(rf'<svg[^>]*\sheight="({NUM})', text)
73
+ if mw and mh:
74
+ w, h = number(mw.group(1), 0, 0), number(mh.group(1), 0, 0)
75
+ w, h = w or 64.0, h or 64.0
76
+ mime = "image/svg+xml"
77
+ else:
78
+ with Image.open(p) as im:
79
+ w, h = im.size
80
+ return {"href": f"data:{mime};base64,{base64.b64encode(data).decode()}", "w": float(w), "h": float(h)}