predictygraph 0.1.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {predictygraph-0.1.1 → predictygraph-0.2.2}/PKG-INFO +2 -1
  2. {predictygraph-0.1.1 → predictygraph-0.2.2}/README.md +1 -0
  3. {predictygraph-0.1.1 → predictygraph-0.2.2}/pyproject.toml +1 -1
  4. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/cli.py +4 -2
  5. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/default.css +8 -0
  6. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/layout.py +65 -19
  7. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/model.py +22 -0
  8. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/parser.py +35 -1
  9. predictygraph-0.2.2/src/predictygraph/render/apng.py +72 -0
  10. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/render/svg.py +2 -0
  11. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/route.py +24 -0
  12. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/scene.py +216 -46
  13. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/style.py +43 -8
  14. predictygraph-0.2.2/src/predictygraph/styleutil.py +61 -0
  15. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/theme.py +4 -0
  16. predictygraph-0.2.2/src/predictygraph/themes/hami.toml +47 -0
  17. predictygraph-0.2.2/tests/test_annotations.py +107 -0
  18. predictygraph-0.2.2/tests/test_stack.py +41 -0
  19. predictygraph-0.1.1/src/predictygraph/render/apng.py +0 -43
  20. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/__init__.py +0 -0
  21. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/__main__.py +0 -0
  22. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/anim.py +0 -0
  23. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
  24. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
  25. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  26. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
  27. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  28. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  29. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  30. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  31. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  32. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  33. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts.py +0 -0
  34. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/ir.py +0 -0
  35. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/render/__init__.py +0 -0
  36. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/safe.py +0 -0
  37. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/text.py +0 -0
  38. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
  39. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
  40. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
  41. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
  42. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/default.toml +0 -0
  43. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/dracula.toml +0 -0
  44. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/github-light.toml +0 -0
  45. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
  46. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/nord.toml +0 -0
  47. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/solarized-dark.toml +0 -0
  48. {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/solarized-light.toml +0 -0
  49. {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_diagonal.py +0 -0
  50. {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_fixes.py +0 -0
  51. {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_hardening.py +0 -0
  52. {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_predictygraph.py +0 -0
  53. {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_routing.py +0 -0
  54. {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_scene.py +0 -0
  55. {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: predictygraph
3
- Version: 0.1.1
3
+ Version: 0.2.2
4
4
  Summary: Predictable graphs: a readable, CSS-styled diagram DSL with per-group layouts, rendered to SVG/APNG
5
5
  Author-Email: Reza Jelveh <git@jelveh.me>
6
6
  License: GPL-3.0-or-later
@@ -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.1"
15
+ version = "0.2.2"
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,11 @@ 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; }
24
+ bracket { stroke: #9ca3af; stroke-width: 1.5; color: #4b5563; font-size: 11px; font-style: italic; }
@@ -10,17 +10,22 @@ from collections import defaultdict
10
10
 
11
11
  from .model import Document, Group, Item, Node
12
12
  from .style import box4, px
13
- from .text import load_image, measure
13
+ from .styleutil import has_title_band, node_padding, text_block, title_position
14
+ from .text import load_image
14
15
 
15
16
 
16
17
  # ---------------------------------------------------------------- sizing
18
+ SUB_GAP = 3.0 # px between a label and its `sub=` lines
19
+
20
+
17
21
  def size_node(n: Node, doc: Document) -> None:
18
22
  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
23
  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, [])
23
- pt, pr, pb, pl = box4(st.get("padding"), 8)
24
+ n.text_w, n.text_h, n.text_lines = text_block(label, st, 14)
25
+ sw, sh, n.sub_lines = text_block(n.attrs.get("sub"), n.sub_style or st, 12)
26
+ n.sub_h = sh + SUB_GAP if n.sub_lines else 0.0
27
+ n.text_w, n.text_h = max(n.text_w, sw), n.text_h + n.sub_h # text_h covers label + sub
28
+ pt, pr, pb, pl = node_padding(st)
24
29
  shape = n.attrs.get("shape")
25
30
  if "image" in n.attrs:
26
31
  img = load_image(n.attrs["image"], doc.base_dir, doc.allow_outside)
@@ -114,19 +119,50 @@ class LineLayout(Layout):
114
119
  gap = px(opt(self.g, "gap", "24"))
115
120
  align = opt(self.g, "align", "center")
116
121
  cross = max((k.h if horizontal else k.w) for k in self.kids)
117
- pos = 0.0
118
- for k in self.kids:
122
+ need = self.label_gaps(horizontal)
123
+ pos, last = 0.0, 0.0
124
+ for idx, k in enumerate(self.kids):
125
+ if align == "stretch": # every child as wide (column) / tall (row) as the widest
126
+ if horizontal:
127
+ k.h = cross
128
+ else:
129
+ grow = cross - k.w
130
+ k.w = cross
131
+ if isinstance(k, Group) and opt(k, "justify", "start") == "center":
132
+ k.ox += grow / 2 # content stays left unless the tier asks to centre it
119
133
  size_c = k.h if horizontal else k.w
120
134
  off = {"start": 0, "end": cross - size_c}.get(align, (cross - size_c) / 2)
135
+ step = max(gap, need.get(idx, 0.0))
121
136
  if horizontal:
122
137
  k.x, k.y = pos, off
123
- pos += k.w + gap
138
+ pos += k.w + step
124
139
  else:
125
140
  k.x, k.y = off, pos
126
- pos += k.h + gap
127
- pos -= gap
141
+ pos += k.h + step
142
+ last = step
143
+ pos -= last
128
144
  return (pos, cross) if horizontal else (cross, pos)
129
145
 
146
+ def label_gaps(self, horizontal: bool) -> dict[int, float]:
147
+ """Gap needed after child i so labels of edges between neighbours i and i+1 fit
148
+ (a row only: a label runs along the gap). Room = label width + arrowhead + margin."""
149
+ if not horizontal:
150
+ return {}
151
+ index = {k.id: i for i, k in enumerate(self.kids)}
152
+ need: dict[int, float] = {}
153
+ for e in self.doc.edges:
154
+ if not e.label:
155
+ continue
156
+ a, b = direct_child(self.doc.items[e.src], self.g), direct_child(self.doc.items[e.dst], self.g)
157
+ if a is None or b is None or a is b:
158
+ continue
159
+ i, j = sorted((index[a.id], index[b.id]))
160
+ if j != i + 1:
161
+ continue
162
+ w, _, _ = text_block(e.label, e.label_style or e.style, 11, bold=False)
163
+ need[i] = max(need.get(i, 0.0), w + 34)
164
+ return need
165
+
130
166
 
131
167
  class GridLayout(Layout):
132
168
  def run(self):
@@ -485,18 +521,28 @@ def layout_group(g: Group, doc: Document) -> None:
485
521
  else:
486
522
  lay = LAYOUTS[g.attrs["layout"]](g, doc)
487
523
  cw, ch = lay.run()
488
- g.label_h = 0.0
524
+ g.label_h = g.title_h = 0.0
525
+ g.side_w = 0.0 # label-position: left -> a column for the rotated title
489
526
  lw = 0.0
490
- 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
527
+ pos = title_position(g.style)
528
+ if g.label and g.kind == "group" and pos == "left":
529
+ tw, th, _ = text_block(g.label, g.style, 12)
530
+ g.title_h = th
531
+ g.side_w = th + 12
532
+ lw = 0.0
533
+ ch = max(ch, tw - pt - pb + 20) # tall enough for the rotated title, with a margin
534
+ elif g.label and g.kind == "group":
535
+ lw, g.title_h, _ = text_block(g.label, g.style, 12)
536
+ sw, sh, _ = text_block(g.attrs.get("sub"), g.sub_style or g.style, 11)
537
+ lw = max(lw, sw)
538
+ g.label_h = g.title_h + (sh + SUB_GAP if sh else 0.0) + 6
539
+ if has_title_band(g.style):
540
+ g.label_h += 6
495
541
  inner_w = max(cw, lw) + getattr(g, "extra_w", 0.0) # extra_w: room requested for the title
496
- top = g.style.get("label-position", "top") != "bottom"
497
- g.ox = pl + (inner_w - cw) / 2
542
+ top = pos not in ("bottom", "left")
543
+ g.ox = pl + g.side_w + (inner_w - cw) / 2
498
544
  g.oy = pt + (g.label_h if top else 0)
499
- g.w = inner_w + pl + pr
545
+ g.w = inner_w + pl + pr + g.side_w
500
546
  g.h = ch + pt + pb + g.label_h
501
547
  g.layout_obj = lay
502
548
 
@@ -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,9 @@ 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
+ side_w: float = 0.0 # label-position: left -> width of the rotated-title column
67
+ part_styles: dict[str, dict[str, str]] = field(default_factory=dict) # root: caption, legend
62
68
 
63
69
 
64
70
  @dataclass
@@ -73,6 +79,7 @@ class Edge:
73
79
  scope: Group | None = None
74
80
  style: dict[str, str] = field(default_factory=dict)
75
81
  label_style: dict[str, str] = field(default_factory=dict)
82
+ step_style: dict[str, str] = field(default_factory=dict)
76
83
  index: int = 0
77
84
  waypoints: list[tuple[float, float]] = field(default_factory=list)
78
85
  wp_owner: Group | None = None
@@ -81,6 +88,18 @@ class Edge:
81
88
  id: str = ""
82
89
 
83
90
 
91
+ @dataclass
92
+ class Bracket:
93
+ """`bracket a, b "note"`: a curly bracket beside the span of the listed items."""
94
+ ids: list[str]
95
+ label: str = ""
96
+ classes: list[str] = field(default_factory=list)
97
+ attrs: dict[str, str] = field(default_factory=dict)
98
+ style: dict[str, str] = field(default_factory=dict)
99
+ kind: str = "bracket"
100
+ id: str = ""
101
+
102
+
84
103
  @dataclass
85
104
  class Document:
86
105
  root: Group
@@ -89,6 +108,9 @@ class Document:
89
108
  items: dict[str, Item] = field(default_factory=dict)
90
109
  base_dir: str = "."
91
110
  allow_outside: bool = False # let image=/css "..." read files outside base_dir
111
+ legend: list[Node] = field(default_factory=list) # `legend .cls "text"` entries (not laid out)
112
+ legend_title: str | None = None
113
+ brackets: list["Bracket"] = field(default_factory=list)
92
114
 
93
115
  def walk(self, g: Group | None = None):
94
116
  g = g or self.root
@@ -8,9 +8,11 @@ from __future__ import annotations
8
8
  import re
9
9
  from pathlib import Path
10
10
 
11
- from .model import Document, Edge, Group, Node
11
+ from .model import Bracket, Document, Edge, Group, Node
12
12
  from .safe import MAX_EDGES, safe_path
13
13
 
14
+ MAX_ANNOTATIONS = 64 # legend entries / brackets per diagram
15
+
14
16
 
15
17
  class PdgSyntaxError(ValueError):
16
18
  def __init__(self, msg: str, line: int):
@@ -133,6 +135,13 @@ class Parser:
133
135
  raise PdgSyntaxError(f"unexpected token {val!r}", lineno)
134
136
  return label, classes, attrs
135
137
 
138
+ def _annotation_room(self, stmt: str, existing: list, what: str, lineno: int) -> None:
139
+ """`legend` / `bracket`: top level only, at most MAX_ANNOTATIONS of each."""
140
+ if self.scope is not self.root:
141
+ raise PdgSyntaxError(f"'{stmt}' only allowed at top level", lineno)
142
+ if len(existing) >= MAX_ANNOTATIONS:
143
+ raise PdgSyntaxError(f"too many {what} (limit {MAX_ANNOTATIONS})", lineno)
144
+
136
145
  def _get_node(self, nid: str, lineno: int):
137
146
  item = self.doc.items.get(nid)
138
147
  if item is None:
@@ -189,6 +198,27 @@ class Parser:
189
198
  if classes:
190
199
  d["__classes__"] = " ".join(classes)
191
200
  return
201
+ if head == "bracket" and len(toks) >= 2 and toks[1][0] == "ident":
202
+ self._annotation_room(head, self.doc.brackets, "brackets", lineno)
203
+ ids, i = [], 1
204
+ while i < len(toks) and toks[i][0] in ("ident", "punct") and toks[i][1] != "{":
205
+ if toks[i][0] == "ident":
206
+ ids.append(toks[i][1])
207
+ i += 1
208
+ label, classes, attrs = self._tail(toks, i, lineno)
209
+ self.doc.brackets.append(Bracket(ids=ids, label=label or "", classes=list(classes), attrs=attrs))
210
+ return
211
+ if head == "legend" and (len(toks) == 1 or toks[1][0] in ("string", "cls")):
212
+ label, classes, attrs = self._tail(toks, 1, lineno)
213
+ if not classes: # `legend "Key"` names the box
214
+ self._annotation_room(head, [], "", lineno)
215
+ self.doc.legend_title = label
216
+ return
217
+ self._annotation_room(head, self.doc.legend, "legend entries", lineno)
218
+ entry = Node(id=f"pdg-legend-{len(self.doc.legend)}", label=label or " ".join(classes),
219
+ classes=list(classes), attrs=attrs, parent=self.root)
220
+ self.doc.legend.append(entry)
221
+ return
192
222
  if head == "group" and len(toks) >= 2 and toks[1][0] == "ident":
193
223
  if toks[-1] != ("punct", "{"):
194
224
  raise PdgSyntaxError("group needs '{'", lineno)
@@ -298,6 +328,10 @@ class Parser:
298
328
  for nid in (e.src, e.dst):
299
329
  if nid not in self.doc.items:
300
330
  raise PdgSyntaxError(f"unknown id {nid!r}", 0)
331
+ for b in self.doc.brackets:
332
+ missing = [i for i in b.ids if i not in self.doc.items]
333
+ if missing or not b.ids:
334
+ raise PdgSyntaxError(f"bracket: unknown id {missing[0]!r}" if missing else "bracket needs ids", 0)
301
335
  return self.doc
302
336
 
303
337
 
@@ -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)
@@ -71,6 +71,8 @@ class SvgWriter:
71
71
  return f"<g {self.attrs_str(base)}>\n{inner}\n</g>" if base else f"<g>\n{inner}\n</g>"
72
72
  if el.kind == "text":
73
73
  a = {"x": num(float(g["x"])), "y": num(float(g["y"])), "text-anchor": g.get("anchor", "middle")}
74
+ if g.get("rotate"): # about the text's own anchor point
75
+ a["transform"] = f"rotate({num(float(g['rotate']))} {a['x']} {a['y']})"
74
76
  a.update(self.paint_attrs(el))
75
77
  a.update(base)
76
78
  return f"<text {self.attrs_str(a)}>{escape(g['text'])}</text>"
@@ -365,6 +365,19 @@ class Router:
365
365
  if da.id in ri and db.id in ri and abs(ri[da.id] - ri[db.id]) == 1:
366
366
  self.gapkey[e.id] = (owner.id, min(ri[da.id], ri[db.id]))
367
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
+
368
381
  def geometry(self, e: Edge):
369
382
  """-> (points, axis). axis: v | h (flow axis), L (explicit L port) or free."""
370
383
  a, b = self.doc.items[e.src], self.doc.items[e.dst]
@@ -383,6 +396,17 @@ class Router:
383
396
  if v0 != v1: # L-shape: one corner
384
397
  corner = (p0[0], p1[1]) if v0 else (p1[0], p0[1])
385
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"
386
410
  return [p0] + wps + [p1], ("v" if v0 else "h")
387
411
  if d in ("TB", "BT", "LR", "RL") and route != "straight":
388
412
  vert = d in ("TB", "BT")