predictygraph 0.2.1__tar.gz → 0.2.3__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.3}/PKG-INFO +1 -1
- {predictygraph-0.2.1 → predictygraph-0.2.3}/pyproject.toml +1 -1
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/default.css +1 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/layout.py +76 -30
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/model.py +14 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/parser.py +25 -6
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/render/svg.py +2 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/scene.py +188 -93
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/style.py +3 -0
- predictygraph-0.2.3/src/predictygraph/styleutil.py +61 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_annotations.py +30 -0
- predictygraph-0.2.3/tests/test_stack.py +84 -0
- predictygraph-0.2.1/tests/test_stack.py +0 -41
- {predictygraph-0.2.1 → predictygraph-0.2.3}/README.md +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/__init__.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/__main__.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/anim.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/cli.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/ir.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/render/__init__.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/render/apng.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/route.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/safe.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/text.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/theme.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/default.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/dracula.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/github-light.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/hami.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/nord.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/solarized-dark.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/solarized-light.toml +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_diagonal.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_fixes.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_hardening.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_predictygraph.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_routing.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_scene.py +0 -0
- {predictygraph-0.2.1 → predictygraph-0.2.3}/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
|
|
|
@@ -541,6 +555,37 @@ def absolutize(g: Group, ox: float, oy: float) -> None:
|
|
|
541
555
|
absolutize(c, c.ax, c.ay)
|
|
542
556
|
|
|
543
557
|
|
|
558
|
+
def shift(item: Item, dx: float) -> None:
|
|
559
|
+
"""Move an item sideways by dx after absolutize: its own x (relative to its parent)
|
|
560
|
+
and the absolute position of it and everything inside it."""
|
|
561
|
+
item.x += dx
|
|
562
|
+
|
|
563
|
+
def move(i: Item) -> None:
|
|
564
|
+
i.ax += dx
|
|
565
|
+
if isinstance(i, Group):
|
|
566
|
+
for c in i.children:
|
|
567
|
+
move(c)
|
|
568
|
+
move(item)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def align_x(doc: Document) -> None:
|
|
572
|
+
"""`align-x=ID`: centre an item horizontally on another item, e.g. line up a box with
|
|
573
|
+
the one it calls in the panel below. It only moves within its parent's content box and
|
|
574
|
+
never past a sibling, so nothing overlaps and no group is resized."""
|
|
575
|
+
for item in list(doc.walk()):
|
|
576
|
+
ref = doc.items.get(item.attrs.get("align-x", ""))
|
|
577
|
+
p = item.parent
|
|
578
|
+
if ref is None or ref is item or p is None:
|
|
579
|
+
continue
|
|
580
|
+
pr = box4(p.style.get("padding"), 14)[1]
|
|
581
|
+
sibs = [s for s in p.children if s is not item]
|
|
582
|
+
lo = max([s.ax + s.w + 8 for s in sibs if s.ax + s.w <= item.ax + 1e-6], default=p.ax + p.ox)
|
|
583
|
+
hi = min([s.ax - 8 for s in sibs if s.ax >= item.ax + item.w - 1e-6], default=p.ax + p.w - pr)
|
|
584
|
+
new = min(max(ref.cx - item.w / 2, lo), hi - item.w)
|
|
585
|
+
if abs(new - item.ax) > 0.01:
|
|
586
|
+
shift(item, new - item.ax)
|
|
587
|
+
|
|
588
|
+
|
|
544
589
|
def layout(doc: Document) -> None:
|
|
545
590
|
root = doc.root
|
|
546
591
|
for e in doc.edges: # layout may run again after the router widens gaps
|
|
@@ -548,6 +593,7 @@ def layout(doc: Document) -> None:
|
|
|
548
593
|
e.waypoints, e.wp_owner = [], None
|
|
549
594
|
layout_group(root, doc)
|
|
550
595
|
absolutize(root, 0, 0)
|
|
596
|
+
align_x(doc)
|
|
551
597
|
# translate virtual waypoints into absolute coords and attach to edges
|
|
552
598
|
for g in [root] + [i for i in doc.walk() if isinstance(i, Group)]:
|
|
553
599
|
lay = getattr(g, "layout_obj", None)
|
|
@@ -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,65 @@ 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
|
+
|
|
41
|
+
def corner_radius(st: dict, shape: str, w: float, h: float) -> float:
|
|
42
|
+
"""The corner radius a box shape is drawn with (shared by the box and its accent bar)."""
|
|
43
|
+
if shape == "pill":
|
|
44
|
+
return h / 2
|
|
45
|
+
r = px(st.get("border-radius"), 0)
|
|
46
|
+
return max(r, 10) if shape == "round" else r
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def accent_segs(x: float, y: float, w: float, h: float, bw: float, r: float, side: str) -> list:
|
|
50
|
+
"""Path for a `bw`-wide strip down one side of an (x, y, w, h) box with corner radius r.
|
|
51
|
+
The outer edge follows the box's corner arcs and the inner edge is straight, so the
|
|
52
|
+
strip stays inside the rounded outline. Drawn for the left side, mirrored for right."""
|
|
53
|
+
bw, r = min(bw, w / 2), min(r, h / 2)
|
|
54
|
+
if r <= 0:
|
|
55
|
+
pts = [(0, 0), (bw, 0), (bw, h), (0, h)]
|
|
56
|
+
segs = [("M", *pts[0])] + [("L", *p) for p in pts[1:]]
|
|
57
|
+
elif bw < r: # inner edge cuts the corner arc at height dy below the top
|
|
58
|
+
dy = r - math.sqrt(r * r - (r - bw) ** 2)
|
|
59
|
+
segs = [("M", 0, r), ("A", r, r, 0, 0, 1, bw, dy), ("L", bw, h - dy),
|
|
60
|
+
("A", r, r, 0, 0, 1, 0, h - r)]
|
|
61
|
+
else: # strip wider than the radius: full corner arc, then straight along top/bottom
|
|
62
|
+
segs = [("M", 0, r), ("A", r, r, 0, 0, 1, r, 0), ("L", bw, 0), ("L", bw, h), ("L", r, h),
|
|
63
|
+
("A", r, r, 0, 0, 1, 0, h - r)]
|
|
64
|
+
segs.append(("Z",))
|
|
65
|
+
flip = side != "left"
|
|
66
|
+
|
|
67
|
+
def place(s):
|
|
68
|
+
if s[0] == "A": # (A, rx, ry, rot, large, sweep, px, py): mirroring flips the sweep
|
|
69
|
+
_, rx, ry, rot, large, sweep, px_, py_ = s
|
|
70
|
+
return ("A", rx, ry, rot, large, 1 - sweep if flip else sweep,
|
|
71
|
+
x + (w - px_ if flip else px_), y + py_)
|
|
72
|
+
if s[0] == "Z":
|
|
73
|
+
return s
|
|
74
|
+
return (s[0], x + (w - s[1] if flip else s[1]), y + s[2])
|
|
75
|
+
return [place(s) for s in segs]
|
|
76
|
+
|
|
77
|
+
|
|
29
78
|
class SceneBuilder:
|
|
30
79
|
def __init__(self, doc: Document, router: Router):
|
|
31
80
|
self.doc, self.router = doc, router
|
|
@@ -47,6 +96,10 @@ class SceneBuilder:
|
|
|
47
96
|
if self.title_layer.children:
|
|
48
97
|
layers.append(self.title_layer)
|
|
49
98
|
w, h = root.w, root.h
|
|
99
|
+
if self.doc.brackets:
|
|
100
|
+
br, bw = self.brackets(w)
|
|
101
|
+
layers.append(br)
|
|
102
|
+
w += bw
|
|
50
103
|
legend, lw, lh = self.legend(h)
|
|
51
104
|
if legend:
|
|
52
105
|
layers.append(legend)
|
|
@@ -54,8 +107,8 @@ class SceneBuilder:
|
|
|
54
107
|
cap = root.attrs.get("caption")
|
|
55
108
|
if cap:
|
|
56
109
|
st = root.part_styles.get("caption") or root.style
|
|
57
|
-
fs =
|
|
58
|
-
cw, ch, lines =
|
|
110
|
+
fs = font_size(st, 12)
|
|
111
|
+
cw, ch, lines = text_block(cap, st, 12)
|
|
59
112
|
w = max(w, cw + 28)
|
|
60
113
|
align = st.get("text-align", "center")
|
|
61
114
|
x, anchor = {"left": (14, "start"), "right": (w - 14, "end")}.get(align, (w / 2, "middle"))
|
|
@@ -63,31 +116,72 @@ class SceneBuilder:
|
|
|
63
116
|
h += ch + 14
|
|
64
117
|
return Scene(w, h, root.style.get("background"), layers)
|
|
65
118
|
|
|
119
|
+
def brackets(self, right: float) -> tuple[Elem, float]:
|
|
120
|
+
"""`bracket a, b "note"`: a curly bracket right of the diagram spanning the listed
|
|
121
|
+
items top to bottom, the note beside it. Brackets that overlap vertically are
|
|
122
|
+
stacked outward. Returns the layer and the extra canvas width."""
|
|
123
|
+
out = Elem("group", cls="pdg-brackets")
|
|
124
|
+
depth = 8.0
|
|
125
|
+
spans = [] # (height, y0, y1, bracket, text width, text height, lines)
|
|
126
|
+
for b in self.doc.brackets:
|
|
127
|
+
items = [self.doc.items[i] for i in b.ids]
|
|
128
|
+
y0, y1 = min(i.ay for i in items), max(i.ay + i.h for i in items)
|
|
129
|
+
spans.append((y1 - y0, y0, y1, b, *text_block(b.label, b.style, 11)))
|
|
130
|
+
spans.sort(key=lambda s: s[0]) # short spans nearest the diagram, long ones outside
|
|
131
|
+
cols: list[list[tuple[float, float]]] = []
|
|
132
|
+
col_w: list[float] = []
|
|
133
|
+
placed = []
|
|
134
|
+
for _, y0, y1, b, tw, th, lines in spans:
|
|
135
|
+
col = 0
|
|
136
|
+
while col < len(cols) and any(not (y1 < a or y0 > z) for a, z in cols[col]):
|
|
137
|
+
col += 1
|
|
138
|
+
if col == len(cols):
|
|
139
|
+
cols.append([])
|
|
140
|
+
col_w.append(0.0)
|
|
141
|
+
cols[col].append((y0, y1))
|
|
142
|
+
col_w[col] = max(col_w[col], 2 * depth + 6 + tw + 14)
|
|
143
|
+
placed.append((col, y0, y1, b, th, lines))
|
|
144
|
+
for col, y0, y1, b, th, lines in placed:
|
|
145
|
+
st, fs = b.style, font_size(b.style, 11)
|
|
146
|
+
x = right + 4 + sum(col_w[:col])
|
|
147
|
+
ym = (y0 + y1) / 2
|
|
148
|
+
q = min(depth, (y1 - y0) / 4)
|
|
149
|
+
segs = [("M", x, y0), ("Q", x + depth, y0, x + depth, y0 + q), ("L", x + depth, ym - q),
|
|
150
|
+
("Q", x + depth, ym, x + 2 * depth, ym), ("Q", x + depth, ym, x + depth, ym + q),
|
|
151
|
+
("L", x + depth, y1 - q), ("Q", x + depth, y1, x, y1)]
|
|
152
|
+
kids = [Elem("path", cls="pdg-bracket-line", geom={"segs": segs},
|
|
153
|
+
paint={"fill": "none", "stroke": st.get("stroke", "#9ca3af"),
|
|
154
|
+
"stroke-width": px(st.get("stroke-width"), 1.5), "stroke-linecap": "round"})]
|
|
155
|
+
kids += self.text(lines, x + 2 * depth + 6, ym - th / 2, fs, st, "start")
|
|
156
|
+
out.children.append(Elem("group", cls=" ".join(["pdg-bracket"] + b.classes), children=kids))
|
|
157
|
+
return out, 4 + sum(col_w)
|
|
158
|
+
|
|
66
159
|
def legend(self, top: float) -> tuple[Elem | None, float, float]:
|
|
67
160
|
"""`legend .cls "text"` entries: a key box under the diagram, swatches styled like nodes."""
|
|
68
161
|
entries = self.doc.legend
|
|
69
162
|
if not entries:
|
|
70
163
|
return None, 0.0, 0.0
|
|
71
164
|
st = self.doc.root.part_styles.get("legend") or {}
|
|
72
|
-
|
|
73
|
-
|
|
165
|
+
st = {"font-family": self.doc.root.style.get("font-family", "sans-serif"), **st}
|
|
166
|
+
fs = font_size(st, 11)
|
|
74
167
|
sw, gap, pad = 14.0, 16.0, 10.0
|
|
75
168
|
title = self.doc.legend_title
|
|
76
|
-
tw,
|
|
169
|
+
tw, _, _ = text_block(title, st, 11, bold=True)
|
|
77
170
|
x = pad + (tw + gap if title else 0.0)
|
|
78
|
-
kids, row_h = [], max(sw, fs *
|
|
171
|
+
kids, row_h = [], max(sw, fs * LINE_H)
|
|
79
172
|
y0 = top + pad
|
|
173
|
+
ty = y0 + (row_h - fs * LINE_H) / 2 # text top, vertically centred in the row
|
|
80
174
|
if title:
|
|
81
|
-
kids += self.text([title], 14 + pad,
|
|
175
|
+
kids += self.text([title], 14 + pad, ty, fs, {**st, "font-weight": "bold"}, "start")
|
|
82
176
|
for n in entries:
|
|
83
|
-
ew, _, _ =
|
|
177
|
+
ew, _, _ = text_block(n.label, st, 11, bold=False)
|
|
84
178
|
ns = n.style
|
|
85
179
|
r = min(px(ns.get("border-radius"), 3), 4)
|
|
86
180
|
kids.append(Elem("rect", cls=" ".join(["pdg-legend-swatch"] + n.classes),
|
|
87
181
|
geom={"x": 14 + x, "y": y0 + (row_h - sw) / 2, "w": sw, "h": sw, "rx": r},
|
|
88
182
|
paint={"fill": ns.get("fill", "#ffffff"), "stroke": ns.get("stroke", "#9ca3af"),
|
|
89
183
|
"stroke-width": 1.2}))
|
|
90
|
-
kids += self.text([n.label], 14 + x + sw + 6,
|
|
184
|
+
kids += self.text([n.label], 14 + x + sw + 6, ty, fs, st, "start")
|
|
91
185
|
x += sw + 6 + ew + gap
|
|
92
186
|
bw, bh = x - gap + pad, row_h + 2 * pad
|
|
93
187
|
box = Elem("rect", cls="pdg-legend-box", geom={"x": 14, "y": top, "w": bw, "h": bh, "rx": 6},
|
|
@@ -96,11 +190,15 @@ class SceneBuilder:
|
|
|
96
190
|
|
|
97
191
|
# ------------------------------------------------------------ text
|
|
98
192
|
@staticmethod
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
193
|
+
def text_paint(st: dict, fs: float) -> dict:
|
|
194
|
+
return {"fill": st.get("color", "#111827"), "font-family": st.get("font-family", "sans-serif"),
|
|
195
|
+
"font-size": fs, "font-weight": st.get("font-weight", "normal"),
|
|
196
|
+
"font-style": st.get("font-style", "normal")}
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def text(cls, lines, cx, top, fs, st, anchor="middle") -> list[Elem]:
|
|
200
|
+
lh = fs * LINE_H
|
|
201
|
+
paint = cls.text_paint(st, fs)
|
|
104
202
|
return [Elem("text", geom={"x": cx, "y": top + lh * i + fs * 0.95, "text": line, "anchor": anchor},
|
|
105
203
|
paint=dict(paint)) for i, line in enumerate(lines)]
|
|
106
204
|
|
|
@@ -116,11 +214,7 @@ class SceneBuilder:
|
|
|
116
214
|
paint["opacity"] = st["opacity"]
|
|
117
215
|
if st.get("shadow") == "soft":
|
|
118
216
|
paint["filter"] = "shadow"
|
|
119
|
-
r =
|
|
120
|
-
if shape == "round":
|
|
121
|
-
r = max(r, 10)
|
|
122
|
-
if shape == "pill":
|
|
123
|
-
r = h / 2
|
|
217
|
+
r = corner_radius(st, shape, w, h)
|
|
124
218
|
if shape in ("box", "round", "pill", "note", "square"):
|
|
125
219
|
el = Elem("rect", geom={"x": x, "y": y, "w": w, "h": h, "rx": r}, paint=paint)
|
|
126
220
|
elif shape in ("circle", "ellipse"):
|
|
@@ -161,7 +255,7 @@ class SceneBuilder:
|
|
|
161
255
|
shape = n.attrs.get("shape", "box")
|
|
162
256
|
out = Elem("group", id=n.id, cls=" ".join(["pdg-node"] + n.classes), children=self.shape(n, shape, st))
|
|
163
257
|
out.children += self.accent(n, st)
|
|
164
|
-
fs =
|
|
258
|
+
fs = font_size(st, 14)
|
|
165
259
|
if n.image:
|
|
166
260
|
img = n.image
|
|
167
261
|
ix = n.cx - img["dw"] / 2
|
|
@@ -177,84 +271,91 @@ class SceneBuilder:
|
|
|
177
271
|
|
|
178
272
|
def _node_text(self, n, st, fs):
|
|
179
273
|
align = st.get("text-align", "center")
|
|
180
|
-
pt, pr, pb, pl =
|
|
181
|
-
pl += accent_width(st, "left")
|
|
182
|
-
pr += accent_width(st, "right")
|
|
274
|
+
pt, pr, pb, pl = node_padding(st)
|
|
183
275
|
top = n.cy - n.text_h / 2
|
|
184
276
|
x, anchor = {"left": (n.ax + pl, "start"), "right": (n.ax + n.w - pr, "end")}.get(align, (n.cx, "middle"))
|
|
185
277
|
out = self.text(n.text_lines, x, top, fs, st, anchor)
|
|
186
278
|
if n.sub_lines:
|
|
187
279
|
sst = n.sub_style or st
|
|
188
|
-
sfs = px(sst.get("font-size"), 12)
|
|
189
280
|
stop = top + n.text_h - n.sub_h + SUB_GAP
|
|
190
|
-
out += self.text(n.sub_lines, x, stop,
|
|
281
|
+
out += self.text(n.sub_lines, x, stop, font_size(sst, 12), sst, anchor)
|
|
191
282
|
return out
|
|
192
283
|
|
|
193
284
|
def accent(self, it, st) -> list[Elem]:
|
|
194
|
-
"""`accent-bar: 4px #22c55e
|
|
195
|
-
|
|
196
|
-
|
|
285
|
+
"""`accent-bar: 4px #22c55e`: a strip along one side (`accent-side`), clipped to the
|
|
286
|
+
box's own rounded corners so it never pokes outside them."""
|
|
287
|
+
a = accent(st)
|
|
288
|
+
if not a:
|
|
197
289
|
return []
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
x = it.ax if side == "left" else it.ax + it.w - bw
|
|
203
|
-
r = min(px(st.get("border-radius"), 6), bw)
|
|
204
|
-
return [Elem("rect", cls="pdg-accent", geom={"x": x, "y": it.ay, "w": bw, "h": it.h, "rx": r},
|
|
205
|
-
paint={"fill": color})]
|
|
290
|
+
bw, color, side = a
|
|
291
|
+
r = min(corner_radius(st, it.attrs.get("shape", "box"), it.w, it.h), it.h / 2)
|
|
292
|
+
return [Elem("path", cls="pdg-accent", geom={"segs": accent_segs(it.ax, it.ay, it.w, it.h, bw, r, side)},
|
|
293
|
+
paint={"fill": color or st.get("stroke", "#22c55e")})]
|
|
206
294
|
|
|
207
295
|
def group(self, g: Group) -> Elem:
|
|
208
296
|
st = g.style
|
|
209
297
|
out = Elem("group", id=g.id, cls=" ".join(["pdg-group"] + g.classes),
|
|
210
298
|
children=self.shape(g, g.attrs.get("shape", "box"), st))
|
|
211
299
|
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
|
|
300
|
+
if g.label and title_position(st) == "left":
|
|
301
|
+
out.children.append(self._side_title(g, st))
|
|
302
|
+
elif g.label:
|
|
303
|
+
if has_title_band(st):
|
|
304
|
+
out.children.append(self._title_band(g, st))
|
|
305
|
+
self._top_title(g, st, out)
|
|
253
306
|
for c in g.children:
|
|
254
307
|
if isinstance(c, Group):
|
|
255
308
|
out.children.append(self.group(c))
|
|
256
309
|
return out
|
|
257
310
|
|
|
311
|
+
def _title_band(self, g: Group, st: dict) -> Elem:
|
|
312
|
+
"""`title-fill`: a band behind the title, rounded like the group's top corners."""
|
|
313
|
+
bh = box4(st.get("padding"), 14)[0] + g.label_h - 3
|
|
314
|
+
r = min(corner_radius(st, g.attrs.get("shape", "box"), g.w, g.h), bh)
|
|
315
|
+
return Elem("path", cls="pdg-title-band", paint={"fill": st["title-fill"]}, geom={"segs": [
|
|
316
|
+
("M", g.ax, g.ay + bh), ("L", g.ax, g.ay + r), ("A", r, r, 0, 0, 1, g.ax + r, g.ay),
|
|
317
|
+
("L", g.ax + g.w - r, g.ay), ("A", r, r, 0, 0, 1, g.ax + g.w, g.ay + r),
|
|
318
|
+
("L", g.ax + g.w, g.ay + bh), ("Z",)]})
|
|
319
|
+
|
|
320
|
+
def _side_title(self, g: Group, st: dict) -> Elem:
|
|
321
|
+
"""`label-position: left`: the title turned 90°, pinned near the left edge (padding
|
|
322
|
+
moves the content, not the title, so an edge can pass between them)."""
|
|
323
|
+
fs = font_size(st, 12)
|
|
324
|
+
x = g.ax + 8 + g.side_w / 2 + fs * 0.35
|
|
325
|
+
return Elem("text", geom={"x": x, "y": g.cy, "text": g.label.replace("\n", " "),
|
|
326
|
+
"anchor": "middle", "rotate": -90}, paint=self.text_paint(st, fs))
|
|
327
|
+
|
|
328
|
+
def _top_title(self, g: Group, st: dict, out: Elem) -> None:
|
|
329
|
+
"""Title (and `sub=` line) at the top or bottom, sliding clear of crossing edges."""
|
|
330
|
+
fs = font_size(st, 12)
|
|
331
|
+
pt, pr, pb, pl = box4(st.get("padding"), 14)
|
|
332
|
+
tw, th, _ = text_block(g.label, st, 12)
|
|
333
|
+
bottom = title_position(st) == "bottom"
|
|
334
|
+
top = g.ay + g.h - pb - g.label_h + 6 if bottom else g.ay + pt
|
|
335
|
+
x0 = self._title_x(g, st, tw, pl, pr, top, th)
|
|
336
|
+
box = (x0 - TITLE_PAD, top - 2, x0 + tw + TITLE_PAD, top + th + 2)
|
|
337
|
+
texts = self.text(g.label.split("\n"), x0, top, fs, st, "start")
|
|
338
|
+
if g.attrs.get("sub") and not bottom:
|
|
339
|
+
sst = g.sub_style or st
|
|
340
|
+
texts += self.text(g.attrs["sub"].split("\n"), x0, top + g.title_h + SUB_GAP,
|
|
341
|
+
font_size(sst, 11), sst, "start")
|
|
342
|
+
xs = self._crossings(box[1], box[3], box[0], box[2])
|
|
343
|
+
if not xs:
|
|
344
|
+
out.children += texts
|
|
345
|
+
return
|
|
346
|
+
# ask the pipeline for a wider group: children are centred, so widening by d
|
|
347
|
+
# moves the crossing edges d/2 away from the left-aligned title
|
|
348
|
+
self.title_needs[g.id] = 2 * max(box[2] + 2 - min(xs), 0.0) + 4
|
|
349
|
+
# nowhere free to slide to: lift the title above the edges on a patch of the
|
|
350
|
+
# group's own fill, so the line visibly stops around the text
|
|
351
|
+
fill = st.get("fill", "none")
|
|
352
|
+
if fill in ("none", "transparent"):
|
|
353
|
+
fill = self.doc.root.style.get("background", "#ffffff")
|
|
354
|
+
patch = Elem("rect", cls="pdg-title-bg",
|
|
355
|
+
geom={"x": box[0], "y": box[1], "w": box[2] - box[0], "h": box[3] - box[1], "rx": 3},
|
|
356
|
+
paint={"fill": fill})
|
|
357
|
+
self.title_layer.children.append(Elem("group", cls="pdg-group-title", children=[patch] + texts))
|
|
358
|
+
|
|
258
359
|
def _title_x(self, g, st, tw, pl, pr, top, th) -> float:
|
|
259
360
|
"""Preferred x from text-align; slide along the title band if edges cross there."""
|
|
260
361
|
align = st.get("text-align", "left")
|
|
@@ -379,25 +480,19 @@ class SceneBuilder:
|
|
|
379
480
|
"""Numbered circle on the edge, a third of the way along its path. With a label the
|
|
380
481
|
badge sits just left of the label box, so neither covers the other."""
|
|
381
482
|
st = e.step_style or e.style
|
|
382
|
-
|
|
483
|
+
st = {**st, "font-weight": st.get("font-weight", "bold")}
|
|
484
|
+
fs = font_size(st, 11)
|
|
383
485
|
txt = e.attrs["step"][:3]
|
|
384
|
-
w, h, _ =
|
|
486
|
+
w, h, _ = text_block(txt, st, 11)
|
|
385
487
|
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
488
|
if label_box:
|
|
396
489
|
cx, cy = label_box["x"] - r - 3, label_box["y"] + label_box["h"] / 2
|
|
490
|
+
else:
|
|
491
|
+
cx, cy = point_along(poly, 1 / 3)
|
|
397
492
|
circ = Elem("circle", cls="pdg-step", geom={"cx": cx, "cy": cy, "r": r},
|
|
398
493
|
paint={"fill": st.get("fill", "#374151"), "stroke": self.doc.root.style.get("background", "#ffffff"),
|
|
399
494
|
"stroke-width": 1.5})
|
|
400
|
-
return [circ] + self.text([txt], cx, cy - fs *
|
|
495
|
+
return [circ] + self.text([txt], cx, cy - fs * LINE_H / 2, fs, st)
|
|
401
496
|
|
|
402
497
|
@staticmethod
|
|
403
498
|
def head(kind, tip, d, hl, color, width, bg="#ffffff") -> Elem:
|
|
@@ -433,8 +528,8 @@ class SceneBuilder:
|
|
|
433
528
|
|
|
434
529
|
def edge_label(self, e, poly) -> list[Elem]:
|
|
435
530
|
st = e.label_style or e.style
|
|
436
|
-
fs =
|
|
437
|
-
w, h, lines =
|
|
531
|
+
fs = font_size(st, 11)
|
|
532
|
+
w, h, lines = text_block(e.label, st, 11, bold=False) # same call as layout.label_gaps
|
|
438
533
|
# bent edges: the run into the target is the edge's own (a fan-out bus is shared),
|
|
439
534
|
# so labels go there when it fits; otherwise the longest run
|
|
440
535
|
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"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""align=stretch tiers, same-side C-shaped edges, theme css."""
|
|
6
|
+
from predictygraph import build_scene
|
|
7
|
+
from predictygraph.parser import parse
|
|
8
|
+
from predictygraph.theme import theme_css
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_stretch_makes_tiers_equal_width():
|
|
12
|
+
doc = parse("graph layout=column align=stretch\ngroup a {\n x\n}\ngroup b {\n p\n q\n r\n}\n")
|
|
13
|
+
build_scene(doc)
|
|
14
|
+
assert doc.items["a"].w == doc.items["b"].w
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_same_side_edge_goes_around_boxes_between():
|
|
18
|
+
src = ("graph layout=column\nlong \"a fairly wide box\"\nmid \"an even wider box in the middle\"\n"
|
|
19
|
+
"low \"low\"\nlong -> low exit=left enter=left route=ortho\n")
|
|
20
|
+
doc = parse(src)
|
|
21
|
+
build_scene(doc)
|
|
22
|
+
from predictygraph.route import Router
|
|
23
|
+
pts, _ = Router(doc).geometry(doc.edges[0])
|
|
24
|
+
assert min(x for x, _ in pts) < doc.items["mid"].ax
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_hami_theme_has_extra_css():
|
|
28
|
+
css = theme_css("hami")
|
|
29
|
+
assert "accent-bar" in css and "title-fill" in css
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_png_width_colors_and_single_frame(tmp_path):
|
|
33
|
+
import pytest
|
|
34
|
+
pytest.importorskip("cairosvg") # optional extra: predictygraph[apng]
|
|
35
|
+
pytest.importorskip("PIL")
|
|
36
|
+
from PIL import Image
|
|
37
|
+
from predictygraph.render.apng import write_apng
|
|
38
|
+
sc = build_scene(parse('a "x"\nb "y"\na -> b\n'))
|
|
39
|
+
out = write_apng(sc, tmp_path / "d.png", width=300, colors=16)
|
|
40
|
+
im = Image.open(out)
|
|
41
|
+
assert im.size[0] == 300 and getattr(im, "n_frames", 1) == 1 and im.mode == "P"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_align_x_lines_up_across_panels():
|
|
45
|
+
src = ('graph layout=column align=stretch\n'
|
|
46
|
+
'group top layout=row gap=20 {\n a "left box"\n b "short"\n}\n'
|
|
47
|
+
'group bottom layout=row gap=200 {\n c "c"\n d "a much wider box here"\n}\n')
|
|
48
|
+
plain = parse(src)
|
|
49
|
+
build_scene(plain)
|
|
50
|
+
doc = parse(src.replace('b "short"', 'b "short" align-x=d'))
|
|
51
|
+
build_scene(doc)
|
|
52
|
+
assert abs(doc.items["b"].cx - doc.items["d"].cx) < 0.5
|
|
53
|
+
assert abs(plain.items["b"].cx - plain.items["d"].cx) > 1 # it really moved
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_align_x_stays_inside_its_panel():
|
|
57
|
+
src = ('graph layout=column\n'
|
|
58
|
+
'group top layout=row gap=20 {\n a "left box"\n b "short" align-x=d\n}\n'
|
|
59
|
+
'group bottom layout=row gap=400 {\n c "c"\n d "far right"\n}\n')
|
|
60
|
+
doc = parse(src)
|
|
61
|
+
build_scene(doc)
|
|
62
|
+
b, top = doc.items["b"], doc.items["top"]
|
|
63
|
+
assert b.ax + b.w <= top.ax + top.w
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_align_x_never_overlaps_a_sibling():
|
|
67
|
+
src = ('graph layout=column\n'
|
|
68
|
+
'group top layout=row gap=10 {\n a "aaaaaaaa" align-x=z\n b "bbbbbbbb"\n}\n'
|
|
69
|
+
'group bottom layout=row {\n y "y"\n z "z"\n}\n')
|
|
70
|
+
doc = parse(src)
|
|
71
|
+
build_scene(doc)
|
|
72
|
+
a, b = doc.items["a"], doc.items["b"]
|
|
73
|
+
assert a.ax + a.w <= b.ax
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_accent_bar_stays_inside_rounded_corners():
|
|
77
|
+
from predictygraph.scene import accent_segs
|
|
78
|
+
segs = accent_segs(0, 0, 100, 40, 4, 10, "left")
|
|
79
|
+
xs = [s[-2] for s in segs if s[0] != "Z"]
|
|
80
|
+
ys = [s[-1] for s in segs if s[0] != "Z"]
|
|
81
|
+
assert min(xs) >= 0 and max(xs) <= 4
|
|
82
|
+
assert min(ys) > 0 and max(ys) < 40 # starts below the top edge: it follows the arc
|
|
83
|
+
right = accent_segs(0, 0, 100, 40, 4, 10, "right")
|
|
84
|
+
assert min(s[-2] for s in right if s[0] != "Z") >= 96
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
-
#
|
|
3
|
-
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
-
|
|
5
|
-
"""align=stretch tiers, same-side C-shaped edges, theme css."""
|
|
6
|
-
from predictygraph import build_scene
|
|
7
|
-
from predictygraph.parser import parse
|
|
8
|
-
from predictygraph.theme import theme_css
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_stretch_makes_tiers_equal_width():
|
|
12
|
-
doc = parse("graph layout=column align=stretch\ngroup a {\n x\n}\ngroup b {\n p\n q\n r\n}\n")
|
|
13
|
-
build_scene(doc)
|
|
14
|
-
assert doc.items["a"].w == doc.items["b"].w
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_same_side_edge_goes_around_boxes_between():
|
|
18
|
-
src = ("graph layout=column\nlong \"a fairly wide box\"\nmid \"an even wider box in the middle\"\n"
|
|
19
|
-
"low \"low\"\nlong -> low exit=left enter=left route=ortho\n")
|
|
20
|
-
doc = parse(src)
|
|
21
|
-
build_scene(doc)
|
|
22
|
-
from predictygraph.route import Router
|
|
23
|
-
pts, _ = Router(doc).geometry(doc.edges[0])
|
|
24
|
-
assert min(x for x, _ in pts) < doc.items["mid"].ax
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_hami_theme_has_extra_css():
|
|
28
|
-
css = theme_css("hami")
|
|
29
|
-
assert "accent-bar" in css and "title-fill" in css
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_png_width_colors_and_single_frame(tmp_path):
|
|
33
|
-
import pytest
|
|
34
|
-
pytest.importorskip("cairosvg") # optional extra: predictygraph[apng]
|
|
35
|
-
pytest.importorskip("PIL")
|
|
36
|
-
from PIL import Image
|
|
37
|
-
from predictygraph.render.apng import write_apng
|
|
38
|
-
sc = build_scene(parse('a "x"\nb "y"\na -> b\n'))
|
|
39
|
-
out = write_apng(sc, tmp_path / "d.png", width=300, colors=16)
|
|
40
|
-
im = Image.open(out)
|
|
41
|
-
assert im.size[0] == 300 and getattr(im, "n_frames", 1) == 1 and im.mode == "P"
|
|
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.3}/src/predictygraph/fonts/LiberationMono-Regular.ttf
RENAMED
|
File without changes
|
|
File without changes
|
{predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSans-Regular.ttf
RENAMED
|
File without changes
|
{predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSerif-Bold.ttf
RENAMED
|
File without changes
|
{predictygraph-0.2.1 → predictygraph-0.2.3}/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.3}/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
|