predictygraph 0.2.2__tar.gz → 0.2.4__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.2 → predictygraph-0.2.4}/PKG-INFO +32 -8
  2. {predictygraph-0.2.2 → predictygraph-0.2.4}/README.md +31 -7
  3. {predictygraph-0.2.2 → predictygraph-0.2.4}/pyproject.toml +1 -1
  4. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/layout.py +41 -0
  5. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/scene.py +43 -10
  6. predictygraph-0.2.4/tests/test_stack.py +98 -0
  7. predictygraph-0.2.2/tests/test_stack.py +0 -41
  8. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/__init__.py +0 -0
  9. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/__main__.py +0 -0
  10. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/anim.py +0 -0
  11. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/cli.py +0 -0
  12. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/default.css +0 -0
  13. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  14. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  15. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  16. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  17. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  18. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  19. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  20. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  21. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  22. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  23. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/fonts.py +0 -0
  24. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/ir.py +0 -0
  25. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/model.py +0 -0
  26. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/parser.py +0 -0
  27. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/render/__init__.py +0 -0
  28. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/render/apng.py +0 -0
  29. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/render/svg.py +0 -0
  30. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/route.py +0 -0
  31. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/safe.py +0 -0
  32. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/style.py +0 -0
  33. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/styleutil.py +0 -0
  34. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/text.py +0 -0
  35. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/theme.py +0 -0
  36. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  37. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  38. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  39. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  40. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/default.toml +0 -0
  41. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/dracula.toml +0 -0
  42. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/github-light.toml +0 -0
  43. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  44. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/hami.toml +0 -0
  45. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/nord.toml +0 -0
  46. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/solarized-dark.toml +0 -0
  47. {predictygraph-0.2.2 → predictygraph-0.2.4}/src/predictygraph/themes/solarized-light.toml +0 -0
  48. {predictygraph-0.2.2 → predictygraph-0.2.4}/tests/test_annotations.py +0 -0
  49. {predictygraph-0.2.2 → predictygraph-0.2.4}/tests/test_diagonal.py +0 -0
  50. {predictygraph-0.2.2 → predictygraph-0.2.4}/tests/test_fixes.py +0 -0
  51. {predictygraph-0.2.2 → predictygraph-0.2.4}/tests/test_hardening.py +0 -0
  52. {predictygraph-0.2.2 → predictygraph-0.2.4}/tests/test_predictygraph.py +0 -0
  53. {predictygraph-0.2.2 → predictygraph-0.2.4}/tests/test_routing.py +0 -0
  54. {predictygraph-0.2.2 → predictygraph-0.2.4}/tests/test_scene.py +0 -0
  55. {predictygraph-0.2.2 → predictygraph-0.2.4}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: predictygraph
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
@@ -27,16 +27,40 @@ Editor support: [tree-sitter-pdg](https://github.com/predictygraph/tree-sitter-p
27
27
  Rendered examples and the gallery: [predictygraph.github.io](https://predictygraph.github.io/). This repo keeps
28
28
  only `.pdg` sources; images live in the docs repo (git-lfs).
29
29
 
30
+ ## Install
31
+
32
+ ```bash
33
+ uv tool install "predictygraph[apng]" # or: pipx install "predictygraph[apng]"
34
+ ```
35
+
36
+ `[apng]` adds PNG output. `pdg` is the short alias of the `predictygraph` command.
37
+
38
+ ## Use
39
+
30
40
  ```bash
31
- uv run pdg examples/hami_flow.pdg # -> examples/hami_flow.svg (not committed)
32
- uv run pdg diagram.pdg --css theme.css -o out.svg
33
- uv run pdg diagram.pdg -o out.png # animated PNG (APNG)
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
36
- uv run pdg diagram.pdg --target web # size labels to fit any browser font
41
+ pdg examples/annotations.pdg # -> examples/annotations.svg (not committed)
42
+ pdg diagram.pdg --css theme.css -o out.svg
43
+ pdg diagram.pdg -o out.png # PNG (animated if the diagram moves)
44
+ pdg diagram.pdg --check # validate only
45
+ pdg diagram.pdg -o d.png --width 1000 --colors 64 # small PNG for chat
46
+ pdg diagram.pdg --target web # size labels to fit any browser font
37
47
  ```
38
48
 
39
- `pdg` is the short alias of the `predictygraph` command.
49
+ From a checkout, prefix these with `uv run`. Start with
50
+ [examples/annotations.pdg](examples/annotations.pdg): one small diagram that uses every
51
+ annotation feature, with a comment for each.
52
+
53
+ ## Agent skill
54
+
55
+ [skills/predictygraph](skills/predictygraph) teaches an AI agent to write, render and
56
+ check `.pdg` diagrams.
57
+
58
+ ```bash
59
+ # Hermes Agent
60
+ hermes skills install https://raw.githubusercontent.com/predictygraph/predictygraph/master/skills/predictygraph/SKILL.md
61
+ # Claude Code and other agents that read SKILL.md folders: copy the folder
62
+ cp -r skills/predictygraph ~/.claude/skills/
63
+ ```
40
64
 
41
65
  ## Credits
42
66
 
@@ -13,16 +13,40 @@ Editor support: [tree-sitter-pdg](https://github.com/predictygraph/tree-sitter-p
13
13
  Rendered examples and the gallery: [predictygraph.github.io](https://predictygraph.github.io/). This repo keeps
14
14
  only `.pdg` sources; images live in the docs repo (git-lfs).
15
15
 
16
+ ## Install
17
+
18
+ ```bash
19
+ uv tool install "predictygraph[apng]" # or: pipx install "predictygraph[apng]"
20
+ ```
21
+
22
+ `[apng]` adds PNG output. `pdg` is the short alias of the `predictygraph` command.
23
+
24
+ ## Use
25
+
16
26
  ```bash
17
- uv run pdg examples/hami_flow.pdg # -> examples/hami_flow.svg (not committed)
18
- uv run pdg diagram.pdg --css theme.css -o out.svg
19
- uv run pdg diagram.pdg -o out.png # animated PNG (APNG)
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
22
- uv run pdg diagram.pdg --target web # size labels to fit any browser font
27
+ pdg examples/annotations.pdg # -> examples/annotations.svg (not committed)
28
+ pdg diagram.pdg --css theme.css -o out.svg
29
+ pdg diagram.pdg -o out.png # PNG (animated if the diagram moves)
30
+ pdg diagram.pdg --check # validate only
31
+ pdg diagram.pdg -o d.png --width 1000 --colors 64 # small PNG for chat
32
+ pdg diagram.pdg --target web # size labels to fit any browser font
23
33
  ```
24
34
 
25
- `pdg` is the short alias of the `predictygraph` command.
35
+ From a checkout, prefix these with `uv run`. Start with
36
+ [examples/annotations.pdg](examples/annotations.pdg): one small diagram that uses every
37
+ annotation feature, with a comment for each.
38
+
39
+ ## Agent skill
40
+
41
+ [skills/predictygraph](skills/predictygraph) teaches an AI agent to write, render and
42
+ check `.pdg` diagrams.
43
+
44
+ ```bash
45
+ # Hermes Agent
46
+ hermes skills install https://raw.githubusercontent.com/predictygraph/predictygraph/master/skills/predictygraph/SKILL.md
47
+ # Claude Code and other agents that read SKILL.md folders: copy the folder
48
+ cp -r skills/predictygraph ~/.claude/skills/
49
+ ```
26
50
 
27
51
  ## Credits
28
52
 
@@ -12,7 +12,7 @@ dependencies = [
12
12
  ]
13
13
  requires-python = ">=3.11"
14
14
  readme = "README.md"
15
- version = "0.2.2"
15
+ version = "0.2.4"
16
16
 
17
17
  [project.license]
18
18
  text = "GPL-3.0-or-later"
@@ -160,6 +160,9 @@ class LineLayout(Layout):
160
160
  if j != i + 1:
161
161
  continue
162
162
  w, _, _ = text_block(e.label, e.label_style or e.style, 11, bold=False)
163
+ if e.attrs.get("step"): # the step badge sits just left of the label
164
+ bw, bh, _ = text_block(e.attrs["step"][:3], e.step_style or e.style, 11, bold=True)
165
+ w += max(bw, bh) + 12
163
166
  need[i] = max(need.get(i, 0.0), w + 34)
164
167
  return need
165
168
 
@@ -555,6 +558,43 @@ def absolutize(g: Group, ox: float, oy: float) -> None:
555
558
  absolutize(c, c.ax, c.ay)
556
559
 
557
560
 
561
+ def shift(item: Item, dx: float) -> None:
562
+ """Move an item sideways by dx after absolutize: its own x (relative to its parent)
563
+ and the absolute position of it and everything inside it."""
564
+ item.x += dx
565
+
566
+ def move(i: Item) -> None:
567
+ i.ax += dx
568
+ if isinstance(i, Group):
569
+ for c in i.children:
570
+ move(c)
571
+ move(item)
572
+
573
+
574
+ def align_x(doc: Document) -> None:
575
+ """`align-x=ID`: centre an item horizontally on another item, e.g. line up a box with
576
+ the one it calls in the panel below. Siblings on the side it moves towards travel with
577
+ it, so every gap (and the edge labels in it) keeps its width; the whole block stays
578
+ inside the parent's content box, so no group is resized and nothing overlaps."""
579
+ for item in list(doc.walk()):
580
+ ref = doc.items.get(item.attrs.get("align-x", ""))
581
+ p = item.parent
582
+ if ref is None or ref is item or p is None:
583
+ continue
584
+ pr = box4(p.style.get("padding"), 14)[1]
585
+ lo_edge, hi_edge = p.ax + p.ox, p.ax + p.w - pr
586
+ dx = ref.cx - item.cx
587
+ if dx > 0: # item and everything right of it move right, up to the right edge
588
+ block = [s for s in p.children if s is item or s.ax >= item.ax + item.w - 1e-6]
589
+ dx = min(dx, hi_edge - max(s.ax + s.w for s in block))
590
+ else: # item and everything left of it move left, down to the left edge
591
+ block = [s for s in p.children if s is item or s.ax + s.w <= item.ax + 1e-6]
592
+ dx = max(dx, lo_edge - min(s.ax for s in block))
593
+ if abs(dx) > 0.01:
594
+ for s in block:
595
+ shift(s, dx)
596
+
597
+
558
598
  def layout(doc: Document) -> None:
559
599
  root = doc.root
560
600
  for e in doc.edges: # layout may run again after the router widens gaps
@@ -562,6 +602,7 @@ def layout(doc: Document) -> None:
562
602
  e.waypoints, e.wp_owner = [], None
563
603
  layout_group(root, doc)
564
604
  absolutize(root, 0, 0)
605
+ align_x(doc)
565
606
  # translate virtual waypoints into absolute coords and attach to edges
566
607
  for g in [root] + [i for i in doc.walk() if isinstance(i, Group)]:
567
608
  lay = getattr(g, "layout_obj", None)
@@ -38,6 +38,43 @@ def point_along(poly: list[tuple[float, float]], frac: float) -> tuple[float, fl
38
38
  return poly[-1]
39
39
 
40
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
+
41
78
  class SceneBuilder:
42
79
  def __init__(self, doc: Document, router: Router):
43
80
  self.doc, self.router = doc, router
@@ -177,11 +214,7 @@ class SceneBuilder:
177
214
  paint["opacity"] = st["opacity"]
178
215
  if st.get("shadow") == "soft":
179
216
  paint["filter"] = "shadow"
180
- r = px(st.get("border-radius"), 0)
181
- if shape == "round":
182
- r = max(r, 10)
183
- if shape == "pill":
184
- r = h / 2
217
+ r = corner_radius(st, shape, w, h)
185
218
  if shape in ("box", "round", "pill", "note", "square"):
186
219
  el = Elem("rect", geom={"x": x, "y": y, "w": w, "h": h, "rx": r}, paint=paint)
187
220
  elif shape in ("circle", "ellipse"):
@@ -249,14 +282,14 @@ class SceneBuilder:
249
282
  return out
250
283
 
251
284
  def accent(self, it, st) -> list[Elem]:
252
- """`accent-bar: 4px #22c55e` draws a strip inside the box edge (`accent-side`)."""
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."""
253
287
  a = accent(st)
254
288
  if not a:
255
289
  return []
256
290
  bw, color, side = a
257
- x = it.ax if side == "left" else it.ax + it.w - bw
258
- r = min(px(st.get("border-radius"), 6), bw)
259
- return [Elem("rect", cls="pdg-accent", geom={"x": x, "y": it.ay, "w": bw, "h": it.h, "rx": r},
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)},
260
293
  paint={"fill": color or st.get("stroke", "#22c55e")})]
261
294
 
262
295
  def group(self, g: Group) -> Elem:
@@ -278,7 +311,7 @@ class SceneBuilder:
278
311
  def _title_band(self, g: Group, st: dict) -> Elem:
279
312
  """`title-fill`: a band behind the title, rounded like the group's top corners."""
280
313
  bh = box4(st.get("padding"), 14)[0] + g.label_h - 3
281
- r = px(st.get("border-radius"), 8)
314
+ r = min(corner_radius(st, g.attrs.get("shape", "box"), g.w, g.h), bh)
282
315
  return Elem("path", cls="pdg-title-band", paint={"fill": st["title-fill"]}, geom={"segs": [
283
316
  ("M", g.ax, g.ay + bh), ("L", g.ax, g.ay + r), ("A", r, r, 0, 0, 1, g.ax + r, g.ay),
284
317
  ("L", g.ax + g.w - r, g.ay), ("A", r, r, 0, 0, 1, g.ax + g.w, g.ay + r),
@@ -0,0 +1,98 @@
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
85
+
86
+
87
+ def test_align_x_keeps_gaps_by_moving_neighbours():
88
+ src = ('graph layout=column align=stretch\n'
89
+ 'group top layout=row gap=300 {\n p "p"\n q "q"\n}\n'
90
+ 'group bottom layout=row gap=30 {\n a "aaaa" align-x=q\n b "bbbb"\n}\n'
91
+ 'a -> b "a fairly long label"\n')
92
+ plain = parse(src.replace(" align-x=q", ""))
93
+ build_scene(plain)
94
+ doc = parse(src)
95
+ build_scene(doc)
96
+ gap = lambda d: d.items["b"].ax - (d.items["a"].ax + d.items["a"].w)
97
+ assert abs(gap(doc) - gap(plain)) < 0.01 # b moved with a: the label still fits
98
+ assert doc.items["a"].ax > plain.items["a"].ax
@@ -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"