predictygraph 0.2.3__tar.gz → 0.2.5__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.3 → predictygraph-0.2.5}/PKG-INFO +32 -8
  2. {predictygraph-0.2.3 → predictygraph-0.2.5}/README.md +31 -7
  3. {predictygraph-0.2.3 → predictygraph-0.2.5}/pyproject.toml +1 -1
  4. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/layout.py +27 -15
  5. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/scene.py +44 -3
  6. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_annotations.py +19 -0
  7. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_stack.py +14 -0
  8. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/__init__.py +0 -0
  9. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/__main__.py +0 -0
  10. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/anim.py +0 -0
  11. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/cli.py +0 -0
  12. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/default.css +0 -0
  13. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  14. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  15. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  16. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  17. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  18. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  19. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  20. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  21. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  22. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  23. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/fonts.py +0 -0
  24. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/ir.py +0 -0
  25. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/model.py +0 -0
  26. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/parser.py +0 -0
  27. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/render/__init__.py +0 -0
  28. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/render/apng.py +0 -0
  29. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/render/svg.py +0 -0
  30. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/route.py +0 -0
  31. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/safe.py +0 -0
  32. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/style.py +0 -0
  33. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/styleutil.py +0 -0
  34. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/text.py +0 -0
  35. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/theme.py +0 -0
  36. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  37. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  38. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  39. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  40. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/default.toml +0 -0
  41. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/dracula.toml +0 -0
  42. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/github-light.toml +0 -0
  43. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  44. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/hami.toml +0 -0
  45. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/nord.toml +0 -0
  46. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/solarized-dark.toml +0 -0
  47. {predictygraph-0.2.3 → predictygraph-0.2.5}/src/predictygraph/themes/solarized-light.toml +0 -0
  48. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_diagonal.py +0 -0
  49. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_fixes.py +0 -0
  50. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_hardening.py +0 -0
  51. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_predictygraph.py +0 -0
  52. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_routing.py +0 -0
  53. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_scene.py +0 -0
  54. {predictygraph-0.2.3 → predictygraph-0.2.5}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: predictygraph
3
- Version: 0.2.3
3
+ Version: 0.2.5
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.3"
15
+ version = "0.2.5"
16
16
 
17
17
  [project.license]
18
18
  text = "GPL-3.0-or-later"
@@ -144,14 +144,13 @@ class LineLayout(Layout):
144
144
  return (pos, cross) if horizontal else (cross, pos)
145
145
 
146
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 {}
147
+ """Gap needed after child i so what sits on the edges between neighbours i and i+1
148
+ fits. Row: the edge label (plus its step circle) runs along the gap. Column: a step
149
+ circle sits beside the edge, so the gap must be at least one circle plus margins."""
151
150
  index = {k.id: i for i, k in enumerate(self.kids)}
152
151
  need: dict[int, float] = {}
153
152
  for e in self.doc.edges:
154
- if not e.label:
153
+ if not (e.label if horizontal else e.attrs.get("step")):
155
154
  continue
156
155
  a, b = direct_child(self.doc.items[e.src], self.g), direct_child(self.doc.items[e.dst], self.g)
157
156
  if a is None or b is None or a is b:
@@ -159,8 +158,15 @@ class LineLayout(Layout):
159
158
  i, j = sorted((index[a.id], index[b.id]))
160
159
  if j != i + 1:
161
160
  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)
161
+ step = 0.0
162
+ if e.attrs.get("step"): # circle diameter, as drawn by SceneBuilder.step_badge
163
+ sw, sh, _ = text_block(e.attrs["step"][:3], e.step_style or e.style, 11, bold=True)
164
+ step = max(sw, sh) + 6
165
+ if horizontal:
166
+ w, _, _ = text_block(e.label, e.label_style or e.style, 11, bold=False)
167
+ need[i] = max(need.get(i, 0.0), w + (step + 6 if step else 0.0) + 34)
168
+ else:
169
+ need[i] = max(need.get(i, 0.0), step + 14)
164
170
  return need
165
171
 
166
172
 
@@ -570,20 +576,26 @@ def shift(item: Item, dx: float) -> None:
570
576
 
571
577
  def align_x(doc: Document) -> None:
572
578
  """`align-x=ID`: centre an item horizontally on another item, e.g. line up a box with
573
- the one it calls in the panel below. It only moves within its parent's content box and
574
- never past a sibling, so nothing overlaps and no group is resized."""
579
+ the one it calls in the panel below. Siblings on the side it moves towards travel with
580
+ it, so every gap (and the edge labels in it) keeps its width; the whole block stays
581
+ inside the parent's content box, so no group is resized and nothing overlaps."""
575
582
  for item in list(doc.walk()):
576
583
  ref = doc.items.get(item.attrs.get("align-x", ""))
577
584
  p = item.parent
578
585
  if ref is None or ref is item or p is None:
579
586
  continue
580
587
  pr = box4(p.style.get("padding"), 14)[1]
581
- sibs = [s for s in p.children if s is not item]
582
- lo = max([s.ax + s.w + 8 for s in sibs if s.ax + s.w <= item.ax + 1e-6], default=p.ax + p.ox)
583
- hi = min([s.ax - 8 for s in sibs if s.ax >= item.ax + item.w - 1e-6], default=p.ax + p.w - pr)
584
- new = min(max(ref.cx - item.w / 2, lo), hi - item.w)
585
- if abs(new - item.ax) > 0.01:
586
- shift(item, new - item.ax)
588
+ lo_edge, hi_edge = p.ax + p.ox, p.ax + p.w - pr
589
+ dx = ref.cx - item.cx
590
+ if dx > 0: # item and everything right of it move right, up to the right edge
591
+ block = [s for s in p.children if s is item or s.ax >= item.ax + item.w - 1e-6]
592
+ dx = min(dx, hi_edge - max(s.ax + s.w for s in block))
593
+ else: # item and everything left of it move left, down to the left edge
594
+ block = [s for s in p.children if s is item or s.ax + s.w <= item.ax + 1e-6]
595
+ dx = max(dx, lo_edge - min(s.ax for s in block))
596
+ if abs(dx) > 0.01:
597
+ for s in block:
598
+ shift(s, dx)
587
599
 
588
600
 
589
601
  def layout(doc: Document) -> None:
@@ -477,8 +477,10 @@ class SceneBuilder:
477
477
  return out
478
478
 
479
479
  def step_badge(self, e, poly, label_box=None) -> list[Elem]:
480
- """Numbered circle on the edge, a third of the way along its path. With a label the
481
- badge sits just left of the label box, so neither covers the other."""
480
+ """Numbered circle for an edge. Next to the label when there is one; on the line a
481
+ third of the way along when the edge is long enough to hold it with room to spare;
482
+ otherwise beside the middle of the edge, so a short arrow between stacked boxes
483
+ stays a visible line and the circle never sits on a box."""
482
484
  st = e.step_style or e.style
483
485
  st = {**st, "font-weight": st.get("font-weight", "bold")}
484
486
  fs = font_size(st, 11)
@@ -488,12 +490,51 @@ class SceneBuilder:
488
490
  if label_box:
489
491
  cx, cy = label_box["x"] - r - 3, label_box["y"] + label_box["h"] / 2
490
492
  else:
491
- cx, cy = point_along(poly, 1 / 3)
493
+ cx, cy = self._step_spot(e, poly, r)
492
494
  circ = Elem("circle", cls="pdg-step", geom={"cx": cx, "cy": cy, "r": r},
493
495
  paint={"fill": st.get("fill", "#374151"), "stroke": self.doc.root.style.get("background", "#ffffff"),
494
496
  "stroke-width": 1.5})
495
497
  return [circ] + self.text([txt], cx, cy - fs * LINE_H / 2, fs, st)
496
498
 
499
+ STEP_ROOM = 3.0 # an edge must be this many circle diameters long to carry its step
500
+
501
+ def _step_spot(self, e, poly, r) -> tuple[float, float]:
502
+ """Where a label-less step circle goes (see step_badge)."""
503
+ length = sum(math.dist(a, b) for a, b in zip(poly, poly[1:]))
504
+ on_line = point_along(poly, 1 / 3)
505
+ if length >= self.STEP_ROOM * 2 * r and not self._on_a_box(on_line, r, e):
506
+ return on_line
507
+ mx, my = point_along(poly, 1 / 2)
508
+ # the segment holding the midpoint gives the edge's direction there
509
+ ax, ay, bx, by = *poly[0], *poly[-1]
510
+ acc, half = 0.0, length / 2
511
+ for a, b in zip(poly, poly[1:]):
512
+ seg = math.dist(a, b)
513
+ if acc + seg >= half:
514
+ (ax, ay), (bx, by) = a, b
515
+ break
516
+ acc += seg
517
+ d = math.hypot(bx - ax, by - ay) or 1.0
518
+ nx, ny = -(by - ay) / d, (bx - ax) / d # unit normal
519
+ off = r + 4
520
+ sides = [(mx - nx * off, my - ny * off), (mx + nx * off, my + ny * off)]
521
+ if abs(by - ay) >= abs(bx - ax): # vertical-ish edge: prefer the left side
522
+ sides.sort(key=lambda p: p[0])
523
+ else: # horizontal-ish edge: prefer above
524
+ sides.sort(key=lambda p: p[1])
525
+ free = [p for p in sides if not self._on_a_box(p, r, e)]
526
+ return (free or sides)[0]
527
+
528
+ def _on_a_box(self, p, r, e) -> bool:
529
+ """Would a circle of radius r at p overlap any node box (with a small margin)?"""
530
+ x, y = p
531
+ for n in self.doc.walk():
532
+ if isinstance(n, Group):
533
+ continue
534
+ if n.ax - r - 2 < x < n.ax + n.w + r + 2 and n.ay - r - 2 < y < n.ay + n.h + r + 2:
535
+ return True
536
+ return False
537
+
497
538
  @staticmethod
498
539
  def head(kind, tip, d, hl, color, width, bg="#ffffff") -> Elem:
499
540
  """Arrowhead as plain geometry: tip sits exactly on the target outline.
@@ -105,3 +105,22 @@ def test_left_label_is_rotated_and_takes_a_column():
105
105
  rotated.append(e.geom["text"])
106
106
  stack += e.children
107
107
  assert rotated == ["Kernel space"]
108
+
109
+
110
+ def test_step_on_short_arrow_sits_beside_it_and_clear_of_boxes():
111
+ doc, sc = scene('graph layout=column gap=4 steps=auto\na "top box"\nb "bottom box"\na -> b\n')
112
+ circles = []
113
+ stack = list(sc.layers)
114
+ while stack:
115
+ e = stack.pop()
116
+ if e.kind == "circle" and "pdg-step" in (e.cls or ""):
117
+ circles.append(e.geom)
118
+ stack += e.children
119
+ (c,) = circles
120
+ for n in (doc.items["a"], doc.items["b"]):
121
+ inside_x = n.ax - c["r"] < c["cx"] < n.ax + n.w + c["r"]
122
+ inside_y = n.ay - c["r"] < c["cy"] < n.ay + n.h + c["r"]
123
+ assert not (inside_x and inside_y)
124
+ # the column gap grew to fit the circle, even though gap=4 was asked for
125
+ a, b = doc.items["a"], doc.items["b"]
126
+ assert b.ay - (a.ay + a.h) >= 2 * c["r"]
@@ -82,3 +82,17 @@ def test_accent_bar_stays_inside_rounded_corners():
82
82
  assert min(ys) > 0 and max(ys) < 40 # starts below the top edge: it follows the arc
83
83
  right = accent_segs(0, 0, 100, 40, 4, 10, "right")
84
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