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.
Files changed (55) hide show
  1. {predictygraph-0.2.1 → predictygraph-0.2.3}/PKG-INFO +1 -1
  2. {predictygraph-0.2.1 → predictygraph-0.2.3}/pyproject.toml +1 -1
  3. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/default.css +1 -0
  4. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/layout.py +76 -30
  5. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/model.py +14 -0
  6. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/parser.py +25 -6
  7. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/render/svg.py +2 -0
  8. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/scene.py +188 -93
  9. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/style.py +3 -0
  10. predictygraph-0.2.3/src/predictygraph/styleutil.py +61 -0
  11. {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_annotations.py +30 -0
  12. predictygraph-0.2.3/tests/test_stack.py +84 -0
  13. predictygraph-0.2.1/tests/test_stack.py +0 -41
  14. {predictygraph-0.2.1 → predictygraph-0.2.3}/README.md +0 -0
  15. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/__init__.py +0 -0
  16. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/__main__.py +0 -0
  17. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/anim.py +0 -0
  18. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/cli.py +0 -0
  19. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  20. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  21. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  22. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  23. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  24. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  25. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  26. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  27. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  28. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  29. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/fonts.py +0 -0
  30. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/ir.py +0 -0
  31. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/render/__init__.py +0 -0
  32. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/render/apng.py +0 -0
  33. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/route.py +0 -0
  34. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/safe.py +0 -0
  35. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/text.py +0 -0
  36. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/theme.py +0 -0
  37. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  38. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  39. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  40. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  41. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/default.toml +0 -0
  42. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/dracula.toml +0 -0
  43. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/github-light.toml +0 -0
  44. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  45. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/hami.toml +0 -0
  46. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/nord.toml +0 -0
  47. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/solarized-dark.toml +0 -0
  48. {predictygraph-0.2.1 → predictygraph-0.2.3}/src/predictygraph/themes/solarized-light.toml +0 -0
  49. {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_diagonal.py +0 -0
  50. {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_fixes.py +0 -0
  51. {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_hardening.py +0 -0
  52. {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_predictygraph.py +0 -0
  53. {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_routing.py +0 -0
  54. {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_scene.py +0 -0
  55. {predictygraph-0.2.1 → predictygraph-0.2.3}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: predictygraph
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Predictable graphs: a readable, CSS-styled diagram DSL with per-group layouts, rendered to SVG/APNG
5
5
  Author-Email: Reza Jelveh <git@jelveh.me>
6
6
  License: GPL-3.0-or-later
@@ -12,7 +12,7 @@ dependencies = [
12
12
  ]
13
13
  requires-python = ">=3.11"
14
14
  readme = "README.md"
15
- version = "0.2.1"
15
+ version = "0.2.3"
16
16
 
17
17
  [project.license]
18
18
  text = "GPL-3.0-or-later"
@@ -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 .text import load_image, measure
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 = box4(st.get("padding"), 8)
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
- pos = 0.0
140
- for k in self.kids:
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 + gap
138
+ pos += k.w + step
154
139
  else:
155
140
  k.x, k.y = off, pos
156
- pos += k.h + gap
157
- pos -= gap
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
- if g.label and g.kind == "group":
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 g.style.get("title-fill") not in (None, "none") and g.style.get("label-position", "top") != "bottom":
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 = g.style.get("label-position", "top") != "bottom"
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
- MAX_LEGEND = 64
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
- if len(self.doc.legend) >= MAX_LEGEND:
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, accent_width
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 .text import measure
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 = px(st.get("font-size"), 12)
58
- cw, ch, lines = measure(cap, st.get("font-family", "sans-serif"), fs)
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
- fs = px(st.get("font-size"), 11)
73
- fam = st.get("font-family", self.doc.root.style.get("font-family", "sans-serif"))
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, th, _ = measure(title, fam, fs, True) if title else (0.0, 0.0, [])
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 * 1.25)
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, y0 + (row_h - fs * 1.25) / 2, fs, {**st, "font-weight": "bold"}, "start")
175
+ kids += self.text([title], 14 + pad, ty, fs, {**st, "font-weight": "bold"}, "start")
82
176
  for n in entries:
83
- ew, _, _ = measure(n.label, fam, fs)
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, y0 + (row_h - fs * 1.25) / 2, fs, st, "start")
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 text(lines, cx, top, fs, st, anchor="middle") -> list[Elem]:
100
- lh = fs * 1.25
101
- paint = {"fill": st.get("color", "#111827"), "font-family": st.get("font-family", "sans-serif"),
102
- "font-size": fs, "font-weight": st.get("font-weight", "normal"),
103
- "font-style": st.get("font-style", "normal")}
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 = px(st.get("border-radius"), 0)
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 = px(st.get("font-size"), 14)
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 = box4(st.get("padding"), 8)
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, sfs, sst, anchor)
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` draws a strip inside the box edge (`accent-side`)."""
195
- spec = st.get("accent-bar")
196
- if not spec or spec.strip() == "none":
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
- parts = spec.split()
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")
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 st.get("title-fill") not in (None, "none") and st.get("label-position", "top") != "bottom":
213
- pt = box4(st.get("padding"), 14)[0]
214
- bh = pt + g.label_h - 3
215
- r = px(st.get("border-radius"), 8)
216
- out.children.append(Elem("path", cls="pdg-title-band", paint={"fill": st["title-fill"]}, geom={"segs": [
217
- ("M", g.ax, g.ay + bh), ("L", g.ax, g.ay + r), ("A", r, r, 0, 0, 1, g.ax + r, g.ay),
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
- fs = px(st.get("font-size"), 11)
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, _ = measure(txt, st.get("font-family", "sans-serif"), fs, True)
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 * 1.25 / 2, fs, {**st, "font-weight": st.get("font-weight", "bold")})
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 = px(st.get("font-size"), 11)
437
- w, h, lines = measure(e.label, st.get("font-family", "sans-serif"), fs)
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