predictygraph 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {predictygraph-0.2.2 → predictygraph-0.2.3}/PKG-INFO +1 -1
  2. {predictygraph-0.2.2 → predictygraph-0.2.3}/pyproject.toml +1 -1
  3. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/layout.py +32 -0
  4. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/scene.py +43 -10
  5. predictygraph-0.2.3/tests/test_stack.py +84 -0
  6. predictygraph-0.2.2/tests/test_stack.py +0 -41
  7. {predictygraph-0.2.2 → predictygraph-0.2.3}/README.md +0 -0
  8. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/__init__.py +0 -0
  9. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/__main__.py +0 -0
  10. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/anim.py +0 -0
  11. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/cli.py +0 -0
  12. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/default.css +0 -0
  13. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  14. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  15. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  16. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  17. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  18. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  19. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  20. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  21. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  22. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  23. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/fonts.py +0 -0
  24. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/ir.py +0 -0
  25. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/model.py +0 -0
  26. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/parser.py +0 -0
  27. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/render/__init__.py +0 -0
  28. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/render/apng.py +0 -0
  29. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/render/svg.py +0 -0
  30. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/route.py +0 -0
  31. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/safe.py +0 -0
  32. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/style.py +0 -0
  33. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/styleutil.py +0 -0
  34. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/text.py +0 -0
  35. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/theme.py +0 -0
  36. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  37. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  38. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  39. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  40. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/default.toml +0 -0
  41. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/dracula.toml +0 -0
  42. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/github-light.toml +0 -0
  43. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  44. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/hami.toml +0 -0
  45. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/nord.toml +0 -0
  46. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/solarized-dark.toml +0 -0
  47. {predictygraph-0.2.2 → predictygraph-0.2.3}/src/predictygraph/themes/solarized-light.toml +0 -0
  48. {predictygraph-0.2.2 → predictygraph-0.2.3}/tests/test_annotations.py +0 -0
  49. {predictygraph-0.2.2 → predictygraph-0.2.3}/tests/test_diagonal.py +0 -0
  50. {predictygraph-0.2.2 → predictygraph-0.2.3}/tests/test_fixes.py +0 -0
  51. {predictygraph-0.2.2 → predictygraph-0.2.3}/tests/test_hardening.py +0 -0
  52. {predictygraph-0.2.2 → predictygraph-0.2.3}/tests/test_predictygraph.py +0 -0
  53. {predictygraph-0.2.2 → predictygraph-0.2.3}/tests/test_routing.py +0 -0
  54. {predictygraph-0.2.2 → predictygraph-0.2.3}/tests/test_scene.py +0 -0
  55. {predictygraph-0.2.2 → predictygraph-0.2.3}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: predictygraph
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Predictable graphs: a readable, CSS-styled diagram DSL with per-group layouts, rendered to SVG/APNG
5
5
  Author-Email: Reza Jelveh <git@jelveh.me>
6
6
  License: GPL-3.0-or-later
@@ -12,7 +12,7 @@ dependencies = [
12
12
  ]
13
13
  requires-python = ">=3.11"
14
14
  readme = "README.md"
15
- version = "0.2.2"
15
+ version = "0.2.3"
16
16
 
17
17
  [project.license]
18
18
  text = "GPL-3.0-or-later"
@@ -555,6 +555,37 @@ def absolutize(g: Group, ox: float, oy: float) -> None:
555
555
  absolutize(c, c.ax, c.ay)
556
556
 
557
557
 
558
+ def shift(item: Item, dx: float) -> None:
559
+ """Move an item sideways by dx after absolutize: its own x (relative to its parent)
560
+ and the absolute position of it and everything inside it."""
561
+ item.x += dx
562
+
563
+ def move(i: Item) -> None:
564
+ i.ax += dx
565
+ if isinstance(i, Group):
566
+ for c in i.children:
567
+ move(c)
568
+ move(item)
569
+
570
+
571
+ def align_x(doc: Document) -> None:
572
+ """`align-x=ID`: centre an item horizontally on another item, e.g. line up a box with
573
+ the one it calls in the panel below. It only moves within its parent's content box and
574
+ never past a sibling, so nothing overlaps and no group is resized."""
575
+ for item in list(doc.walk()):
576
+ ref = doc.items.get(item.attrs.get("align-x", ""))
577
+ p = item.parent
578
+ if ref is None or ref is item or p is None:
579
+ continue
580
+ pr = box4(p.style.get("padding"), 14)[1]
581
+ sibs = [s for s in p.children if s is not item]
582
+ lo = max([s.ax + s.w + 8 for s in sibs if s.ax + s.w <= item.ax + 1e-6], default=p.ax + p.ox)
583
+ hi = min([s.ax - 8 for s in sibs if s.ax >= item.ax + item.w - 1e-6], default=p.ax + p.w - pr)
584
+ new = min(max(ref.cx - item.w / 2, lo), hi - item.w)
585
+ if abs(new - item.ax) > 0.01:
586
+ shift(item, new - item.ax)
587
+
588
+
558
589
  def layout(doc: Document) -> None:
559
590
  root = doc.root
560
591
  for e in doc.edges: # layout may run again after the router widens gaps
@@ -562,6 +593,7 @@ def layout(doc: Document) -> None:
562
593
  e.waypoints, e.wp_owner = [], None
563
594
  layout_group(root, doc)
564
595
  absolutize(root, 0, 0)
596
+ align_x(doc)
565
597
  # translate virtual waypoints into absolute coords and attach to edges
566
598
  for g in [root] + [i for i in doc.walk() if isinstance(i, Group)]:
567
599
  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,84 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """align=stretch tiers, same-side C-shaped edges, theme css."""
6
+ from predictygraph import build_scene
7
+ from predictygraph.parser import parse
8
+ from predictygraph.theme import theme_css
9
+
10
+
11
+ def test_stretch_makes_tiers_equal_width():
12
+ doc = parse("graph layout=column align=stretch\ngroup a {\n x\n}\ngroup b {\n p\n q\n r\n}\n")
13
+ build_scene(doc)
14
+ assert doc.items["a"].w == doc.items["b"].w
15
+
16
+
17
+ def test_same_side_edge_goes_around_boxes_between():
18
+ src = ("graph layout=column\nlong \"a fairly wide box\"\nmid \"an even wider box in the middle\"\n"
19
+ "low \"low\"\nlong -> low exit=left enter=left route=ortho\n")
20
+ doc = parse(src)
21
+ build_scene(doc)
22
+ from predictygraph.route import Router
23
+ pts, _ = Router(doc).geometry(doc.edges[0])
24
+ assert min(x for x, _ in pts) < doc.items["mid"].ax
25
+
26
+
27
+ def test_hami_theme_has_extra_css():
28
+ css = theme_css("hami")
29
+ assert "accent-bar" in css and "title-fill" in css
30
+
31
+
32
+ def test_png_width_colors_and_single_frame(tmp_path):
33
+ import pytest
34
+ pytest.importorskip("cairosvg") # optional extra: predictygraph[apng]
35
+ pytest.importorskip("PIL")
36
+ from PIL import Image
37
+ from predictygraph.render.apng import write_apng
38
+ sc = build_scene(parse('a "x"\nb "y"\na -> b\n'))
39
+ out = write_apng(sc, tmp_path / "d.png", width=300, colors=16)
40
+ im = Image.open(out)
41
+ assert im.size[0] == 300 and getattr(im, "n_frames", 1) == 1 and im.mode == "P"
42
+
43
+
44
+ def test_align_x_lines_up_across_panels():
45
+ src = ('graph layout=column align=stretch\n'
46
+ 'group top layout=row gap=20 {\n a "left box"\n b "short"\n}\n'
47
+ 'group bottom layout=row gap=200 {\n c "c"\n d "a much wider box here"\n}\n')
48
+ plain = parse(src)
49
+ build_scene(plain)
50
+ doc = parse(src.replace('b "short"', 'b "short" align-x=d'))
51
+ build_scene(doc)
52
+ assert abs(doc.items["b"].cx - doc.items["d"].cx) < 0.5
53
+ assert abs(plain.items["b"].cx - plain.items["d"].cx) > 1 # it really moved
54
+
55
+
56
+ def test_align_x_stays_inside_its_panel():
57
+ src = ('graph layout=column\n'
58
+ 'group top layout=row gap=20 {\n a "left box"\n b "short" align-x=d\n}\n'
59
+ 'group bottom layout=row gap=400 {\n c "c"\n d "far right"\n}\n')
60
+ doc = parse(src)
61
+ build_scene(doc)
62
+ b, top = doc.items["b"], doc.items["top"]
63
+ assert b.ax + b.w <= top.ax + top.w
64
+
65
+
66
+ def test_align_x_never_overlaps_a_sibling():
67
+ src = ('graph layout=column\n'
68
+ 'group top layout=row gap=10 {\n a "aaaaaaaa" align-x=z\n b "bbbbbbbb"\n}\n'
69
+ 'group bottom layout=row {\n y "y"\n z "z"\n}\n')
70
+ doc = parse(src)
71
+ build_scene(doc)
72
+ a, b = doc.items["a"], doc.items["b"]
73
+ assert a.ax + a.w <= b.ax
74
+
75
+
76
+ def test_accent_bar_stays_inside_rounded_corners():
77
+ from predictygraph.scene import accent_segs
78
+ segs = accent_segs(0, 0, 100, 40, 4, 10, "left")
79
+ xs = [s[-2] for s in segs if s[0] != "Z"]
80
+ ys = [s[-1] for s in segs if s[0] != "Z"]
81
+ assert min(xs) >= 0 and max(xs) <= 4
82
+ assert min(ys) > 0 and max(ys) < 40 # starts below the top edge: it follows the arc
83
+ right = accent_segs(0, 0, 100, 40, 4, 10, "right")
84
+ assert min(s[-2] for s in right if s[0] != "Z") >= 96
@@ -1,41 +0,0 @@
1
- # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
- #
3
- # SPDX-License-Identifier: GPL-3.0-or-later
4
-
5
- """align=stretch tiers, same-side C-shaped edges, theme css."""
6
- from predictygraph import build_scene
7
- from predictygraph.parser import parse
8
- from predictygraph.theme import theme_css
9
-
10
-
11
- def test_stretch_makes_tiers_equal_width():
12
- doc = parse("graph layout=column align=stretch\ngroup a {\n x\n}\ngroup b {\n p\n q\n r\n}\n")
13
- build_scene(doc)
14
- assert doc.items["a"].w == doc.items["b"].w
15
-
16
-
17
- def test_same_side_edge_goes_around_boxes_between():
18
- src = ("graph layout=column\nlong \"a fairly wide box\"\nmid \"an even wider box in the middle\"\n"
19
- "low \"low\"\nlong -> low exit=left enter=left route=ortho\n")
20
- doc = parse(src)
21
- build_scene(doc)
22
- from predictygraph.route import Router
23
- pts, _ = Router(doc).geometry(doc.edges[0])
24
- assert min(x for x, _ in pts) < doc.items["mid"].ax
25
-
26
-
27
- def test_hami_theme_has_extra_css():
28
- css = theme_css("hami")
29
- assert "accent-bar" in css and "title-fill" in css
30
-
31
-
32
- def test_png_width_colors_and_single_frame(tmp_path):
33
- import pytest
34
- pytest.importorskip("cairosvg") # optional extra: predictygraph[apng]
35
- pytest.importorskip("PIL")
36
- from PIL import Image
37
- from predictygraph.render.apng import write_apng
38
- sc = build_scene(parse('a "x"\nb "y"\na -> b\n'))
39
- out = write_apng(sc, tmp_path / "d.png", width=300, colors=16)
40
- im = Image.open(out)
41
- assert im.size[0] == 300 and getattr(im, "n_frames", 1) == 1 and im.mode == "P"
File without changes