predictygraph 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {predictygraph-0.2.1 → predictygraph-0.2.2}/PKG-INFO +1 -1
  2. {predictygraph-0.2.1 → predictygraph-0.2.2}/pyproject.toml +1 -1
  3. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/default.css +1 -0
  4. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/layout.py +44 -30
  5. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/model.py +14 -0
  6. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/parser.py +25 -6
  7. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/render/svg.py +2 -0
  8. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/scene.py +146 -84
  9. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/style.py +3 -0
  10. predictygraph-0.2.2/src/predictygraph/styleutil.py +61 -0
  11. {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_annotations.py +30 -0
  12. {predictygraph-0.2.1 → predictygraph-0.2.2}/README.md +0 -0
  13. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/__init__.py +0 -0
  14. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/__main__.py +0 -0
  15. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/anim.py +0 -0
  16. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/cli.py +0 -0
  17. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  18. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  19. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  20. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  21. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  22. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  23. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  24. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  25. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  26. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  27. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/fonts.py +0 -0
  28. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/ir.py +0 -0
  29. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/render/__init__.py +0 -0
  30. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/render/apng.py +0 -0
  31. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/route.py +0 -0
  32. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/safe.py +0 -0
  33. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/text.py +0 -0
  34. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/theme.py +0 -0
  35. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  36. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  37. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  38. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  39. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/default.toml +0 -0
  40. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/dracula.toml +0 -0
  41. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/github-light.toml +0 -0
  42. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  43. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/hami.toml +0 -0
  44. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/nord.toml +0 -0
  45. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/solarized-dark.toml +0 -0
  46. {predictygraph-0.2.1 → predictygraph-0.2.2}/src/predictygraph/themes/solarized-light.toml +0 -0
  47. {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_diagonal.py +0 -0
  48. {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_fixes.py +0 -0
  49. {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_hardening.py +0 -0
  50. {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_predictygraph.py +0 -0
  51. {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_routing.py +0 -0
  52. {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_scene.py +0 -0
  53. {predictygraph-0.2.1 → predictygraph-0.2.2}/tests/test_stack.py +0 -0
  54. {predictygraph-0.2.1 → predictygraph-0.2.2}/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.2
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.2"
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
 
@@ -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,28 @@ import math
16
16
 
17
17
  from .anim import BORDER_MODES, Track, border_tracks, dash_len, flow_tracks, seconds
18
18
  from .ir import Elem, Scene
19
- from .layout import SUB_GAP, 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
+
29
41
  class SceneBuilder:
30
42
  def __init__(self, doc: Document, router: Router):
31
43
  self.doc, self.router = doc, router
@@ -47,6 +59,10 @@ class SceneBuilder:
47
59
  if self.title_layer.children:
48
60
  layers.append(self.title_layer)
49
61
  w, h = root.w, root.h
62
+ if self.doc.brackets:
63
+ br, bw = self.brackets(w)
64
+ layers.append(br)
65
+ w += bw
50
66
  legend, lw, lh = self.legend(h)
51
67
  if legend:
52
68
  layers.append(legend)
@@ -54,8 +70,8 @@ class SceneBuilder:
54
70
  cap = root.attrs.get("caption")
55
71
  if cap:
56
72
  st = root.part_styles.get("caption") or root.style
57
- fs = px(st.get("font-size"), 12)
58
- cw, ch, lines = measure(cap, st.get("font-family", "sans-serif"), fs)
73
+ fs = font_size(st, 12)
74
+ cw, ch, lines = text_block(cap, st, 12)
59
75
  w = max(w, cw + 28)
60
76
  align = st.get("text-align", "center")
61
77
  x, anchor = {"left": (14, "start"), "right": (w - 14, "end")}.get(align, (w / 2, "middle"))
@@ -63,31 +79,72 @@ class SceneBuilder:
63
79
  h += ch + 14
64
80
  return Scene(w, h, root.style.get("background"), layers)
65
81
 
82
+ def brackets(self, right: float) -> tuple[Elem, float]:
83
+ """`bracket a, b "note"`: a curly bracket right of the diagram spanning the listed
84
+ items top to bottom, the note beside it. Brackets that overlap vertically are
85
+ stacked outward. Returns the layer and the extra canvas width."""
86
+ out = Elem("group", cls="pdg-brackets")
87
+ depth = 8.0
88
+ spans = [] # (height, y0, y1, bracket, text width, text height, lines)
89
+ for b in self.doc.brackets:
90
+ items = [self.doc.items[i] for i in b.ids]
91
+ y0, y1 = min(i.ay for i in items), max(i.ay + i.h for i in items)
92
+ spans.append((y1 - y0, y0, y1, b, *text_block(b.label, b.style, 11)))
93
+ spans.sort(key=lambda s: s[0]) # short spans nearest the diagram, long ones outside
94
+ cols: list[list[tuple[float, float]]] = []
95
+ col_w: list[float] = []
96
+ placed = []
97
+ for _, y0, y1, b, tw, th, lines in spans:
98
+ col = 0
99
+ while col < len(cols) and any(not (y1 < a or y0 > z) for a, z in cols[col]):
100
+ col += 1
101
+ if col == len(cols):
102
+ cols.append([])
103
+ col_w.append(0.0)
104
+ cols[col].append((y0, y1))
105
+ col_w[col] = max(col_w[col], 2 * depth + 6 + tw + 14)
106
+ placed.append((col, y0, y1, b, th, lines))
107
+ for col, y0, y1, b, th, lines in placed:
108
+ st, fs = b.style, font_size(b.style, 11)
109
+ x = right + 4 + sum(col_w[:col])
110
+ ym = (y0 + y1) / 2
111
+ q = min(depth, (y1 - y0) / 4)
112
+ segs = [("M", x, y0), ("Q", x + depth, y0, x + depth, y0 + q), ("L", x + depth, ym - q),
113
+ ("Q", x + depth, ym, x + 2 * depth, ym), ("Q", x + depth, ym, x + depth, ym + q),
114
+ ("L", x + depth, y1 - q), ("Q", x + depth, y1, x, y1)]
115
+ kids = [Elem("path", cls="pdg-bracket-line", geom={"segs": segs},
116
+ paint={"fill": "none", "stroke": st.get("stroke", "#9ca3af"),
117
+ "stroke-width": px(st.get("stroke-width"), 1.5), "stroke-linecap": "round"})]
118
+ kids += self.text(lines, x + 2 * depth + 6, ym - th / 2, fs, st, "start")
119
+ out.children.append(Elem("group", cls=" ".join(["pdg-bracket"] + b.classes), children=kids))
120
+ return out, 4 + sum(col_w)
121
+
66
122
  def legend(self, top: float) -> tuple[Elem | None, float, float]:
67
123
  """`legend .cls "text"` entries: a key box under the diagram, swatches styled like nodes."""
68
124
  entries = self.doc.legend
69
125
  if not entries:
70
126
  return None, 0.0, 0.0
71
127
  st = self.doc.root.part_styles.get("legend") or {}
72
- fs = px(st.get("font-size"), 11)
73
- fam = st.get("font-family", self.doc.root.style.get("font-family", "sans-serif"))
128
+ st = {"font-family": self.doc.root.style.get("font-family", "sans-serif"), **st}
129
+ fs = font_size(st, 11)
74
130
  sw, gap, pad = 14.0, 16.0, 10.0
75
131
  title = self.doc.legend_title
76
- tw, th, _ = measure(title, fam, fs, True) if title else (0.0, 0.0, [])
132
+ tw, _, _ = text_block(title, st, 11, bold=True)
77
133
  x = pad + (tw + gap if title else 0.0)
78
- kids, row_h = [], max(sw, fs * 1.25)
134
+ kids, row_h = [], max(sw, fs * LINE_H)
79
135
  y0 = top + pad
136
+ ty = y0 + (row_h - fs * LINE_H) / 2 # text top, vertically centred in the row
80
137
  if title:
81
- kids += self.text([title], 14 + pad, y0 + (row_h - fs * 1.25) / 2, fs, {**st, "font-weight": "bold"}, "start")
138
+ kids += self.text([title], 14 + pad, ty, fs, {**st, "font-weight": "bold"}, "start")
82
139
  for n in entries:
83
- ew, _, _ = measure(n.label, fam, fs)
140
+ ew, _, _ = text_block(n.label, st, 11, bold=False)
84
141
  ns = n.style
85
142
  r = min(px(ns.get("border-radius"), 3), 4)
86
143
  kids.append(Elem("rect", cls=" ".join(["pdg-legend-swatch"] + n.classes),
87
144
  geom={"x": 14 + x, "y": y0 + (row_h - sw) / 2, "w": sw, "h": sw, "rx": r},
88
145
  paint={"fill": ns.get("fill", "#ffffff"), "stroke": ns.get("stroke", "#9ca3af"),
89
146
  "stroke-width": 1.2}))
90
- kids += self.text([n.label], 14 + x + sw + 6, y0 + (row_h - fs * 1.25) / 2, fs, st, "start")
147
+ kids += self.text([n.label], 14 + x + sw + 6, ty, fs, st, "start")
91
148
  x += sw + 6 + ew + gap
92
149
  bw, bh = x - gap + pad, row_h + 2 * pad
93
150
  box = Elem("rect", cls="pdg-legend-box", geom={"x": 14, "y": top, "w": bw, "h": bh, "rx": 6},
@@ -96,11 +153,15 @@ class SceneBuilder:
96
153
 
97
154
  # ------------------------------------------------------------ text
98
155
  @staticmethod
99
- def 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")}
156
+ def text_paint(st: dict, fs: float) -> dict:
157
+ return {"fill": st.get("color", "#111827"), "font-family": st.get("font-family", "sans-serif"),
158
+ "font-size": fs, "font-weight": st.get("font-weight", "normal"),
159
+ "font-style": st.get("font-style", "normal")}
160
+
161
+ @classmethod
162
+ def text(cls, lines, cx, top, fs, st, anchor="middle") -> list[Elem]:
163
+ lh = fs * LINE_H
164
+ paint = cls.text_paint(st, fs)
104
165
  return [Elem("text", geom={"x": cx, "y": top + lh * i + fs * 0.95, "text": line, "anchor": anchor},
105
166
  paint=dict(paint)) for i, line in enumerate(lines)]
106
167
 
@@ -161,7 +222,7 @@ class SceneBuilder:
161
222
  shape = n.attrs.get("shape", "box")
162
223
  out = Elem("group", id=n.id, cls=" ".join(["pdg-node"] + n.classes), children=self.shape(n, shape, st))
163
224
  out.children += self.accent(n, st)
164
- fs = px(st.get("font-size"), 14)
225
+ fs = font_size(st, 14)
165
226
  if n.image:
166
227
  img = n.image
167
228
  ix = n.cx - img["dw"] / 2
@@ -177,84 +238,91 @@ class SceneBuilder:
177
238
 
178
239
  def _node_text(self, n, st, fs):
179
240
  align = st.get("text-align", "center")
180
- pt, pr, pb, pl = box4(st.get("padding"), 8)
181
- pl += accent_width(st, "left")
182
- pr += accent_width(st, "right")
241
+ pt, pr, pb, pl = node_padding(st)
183
242
  top = n.cy - n.text_h / 2
184
243
  x, anchor = {"left": (n.ax + pl, "start"), "right": (n.ax + n.w - pr, "end")}.get(align, (n.cx, "middle"))
185
244
  out = self.text(n.text_lines, x, top, fs, st, anchor)
186
245
  if n.sub_lines:
187
246
  sst = n.sub_style or st
188
- sfs = px(sst.get("font-size"), 12)
189
247
  stop = top + n.text_h - n.sub_h + SUB_GAP
190
- out += self.text(n.sub_lines, x, stop, sfs, sst, anchor)
248
+ out += self.text(n.sub_lines, x, stop, font_size(sst, 12), sst, anchor)
191
249
  return out
192
250
 
193
251
  def accent(self, it, st) -> list[Elem]:
194
252
  """`accent-bar: 4px #22c55e` draws a strip inside the box edge (`accent-side`)."""
195
- spec = st.get("accent-bar")
196
- if not spec or spec.strip() == "none":
253
+ a = accent(st)
254
+ if not a:
197
255
  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")
256
+ bw, color, side = a
202
257
  x = it.ax if side == "left" else it.ax + it.w - bw
203
258
  r = min(px(st.get("border-radius"), 6), bw)
204
259
  return [Elem("rect", cls="pdg-accent", geom={"x": x, "y": it.ay, "w": bw, "h": it.h, "rx": r},
205
- paint={"fill": color})]
260
+ paint={"fill": color or st.get("stroke", "#22c55e")})]
206
261
 
207
262
  def group(self, g: Group) -> Elem:
208
263
  st = g.style
209
264
  out = Elem("group", id=g.id, cls=" ".join(["pdg-group"] + g.classes),
210
265
  children=self.shape(g, g.attrs.get("shape", "box"), st))
211
266
  out.children += self.accent(g, st)
212
- if g.label and 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
267
+ if g.label and title_position(st) == "left":
268
+ out.children.append(self._side_title(g, st))
269
+ elif g.label:
270
+ if has_title_band(st):
271
+ out.children.append(self._title_band(g, st))
272
+ self._top_title(g, st, out)
253
273
  for c in g.children:
254
274
  if isinstance(c, Group):
255
275
  out.children.append(self.group(c))
256
276
  return out
257
277
 
278
+ def _title_band(self, g: Group, st: dict) -> Elem:
279
+ """`title-fill`: a band behind the title, rounded like the group's top corners."""
280
+ bh = box4(st.get("padding"), 14)[0] + g.label_h - 3
281
+ r = px(st.get("border-radius"), 8)
282
+ return Elem("path", cls="pdg-title-band", paint={"fill": st["title-fill"]}, geom={"segs": [
283
+ ("M", g.ax, g.ay + bh), ("L", g.ax, g.ay + r), ("A", r, r, 0, 0, 1, g.ax + r, g.ay),
284
+ ("L", g.ax + g.w - r, g.ay), ("A", r, r, 0, 0, 1, g.ax + g.w, g.ay + r),
285
+ ("L", g.ax + g.w, g.ay + bh), ("Z",)]})
286
+
287
+ def _side_title(self, g: Group, st: dict) -> Elem:
288
+ """`label-position: left`: the title turned 90°, pinned near the left edge (padding
289
+ moves the content, not the title, so an edge can pass between them)."""
290
+ fs = font_size(st, 12)
291
+ x = g.ax + 8 + g.side_w / 2 + fs * 0.35
292
+ return Elem("text", geom={"x": x, "y": g.cy, "text": g.label.replace("\n", " "),
293
+ "anchor": "middle", "rotate": -90}, paint=self.text_paint(st, fs))
294
+
295
+ def _top_title(self, g: Group, st: dict, out: Elem) -> None:
296
+ """Title (and `sub=` line) at the top or bottom, sliding clear of crossing edges."""
297
+ fs = font_size(st, 12)
298
+ pt, pr, pb, pl = box4(st.get("padding"), 14)
299
+ tw, th, _ = text_block(g.label, st, 12)
300
+ bottom = title_position(st) == "bottom"
301
+ top = g.ay + g.h - pb - g.label_h + 6 if bottom else g.ay + pt
302
+ x0 = self._title_x(g, st, tw, pl, pr, top, th)
303
+ box = (x0 - TITLE_PAD, top - 2, x0 + tw + TITLE_PAD, top + th + 2)
304
+ texts = self.text(g.label.split("\n"), x0, top, fs, st, "start")
305
+ if g.attrs.get("sub") and not bottom:
306
+ sst = g.sub_style or st
307
+ texts += self.text(g.attrs["sub"].split("\n"), x0, top + g.title_h + SUB_GAP,
308
+ font_size(sst, 11), sst, "start")
309
+ xs = self._crossings(box[1], box[3], box[0], box[2])
310
+ if not xs:
311
+ out.children += texts
312
+ return
313
+ # ask the pipeline for a wider group: children are centred, so widening by d
314
+ # moves the crossing edges d/2 away from the left-aligned title
315
+ self.title_needs[g.id] = 2 * max(box[2] + 2 - min(xs), 0.0) + 4
316
+ # nowhere free to slide to: lift the title above the edges on a patch of the
317
+ # group's own fill, so the line visibly stops around the text
318
+ fill = st.get("fill", "none")
319
+ if fill in ("none", "transparent"):
320
+ fill = self.doc.root.style.get("background", "#ffffff")
321
+ patch = Elem("rect", cls="pdg-title-bg",
322
+ geom={"x": box[0], "y": box[1], "w": box[2] - box[0], "h": box[3] - box[1], "rx": 3},
323
+ paint={"fill": fill})
324
+ self.title_layer.children.append(Elem("group", cls="pdg-group-title", children=[patch] + texts))
325
+
258
326
  def _title_x(self, g, st, tw, pl, pr, top, th) -> float:
259
327
  """Preferred x from text-align; slide along the title band if edges cross there."""
260
328
  align = st.get("text-align", "left")
@@ -379,25 +447,19 @@ class SceneBuilder:
379
447
  """Numbered circle on the edge, a third of the way along its path. With a label the
380
448
  badge sits just left of the label box, so neither covers the other."""
381
449
  st = e.step_style or e.style
382
- fs = px(st.get("font-size"), 11)
450
+ st = {**st, "font-weight": st.get("font-weight", "bold")}
451
+ fs = font_size(st, 11)
383
452
  txt = e.attrs["step"][:3]
384
- w, h, _ = measure(txt, st.get("font-family", "sans-serif"), fs, True)
453
+ w, h, _ = text_block(txt, st, 11)
385
454
  r = max(w, h) / 2 + 3
386
- lens = [math.dist(a, b) for a, b in zip(poly, poly[1:])]
387
- goal, acc = sum(lens) / 3, 0.0
388
- cx, cy = poly[0]
389
- for (a, b), l in zip(zip(poly, poly[1:]), lens):
390
- if acc + l >= goal and l:
391
- t = (goal - acc) / l
392
- cx, cy = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
393
- break
394
- acc += l
395
455
  if label_box:
396
456
  cx, cy = label_box["x"] - r - 3, label_box["y"] + label_box["h"] / 2
457
+ else:
458
+ cx, cy = point_along(poly, 1 / 3)
397
459
  circ = Elem("circle", cls="pdg-step", geom={"cx": cx, "cy": cy, "r": r},
398
460
  paint={"fill": st.get("fill", "#374151"), "stroke": self.doc.root.style.get("background", "#ffffff"),
399
461
  "stroke-width": 1.5})
400
- return [circ] + self.text([txt], cx, cy - fs * 1.25 / 2, fs, {**st, "font-weight": st.get("font-weight", "bold")})
462
+ return [circ] + self.text([txt], cx, cy - fs * LINE_H / 2, fs, st)
401
463
 
402
464
  @staticmethod
403
465
  def head(kind, tip, d, hl, color, width, bg="#ffffff") -> Elem:
@@ -433,8 +495,8 @@ class SceneBuilder:
433
495
 
434
496
  def edge_label(self, e, poly) -> list[Elem]:
435
497
  st = e.label_style or e.style
436
- fs = px(st.get("font-size"), 11)
437
- w, h, lines = measure(e.label, st.get("font-family", "sans-serif"), fs)
498
+ fs = font_size(st, 11)
499
+ w, h, lines = text_block(e.label, st, 11, bold=False) # same call as layout.label_gaps
438
500
  # bent edges: the run into the target is the edge's own (a fan-out bus is shared),
439
501
  # so labels go there when it fits; otherwise the longest run
440
502
  segs = list(zip(poly, poly[1:]))
@@ -185,6 +185,9 @@ def apply_styles(doc: Document, target: str | None = None) -> None:
185
185
  number_steps(doc)
186
186
  root.style = sheet.resolve_vars(sheet.compute(root, []))
187
187
  root.part_styles = {k: part_style(sheet, k, root, [], root.style) for k in ("caption", "legend")}
188
+ for b in doc.brackets:
189
+ b.style = sheet.resolve_vars({"font-family": root.style.get("font-family", "sans-serif"),
190
+ **sheet.compute(b, [root])})
188
191
 
189
192
  for item in [*doc.walk(), *doc.legend]:
190
193
  anc = item.ancestors() # nearest first; root last
@@ -0,0 +1,61 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Small style readers shared by layout (sizing) and scene (drawing), so both sides
6
+ agree on what a style means: text metrics, accent bars, header bands, title position."""
7
+ from __future__ import annotations
8
+
9
+ from .style import box4, px
10
+ from .text import measure
11
+
12
+ BOLD = ("bold", "bolder", "600", "700", "800", "900")
13
+ LINE_H = 1.25 # line height as a multiple of the font size (matches SceneBuilder.text)
14
+
15
+
16
+ def font_size(st: dict, default: float) -> float:
17
+ return px(st.get("font-size"), default)
18
+
19
+
20
+ def is_bold(st: dict, default: bool = False) -> bool:
21
+ w = st.get("font-weight")
22
+ return default if w is None else w in BOLD
23
+
24
+
25
+ def text_block(text: str | None, st: dict, default_fs: float,
26
+ bold: bool | None = None) -> tuple[float, float, list[str]]:
27
+ """(width, height, lines) of `text` in style `st`; zeros for empty text.
28
+ `bold` overrides the style's weight (step numbers, legend titles)."""
29
+ if not text:
30
+ return 0.0, 0.0, []
31
+ return measure(text, st.get("font-family", "sans-serif"), font_size(st, default_fs),
32
+ is_bold(st) if bold is None else bold)
33
+
34
+
35
+ def accent(st: dict) -> tuple[float, str | None, str] | None:
36
+ """`accent-bar: 4px #16a34a` -> (width, colour or None, side); None when off."""
37
+ spec = (st.get("accent-bar") or "").split()
38
+ if not spec or spec[0] == "none":
39
+ return None
40
+ return px(spec[0], 4), (spec[1] if len(spec) > 1 else None), st.get("accent-side", "left")
41
+
42
+
43
+ def accent_width(st: dict, side: str) -> float:
44
+ """Room an accent bar takes on `side` (inside the box, before the padding)."""
45
+ a = accent(st)
46
+ return a[0] if a and a[2] == side else 0.0
47
+
48
+
49
+ def node_padding(st: dict) -> tuple[float, float, float, float]:
50
+ """A node's (top, right, bottom, left) padding, widened by its accent bar."""
51
+ pt, pr, pb, pl = box4(st.get("padding"), 8)
52
+ return pt, pr + accent_width(st, "right"), pb, pl + accent_width(st, "left")
53
+
54
+
55
+ def title_position(st: dict) -> str:
56
+ return st.get("label-position", "top")
57
+
58
+
59
+ def has_title_band(st: dict) -> bool:
60
+ """`title-fill` draws a band behind a top title only."""
61
+ return st.get("title-fill") not in (None, "none") and title_position(st) == "top"
@@ -75,3 +75,33 @@ def test_accent_bar_widens_node_and_draws():
75
75
  def test_title_fill_band():
76
76
  _, sc = scene('css { group { title-fill: #dbeafe; } }\ngroup g "G" {\n a\n}\n')
77
77
  assert "pdg-title-band" in classes(sc)
78
+
79
+
80
+ def test_bracket_widens_canvas_and_draws_note():
81
+ _, base = scene('graph layout=column\na\nb\nc\n')
82
+ _, sc = scene('graph layout=column\na\nb\nc\nbracket a, b "first two"\nbracket a, c "all"\n')
83
+ assert sc.width > base.width
84
+ assert {"first two", "all"} <= set(texts(sc))
85
+ assert classes(sc).count("pdg-bracket-line") == 2
86
+
87
+
88
+ def test_bracket_unknown_id_is_an_error():
89
+ import pytest
90
+ from predictygraph.parser import PdgSyntaxError
91
+ with pytest.raises(PdgSyntaxError):
92
+ parse('a\nbracket a, nope "x"\n')
93
+
94
+
95
+ def test_left_label_is_rotated_and_takes_a_column():
96
+ src = 'css { group { label-position: left; } }\ngroup g "Kernel space" {\n a\n}\n'
97
+ doc, sc = scene(src)
98
+ g = doc.items["g"]
99
+ assert g.side_w > 0
100
+ rotated = []
101
+ stack = list(sc.layers)
102
+ while stack:
103
+ e = stack.pop()
104
+ if e.kind == "text" and e.geom.get("rotate"):
105
+ rotated.append(e.geom["text"])
106
+ stack += e.children
107
+ assert rotated == ["Kernel space"]
File without changes