predictygraph 0.1.0__tar.gz → 0.2.1__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.1.0 → predictygraph-0.2.1}/PKG-INFO +2 -1
  2. {predictygraph-0.1.0 → predictygraph-0.2.1}/README.md +1 -0
  3. {predictygraph-0.1.0 → predictygraph-0.2.1}/pyproject.toml +1 -1
  4. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/cli.py +4 -2
  5. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/default.css +7 -0
  6. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/layout.py +40 -8
  7. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/model.py +8 -0
  8. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/parser.py +15 -0
  9. predictygraph-0.2.1/src/predictygraph/render/apng.py +72 -0
  10. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/route.py +73 -22
  11. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/scene.py +115 -7
  12. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/style.py +40 -8
  13. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/theme.py +4 -0
  14. predictygraph-0.2.1/src/predictygraph/themes/hami.toml +47 -0
  15. predictygraph-0.2.1/tests/test_annotations.py +77 -0
  16. predictygraph-0.2.1/tests/test_routing.py +60 -0
  17. predictygraph-0.2.1/tests/test_stack.py +41 -0
  18. predictygraph-0.1.0/src/predictygraph/render/apng.py +0 -43
  19. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/__init__.py +0 -0
  20. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/__main__.py +0 -0
  21. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/anim.py +0 -0
  22. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  23. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  24. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  25. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  26. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  27. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  28. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  29. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  30. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  31. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  32. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts.py +0 -0
  33. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/ir.py +0 -0
  34. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/render/__init__.py +0 -0
  35. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/render/svg.py +0 -0
  36. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/safe.py +0 -0
  37. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/text.py +0 -0
  38. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  39. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  40. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  41. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  42. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/default.toml +0 -0
  43. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/dracula.toml +0 -0
  44. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/github-light.toml +0 -0
  45. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  46. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/nord.toml +0 -0
  47. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/solarized-dark.toml +0 -0
  48. {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/solarized-light.toml +0 -0
  49. {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_diagonal.py +0 -0
  50. {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_fixes.py +0 -0
  51. {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_hardening.py +0 -0
  52. {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_predictygraph.py +0 -0
  53. {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_scene.py +0 -0
  54. {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: predictygraph
3
- Version: 0.1.0
3
+ Version: 0.2.1
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
@@ -32,6 +32,7 @@ uv run pdg examples/hami_flow.pdg # -> examples/hami_flow.svg
32
32
  uv run pdg diagram.pdg --css theme.css -o out.svg
33
33
  uv run pdg diagram.pdg -o out.png # animated PNG (APNG)
34
34
  uv run pdg diagram.pdg --check # validate only
35
+ uv run pdg diagram.pdg -o d.png --width 1000 --colors 64 # small PNG for chat
35
36
  uv run pdg diagram.pdg --target web # size labels to fit any browser font
36
37
  ```
37
38
 
@@ -18,6 +18,7 @@ uv run pdg examples/hami_flow.pdg # -> examples/hami_flow.svg
18
18
  uv run pdg diagram.pdg --css theme.css -o out.svg
19
19
  uv run pdg diagram.pdg -o out.png # animated PNG (APNG)
20
20
  uv run pdg diagram.pdg --check # validate only
21
+ uv run pdg diagram.pdg -o d.png --width 1000 --colors 64 # small PNG for chat
21
22
  uv run pdg diagram.pdg --target web # size labels to fit any browser font
22
23
  ```
23
24
 
@@ -12,7 +12,7 @@ dependencies = [
12
12
  ]
13
13
  requires-python = ">=3.11"
14
14
  readme = "README.md"
15
- version = "0.1.0"
15
+ version = "0.2.1"
16
16
 
17
17
  [project.license]
18
18
  text = "GPL-3.0-or-later"
@@ -31,7 +31,9 @@ def main(
31
31
  at: float | None = typer.Option(None, "--at", help="SVG frozen at this time (seconds)"),
32
32
  fps: int = typer.Option(20, "--fps", help="APNG frames per second"),
33
33
  seconds: float | None = typer.Option(None, "--seconds", help="APNG length (default: one full loop)"),
34
- scale: float = typer.Option(2.0, "--scale", help="APNG pixel scale"),
34
+ scale: float = typer.Option(2.0, "--scale", help="PNG pixel scale"),
35
+ width: int | None = typer.Option(None, "--width", help="PNG width in pixels (overrides --scale)"),
36
+ colors: int | None = typer.Option(None, "--colors", help="PNG palette size, 2-256 (smaller files)"),
35
37
  allow_outside: bool = typer.Option(False, "--allow-outside",
36
38
  help="Let image= and css \"...\" read files outside the input's directory"),
37
39
  ) -> None:
@@ -52,7 +54,7 @@ def main(
52
54
  typer.echo(f"{src}: ok")
53
55
  return
54
56
  if dest.suffix.lower() in (".png", ".apng"):
55
- write_apng(scene, dest, fps, scale, seconds)
57
+ write_apng(scene, dest, fps, scale, seconds, width, static, colors)
56
58
  else:
57
59
  t = at if at is not None else (0.0 if static else None)
58
60
  dest.write_text(write_svg(scene, t), encoding="utf-8")
@@ -14,3 +14,10 @@ group { fill: #f3f4f6; stroke: #9ca3af; stroke-width: 1; border-radius: 10px;
14
14
  padding: 14px; color: #374151; font-size: 12px; font-weight: bold;
15
15
  label-position: top; }
16
16
  edge { stroke: #4b5563; stroke-width: 1.5; color: #374151; font-size: 11px; }
17
+
18
+ /* Text parts: `sub=` lines, step badges, figure caption, legend box. */
19
+ sub { color: #6b7280; font-size: 0.85em; }
20
+ group sub { font-weight: normal; }
21
+ step { fill: #374151; color: #ffffff; font-size: 11px; font-weight: bold; }
22
+ caption { color: #6b7280; font-size: 12px; font-style: italic; text-align: center; }
23
+ legend { fill: #ffffff; stroke: #d1d5db; color: #374151; font-size: 11px; }
@@ -14,13 +14,27 @@ from .text import load_image, measure
14
14
 
15
15
 
16
16
  # ---------------------------------------------------------------- sizing
17
+ BOLD = ("bold", "bolder", "600", "700", "800", "900")
18
+ SUB_GAP = 3.0 # px between a label and its `sub=` lines
19
+
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
+
17
28
  def size_node(n: Node, doc: Document) -> None:
18
29
  st = n.style
19
- fs = px(st.get("font-size"), 14)
20
- bold = st.get("font-weight", "normal") in ("bold", "600", "700", "800", "900")
21
30
  label = n.label if n.label is not None else n.id
22
- n.text_w, n.text_h, n.text_lines = measure(label, st.get("font-family", "sans-serif"), fs, bold) if label else (0.0, 0.0, [])
31
+ n.text_w, n.text_h, n.text_lines = text_block(label, st, 14)
32
+ sw, sh, n.sub_lines = text_block(n.attrs.get("sub"), n.sub_style or st, 12)
33
+ n.sub_h = sh + SUB_GAP if n.sub_lines else 0.0
34
+ n.text_w, n.text_h = max(n.text_w, sw), n.text_h + n.sub_h # text_h covers label + sub
23
35
  pt, pr, pb, pl = box4(st.get("padding"), 8)
36
+ pl += accent_width(st, "left")
37
+ pr += accent_width(st, "right")
24
38
  shape = n.attrs.get("shape")
25
39
  if "image" in n.attrs:
26
40
  img = load_image(n.attrs["image"], doc.base_dir, doc.allow_outside)
@@ -71,6 +85,14 @@ def size_node(n: Node, doc: Document) -> None:
71
85
 
72
86
 
73
87
  # ---------------------------------------------------------------- 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
+
74
96
  def direct_child(item: Item, g: Group) -> Item | None:
75
97
  """Ancestor-or-self of `item` that is a direct child of g."""
76
98
  cur = item
@@ -116,6 +138,14 @@ class LineLayout(Layout):
116
138
  cross = max((k.h if horizontal else k.w) for k in self.kids)
117
139
  pos = 0.0
118
140
  for k in self.kids:
141
+ if align == "stretch": # every child as wide (column) / tall (row) as the widest
142
+ if horizontal:
143
+ k.h = cross
144
+ else:
145
+ grow = cross - k.w
146
+ k.w = cross
147
+ if isinstance(k, Group) and opt(k, "justify", "start") == "center":
148
+ k.ox += grow / 2 # content stays left unless the tier asks to centre it
119
149
  size_c = k.h if horizontal else k.w
120
150
  off = {"start": 0, "end": cross - size_c}.get(align, (cross - size_c) / 2)
121
151
  if horizontal:
@@ -485,13 +515,15 @@ def layout_group(g: Group, doc: Document) -> None:
485
515
  else:
486
516
  lay = LAYOUTS[g.attrs["layout"]](g, doc)
487
517
  cw, ch = lay.run()
488
- g.label_h = 0.0
518
+ g.label_h = g.title_h = 0.0
489
519
  lw = 0.0
490
520
  if g.label and g.kind == "group":
491
- fs = px(g.style.get("font-size"), 12)
492
- lw, g.label_h, _ = measure(g.label, g.style.get("font-family", "sans-serif"), fs,
493
- g.style.get("font-weight", "normal") in ("bold", "700"))
494
- g.label_h += 6
521
+ lw, g.title_h, _ = text_block(g.label, g.style, 12)
522
+ sw, sh, _ = text_block(g.attrs.get("sub"), g.sub_style or g.style, 11)
523
+ lw = max(lw, sw)
524
+ 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":
526
+ g.label_h += 6
495
527
  inner_w = max(cw, lw) + getattr(g, "extra_w", 0.0) # extra_w: room requested for the title
496
528
  top = g.style.get("label-position", "top") != "bottom"
497
529
  g.ox = pl + (inner_w - cw) / 2
@@ -16,6 +16,7 @@ class Item:
16
16
  attrs: dict[str, str] = field(default_factory=dict)
17
17
  parent: "Group | None" = None
18
18
  style: dict[str, str] = field(default_factory=dict)
19
+ sub_style: dict[str, str] = field(default_factory=dict) # `sub=` text (CSS `sub` selector)
19
20
  # layout results (relative to parent content origin, then absolute)
20
21
  w: float = 0.0
21
22
  h: float = 0.0
@@ -48,6 +49,8 @@ class Node(Item):
48
49
  text_lines: list[str] = field(default_factory=list)
49
50
  text_w: float = 0.0
50
51
  text_h: float = 0.0
52
+ sub_lines: list[str] = field(default_factory=list) # secondary text under the label
53
+ sub_h: float = 0.0
51
54
 
52
55
 
53
56
  @dataclass
@@ -59,6 +62,8 @@ class Group(Item):
59
62
  ox: float = 0.0
60
63
  oy: float = 0.0
61
64
  label_h: float = 0.0
65
+ title_h: float = 0.0 # label only; label_h also counts the subtitle
66
+ part_styles: dict[str, dict[str, str]] = field(default_factory=dict) # root: caption, legend
62
67
 
63
68
 
64
69
  @dataclass
@@ -73,6 +78,7 @@ class Edge:
73
78
  scope: Group | None = None
74
79
  style: dict[str, str] = field(default_factory=dict)
75
80
  label_style: dict[str, str] = field(default_factory=dict)
81
+ step_style: dict[str, str] = field(default_factory=dict)
76
82
  index: int = 0
77
83
  waypoints: list[tuple[float, float]] = field(default_factory=list)
78
84
  wp_owner: Group | None = None
@@ -89,6 +95,8 @@ class Document:
89
95
  items: dict[str, Item] = field(default_factory=dict)
90
96
  base_dir: str = "."
91
97
  allow_outside: bool = False # let image=/css "..." read files outside base_dir
98
+ legend: list[Node] = field(default_factory=list) # `legend .cls "text"` entries (not laid out)
99
+ legend_title: str | None = None
92
100
 
93
101
  def walk(self, g: Group | None = None):
94
102
  g = g or self.root
@@ -11,6 +11,8 @@ from pathlib import Path
11
11
  from .model import Document, Edge, Group, Node
12
12
  from .safe import MAX_EDGES, safe_path
13
13
 
14
+ MAX_LEGEND = 64
15
+
14
16
 
15
17
  class PdgSyntaxError(ValueError):
16
18
  def __init__(self, msg: str, line: int):
@@ -189,6 +191,19 @@ class Parser:
189
191
  if classes:
190
192
  d["__classes__"] = " ".join(classes)
191
193
  return
194
+ 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
+ label, classes, attrs = self._tail(toks, 1, lineno)
198
+ if not classes: # `legend "Key"` names the box
199
+ self.doc.legend_title = label
200
+ return
201
+ if len(self.doc.legend) >= MAX_LEGEND:
202
+ raise PdgSyntaxError(f"too many legend entries (limit {MAX_LEGEND})", lineno)
203
+ entry = Node(id=f"pdg-legend-{len(self.doc.legend)}", label=label or " ".join(classes),
204
+ classes=list(classes), attrs=attrs, parent=self.root)
205
+ self.doc.legend.append(entry)
206
+ return
192
207
  if head == "group" and len(toks) >= 2 and toks[1][0] == "ident":
193
208
  if toks[-1] != ("punct", "{"):
194
209
  raise PdgSyntaxError("group needs '{'", lineno)
@@ -0,0 +1,72 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """PNG / APNG writer: Scene IR -> PNG (one frame) or animated PNG (frames sampled from
6
+ the animation tracks).
7
+
8
+ Size controls: `scale` (pixel scale) or `width` (target pixel width, overrides scale);
9
+ `still` forces one frame; `colors` quantises to a palette (much smaller files for flat
10
+ diagram colours). A scene without animation is always written as a single frame.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ from pathlib import Path
16
+
17
+ from ..anim import loop_length
18
+ from ..ir import Scene
19
+ from .svg import write_svg
20
+
21
+ MAX_PIXELS = 8000 # longest side; keeps a typo in --width/--scale from eating memory
22
+
23
+
24
+ class ApngWriter:
25
+ def __init__(self, scene: Scene, fps: int = 20, scale: float = 2.0, seconds: float | None = None,
26
+ width: int | None = None, still: bool = False, colors: int | None = None):
27
+ self.scene, self.fps = scene, fps
28
+ if width:
29
+ scale = width / max(scene.width, 1.0)
30
+ longest = max(scene.width, scene.height, 1.0)
31
+ self.scale = max(0.05, min(scale, MAX_PIXELS / longest))
32
+ durs = scene.durations()
33
+ self.still = still or not durs
34
+ self.seconds = seconds or (loop_length(durs) if durs else 1.0)
35
+ self.colors = max(2, min(int(colors), 256)) if colors else None
36
+
37
+ def _raster(self, svg: str):
38
+ try:
39
+ import cairosvg
40
+ except ImportError as exc: # pragma: no cover
41
+ raise RuntimeError("PNG export needs cairosvg: install predictygraph[apng]") from exc
42
+ from PIL import Image
43
+ png = cairosvg.svg2png(bytestring=svg.encode(), scale=self.scale)
44
+ return Image.open(io.BytesIO(png)).convert("RGBA")
45
+
46
+ def frames(self):
47
+ n = 1 if self.still else max(1, round(self.seconds * self.fps))
48
+ return [self._raster(write_svg(self.scene, i / self.fps)) for i in range(n)]
49
+
50
+ def _flatten(self, im):
51
+ """Opaque RGB (diagrams have a background) and, with `colors`, a palette image."""
52
+ from PIL import Image
53
+ bg = Image.new("RGBA", im.size, self.scene.background or "#ffffff")
54
+ rgb = Image.alpha_composite(bg, im).convert("RGB")
55
+ if self.colors:
56
+ return rgb.quantize(self.colors, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE)
57
+ return rgb
58
+
59
+ def save(self, out: Path) -> Path:
60
+ frames = [self._flatten(f) for f in self.frames()]
61
+ if len(frames) == 1:
62
+ frames[0].save(out, format="PNG", optimize=True)
63
+ else:
64
+ frames[0].save(out, format="PNG", save_all=True, append_images=frames[1:],
65
+ duration=round(1000 / self.fps), loop=0, disposal=1, blend=0,
66
+ default_image=False, optimize=True)
67
+ return out
68
+
69
+
70
+ def write_apng(scene: Scene, out: Path, fps: int = 20, scale: float = 2.0, seconds: float | None = None,
71
+ width: int | None = None, still: bool = False, colors: int | None = None) -> Path:
72
+ return ApngWriter(scene, fps, scale, seconds, width, still, colors).save(out)
@@ -20,6 +20,7 @@ from .style import px
20
20
  LANE = 9.0 # distance between parallel lanes
21
21
  CLEAR = 12.0 # min distance between a horizontal run and the boxes above/below it
22
22
  RECT_SHAPES = {"box", "square", "round", "pill", "cylinder", "note", "image", "none"}
23
+ ROUND_SHAPES = {"circle", "ellipse", "diamond"}
23
24
 
24
25
 
25
26
  def lca(a, b, root):
@@ -62,6 +63,23 @@ def port(n, side):
62
63
  "left": (n.ax, n.cy), "right": (n.ax + n.w, n.cy)}[side]
63
64
 
64
65
 
66
+ def on_outline(n, p, vert):
67
+ """Move a port that was shifted along a side back onto the node's outline.
68
+ Boxes are flat, so only circles, ellipses and diamonds need it: the point slides
69
+ along the flow axis until it touches the curve (or slanted edge)."""
70
+ shape = n.attrs.get("shape", "box")
71
+ if isinstance(n, Group) or shape not in ROUND_SHAPES:
72
+ return p
73
+ hw, hh = n.w / 2, n.h / 2
74
+ if vert:
75
+ u = min(abs(p[0] - n.cx) / hw, 1.0)
76
+ reach = hh * (math.sqrt(1 - u * u) if shape != "diamond" else 1 - u)
77
+ return p[0], n.cy + math.copysign(reach, p[1] - n.cy)
78
+ u = min(abs(p[1] - n.cy) / hh, 1.0)
79
+ reach = hw * (math.sqrt(1 - u * u) if shape != "diamond" else 1 - u)
80
+ return n.cx + math.copysign(reach, p[0] - n.cx), p[1]
81
+
82
+
65
83
  @dataclass
66
84
  class Diagonal:
67
85
  """When may a bend/ortho jog be a slant instead of a right-angle step?"""
@@ -166,42 +184,48 @@ class Router:
166
184
 
167
185
  # ------------------------------------------------------------ passes
168
186
  def _parallel_offsets(self):
187
+ """Edges between the same two nodes get side-by-side lanes. The offset is in
188
+ absolute coordinates, so an edge and its reverse simply take different lanes."""
169
189
  pairs: dict[tuple[str, str], list[Edge]] = {}
170
190
  for e in self.doc.edges:
171
191
  pairs.setdefault(tuple(sorted((e.src, e.dst))), []).append(e)
172
192
  for es in pairs.values():
173
193
  n = len(es)
174
194
  for i, e in enumerate(es):
175
- k = (i - (n - 1) / 2) * 12
176
- # opposite-direction edges get mirrored sign so they stay apart
177
- self.offset[e.id] = k if e.src <= e.dst else -k
195
+ self.offset[e.id] = (i - (n - 1) / 2) * 12
178
196
 
179
197
  def _spread_ports(self):
180
198
  """Several curved/bent edges on one side of a node: spread them symmetrically
181
- around the side's middle (ordered by where they go)."""
199
+ around the side's middle, ordered by where they go, so they don't cross.
200
+ Parallel edges (same two nodes) take part too, which keeps them apart and in
201
+ the same order at both ends."""
182
202
  sides: dict[tuple[str, str], list] = {}
183
- for e in self.doc.edges:
184
- if e.attrs.get("exit") or e.attrs.get("enter") or self.offset.get(e.id):
185
- continue
186
- pts, axis = self.geometry(e)
187
- if axis not in ("v", "h"):
188
- continue
189
- a, b = self.doc.items[e.src], self.doc.items[e.dst]
190
- if flow_dir(lca(a, b, self.doc.root)) == "auto" or e.style.get("route", "curve") in ("straight", "ortho"):
191
- continue # ortho fan-in/out merges into a bus instead
192
- i = 0 if axis == "v" else 1
193
- for end, node, here, there in ((0, a, pts[0], pts[1] if len(pts) > 2 else pts[-1]),
194
- (1, b, pts[-1], pts[-2] if len(pts) > 2 else pts[0])):
195
- side = ("bottom" if there[1] > here[1] else "top") if axis == "v" else \
196
- ("right" if there[0] > here[0] else "left")
197
- sides.setdefault((node.id, side), []).append((there[i], e.id, end))
203
+ saved, self.offset = self.offset, {} # order by the unshifted geometry
204
+ try:
205
+ for e in self.doc.edges:
206
+ if e.attrs.get("exit") or e.attrs.get("enter"):
207
+ continue
208
+ pts, axis = self.geometry(e)
209
+ if axis not in ("v", "h"):
210
+ continue
211
+ a, b = self.doc.items[e.src], self.doc.items[e.dst]
212
+ if flow_dir(lca(a, b, self.doc.root)) == "auto" or e.style.get("route", "curve") in ("straight", "ortho"):
213
+ continue # ortho fan-in/out merges into a bus instead
214
+ i = 0 if axis == "v" else 1
215
+ for end, node, here, there in ((0, a, pts[0], pts[1] if len(pts) > 2 else pts[-1]),
216
+ (1, b, pts[-1], pts[-2] if len(pts) > 2 else pts[0])):
217
+ side = ("bottom" if there[1] > here[1] else "top") if axis == "v" else \
218
+ ("right" if there[0] > here[0] else "left")
219
+ sides.setdefault((node.id, side), []).append((there[i], e.id, end))
220
+ finally:
221
+ self.offset = saved
198
222
  for (nid, side), lst in sides.items():
199
223
  if len(lst) < 2:
200
224
  continue
201
225
  n = self.doc.items[nid]
202
- if n.attrs.get("shape", "box") in ("diamond", "circle", "ellipse") and not isinstance(n, Group):
203
- continue
204
226
  span = n.w if side in ("top", "bottom") else n.h
227
+ if n.attrs.get("shape", "box") in ROUND_SHAPES and not isinstance(n, Group):
228
+ span *= 0.6 # stay on the part of the outline that faces the side
205
229
  step = min(18.0, (span - 12) / (len(lst) - 1)) if span > 12 else 0.0
206
230
  lst.sort()
207
231
  for k, (_, eid, end) in enumerate(lst):
@@ -341,6 +365,19 @@ class Router:
341
365
  if da.id in ri and db.id in ri and abs(ri[da.id] - ri[db.id]) == 1:
342
366
  self.gapkey[e.id] = (owner.id, min(ri[da.id], ri[db.id]))
343
367
 
368
+ def span_items(self, a, b):
369
+ """Leaf nodes overlapping the band between a and b (both axes), plus a and b:
370
+ the things a same-side C-shaped edge must go around."""
371
+ x0, x1 = min(a.ax, b.ax), max(a.ax + a.w, b.ax + b.w)
372
+ y0, y1 = min(a.ay, b.ay), max(a.ay + a.h, b.ay + b.h)
373
+ out = [a, b]
374
+ for it in self.doc.walk():
375
+ if isinstance(it, Group) or it in (a, b):
376
+ continue
377
+ if it.ay < y1 and it.ay + it.h > y0 and it.ax < x1 and it.ax + it.w > x0:
378
+ out.append(it)
379
+ return out
380
+
344
381
  def geometry(self, e: Edge):
345
382
  """-> (points, axis). axis: v | h (flow axis), L (explicit L port) or free."""
346
383
  a, b = self.doc.items[e.src], self.doc.items[e.dst]
@@ -359,6 +396,17 @@ class Router:
359
396
  if v0 != v1: # L-shape: one corner
360
397
  corner = (p0[0], p1[1]) if v0 else (p1[0], p0[1])
361
398
  return [p0, corner, p1], "L"
399
+ if ex == en and not wps: # same side: C-shape around everything in between
400
+ gap = px(e.style.get("stub"), 24) if e.style.get("stub") else 24.0
401
+ if ex in ("left", "right"):
402
+ lo = min(it.ax for it in self.span_items(a, b)) if ex == "left" else None
403
+ hi = max(it.ax + it.w for it in self.span_items(a, b)) if ex == "right" else None
404
+ x = lo - gap if ex == "left" else hi + gap
405
+ return [p0, (x, p0[1]), (x, p1[1]), p1], "L"
406
+ lo = min(it.ay for it in self.span_items(a, b)) if ex == "top" else None
407
+ hi = max(it.ay + it.h for it in self.span_items(a, b)) if ex == "bottom" else None
408
+ y = lo - gap if ex == "top" else hi + gap
409
+ return [p0, (p0[0], y), (p1[0], y), p1], "L"
362
410
  return [p0] + wps + [p1], ("v" if v0 else "h")
363
411
  if d in ("TB", "BT", "LR", "RL") and route != "straight":
364
412
  vert = d in ("TB", "BT")
@@ -371,18 +419,21 @@ class Router:
371
419
  elif not wps:
372
420
  self._record_channel(e, owner, da, db, vert)
373
421
  p0, p1 = self._ports(a, b, vert)
422
+ spread = (e.id, 0) in self.port_shift or (e.id, 1) in self.port_shift
374
423
  s0, s1 = self.port_shift.get((e.id, 0), 0.0), self.port_shift.get((e.id, 1), 0.0)
375
424
  if vert:
376
425
  p0, p1 = (p0[0] + s0, p0[1]), (p1[0] + s1, p1[1])
377
426
  else:
378
427
  p0, p1 = (p0[0], p0[1] + s0), (p1[0], p1[1] + s1)
379
- if s0 or s1:
428
+ if spread:
429
+ p0, p1 = on_outline(a, p0, vert), on_outline(b, p1, vert)
380
430
  return [p0] + wps + [p1], ("v" if vert else "h")
381
431
  if off:
382
432
  if vert:
383
433
  p0, p1 = (p0[0] + off, p0[1]), (p1[0] + off, p1[1])
384
434
  else:
385
435
  p0, p1 = (p0[0], p0[1] + off), (p1[0], p1[1] + off)
436
+ p0, p1 = on_outline(a, p0, vert), on_outline(b, p1, vert)
386
437
  # boxes overlap on the cross axis: one straight line, no jog / double trace
387
438
  boxy = all(x.attrs.get("shape", "box") in RECT_SHAPES or isinstance(x, Group) for x in (a, b))
388
439
  if not wps and not off and boxy:
@@ -16,6 +16,7 @@ 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
20
  from .model import Document, Edge, Group, Node
20
21
  from .route import Diagonal, Router, flow_dir, lca
21
22
  from .style import box4, px
@@ -45,7 +46,53 @@ class SceneBuilder:
45
46
  layers = [groups_layer, edges_layer, nodes_layer]
46
47
  if self.title_layer.children:
47
48
  layers.append(self.title_layer)
48
- return Scene(root.w, root.h, root.style.get("background"), layers)
49
+ w, h = root.w, root.h
50
+ legend, lw, lh = self.legend(h)
51
+ if legend:
52
+ layers.append(legend)
53
+ w, h = max(w, lw), h + lh
54
+ cap = root.attrs.get("caption")
55
+ if cap:
56
+ 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)
59
+ w = max(w, cw + 28)
60
+ align = st.get("text-align", "center")
61
+ x, anchor = {"left": (14, "start"), "right": (w - 14, "end")}.get(align, (w / 2, "middle"))
62
+ layers.append(Elem("group", cls="pdg-caption", children=self.text(lines, x, h, fs, st, anchor)))
63
+ h += ch + 14
64
+ return Scene(w, h, root.style.get("background"), layers)
65
+
66
+ def legend(self, top: float) -> tuple[Elem | None, float, float]:
67
+ """`legend .cls "text"` entries: a key box under the diagram, swatches styled like nodes."""
68
+ entries = self.doc.legend
69
+ if not entries:
70
+ return None, 0.0, 0.0
71
+ 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"))
74
+ sw, gap, pad = 14.0, 16.0, 10.0
75
+ title = self.doc.legend_title
76
+ tw, th, _ = measure(title, fam, fs, True) if title else (0.0, 0.0, [])
77
+ x = pad + (tw + gap if title else 0.0)
78
+ kids, row_h = [], max(sw, fs * 1.25)
79
+ y0 = top + pad
80
+ if title:
81
+ kids += self.text([title], 14 + pad, y0 + (row_h - fs * 1.25) / 2, fs, {**st, "font-weight": "bold"}, "start")
82
+ for n in entries:
83
+ ew, _, _ = measure(n.label, fam, fs)
84
+ ns = n.style
85
+ r = min(px(ns.get("border-radius"), 3), 4)
86
+ kids.append(Elem("rect", cls=" ".join(["pdg-legend-swatch"] + n.classes),
87
+ geom={"x": 14 + x, "y": y0 + (row_h - sw) / 2, "w": sw, "h": sw, "rx": r},
88
+ paint={"fill": ns.get("fill", "#ffffff"), "stroke": ns.get("stroke", "#9ca3af"),
89
+ "stroke-width": 1.2}))
90
+ kids += self.text([n.label], 14 + x + sw + 6, y0 + (row_h - fs * 1.25) / 2, fs, st, "start")
91
+ x += sw + 6 + ew + gap
92
+ bw, bh = x - gap + pad, row_h + 2 * pad
93
+ box = Elem("rect", cls="pdg-legend-box", geom={"x": 14, "y": top, "w": bw, "h": bh, "rx": 6},
94
+ paint={"fill": st.get("fill", "#ffffff"), "stroke": st.get("stroke", "#d1d5db"), "stroke-width": 1})
95
+ return Elem("group", cls="pdg-legend", children=[box] + kids), bw + 28, bh + 14
49
96
 
50
97
  # ------------------------------------------------------------ text
51
98
  @staticmethod
@@ -113,6 +160,7 @@ class SceneBuilder:
113
160
  st = n.style
114
161
  shape = n.attrs.get("shape", "box")
115
162
  out = Elem("group", id=n.id, cls=" ".join(["pdg-node"] + n.classes), children=self.shape(n, shape, st))
163
+ out.children += self.accent(n, st)
116
164
  fs = px(st.get("font-size"), 14)
117
165
  if n.image:
118
166
  img = n.image
@@ -130,17 +178,45 @@ class SceneBuilder:
130
178
  def _node_text(self, n, st, fs):
131
179
  align = st.get("text-align", "center")
132
180
  pt, pr, pb, pl = box4(st.get("padding"), 8)
181
+ pl += accent_width(st, "left")
182
+ pr += accent_width(st, "right")
133
183
  top = n.cy - n.text_h / 2
134
- if align == "left":
135
- return self.text(n.text_lines, n.ax + pl, top, fs, st, "start")
136
- if align == "right":
137
- return self.text(n.text_lines, n.ax + n.w - pr, top, fs, st, "end")
138
- return self.text(n.text_lines, n.cx, top, fs, st)
184
+ x, anchor = {"left": (n.ax + pl, "start"), "right": (n.ax + n.w - pr, "end")}.get(align, (n.cx, "middle"))
185
+ out = self.text(n.text_lines, x, top, fs, st, anchor)
186
+ if n.sub_lines:
187
+ sst = n.sub_style or st
188
+ sfs = px(sst.get("font-size"), 12)
189
+ stop = top + n.text_h - n.sub_h + SUB_GAP
190
+ out += self.text(n.sub_lines, x, stop, sfs, sst, anchor)
191
+ return out
192
+
193
+ 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":
197
+ 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})]
139
206
 
140
207
  def group(self, g: Group) -> Elem:
141
208
  st = g.style
142
209
  out = Elem("group", id=g.id, cls=" ".join(["pdg-group"] + g.classes),
143
210
  children=self.shape(g, g.attrs.get("shape", "box"), st))
211
+ 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",)]}))
144
220
  if g.label:
145
221
  fs = px(st.get("font-size"), 12)
146
222
  pt, pr, pb, pl = box4(st.get("padding"), 14)
@@ -152,6 +228,10 @@ class SceneBuilder:
152
228
  x0 = self._title_x(g, st, tw, pl, pr, top, th)
153
229
  box = (x0 - TITLE_PAD, top - 2, x0 + tw + TITLE_PAD, top + th + 2)
154
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")
155
235
  xs = self._crossings(box[1], box[3], box[0], box[2])
156
236
  if xs:
157
237
  # ask the pipeline for a wider group: children are centred, so widening
@@ -288,9 +368,37 @@ class SceneBuilder:
288
368
  out.children.append(self.head(kind, tip, dvec, hl, color, width,
289
369
  self.doc.root.style.get("background", "#ffffff")))
290
370
  if e.label:
291
- out.children += self.edge_label(e, self.edge_polys[e.id])
371
+ lab = self.edge_label(e, self.edge_polys[e.id])
372
+ out.children += lab
373
+ if e.attrs.get("step"):
374
+ box = lab[0].geom if e.label else None
375
+ out.children += self.step_badge(e, self.edge_polys[e.id], box)
292
376
  return out
293
377
 
378
+ def step_badge(self, e, poly, label_box=None) -> list[Elem]:
379
+ """Numbered circle on the edge, a third of the way along its path. With a label the
380
+ badge sits just left of the label box, so neither covers the other."""
381
+ st = e.step_style or e.style
382
+ fs = px(st.get("font-size"), 11)
383
+ txt = e.attrs["step"][:3]
384
+ w, h, _ = measure(txt, st.get("font-family", "sans-serif"), fs, True)
385
+ 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
+ if label_box:
396
+ cx, cy = label_box["x"] - r - 3, label_box["y"] + label_box["h"] / 2
397
+ circ = Elem("circle", cls="pdg-step", geom={"cx": cx, "cy": cy, "r": r},
398
+ paint={"fill": st.get("fill", "#374151"), "stroke": self.doc.root.style.get("background", "#ffffff"),
399
+ "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")})
401
+
294
402
  @staticmethod
295
403
  def head(kind, tip, d, hl, color, width, bg="#ffffff") -> Elem:
296
404
  """Arrowhead as plain geometry: tip sits exactly on the target outline.
@@ -134,18 +134,46 @@ class StyleSheet:
134
134
 
135
135
 
136
136
  BOX_KEYS = ("fill", "stroke", "color", "font-size", "stroke-width", "border-radius", "font-weight",
137
- "stroke-dasharray", "padding", "border", "border-speed")
137
+ "stroke-dasharray", "padding", "border", "border-speed", "accent-bar", "accent-side", "title-fill")
138
138
  EDGE_KEYS = ("arrow", "head", "head-start", "head-size", "route", "radius", "stub", "flow", "flow-speed",
139
139
  "diagonal", "diagonal-angle")
140
140
  INHERITED_EDGE_KEYS = tuple(k for k in EDGE_KEYS if k not in ("flow", "head-start"))
141
+ TEXT_KEYS = ("font-family", "font-size", "color", "font-weight", "font-style", "text-align")
141
142
 
142
143
 
143
144
  @dataclass
144
- class _Label:
145
- """Pseudo item so `label` / `edge.x label` selectors match an edge's label."""
145
+ class _Part:
146
+ """Pseudo item so `label`, `sub`, `step`, `caption`, `legend` selectors match a piece
147
+ of text that belongs to an item (an edge label, a node subtitle, a step badge, ...)."""
148
+ kind: str
146
149
  classes: list
147
150
  id: str = ""
148
- kind: str = "label"
151
+
152
+
153
+ def part_style(sheet: "StyleSheet", kind: str, owner, anc: list, inherit: dict) -> dict[str, str]:
154
+ """Style for a text part: inherits text keys from its owner, then CSS for `kind`.
155
+ Subtitles drop the owner's weight: a bold title should not make the second line bold.
156
+ `em`/`%` font sizes are relative to the owner's size."""
157
+ base = {k: inherit[k] for k in TEXT_KEYS if k in inherit and not (kind == "sub" and k == "font-weight")}
158
+ st = sheet.resolve_vars({**base, **sheet.compute(_Part(kind, list(owner.classes)), [owner] + anc)})
159
+ fs = st.get("font-size", "").strip()
160
+ if fs.endswith("em"):
161
+ st["font-size"] = f"{px(inherit.get('font-size'), 14) * number(fs[:-2], 1, 0.1, 10):.2f}px"
162
+ elif fs.endswith("%"):
163
+ st["font-size"] = f"{px(inherit.get('font-size'), 14) * number(fs[:-1], 100, 10, 1000) / 100:.2f}px"
164
+ return st
165
+
166
+
167
+ def number_steps(doc: Document) -> None:
168
+ """`graph steps=auto`: number every edge without its own `step=` in file order."""
169
+ if doc.root.attrs.get("steps") != "auto":
170
+ return
171
+ n = 0
172
+ for e in doc.edges:
173
+ if "step" in e.attrs:
174
+ continue
175
+ n += 1
176
+ e.attrs["step"] = str(n)
149
177
 
150
178
 
151
179
  def apply_styles(doc: Document, target: str | None = None) -> None:
@@ -154,15 +182,19 @@ def apply_styles(doc: Document, target: str | None = None) -> None:
154
182
  for css, _ in doc.stylesheets:
155
183
  sheet.add(css)
156
184
  root = doc.root
185
+ number_steps(doc)
157
186
  root.style = sheet.resolve_vars(sheet.compute(root, []))
187
+ root.part_styles = {k: part_style(sheet, k, root, [], root.style) for k in ("caption", "legend")}
158
188
 
159
- for item in doc.walk():
189
+ for item in [*doc.walk(), *doc.legend]:
160
190
  anc = item.ancestors() # nearest first; root last
161
191
  parent = item.parent.style if item.parent else root.style
162
192
  st = {k: parent[k] for k in ("font-family",) if k in parent}
163
193
  st.update(sheet.compute(item, anc))
164
194
  st.update({k: v for k, v in item.attrs.items() if k in BOX_KEYS})
165
195
  item.style = sheet.resolve_vars(st)
196
+ if "sub" in item.attrs:
197
+ item.sub_style = part_style(sheet, "sub", item, anc, item.style)
166
198
  ge = root.attrs
167
199
  for e in doc.edges:
168
200
  anc = [a for a in (([e.scope] + e.scope.ancestors()) if e.scope is not root else [root]) if a is not None]
@@ -181,9 +213,9 @@ def apply_styles(doc: Document, target: str | None = None) -> None:
181
213
  elif k in EDGE_KEYS or k in ("stroke", "color", "stroke-dasharray", "font-size"):
182
214
  st[k] = v
183
215
  e.style = sheet.resolve_vars(st)
184
- lab = sheet.compute(_Label(e.classes), [e] + anc)
185
- e.label_style = sheet.resolve_vars({**{k: e.style[k] for k in ("font-family", "font-size", "color")
186
- if k in e.style}, **lab})
216
+ e.label_style = part_style(sheet, "label", e, anc, e.style)
217
+ if "step" in e.attrs:
218
+ e.step_style = part_style(sheet, "step", e, anc, e.style)
187
219
 
188
220
 
189
221
  def px(v: str | None, default: float = 0.0) -> float:
@@ -56,6 +56,8 @@ def load_theme(name: str, base_dir: str = ".", _seen=()) -> dict:
56
56
  parent = load_theme(data["extends"], str(path.parent), _seen + (name,))
57
57
  for sect in ("palette", "roles", "shape"):
58
58
  parent.setdefault(sect, {}).update(data.get(sect, {}))
59
+ if "css" in data: # extra rules stack: parent's first, then the child's
60
+ parent["css"] = parent.get("css", "") + "\n" + data["css"]
59
61
  parent["name"] = data.get("name", name)
60
62
  parent["dark"] = data.get("dark", parent.get("dark", False))
61
63
  data = parent
@@ -104,4 +106,6 @@ def theme_css(name: str, base_dir: str = ".") -> str:
104
106
  lines.append("node.solid { fill: var(--fg); stroke: var(--fg); color: var(--bg); }")
105
107
  if "edge-width" in shape:
106
108
  lines.append(f"edge {{ stroke-width: {shape['edge-width']}; }}")
109
+ if t.get("css"): # free-form rules after the generated ones; var(--hue) works here
110
+ lines.append(t["css"])
107
111
  return "\n".join(lines) + "\n"
@@ -0,0 +1,47 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ # The look of the HAMi documentation diagrams: soft blue panels with a header band,
6
+ # white rounded boxes with a bold title and a grey second line (`sub=`), HAMi green
7
+ # for highlighted components (accent bar on the left), numbered steps.
8
+ schema = 1
9
+ name = "hami"
10
+ dark = false
11
+
12
+ # extra rules after the generated palette CSS (must sit before the first [table])
13
+ css = """
14
+ graph { font-family: "Source Sans 3", "Source Sans Pro", Arial, Helvetica, "Liberation Sans", sans-serif; }
15
+ node { font-weight: 600; padding: 10px 14px; }
16
+ sub { color: var(--muted); font-size: 0.85em; }
17
+ group { title-fill: #dbeafe; stroke: #bfdbfe; font-size: 13px; }
18
+ group.green { title-fill: #dcfce7; stroke: #bbf7d0; fill: #f0fdf4; }
19
+ node.green { fill: #ffffff; stroke: var(--green); accent-bar: 4px var(--green); }
20
+ node.blue { fill: #ffffff; stroke: var(--blue); accent-bar: 4px var(--blue); }
21
+ step { fill: var(--green); color: #ffffff; }
22
+ legend { stroke: var(--border); color: var(--fg); }
23
+ caption { color: var(--muted); }
24
+ """
25
+
26
+ [palette]
27
+ bg = "#ffffff"
28
+ bg-alt = "#f1f5f9"
29
+ surface = "#ffffff"
30
+ fg = "#1e293b"
31
+ muted = "#64748b"
32
+ border = "#cbd5e1"
33
+ red = "#dc2626"
34
+ orange = "#ea580c"
35
+ yellow = "#ca8a04"
36
+ green = "#16a34a"
37
+ cyan = "#0891b2"
38
+ blue = "#2563eb"
39
+ purple = "#7c3aed"
40
+
41
+ [roles]
42
+ group-fill = "bg-alt"
43
+ group-text = "fg"
44
+ edge = "muted"
45
+
46
+ [shape]
47
+ radius = 10
@@ -0,0 +1,77 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Subtitles, step badges, legend, caption, accent bars and header bands."""
6
+ from predictygraph import build_scene
7
+ from predictygraph.parser import parse
8
+
9
+
10
+ def scene(src):
11
+ doc = parse(src)
12
+ return doc, build_scene(doc)
13
+
14
+
15
+ def classes(sc):
16
+ out, stack = [], list(sc.layers)
17
+ while stack:
18
+ e = stack.pop()
19
+ out.append(e.cls or "")
20
+ stack += e.children
21
+ return " ".join(out)
22
+
23
+
24
+ def texts(sc):
25
+ out, stack = [], list(sc.layers)
26
+ while stack:
27
+ e = stack.pop()
28
+ if e.kind == "text":
29
+ out.append(e.geom["text"])
30
+ stack += e.children
31
+ return out
32
+
33
+
34
+ def test_sub_adds_height_and_is_drawn():
35
+ plain, _ = scene('a "Title"\n')
36
+ doc, sc = scene('a "Title" sub="second line"\n')
37
+ assert doc.items["a"].h > plain.items["a"].h
38
+ assert "second line" in texts(sc)
39
+
40
+
41
+ def test_sub_is_not_bold_and_smaller():
42
+ doc, _ = scene('css { node { font-weight: bold; font-size: 20px; } }\na "T" sub="s"\n')
43
+ st = doc.items["a"].sub_style
44
+ assert "font-weight" not in st or st["font-weight"] == "normal"
45
+ assert float(st["font-size"].rstrip("px")) < 20
46
+
47
+
48
+ def test_group_sub_drawn_under_title():
49
+ doc, sc = scene('group g "Panel" sub="what it does" {\n a\n}\n')
50
+ assert "what it does" in texts(sc)
51
+ assert doc.items["g"].label_h > doc.items["g"].title_h
52
+
53
+
54
+ def test_steps_auto_and_explicit():
55
+ doc, sc = scene("graph steps=auto\na -> b\nb -> c step=9\nc -> d\n")
56
+ assert [e.attrs["step"] for e in doc.edges] == ["1", "9", "2"]
57
+ assert classes(sc).count("pdg-step") == 3
58
+
59
+
60
+ def test_legend_and_caption_grow_canvas():
61
+ _, base = scene("a -> b\n")
62
+ _, sc = scene('graph caption="Figure 1"\na -> b\nlegend "Key"\nlegend .green "HAMi"\n')
63
+ assert sc.height > base.height
64
+ assert {"Key", "HAMi", "Figure 1"} <= set(texts(sc))
65
+ assert "pdg-legend-box" in classes(sc)
66
+
67
+
68
+ def test_accent_bar_widens_node_and_draws():
69
+ plain, _ = scene('a "a longer label"\n')
70
+ doc, sc = scene('css { node { accent-bar: 5px #0f0; } }\na "a longer label"\n')
71
+ assert doc.items["a"].w == plain.items["a"].w + 5
72
+ assert "pdg-accent" in classes(sc)
73
+
74
+
75
+ def test_title_fill_band():
76
+ _, sc = scene('css { group { title-fill: #dbeafe; } }\ngroup g "G" {\n a\n}\n')
77
+ assert "pdg-title-band" in classes(sc)
@@ -0,0 +1,60 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Routing regressions: edges between the same nodes, and edges fanning out of a
6
+ round node, must stay apart and must not cross."""
7
+ from predictygraph import _style, parse
8
+ from predictygraph.layout import layout
9
+ from predictygraph.route import Router
10
+
11
+ STATES = """graph dir=LR
12
+ default node shape=circle
13
+ idle "Idle"
14
+ running "Running"
15
+ error "Error"
16
+ done "Done"
17
+ idle -> running "Start"
18
+ running -> error "Fail"
19
+ running -> done "OK"
20
+ error -> running "Retry"
21
+ done -> idle "Reset"
22
+ """
23
+
24
+
25
+ def _ends():
26
+ doc = _style(parse(STATES), None, None, None)
27
+ layout(doc)
28
+ r = Router(doc).route()
29
+ ends = {}
30
+ for e in doc.edges:
31
+ pts, _ = r.geometry(e)
32
+ ends[(e.src, e.dst)] = (pts[0], pts[-1])
33
+ return ends
34
+
35
+
36
+ def test_edge_and_its_reverse_do_not_overlap():
37
+ ends = _ends()
38
+ fail = ends[("running", "error")]
39
+ retry = ends[("error", "running")]
40
+ # Fail leaves Running at p0, Retry arrives at Running at p1 (and vice versa at Error)
41
+ assert abs(fail[0][1] - retry[1][1]) >= 8, "Fail and Retry share a port on Running"
42
+ assert abs(fail[1][1] - retry[0][1]) >= 8, "Fail and Retry share a port on Error"
43
+
44
+
45
+ def test_edges_leaving_one_side_keep_their_order():
46
+ ends = _ends()
47
+ # on Running's right side: Fail (to Error, top) above Retry above OK (to Done, bottom)
48
+ ys = [ends[("running", "error")][0][1], ends[("error", "running")][1][1], ends[("running", "done")][0][1]]
49
+ assert ys == sorted(ys), f"edges cross on Running's right side: {ys}"
50
+
51
+
52
+ def test_shifted_ports_sit_on_the_circle():
53
+ doc = _style(parse(STATES), None, None, None)
54
+ layout(doc)
55
+ r = Router(doc).route()
56
+ for e in doc.edges:
57
+ pts, _ = r.geometry(e)
58
+ for n, p in ((doc.items[e.src], pts[0]), (doc.items[e.dst], pts[-1])):
59
+ d = ((p[0] - n.cx) / (n.w / 2)) ** 2 + ((p[1] - n.cy) / (n.h / 2)) ** 2
60
+ assert abs(d - 1) < 0.02, f"{e.src}->{e.dst} end is off the outline of {n.id}"
@@ -0,0 +1,41 @@
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"
@@ -1,43 +0,0 @@
1
- # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
- #
3
- # SPDX-License-Identifier: GPL-3.0-or-later
4
-
5
- """APNG writer: Scene IR -> animated PNG (frames sampled from the animation tracks)."""
6
- from __future__ import annotations
7
-
8
- import io
9
- from pathlib import Path
10
-
11
- from ..anim import loop_length
12
- from ..ir import Scene
13
- from .svg import write_svg
14
-
15
-
16
- class ApngWriter:
17
- def __init__(self, scene: Scene, fps: int = 20, scale: float = 2.0, seconds: float | None = None):
18
- self.scene, self.fps, self.scale = scene, fps, scale
19
- durs = scene.durations()
20
- self.seconds = seconds or (loop_length(durs) if durs else 1.0)
21
-
22
- def _raster(self, svg: str):
23
- try:
24
- import cairosvg
25
- except ImportError as exc: # pragma: no cover
26
- raise RuntimeError("APNG export needs cairosvg: install predictygraph[apng]") from exc
27
- from PIL import Image
28
- png = cairosvg.svg2png(bytestring=svg.encode(), scale=self.scale)
29
- return Image.open(io.BytesIO(png)).convert("RGBA")
30
-
31
- def frames(self):
32
- n = max(1, round(self.seconds * self.fps))
33
- return [self._raster(write_svg(self.scene, i / self.fps)) for i in range(n)]
34
-
35
- def save(self, out: Path) -> Path:
36
- frames = self.frames()
37
- frames[0].save(out, format="PNG", save_all=True, append_images=frames[1:],
38
- duration=round(1000 / self.fps), loop=0, disposal=1, blend=0, default_image=False)
39
- return out
40
-
41
-
42
- def write_apng(scene: Scene, out: Path, fps: int = 20, scale: float = 2.0, seconds: float | None = None) -> Path:
43
- return ApngWriter(scene, fps, scale, seconds).save(out)