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.
- {predictygraph-0.1.1 → predictygraph-0.2.2}/PKG-INFO +2 -1
- {predictygraph-0.1.1 → predictygraph-0.2.2}/README.md +1 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/pyproject.toml +1 -1
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/cli.py +4 -2
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/default.css +8 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/layout.py +65 -19
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/model.py +22 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/parser.py +35 -1
- predictygraph-0.2.2/src/predictygraph/render/apng.py +72 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/render/svg.py +2 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/route.py +24 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/scene.py +216 -46
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/style.py +43 -8
- predictygraph-0.2.2/src/predictygraph/styleutil.py +61 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/theme.py +4 -0
- predictygraph-0.2.2/src/predictygraph/themes/hami.toml +47 -0
- predictygraph-0.2.2/tests/test_annotations.py +107 -0
- predictygraph-0.2.2/tests/test_stack.py +41 -0
- predictygraph-0.1.1/src/predictygraph/render/apng.py +0 -43
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/__init__.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/__main__.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/anim.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/fonts.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/ir.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/render/__init__.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/safe.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/text.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/default.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/dracula.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/github-light.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/nord.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/solarized-dark.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/src/predictygraph/themes/solarized-light.toml +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_diagonal.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_fixes.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_hardening.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_predictygraph.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_routing.py +0 -0
- {predictygraph-0.1.1 → predictygraph-0.2.2}/tests/test_scene.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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="
|
|
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 .
|
|
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 =
|
|
23
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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 +
|
|
138
|
+
pos += k.w + step
|
|
124
139
|
else:
|
|
125
140
|
k.x, k.y = off, pos
|
|
126
|
-
pos += k.h +
|
|
127
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
g.
|
|
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 =
|
|
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")
|