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.
- predictygraph/__init__.py +108 -0
- predictygraph/__main__.py +7 -0
- predictygraph/anim.py +91 -0
- predictygraph/cli.py +62 -0
- predictygraph/default.css +16 -0
- predictygraph/fonts/Carlito-Bold.ttf +0 -0
- predictygraph/fonts/Carlito-Regular.ttf +0 -0
- predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- predictygraph/fonts/DejaVuSans.ttf +0 -0
- predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- predictygraph/fonts.py +167 -0
- predictygraph/ir.py +62 -0
- predictygraph/layout.py +534 -0
- predictygraph/model.py +98 -0
- predictygraph/parser.py +310 -0
- predictygraph/render/__init__.py +17 -0
- predictygraph/render/apng.py +43 -0
- predictygraph/render/svg.py +121 -0
- predictygraph/route.py +453 -0
- predictygraph/safe.py +41 -0
- predictygraph/scene.py +349 -0
- predictygraph/style.py +204 -0
- predictygraph/text.py +80 -0
- predictygraph/theme.py +107 -0
- predictygraph/themes/catppuccin-frappe.toml +27 -0
- predictygraph/themes/catppuccin-latte.toml +27 -0
- predictygraph/themes/catppuccin-macchiato.toml +27 -0
- predictygraph/themes/catppuccin-mocha.toml +27 -0
- predictygraph/themes/default.toml +22 -0
- predictygraph/themes/dracula.toml +25 -0
- predictygraph/themes/github-light.toml +25 -0
- predictygraph/themes/gruvbox-dark.toml +24 -0
- predictygraph/themes/nord.toml +25 -0
- predictygraph/themes/solarized-dark.toml +15 -0
- predictygraph/themes/solarized-light.toml +24 -0
- predictygraph-0.1.0.dist-info/METADATA +60 -0
- predictygraph-0.1.0.dist-info/RECORD +44 -0
- predictygraph-0.1.0.dist-info/WHEEL +4 -0
- 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)}
|