predictygraph 0.2.1__tar.gz → 0.2.2__tar.gz
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-0.2.1 → predictygraph-0.2.2}/PKG-INFO +1 -1
- {predictygraph-0.2.1 → predictygraph-0.2.2}/pyproject.toml +1 -1
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/default.css +1 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/layout.py +44 -30
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/model.py +14 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/parser.py +25 -6
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/render/svg.py +2 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/scene.py +146 -84
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/style.py +3 -0
- predictygraph-0.2.2/src/predictygraph/styleutil.py +61 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_annotations.py +30 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/README.md +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/__init__.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/__main__.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/anim.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/cli.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/ir.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/render/__init__.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/render/apng.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/route.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/safe.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/text.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/theme.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/default.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/dracula.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/github-light.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/hami.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/nord.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/solarized-dark.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/solarized-light.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_diagonal.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_fixes.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_hardening.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_predictygraph.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_routing.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_scene.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_stack.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_targets.py +0 -0
|
@@ -21,3 +21,4 @@ group sub { font-weight: normal; }
|
|
|
21
21
|
step { fill: #374151; color: #ffffff; font-size: 11px; font-weight: bold; }
|
|
22
22
|
caption { color: #6b7280; font-size: 12px; font-style: italic; text-align: center; }
|
|
23
23
|
legend { fill: #ffffff; stroke: #d1d5db; color: #374151; font-size: 11px; }
|
|
24
|
+
bracket { stroke: #9ca3af; stroke-width: 1.5; color: #4b5563; font-size: 11px; font-style: italic; }
|
|
@@ -10,21 +10,14 @@ from collections import defaultdict
|
|
|
10
10
|
|
|
11
11
|
from .model import Document, Group, Item, Node
|
|
12
12
|
from .style import box4, px
|
|
13
|
-
from .
|
|
13
|
+
from .styleutil import has_title_band, node_padding, text_block, title_position
|
|
14
|
+
from .text import load_image
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
# ---------------------------------------------------------------- sizing
|
|
17
|
-
BOLD = ("bold", "bolder", "600", "700", "800", "900")
|
|
18
18
|
SUB_GAP = 3.0 # px between a label and its `sub=` lines
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def text_block(text: str | None, st: dict, default_fs: float) -> tuple[float, float, list[str]]:
|
|
22
|
-
if not text:
|
|
23
|
-
return 0.0, 0.0, []
|
|
24
|
-
fs = px(st.get("font-size"), default_fs)
|
|
25
|
-
return measure(text, st.get("font-family", "sans-serif"), fs, st.get("font-weight", "normal") in BOLD)
|
|
26
|
-
|
|
27
|
-
|
|
28
21
|
def size_node(n: Node, doc: Document) -> None:
|
|
29
22
|
st = n.style
|
|
30
23
|
label = n.label if n.label is not None else n.id
|
|
@@ -32,9 +25,7 @@ def size_node(n: Node, doc: Document) -> None:
|
|
|
32
25
|
sw, sh, n.sub_lines = text_block(n.attrs.get("sub"), n.sub_style or st, 12)
|
|
33
26
|
n.sub_h = sh + SUB_GAP if n.sub_lines else 0.0
|
|
34
27
|
n.text_w, n.text_h = max(n.text_w, sw), n.text_h + n.sub_h # text_h covers label + sub
|
|
35
|
-
pt, pr, pb, pl =
|
|
36
|
-
pl += accent_width(st, "left")
|
|
37
|
-
pr += accent_width(st, "right")
|
|
28
|
+
pt, pr, pb, pl = node_padding(st)
|
|
38
29
|
shape = n.attrs.get("shape")
|
|
39
30
|
if "image" in n.attrs:
|
|
40
31
|
img = load_image(n.attrs["image"], doc.base_dir, doc.allow_outside)
|
|
@@ -85,14 +76,6 @@ def size_node(n: Node, doc: Document) -> None:
|
|
|
85
76
|
|
|
86
77
|
|
|
87
78
|
# ---------------------------------------------------------------- helpers
|
|
88
|
-
def accent_width(st: dict, side: str) -> float:
|
|
89
|
-
"""Room an `accent-bar` takes on `side` (it sits inside the box, before the padding)."""
|
|
90
|
-
spec = st.get("accent-bar")
|
|
91
|
-
if not spec or spec.strip() == "none" or st.get("accent-side", "left") != side:
|
|
92
|
-
return 0.0
|
|
93
|
-
return px(st["accent-bar"].split()[0], 4)
|
|
94
|
-
|
|
95
|
-
|
|
96
79
|
def direct_child(item: Item, g: Group) -> Item | None:
|
|
97
80
|
"""Ancestor-or-self of `item` that is a direct child of g."""
|
|
98
81
|
cur = item
|
|
@@ -136,8 +119,9 @@ class LineLayout(Layout):
|
|
|
136
119
|
gap = px(opt(self.g, "gap", "24"))
|
|
137
120
|
align = opt(self.g, "align", "center")
|
|
138
121
|
cross = max((k.h if horizontal else k.w) for k in self.kids)
|
|
139
|
-
|
|
140
|
-
|
|
122
|
+
need = self.label_gaps(horizontal)
|
|
123
|
+
pos, last = 0.0, 0.0
|
|
124
|
+
for idx, k in enumerate(self.kids):
|
|
141
125
|
if align == "stretch": # every child as wide (column) / tall (row) as the widest
|
|
142
126
|
if horizontal:
|
|
143
127
|
k.h = cross
|
|
@@ -148,15 +132,37 @@ class LineLayout(Layout):
|
|
|
148
132
|
k.ox += grow / 2 # content stays left unless the tier asks to centre it
|
|
149
133
|
size_c = k.h if horizontal else k.w
|
|
150
134
|
off = {"start": 0, "end": cross - size_c}.get(align, (cross - size_c) / 2)
|
|
135
|
+
step = max(gap, need.get(idx, 0.0))
|
|
151
136
|
if horizontal:
|
|
152
137
|
k.x, k.y = pos, off
|
|
153
|
-
pos += k.w +
|
|
138
|
+
pos += k.w + step
|
|
154
139
|
else:
|
|
155
140
|
k.x, k.y = off, pos
|
|
156
|
-
pos += k.h +
|
|
157
|
-
|
|
141
|
+
pos += k.h + step
|
|
142
|
+
last = step
|
|
143
|
+
pos -= last
|
|
158
144
|
return (pos, cross) if horizontal else (cross, pos)
|
|
159
145
|
|
|
146
|
+
def label_gaps(self, horizontal: bool) -> dict[int, float]:
|
|
147
|
+
"""Gap needed after child i so labels of edges between neighbours i and i+1 fit
|
|
148
|
+
(a row only: a label runs along the gap). Room = label width + arrowhead + margin."""
|
|
149
|
+
if not horizontal:
|
|
150
|
+
return {}
|
|
151
|
+
index = {k.id: i for i, k in enumerate(self.kids)}
|
|
152
|
+
need: dict[int, float] = {}
|
|
153
|
+
for e in self.doc.edges:
|
|
154
|
+
if not e.label:
|
|
155
|
+
continue
|
|
156
|
+
a, b = direct_child(self.doc.items[e.src], self.g), direct_child(self.doc.items[e.dst], self.g)
|
|
157
|
+
if a is None or b is None or a is b:
|
|
158
|
+
continue
|
|
159
|
+
i, j = sorted((index[a.id], index[b.id]))
|
|
160
|
+
if j != i + 1:
|
|
161
|
+
continue
|
|
162
|
+
w, _, _ = text_block(e.label, e.label_style or e.style, 11, bold=False)
|
|
163
|
+
need[i] = max(need.get(i, 0.0), w + 34)
|
|
164
|
+
return need
|
|
165
|
+
|
|
160
166
|
|
|
161
167
|
class GridLayout(Layout):
|
|
162
168
|
def run(self):
|
|
@@ -516,19 +522,27 @@ def layout_group(g: Group, doc: Document) -> None:
|
|
|
516
522
|
lay = LAYOUTS[g.attrs["layout"]](g, doc)
|
|
517
523
|
cw, ch = lay.run()
|
|
518
524
|
g.label_h = g.title_h = 0.0
|
|
525
|
+
g.side_w = 0.0 # label-position: left -> a column for the rotated title
|
|
519
526
|
lw = 0.0
|
|
520
|
-
|
|
527
|
+
pos = title_position(g.style)
|
|
528
|
+
if g.label and g.kind == "group" and pos == "left":
|
|
529
|
+
tw, th, _ = text_block(g.label, g.style, 12)
|
|
530
|
+
g.title_h = th
|
|
531
|
+
g.side_w = th + 12
|
|
532
|
+
lw = 0.0
|
|
533
|
+
ch = max(ch, tw - pt - pb + 20) # tall enough for the rotated title, with a margin
|
|
534
|
+
elif g.label and g.kind == "group":
|
|
521
535
|
lw, g.title_h, _ = text_block(g.label, g.style, 12)
|
|
522
536
|
sw, sh, _ = text_block(g.attrs.get("sub"), g.sub_style or g.style, 11)
|
|
523
537
|
lw = max(lw, sw)
|
|
524
538
|
g.label_h = g.title_h + (sh + SUB_GAP if sh else 0.0) + 6
|
|
525
|
-
if
|
|
539
|
+
if has_title_band(g.style):
|
|
526
540
|
g.label_h += 6
|
|
527
541
|
inner_w = max(cw, lw) + getattr(g, "extra_w", 0.0) # extra_w: room requested for the title
|
|
528
|
-
top =
|
|
529
|
-
g.ox = pl + (inner_w - cw) / 2
|
|
542
|
+
top = pos not in ("bottom", "left")
|
|
543
|
+
g.ox = pl + g.side_w + (inner_w - cw) / 2
|
|
530
544
|
g.oy = pt + (g.label_h if top else 0)
|
|
531
|
-
g.w = inner_w + pl + pr
|
|
545
|
+
g.w = inner_w + pl + pr + g.side_w
|
|
532
546
|
g.h = ch + pt + pb + g.label_h
|
|
533
547
|
g.layout_obj = lay
|
|
534
548
|
|
|
@@ -63,6 +63,7 @@ class Group(Item):
|
|
|
63
63
|
oy: float = 0.0
|
|
64
64
|
label_h: float = 0.0
|
|
65
65
|
title_h: float = 0.0 # label only; label_h also counts the subtitle
|
|
66
|
+
side_w: float = 0.0 # label-position: left -> width of the rotated-title column
|
|
66
67
|
part_styles: dict[str, dict[str, str]] = field(default_factory=dict) # root: caption, legend
|
|
67
68
|
|
|
68
69
|
|
|
@@ -87,6 +88,18 @@ class Edge:
|
|
|
87
88
|
id: str = ""
|
|
88
89
|
|
|
89
90
|
|
|
91
|
+
@dataclass
|
|
92
|
+
class Bracket:
|
|
93
|
+
"""`bracket a, b "note"`: a curly bracket beside the span of the listed items."""
|
|
94
|
+
ids: list[str]
|
|
95
|
+
label: str = ""
|
|
96
|
+
classes: list[str] = field(default_factory=list)
|
|
97
|
+
attrs: dict[str, str] = field(default_factory=dict)
|
|
98
|
+
style: dict[str, str] = field(default_factory=dict)
|
|
99
|
+
kind: str = "bracket"
|
|
100
|
+
id: str = ""
|
|
101
|
+
|
|
102
|
+
|
|
90
103
|
@dataclass
|
|
91
104
|
class Document:
|
|
92
105
|
root: Group
|
|
@@ -97,6 +110,7 @@ class Document:
|
|
|
97
110
|
allow_outside: bool = False # let image=/css "..." read files outside base_dir
|
|
98
111
|
legend: list[Node] = field(default_factory=list) # `legend .cls "text"` entries (not laid out)
|
|
99
112
|
legend_title: str | None = None
|
|
113
|
+
brackets: list["Bracket"] = field(default_factory=list)
|
|
100
114
|
|
|
101
115
|
def walk(self, g: Group | None = None):
|
|
102
116
|
g = g or self.root
|
|
@@ -8,10 +8,10 @@ from __future__ import annotations
|
|
|
8
8
|
import re
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from .model import Document, Edge, Group, Node
|
|
11
|
+
from .model import Bracket, Document, Edge, Group, Node
|
|
12
12
|
from .safe import MAX_EDGES, safe_path
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
MAX_ANNOTATIONS = 64 # legend entries / brackets per diagram
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class PdgSyntaxError(ValueError):
|
|
@@ -135,6 +135,13 @@ class Parser:
|
|
|
135
135
|
raise PdgSyntaxError(f"unexpected token {val!r}", lineno)
|
|
136
136
|
return label, classes, attrs
|
|
137
137
|
|
|
138
|
+
def _annotation_room(self, stmt: str, existing: list, what: str, lineno: int) -> None:
|
|
139
|
+
"""`legend` / `bracket`: top level only, at most MAX_ANNOTATIONS of each."""
|
|
140
|
+
if self.scope is not self.root:
|
|
141
|
+
raise PdgSyntaxError(f"'{stmt}' only allowed at top level", lineno)
|
|
142
|
+
if len(existing) >= MAX_ANNOTATIONS:
|
|
143
|
+
raise PdgSyntaxError(f"too many {what} (limit {MAX_ANNOTATIONS})", lineno)
|
|
144
|
+
|
|
138
145
|
def _get_node(self, nid: str, lineno: int):
|
|
139
146
|
item = self.doc.items.get(nid)
|
|
140
147
|
if item is None:
|
|
@@ -191,15 +198,23 @@ class Parser:
|
|
|
191
198
|
if classes:
|
|
192
199
|
d["__classes__"] = " ".join(classes)
|
|
193
200
|
return
|
|
201
|
+
if head == "bracket" and len(toks) >= 2 and toks[1][0] == "ident":
|
|
202
|
+
self._annotation_room(head, self.doc.brackets, "brackets", lineno)
|
|
203
|
+
ids, i = [], 1
|
|
204
|
+
while i < len(toks) and toks[i][0] in ("ident", "punct") and toks[i][1] != "{":
|
|
205
|
+
if toks[i][0] == "ident":
|
|
206
|
+
ids.append(toks[i][1])
|
|
207
|
+
i += 1
|
|
208
|
+
label, classes, attrs = self._tail(toks, i, lineno)
|
|
209
|
+
self.doc.brackets.append(Bracket(ids=ids, label=label or "", classes=list(classes), attrs=attrs))
|
|
210
|
+
return
|
|
194
211
|
if head == "legend" and (len(toks) == 1 or toks[1][0] in ("string", "cls")):
|
|
195
|
-
if self.scope is not self.root:
|
|
196
|
-
raise PdgSyntaxError("'legend' only allowed at top level", lineno)
|
|
197
212
|
label, classes, attrs = self._tail(toks, 1, lineno)
|
|
198
213
|
if not classes: # `legend "Key"` names the box
|
|
214
|
+
self._annotation_room(head, [], "", lineno)
|
|
199
215
|
self.doc.legend_title = label
|
|
200
216
|
return
|
|
201
|
-
|
|
202
|
-
raise PdgSyntaxError(f"too many legend entries (limit {MAX_LEGEND})", lineno)
|
|
217
|
+
self._annotation_room(head, self.doc.legend, "legend entries", lineno)
|
|
203
218
|
entry = Node(id=f"pdg-legend-{len(self.doc.legend)}", label=label or " ".join(classes),
|
|
204
219
|
classes=list(classes), attrs=attrs, parent=self.root)
|
|
205
220
|
self.doc.legend.append(entry)
|
|
@@ -313,6 +328,10 @@ class Parser:
|
|
|
313
328
|
for nid in (e.src, e.dst):
|
|
314
329
|
if nid not in self.doc.items:
|
|
315
330
|
raise PdgSyntaxError(f"unknown id {nid!r}", 0)
|
|
331
|
+
for b in self.doc.brackets:
|
|
332
|
+
missing = [i for i in b.ids if i not in self.doc.items]
|
|
333
|
+
if missing or not b.ids:
|
|
334
|
+
raise PdgSyntaxError(f"bracket: unknown id {missing[0]!r}" if missing else "bracket needs ids", 0)
|
|
316
335
|
return self.doc
|
|
317
336
|
|
|
318
337
|
|
|
@@ -71,6 +71,8 @@ class SvgWriter:
|
|
|
71
71
|
return f"<g {self.attrs_str(base)}>\n{inner}\n</g>" if base else f"<g>\n{inner}\n</g>"
|
|
72
72
|
if el.kind == "text":
|
|
73
73
|
a = {"x": num(float(g["x"])), "y": num(float(g["y"])), "text-anchor": g.get("anchor", "middle")}
|
|
74
|
+
if g.get("rotate"): # about the text's own anchor point
|
|
75
|
+
a["transform"] = f"rotate({num(float(g['rotate']))} {a['x']} {a['y']})"
|
|
74
76
|
a.update(self.paint_attrs(el))
|
|
75
77
|
a.update(base)
|
|
76
78
|
return f"<text {self.attrs_str(a)}>{escape(g['text'])}</text>"
|
|
@@ -16,16 +16,28 @@ import math
|
|
|
16
16
|
|
|
17
17
|
from .anim import BORDER_MODES, Track, border_tracks, dash_len, flow_tracks, seconds
|
|
18
18
|
from .ir import Elem, Scene
|
|
19
|
-
from .layout import SUB_GAP
|
|
19
|
+
from .layout import SUB_GAP
|
|
20
20
|
from .model import Document, Edge, Group, Node
|
|
21
21
|
from .route import Diagonal, Router, flow_dir, lca
|
|
22
22
|
from .style import box4, px
|
|
23
|
-
from .
|
|
23
|
+
from .styleutil import LINE_H, accent, font_size, has_title_band, node_padding, text_block, title_position
|
|
24
24
|
|
|
25
25
|
TITLE_PAD = 6.0
|
|
26
26
|
ER_HEADS = ("one", "many", "zero-one", "zero-many", "one-many")
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def point_along(poly: list[tuple[float, float]], frac: float) -> tuple[float, float]:
|
|
30
|
+
"""The point `frac` (0..1) of the way along a polyline, by length."""
|
|
31
|
+
lens = [math.dist(a, b) for a, b in zip(poly, poly[1:])]
|
|
32
|
+
goal, acc = sum(lens) * frac, 0.0
|
|
33
|
+
for (a, b), seg in zip(zip(poly, poly[1:]), lens):
|
|
34
|
+
if seg and acc + seg >= goal:
|
|
35
|
+
t = (goal - acc) / seg
|
|
36
|
+
return a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
37
|
+
acc += seg
|
|
38
|
+
return poly[-1]
|
|
39
|
+
|
|
40
|
+
|
|
29
41
|
class SceneBuilder:
|
|
30
42
|
def __init__(self, doc: Document, router: Router):
|
|
31
43
|
self.doc, self.router = doc, router
|
|
@@ -47,6 +59,10 @@ class SceneBuilder:
|
|
|
47
59
|
if self.title_layer.children:
|
|
48
60
|
layers.append(self.title_layer)
|
|
49
61
|
w, h = root.w, root.h
|
|
62
|
+
if self.doc.brackets:
|
|
63
|
+
br, bw = self.brackets(w)
|
|
64
|
+
layers.append(br)
|
|
65
|
+
w += bw
|
|
50
66
|
legend, lw, lh = self.legend(h)
|
|
51
67
|
if legend:
|
|
52
68
|
layers.append(legend)
|
|
@@ -54,8 +70,8 @@ class SceneBuilder:
|
|
|
54
70
|
cap = root.attrs.get("caption")
|
|
55
71
|
if cap:
|
|
56
72
|
st = root.part_styles.get("caption") or root.style
|
|
57
|
-
fs =
|
|
58
|
-
cw, ch, lines =
|
|
73
|
+
fs = font_size(st, 12)
|
|
74
|
+
cw, ch, lines = text_block(cap, st, 12)
|
|
59
75
|
w = max(w, cw + 28)
|
|
60
76
|
align = st.get("text-align", "center")
|
|
61
77
|
x, anchor = {"left": (14, "start"), "right": (w - 14, "end")}.get(align, (w / 2, "middle"))
|
|
@@ -63,31 +79,72 @@ class SceneBuilder:
|
|
|
63
79
|
h += ch + 14
|
|
64
80
|
return Scene(w, h, root.style.get("background"), layers)
|
|
65
81
|
|
|
82
|
+
def brackets(self, right: float) -> tuple[Elem, float]:
|
|
83
|
+
"""`bracket a, b "note"`: a curly bracket right of the diagram spanning the listed
|
|
84
|
+
items top to bottom, the note beside it. Brackets that overlap vertically are
|
|
85
|
+
stacked outward. Returns the layer and the extra canvas width."""
|
|
86
|
+
out = Elem("group", cls="pdg-brackets")
|
|
87
|
+
depth = 8.0
|
|
88
|
+
spans = [] # (height, y0, y1, bracket, text width, text height, lines)
|
|
89
|
+
for b in self.doc.brackets:
|
|
90
|
+
items = [self.doc.items[i] for i in b.ids]
|
|
91
|
+
y0, y1 = min(i.ay for i in items), max(i.ay + i.h for i in items)
|
|
92
|
+
spans.append((y1 - y0, y0, y1, b, *text_block(b.label, b.style, 11)))
|
|
93
|
+
spans.sort(key=lambda s: s[0]) # short spans nearest the diagram, long ones outside
|
|
94
|
+
cols: list[list[tuple[float, float]]] = []
|
|
95
|
+
col_w: list[float] = []
|
|
96
|
+
placed = []
|
|
97
|
+
for _, y0, y1, b, tw, th, lines in spans:
|
|
98
|
+
col = 0
|
|
99
|
+
while col < len(cols) and any(not (y1 < a or y0 > z) for a, z in cols[col]):
|
|
100
|
+
col += 1
|
|
101
|
+
if col == len(cols):
|
|
102
|
+
cols.append([])
|
|
103
|
+
col_w.append(0.0)
|
|
104
|
+
cols[col].append((y0, y1))
|
|
105
|
+
col_w[col] = max(col_w[col], 2 * depth + 6 + tw + 14)
|
|
106
|
+
placed.append((col, y0, y1, b, th, lines))
|
|
107
|
+
for col, y0, y1, b, th, lines in placed:
|
|
108
|
+
st, fs = b.style, font_size(b.style, 11)
|
|
109
|
+
x = right + 4 + sum(col_w[:col])
|
|
110
|
+
ym = (y0 + y1) / 2
|
|
111
|
+
q = min(depth, (y1 - y0) / 4)
|
|
112
|
+
segs = [("M", x, y0), ("Q", x + depth, y0, x + depth, y0 + q), ("L", x + depth, ym - q),
|
|
113
|
+
("Q", x + depth, ym, x + 2 * depth, ym), ("Q", x + depth, ym, x + depth, ym + q),
|
|
114
|
+
("L", x + depth, y1 - q), ("Q", x + depth, y1, x, y1)]
|
|
115
|
+
kids = [Elem("path", cls="pdg-bracket-line", geom={"segs": segs},
|
|
116
|
+
paint={"fill": "none", "stroke": st.get("stroke", "#9ca3af"),
|
|
117
|
+
"stroke-width": px(st.get("stroke-width"), 1.5), "stroke-linecap": "round"})]
|
|
118
|
+
kids += self.text(lines, x + 2 * depth + 6, ym - th / 2, fs, st, "start")
|
|
119
|
+
out.children.append(Elem("group", cls=" ".join(["pdg-bracket"] + b.classes), children=kids))
|
|
120
|
+
return out, 4 + sum(col_w)
|
|
121
|
+
|
|
66
122
|
def legend(self, top: float) -> tuple[Elem | None, float, float]:
|
|
67
123
|
"""`legend .cls "text"` entries: a key box under the diagram, swatches styled like nodes."""
|
|
68
124
|
entries = self.doc.legend
|
|
69
125
|
if not entries:
|
|
70
126
|
return None, 0.0, 0.0
|
|
71
127
|
st = self.doc.root.part_styles.get("legend") or {}
|
|
72
|
-
|
|
73
|
-
|
|
128
|
+
st = {"font-family": self.doc.root.style.get("font-family", "sans-serif"), **st}
|
|
129
|
+
fs = font_size(st, 11)
|
|
74
130
|
sw, gap, pad = 14.0, 16.0, 10.0
|
|
75
131
|
title = self.doc.legend_title
|
|
76
|
-
tw,
|
|
132
|
+
tw, _, _ = text_block(title, st, 11, bold=True)
|
|
77
133
|
x = pad + (tw + gap if title else 0.0)
|
|
78
|
-
kids, row_h = [], max(sw, fs *
|
|
134
|
+
kids, row_h = [], max(sw, fs * LINE_H)
|
|
79
135
|
y0 = top + pad
|
|
136
|
+
ty = y0 + (row_h - fs * LINE_H) / 2 # text top, vertically centred in the row
|
|
80
137
|
if title:
|
|
81
|
-
kids += self.text([title], 14 + pad,
|
|
138
|
+
kids += self.text([title], 14 + pad, ty, fs, {**st, "font-weight": "bold"}, "start")
|
|
82
139
|
for n in entries:
|
|
83
|
-
ew, _, _ =
|
|
140
|
+
ew, _, _ = text_block(n.label, st, 11, bold=False)
|
|
84
141
|
ns = n.style
|
|
85
142
|
r = min(px(ns.get("border-radius"), 3), 4)
|
|
86
143
|
kids.append(Elem("rect", cls=" ".join(["pdg-legend-swatch"] + n.classes),
|
|
87
144
|
geom={"x": 14 + x, "y": y0 + (row_h - sw) / 2, "w": sw, "h": sw, "rx": r},
|
|
88
145
|
paint={"fill": ns.get("fill", "#ffffff"), "stroke": ns.get("stroke", "#9ca3af"),
|
|
89
146
|
"stroke-width": 1.2}))
|
|
90
|
-
kids += self.text([n.label], 14 + x + sw + 6,
|
|
147
|
+
kids += self.text([n.label], 14 + x + sw + 6, ty, fs, st, "start")
|
|
91
148
|
x += sw + 6 + ew + gap
|
|
92
149
|
bw, bh = x - gap + pad, row_h + 2 * pad
|
|
93
150
|
box = Elem("rect", cls="pdg-legend-box", geom={"x": 14, "y": top, "w": bw, "h": bh, "rx": 6},
|
|
@@ -96,11 +153,15 @@ class SceneBuilder:
|
|
|
96
153
|
|
|
97
154
|
# ------------------------------------------------------------ text
|
|
98
155
|
@staticmethod
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
156
|
+
def text_paint(st: dict, fs: float) -> dict:
|
|
157
|
+
return {"fill": st.get("color", "#111827"), "font-family": st.get("font-family", "sans-serif"),
|
|
158
|
+
"font-size": fs, "font-weight": st.get("font-weight", "normal"),
|
|
159
|
+
"font-style": st.get("font-style", "normal")}
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def text(cls, lines, cx, top, fs, st, anchor="middle") -> list[Elem]:
|
|
163
|
+
lh = fs * LINE_H
|
|
164
|
+
paint = cls.text_paint(st, fs)
|
|
104
165
|
return [Elem("text", geom={"x": cx, "y": top + lh * i + fs * 0.95, "text": line, "anchor": anchor},
|
|
105
166
|
paint=dict(paint)) for i, line in enumerate(lines)]
|
|
106
167
|
|
|
@@ -161,7 +222,7 @@ class SceneBuilder:
|
|
|
161
222
|
shape = n.attrs.get("shape", "box")
|
|
162
223
|
out = Elem("group", id=n.id, cls=" ".join(["pdg-node"] + n.classes), children=self.shape(n, shape, st))
|
|
163
224
|
out.children += self.accent(n, st)
|
|
164
|
-
fs =
|
|
225
|
+
fs = font_size(st, 14)
|
|
165
226
|
if n.image:
|
|
166
227
|
img = n.image
|
|
167
228
|
ix = n.cx - img["dw"] / 2
|
|
@@ -177,84 +238,91 @@ class SceneBuilder:
|
|
|
177
238
|
|
|
178
239
|
def _node_text(self, n, st, fs):
|
|
179
240
|
align = st.get("text-align", "center")
|
|
180
|
-
pt, pr, pb, pl =
|
|
181
|
-
pl += accent_width(st, "left")
|
|
182
|
-
pr += accent_width(st, "right")
|
|
241
|
+
pt, pr, pb, pl = node_padding(st)
|
|
183
242
|
top = n.cy - n.text_h / 2
|
|
184
243
|
x, anchor = {"left": (n.ax + pl, "start"), "right": (n.ax + n.w - pr, "end")}.get(align, (n.cx, "middle"))
|
|
185
244
|
out = self.text(n.text_lines, x, top, fs, st, anchor)
|
|
186
245
|
if n.sub_lines:
|
|
187
246
|
sst = n.sub_style or st
|
|
188
|
-
sfs = px(sst.get("font-size"), 12)
|
|
189
247
|
stop = top + n.text_h - n.sub_h + SUB_GAP
|
|
190
|
-
out += self.text(n.sub_lines, x, stop,
|
|
248
|
+
out += self.text(n.sub_lines, x, stop, font_size(sst, 12), sst, anchor)
|
|
191
249
|
return out
|
|
192
250
|
|
|
193
251
|
def accent(self, it, st) -> list[Elem]:
|
|
194
252
|
"""`accent-bar: 4px #22c55e` draws a strip inside the box edge (`accent-side`)."""
|
|
195
|
-
|
|
196
|
-
if not
|
|
253
|
+
a = accent(st)
|
|
254
|
+
if not a:
|
|
197
255
|
return []
|
|
198
|
-
|
|
199
|
-
bw = px(parts[0], 4)
|
|
200
|
-
color = parts[1] if len(parts) > 1 else st.get("stroke", "#22c55e")
|
|
201
|
-
side = st.get("accent-side", "left")
|
|
256
|
+
bw, color, side = a
|
|
202
257
|
x = it.ax if side == "left" else it.ax + it.w - bw
|
|
203
258
|
r = min(px(st.get("border-radius"), 6), bw)
|
|
204
259
|
return [Elem("rect", cls="pdg-accent", geom={"x": x, "y": it.ay, "w": bw, "h": it.h, "rx": r},
|
|
205
|
-
paint={"fill": color})]
|
|
260
|
+
paint={"fill": color or st.get("stroke", "#22c55e")})]
|
|
206
261
|
|
|
207
262
|
def group(self, g: Group) -> Elem:
|
|
208
263
|
st = g.style
|
|
209
264
|
out = Elem("group", id=g.id, cls=" ".join(["pdg-group"] + g.classes),
|
|
210
265
|
children=self.shape(g, g.attrs.get("shape", "box"), st))
|
|
211
266
|
out.children += self.accent(g, st)
|
|
212
|
-
if g.label and
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
("L", g.ax + g.w - r, g.ay), ("A", r, r, 0, 0, 1, g.ax + g.w, g.ay + r),
|
|
219
|
-
("L", g.ax + g.w, g.ay + bh), ("Z",)]}))
|
|
220
|
-
if g.label:
|
|
221
|
-
fs = px(st.get("font-size"), 12)
|
|
222
|
-
pt, pr, pb, pl = box4(st.get("padding"), 14)
|
|
223
|
-
lines = g.label.split("\n")
|
|
224
|
-
bold = st.get("font-weight", "normal") in ("bold", "600", "700")
|
|
225
|
-
tw, th, _ = measure(g.label, st.get("font-family", "sans-serif"), fs, bold)
|
|
226
|
-
bottom = st.get("label-position", "top") == "bottom"
|
|
227
|
-
top = g.ay + g.h - pb - g.label_h + 6 if bottom else g.ay + pt
|
|
228
|
-
x0 = self._title_x(g, st, tw, pl, pr, top, th)
|
|
229
|
-
box = (x0 - TITLE_PAD, top - 2, x0 + tw + TITLE_PAD, top + th + 2)
|
|
230
|
-
texts = self.text(lines, x0, top, fs, st, "start")
|
|
231
|
-
if g.attrs.get("sub") and not bottom:
|
|
232
|
-
sst = g.sub_style or st
|
|
233
|
-
texts += self.text(g.attrs["sub"].split("\n"), x0, top + g.title_h + SUB_GAP,
|
|
234
|
-
px(sst.get("font-size"), 11), sst, "start")
|
|
235
|
-
xs = self._crossings(box[1], box[3], box[0], box[2])
|
|
236
|
-
if xs:
|
|
237
|
-
# ask the pipeline for a wider group: children are centred, so widening
|
|
238
|
-
# by d moves the crossing edges d/2 away from the left-aligned title
|
|
239
|
-
over = max(box[2] + 2 - min(xs), 0.0)
|
|
240
|
-
self.title_needs[g.id] = 2 * over + 4
|
|
241
|
-
if self._crossings(box[1], box[3], box[0], box[2]):
|
|
242
|
-
# nowhere free to slide to: lift the title above the edges on a patch of
|
|
243
|
-
# the group's own fill, so the line visibly stops around the text
|
|
244
|
-
fill = st.get("fill", "none")
|
|
245
|
-
if fill in ("none", "transparent"):
|
|
246
|
-
fill = self.doc.root.style.get("background", "#ffffff")
|
|
247
|
-
patch = Elem("rect", cls="pdg-title-bg",
|
|
248
|
-
geom={"x": box[0], "y": box[1], "w": box[2] - box[0], "h": box[3] - box[1], "rx": 3},
|
|
249
|
-
paint={"fill": fill})
|
|
250
|
-
self.title_layer.children.append(Elem("group", cls="pdg-group-title", children=[patch] + texts))
|
|
251
|
-
else:
|
|
252
|
-
out.children += texts
|
|
267
|
+
if g.label and title_position(st) == "left":
|
|
268
|
+
out.children.append(self._side_title(g, st))
|
|
269
|
+
elif g.label:
|
|
270
|
+
if has_title_band(st):
|
|
271
|
+
out.children.append(self._title_band(g, st))
|
|
272
|
+
self._top_title(g, st, out)
|
|
253
273
|
for c in g.children:
|
|
254
274
|
if isinstance(c, Group):
|
|
255
275
|
out.children.append(self.group(c))
|
|
256
276
|
return out
|
|
257
277
|
|
|
278
|
+
def _title_band(self, g: Group, st: dict) -> Elem:
|
|
279
|
+
"""`title-fill`: a band behind the title, rounded like the group's top corners."""
|
|
280
|
+
bh = box4(st.get("padding"), 14)[0] + g.label_h - 3
|
|
281
|
+
r = px(st.get("border-radius"), 8)
|
|
282
|
+
return Elem("path", cls="pdg-title-band", paint={"fill": st["title-fill"]}, geom={"segs": [
|
|
283
|
+
("M", g.ax, g.ay + bh), ("L", g.ax, g.ay + r), ("A", r, r, 0, 0, 1, g.ax + r, g.ay),
|
|
284
|
+
("L", g.ax + g.w - r, g.ay), ("A", r, r, 0, 0, 1, g.ax + g.w, g.ay + r),
|
|
285
|
+
("L", g.ax + g.w, g.ay + bh), ("Z",)]})
|
|
286
|
+
|
|
287
|
+
def _side_title(self, g: Group, st: dict) -> Elem:
|
|
288
|
+
"""`label-position: left`: the title turned 90°, pinned near the left edge (padding
|
|
289
|
+
moves the content, not the title, so an edge can pass between them)."""
|
|
290
|
+
fs = font_size(st, 12)
|
|
291
|
+
x = g.ax + 8 + g.side_w / 2 + fs * 0.35
|
|
292
|
+
return Elem("text", geom={"x": x, "y": g.cy, "text": g.label.replace("\n", " "),
|
|
293
|
+
"anchor": "middle", "rotate": -90}, paint=self.text_paint(st, fs))
|
|
294
|
+
|
|
295
|
+
def _top_title(self, g: Group, st: dict, out: Elem) -> None:
|
|
296
|
+
"""Title (and `sub=` line) at the top or bottom, sliding clear of crossing edges."""
|
|
297
|
+
fs = font_size(st, 12)
|
|
298
|
+
pt, pr, pb, pl = box4(st.get("padding"), 14)
|
|
299
|
+
tw, th, _ = text_block(g.label, st, 12)
|
|
300
|
+
bottom = title_position(st) == "bottom"
|
|
301
|
+
top = g.ay + g.h - pb - g.label_h + 6 if bottom else g.ay + pt
|
|
302
|
+
x0 = self._title_x(g, st, tw, pl, pr, top, th)
|
|
303
|
+
box = (x0 - TITLE_PAD, top - 2, x0 + tw + TITLE_PAD, top + th + 2)
|
|
304
|
+
texts = self.text(g.label.split("\n"), x0, top, fs, st, "start")
|
|
305
|
+
if g.attrs.get("sub") and not bottom:
|
|
306
|
+
sst = g.sub_style or st
|
|
307
|
+
texts += self.text(g.attrs["sub"].split("\n"), x0, top + g.title_h + SUB_GAP,
|
|
308
|
+
font_size(sst, 11), sst, "start")
|
|
309
|
+
xs = self._crossings(box[1], box[3], box[0], box[2])
|
|
310
|
+
if not xs:
|
|
311
|
+
out.children += texts
|
|
312
|
+
return
|
|
313
|
+
# ask the pipeline for a wider group: children are centred, so widening by d
|
|
314
|
+
# moves the crossing edges d/2 away from the left-aligned title
|
|
315
|
+
self.title_needs[g.id] = 2 * max(box[2] + 2 - min(xs), 0.0) + 4
|
|
316
|
+
# nowhere free to slide to: lift the title above the edges on a patch of the
|
|
317
|
+
# group's own fill, so the line visibly stops around the text
|
|
318
|
+
fill = st.get("fill", "none")
|
|
319
|
+
if fill in ("none", "transparent"):
|
|
320
|
+
fill = self.doc.root.style.get("background", "#ffffff")
|
|
321
|
+
patch = Elem("rect", cls="pdg-title-bg",
|
|
322
|
+
geom={"x": box[0], "y": box[1], "w": box[2] - box[0], "h": box[3] - box[1], "rx": 3},
|
|
323
|
+
paint={"fill": fill})
|
|
324
|
+
self.title_layer.children.append(Elem("group", cls="pdg-group-title", children=[patch] + texts))
|
|
325
|
+
|
|
258
326
|
def _title_x(self, g, st, tw, pl, pr, top, th) -> float:
|
|
259
327
|
"""Preferred x from text-align; slide along the title band if edges cross there."""
|
|
260
328
|
align = st.get("text-align", "left")
|
|
@@ -379,25 +447,19 @@ class SceneBuilder:
|
|
|
379
447
|
"""Numbered circle on the edge, a third of the way along its path. With a label the
|
|
380
448
|
badge sits just left of the label box, so neither covers the other."""
|
|
381
449
|
st = e.step_style or e.style
|
|
382
|
-
|
|
450
|
+
st = {**st, "font-weight": st.get("font-weight", "bold")}
|
|
451
|
+
fs = font_size(st, 11)
|
|
383
452
|
txt = e.attrs["step"][:3]
|
|
384
|
-
w, h, _ =
|
|
453
|
+
w, h, _ = text_block(txt, st, 11)
|
|
385
454
|
r = max(w, h) / 2 + 3
|
|
386
|
-
lens = [math.dist(a, b) for a, b in zip(poly, poly[1:])]
|
|
387
|
-
goal, acc = sum(lens) / 3, 0.0
|
|
388
|
-
cx, cy = poly[0]
|
|
389
|
-
for (a, b), l in zip(zip(poly, poly[1:]), lens):
|
|
390
|
-
if acc + l >= goal and l:
|
|
391
|
-
t = (goal - acc) / l
|
|
392
|
-
cx, cy = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
393
|
-
break
|
|
394
|
-
acc += l
|
|
395
455
|
if label_box:
|
|
396
456
|
cx, cy = label_box["x"] - r - 3, label_box["y"] + label_box["h"] / 2
|
|
457
|
+
else:
|
|
458
|
+
cx, cy = point_along(poly, 1 / 3)
|
|
397
459
|
circ = Elem("circle", cls="pdg-step", geom={"cx": cx, "cy": cy, "r": r},
|
|
398
460
|
paint={"fill": st.get("fill", "#374151"), "stroke": self.doc.root.style.get("background", "#ffffff"),
|
|
399
461
|
"stroke-width": 1.5})
|
|
400
|
-
return [circ] + self.text([txt], cx, cy - fs *
|
|
462
|
+
return [circ] + self.text([txt], cx, cy - fs * LINE_H / 2, fs, st)
|
|
401
463
|
|
|
402
464
|
@staticmethod
|
|
403
465
|
def head(kind, tip, d, hl, color, width, bg="#ffffff") -> Elem:
|
|
@@ -433,8 +495,8 @@ class SceneBuilder:
|
|
|
433
495
|
|
|
434
496
|
def edge_label(self, e, poly) -> list[Elem]:
|
|
435
497
|
st = e.label_style or e.style
|
|
436
|
-
fs =
|
|
437
|
-
w, h, lines =
|
|
498
|
+
fs = font_size(st, 11)
|
|
499
|
+
w, h, lines = text_block(e.label, st, 11, bold=False) # same call as layout.label_gaps
|
|
438
500
|
# bent edges: the run into the target is the edge's own (a fan-out bus is shared),
|
|
439
501
|
# so labels go there when it fits; otherwise the longest run
|
|
440
502
|
segs = list(zip(poly, poly[1:]))
|
|
@@ -185,6 +185,9 @@ def apply_styles(doc: Document, target: str | None = None) -> None:
|
|
|
185
185
|
number_steps(doc)
|
|
186
186
|
root.style = sheet.resolve_vars(sheet.compute(root, []))
|
|
187
187
|
root.part_styles = {k: part_style(sheet, k, root, [], root.style) for k in ("caption", "legend")}
|
|
188
|
+
for b in doc.brackets:
|
|
189
|
+
b.style = sheet.resolve_vars({"font-family": root.style.get("font-family", "sans-serif"),
|
|
190
|
+
**sheet.compute(b, [root])})
|
|
188
191
|
|
|
189
192
|
for item in [*doc.walk(), *doc.legend]:
|
|
190
193
|
anc = item.ancestors() # nearest first; root last
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Small style readers shared by layout (sizing) and scene (drawing), so both sides
|
|
6
|
+
agree on what a style means: text metrics, accent bars, header bands, title position."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .style import box4, px
|
|
10
|
+
from .text import measure
|
|
11
|
+
|
|
12
|
+
BOLD = ("bold", "bolder", "600", "700", "800", "900")
|
|
13
|
+
LINE_H = 1.25 # line height as a multiple of the font size (matches SceneBuilder.text)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def font_size(st: dict, default: float) -> float:
|
|
17
|
+
return px(st.get("font-size"), default)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_bold(st: dict, default: bool = False) -> bool:
|
|
21
|
+
w = st.get("font-weight")
|
|
22
|
+
return default if w is None else w in BOLD
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def text_block(text: str | None, st: dict, default_fs: float,
|
|
26
|
+
bold: bool | None = None) -> tuple[float, float, list[str]]:
|
|
27
|
+
"""(width, height, lines) of `text` in style `st`; zeros for empty text.
|
|
28
|
+
`bold` overrides the style's weight (step numbers, legend titles)."""
|
|
29
|
+
if not text:
|
|
30
|
+
return 0.0, 0.0, []
|
|
31
|
+
return measure(text, st.get("font-family", "sans-serif"), font_size(st, default_fs),
|
|
32
|
+
is_bold(st) if bold is None else bold)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def accent(st: dict) -> tuple[float, str | None, str] | None:
|
|
36
|
+
"""`accent-bar: 4px #16a34a` -> (width, colour or None, side); None when off."""
|
|
37
|
+
spec = (st.get("accent-bar") or "").split()
|
|
38
|
+
if not spec or spec[0] == "none":
|
|
39
|
+
return None
|
|
40
|
+
return px(spec[0], 4), (spec[1] if len(spec) > 1 else None), st.get("accent-side", "left")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def accent_width(st: dict, side: str) -> float:
|
|
44
|
+
"""Room an accent bar takes on `side` (inside the box, before the padding)."""
|
|
45
|
+
a = accent(st)
|
|
46
|
+
return a[0] if a and a[2] == side else 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def node_padding(st: dict) -> tuple[float, float, float, float]:
|
|
50
|
+
"""A node's (top, right, bottom, left) padding, widened by its accent bar."""
|
|
51
|
+
pt, pr, pb, pl = box4(st.get("padding"), 8)
|
|
52
|
+
return pt, pr + accent_width(st, "right"), pb, pl + accent_width(st, "left")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def title_position(st: dict) -> str:
|
|
56
|
+
return st.get("label-position", "top")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def has_title_band(st: dict) -> bool:
|
|
60
|
+
"""`title-fill` draws a band behind a top title only."""
|
|
61
|
+
return st.get("title-fill") not in (None, "none") and title_position(st) == "top"
|
|
@@ -75,3 +75,33 @@ def test_accent_bar_widens_node_and_draws():
|
|
|
75
75
|
def test_title_fill_band():
|
|
76
76
|
_, sc = scene('css { group { title-fill: #dbeafe; } }\ngroup g "G" {\n a\n}\n')
|
|
77
77
|
assert "pdg-title-band" in classes(sc)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_bracket_widens_canvas_and_draws_note():
|
|
81
|
+
_, base = scene('graph layout=column\na\nb\nc\n')
|
|
82
|
+
_, sc = scene('graph layout=column\na\nb\nc\nbracket a, b "first two"\nbracket a, c "all"\n')
|
|
83
|
+
assert sc.width > base.width
|
|
84
|
+
assert {"first two", "all"} <= set(texts(sc))
|
|
85
|
+
assert classes(sc).count("pdg-bracket-line") == 2
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_bracket_unknown_id_is_an_error():
|
|
89
|
+
import pytest
|
|
90
|
+
from predictygraph.parser import PdgSyntaxError
|
|
91
|
+
with pytest.raises(PdgSyntaxError):
|
|
92
|
+
parse('a\nbracket a, nope "x"\n')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_left_label_is_rotated_and_takes_a_column():
|
|
96
|
+
src = 'css { group { label-position: left; } }\ngroup g "Kernel space" {\n a\n}\n'
|
|
97
|
+
doc, sc = scene(src)
|
|
98
|
+
g = doc.items["g"]
|
|
99
|
+
assert g.side_w > 0
|
|
100
|
+
rotated = []
|
|
101
|
+
stack = list(sc.layers)
|
|
102
|
+
while stack:
|
|
103
|
+
e = stack.pop()
|
|
104
|
+
if e.kind == "text" and e.geom.get("rotate"):
|
|
105
|
+
rotated.append(e.geom["text"])
|
|
106
|
+
stack += e.children
|
|
107
|
+
assert rotated == ["Kernel space"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Regular.ttf
RENAMED
|
File without changes
|
|
File without changes
|
{predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Regular.ttf
RENAMED
|
File without changes
|
{predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Bold.ttf
RENAMED
|
File without changes
|
{predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Regular.ttf
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-macchiato.toml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|