predictygraph 0.1.0__tar.gz → 0.2.1__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.0 → predictygraph-0.2.1}/PKG-INFO +2 -1
- {predictygraph-0.1.0 → predictygraph-0.2.1}/README.md +1 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/pyproject.toml +1 -1
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/cli.py +4 -2
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/default.css +7 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/layout.py +40 -8
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/model.py +8 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/parser.py +15 -0
- predictygraph-0.2.1/src/predictygraph/render/apng.py +72 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/route.py +73 -22
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/scene.py +115 -7
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/style.py +40 -8
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/theme.py +4 -0
- predictygraph-0.2.1/src/predictygraph/themes/hami.toml +47 -0
- predictygraph-0.2.1/tests/test_annotations.py +77 -0
- predictygraph-0.2.1/tests/test_routing.py +60 -0
- predictygraph-0.2.1/tests/test_stack.py +41 -0
- predictygraph-0.1.0/src/predictygraph/render/apng.py +0 -43
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/__init__.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/__main__.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/anim.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/ir.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/render/__init__.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/render/svg.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/safe.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/text.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-frappe.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-latte.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-macchiato.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-mocha.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/default.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/dracula.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/github-light.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/gruvbox-dark.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/nord.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/solarized-dark.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/solarized-light.toml +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_diagonal.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_fixes.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_hardening.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_predictygraph.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_scene.py +0 -0
- {predictygraph-0.1.0 → predictygraph-0.2.1}/tests/test_targets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: predictygraph
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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,10 @@ 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; }
|
|
@@ -14,13 +14,27 @@ from .text import load_image, measure
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
# ---------------------------------------------------------------- sizing
|
|
17
|
+
BOLD = ("bold", "bolder", "600", "700", "800", "900")
|
|
18
|
+
SUB_GAP = 3.0 # px between a label and its `sub=` lines
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def text_block(text: str | None, st: dict, default_fs: float) -> tuple[float, float, list[str]]:
|
|
22
|
+
if not text:
|
|
23
|
+
return 0.0, 0.0, []
|
|
24
|
+
fs = px(st.get("font-size"), default_fs)
|
|
25
|
+
return measure(text, st.get("font-family", "sans-serif"), fs, st.get("font-weight", "normal") in BOLD)
|
|
26
|
+
|
|
27
|
+
|
|
17
28
|
def size_node(n: Node, doc: Document) -> None:
|
|
18
29
|
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
30
|
label = n.label if n.label is not None else n.id
|
|
22
|
-
n.text_w, n.text_h, n.text_lines =
|
|
31
|
+
n.text_w, n.text_h, n.text_lines = text_block(label, st, 14)
|
|
32
|
+
sw, sh, n.sub_lines = text_block(n.attrs.get("sub"), n.sub_style or st, 12)
|
|
33
|
+
n.sub_h = sh + SUB_GAP if n.sub_lines else 0.0
|
|
34
|
+
n.text_w, n.text_h = max(n.text_w, sw), n.text_h + n.sub_h # text_h covers label + sub
|
|
23
35
|
pt, pr, pb, pl = box4(st.get("padding"), 8)
|
|
36
|
+
pl += accent_width(st, "left")
|
|
37
|
+
pr += accent_width(st, "right")
|
|
24
38
|
shape = n.attrs.get("shape")
|
|
25
39
|
if "image" in n.attrs:
|
|
26
40
|
img = load_image(n.attrs["image"], doc.base_dir, doc.allow_outside)
|
|
@@ -71,6 +85,14 @@ def size_node(n: Node, doc: Document) -> None:
|
|
|
71
85
|
|
|
72
86
|
|
|
73
87
|
# ---------------------------------------------------------------- helpers
|
|
88
|
+
def accent_width(st: dict, side: str) -> float:
|
|
89
|
+
"""Room an `accent-bar` takes on `side` (it sits inside the box, before the padding)."""
|
|
90
|
+
spec = st.get("accent-bar")
|
|
91
|
+
if not spec or spec.strip() == "none" or st.get("accent-side", "left") != side:
|
|
92
|
+
return 0.0
|
|
93
|
+
return px(st["accent-bar"].split()[0], 4)
|
|
94
|
+
|
|
95
|
+
|
|
74
96
|
def direct_child(item: Item, g: Group) -> Item | None:
|
|
75
97
|
"""Ancestor-or-self of `item` that is a direct child of g."""
|
|
76
98
|
cur = item
|
|
@@ -116,6 +138,14 @@ class LineLayout(Layout):
|
|
|
116
138
|
cross = max((k.h if horizontal else k.w) for k in self.kids)
|
|
117
139
|
pos = 0.0
|
|
118
140
|
for k in self.kids:
|
|
141
|
+
if align == "stretch": # every child as wide (column) / tall (row) as the widest
|
|
142
|
+
if horizontal:
|
|
143
|
+
k.h = cross
|
|
144
|
+
else:
|
|
145
|
+
grow = cross - k.w
|
|
146
|
+
k.w = cross
|
|
147
|
+
if isinstance(k, Group) and opt(k, "justify", "start") == "center":
|
|
148
|
+
k.ox += grow / 2 # content stays left unless the tier asks to centre it
|
|
119
149
|
size_c = k.h if horizontal else k.w
|
|
120
150
|
off = {"start": 0, "end": cross - size_c}.get(align, (cross - size_c) / 2)
|
|
121
151
|
if horizontal:
|
|
@@ -485,13 +515,15 @@ def layout_group(g: Group, doc: Document) -> None:
|
|
|
485
515
|
else:
|
|
486
516
|
lay = LAYOUTS[g.attrs["layout"]](g, doc)
|
|
487
517
|
cw, ch = lay.run()
|
|
488
|
-
g.label_h = 0.0
|
|
518
|
+
g.label_h = g.title_h = 0.0
|
|
489
519
|
lw = 0.0
|
|
490
520
|
if g.label and g.kind == "group":
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
g.label_h
|
|
521
|
+
lw, g.title_h, _ = text_block(g.label, g.style, 12)
|
|
522
|
+
sw, sh, _ = text_block(g.attrs.get("sub"), g.sub_style or g.style, 11)
|
|
523
|
+
lw = max(lw, sw)
|
|
524
|
+
g.label_h = g.title_h + (sh + SUB_GAP if sh else 0.0) + 6
|
|
525
|
+
if g.style.get("title-fill") not in (None, "none") and g.style.get("label-position", "top") != "bottom":
|
|
526
|
+
g.label_h += 6
|
|
495
527
|
inner_w = max(cw, lw) + getattr(g, "extra_w", 0.0) # extra_w: room requested for the title
|
|
496
528
|
top = g.style.get("label-position", "top") != "bottom"
|
|
497
529
|
g.ox = pl + (inner_w - cw) / 2
|
|
@@ -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,8 @@ 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
|
+
part_styles: dict[str, dict[str, str]] = field(default_factory=dict) # root: caption, legend
|
|
62
67
|
|
|
63
68
|
|
|
64
69
|
@dataclass
|
|
@@ -73,6 +78,7 @@ class Edge:
|
|
|
73
78
|
scope: Group | None = None
|
|
74
79
|
style: dict[str, str] = field(default_factory=dict)
|
|
75
80
|
label_style: dict[str, str] = field(default_factory=dict)
|
|
81
|
+
step_style: dict[str, str] = field(default_factory=dict)
|
|
76
82
|
index: int = 0
|
|
77
83
|
waypoints: list[tuple[float, float]] = field(default_factory=list)
|
|
78
84
|
wp_owner: Group | None = None
|
|
@@ -89,6 +95,8 @@ class Document:
|
|
|
89
95
|
items: dict[str, Item] = field(default_factory=dict)
|
|
90
96
|
base_dir: str = "."
|
|
91
97
|
allow_outside: bool = False # let image=/css "..." read files outside base_dir
|
|
98
|
+
legend: list[Node] = field(default_factory=list) # `legend .cls "text"` entries (not laid out)
|
|
99
|
+
legend_title: str | None = None
|
|
92
100
|
|
|
93
101
|
def walk(self, g: Group | None = None):
|
|
94
102
|
g = g or self.root
|
|
@@ -11,6 +11,8 @@ from pathlib import Path
|
|
|
11
11
|
from .model import Document, Edge, Group, Node
|
|
12
12
|
from .safe import MAX_EDGES, safe_path
|
|
13
13
|
|
|
14
|
+
MAX_LEGEND = 64
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
class PdgSyntaxError(ValueError):
|
|
16
18
|
def __init__(self, msg: str, line: int):
|
|
@@ -189,6 +191,19 @@ class Parser:
|
|
|
189
191
|
if classes:
|
|
190
192
|
d["__classes__"] = " ".join(classes)
|
|
191
193
|
return
|
|
194
|
+
if head == "legend" and (len(toks) == 1 or toks[1][0] in ("string", "cls")):
|
|
195
|
+
if self.scope is not self.root:
|
|
196
|
+
raise PdgSyntaxError("'legend' only allowed at top level", lineno)
|
|
197
|
+
label, classes, attrs = self._tail(toks, 1, lineno)
|
|
198
|
+
if not classes: # `legend "Key"` names the box
|
|
199
|
+
self.doc.legend_title = label
|
|
200
|
+
return
|
|
201
|
+
if len(self.doc.legend) >= MAX_LEGEND:
|
|
202
|
+
raise PdgSyntaxError(f"too many legend entries (limit {MAX_LEGEND})", lineno)
|
|
203
|
+
entry = Node(id=f"pdg-legend-{len(self.doc.legend)}", label=label or " ".join(classes),
|
|
204
|
+
classes=list(classes), attrs=attrs, parent=self.root)
|
|
205
|
+
self.doc.legend.append(entry)
|
|
206
|
+
return
|
|
192
207
|
if head == "group" and len(toks) >= 2 and toks[1][0] == "ident":
|
|
193
208
|
if toks[-1] != ("punct", "{"):
|
|
194
209
|
raise PdgSyntaxError("group needs '{'", lineno)
|
|
@@ -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)
|
|
@@ -20,6 +20,7 @@ from .style import px
|
|
|
20
20
|
LANE = 9.0 # distance between parallel lanes
|
|
21
21
|
CLEAR = 12.0 # min distance between a horizontal run and the boxes above/below it
|
|
22
22
|
RECT_SHAPES = {"box", "square", "round", "pill", "cylinder", "note", "image", "none"}
|
|
23
|
+
ROUND_SHAPES = {"circle", "ellipse", "diamond"}
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def lca(a, b, root):
|
|
@@ -62,6 +63,23 @@ def port(n, side):
|
|
|
62
63
|
"left": (n.ax, n.cy), "right": (n.ax + n.w, n.cy)}[side]
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
def on_outline(n, p, vert):
|
|
67
|
+
"""Move a port that was shifted along a side back onto the node's outline.
|
|
68
|
+
Boxes are flat, so only circles, ellipses and diamonds need it: the point slides
|
|
69
|
+
along the flow axis until it touches the curve (or slanted edge)."""
|
|
70
|
+
shape = n.attrs.get("shape", "box")
|
|
71
|
+
if isinstance(n, Group) or shape not in ROUND_SHAPES:
|
|
72
|
+
return p
|
|
73
|
+
hw, hh = n.w / 2, n.h / 2
|
|
74
|
+
if vert:
|
|
75
|
+
u = min(abs(p[0] - n.cx) / hw, 1.0)
|
|
76
|
+
reach = hh * (math.sqrt(1 - u * u) if shape != "diamond" else 1 - u)
|
|
77
|
+
return p[0], n.cy + math.copysign(reach, p[1] - n.cy)
|
|
78
|
+
u = min(abs(p[1] - n.cy) / hh, 1.0)
|
|
79
|
+
reach = hw * (math.sqrt(1 - u * u) if shape != "diamond" else 1 - u)
|
|
80
|
+
return n.cx + math.copysign(reach, p[0] - n.cx), p[1]
|
|
81
|
+
|
|
82
|
+
|
|
65
83
|
@dataclass
|
|
66
84
|
class Diagonal:
|
|
67
85
|
"""When may a bend/ortho jog be a slant instead of a right-angle step?"""
|
|
@@ -166,42 +184,48 @@ class Router:
|
|
|
166
184
|
|
|
167
185
|
# ------------------------------------------------------------ passes
|
|
168
186
|
def _parallel_offsets(self):
|
|
187
|
+
"""Edges between the same two nodes get side-by-side lanes. The offset is in
|
|
188
|
+
absolute coordinates, so an edge and its reverse simply take different lanes."""
|
|
169
189
|
pairs: dict[tuple[str, str], list[Edge]] = {}
|
|
170
190
|
for e in self.doc.edges:
|
|
171
191
|
pairs.setdefault(tuple(sorted((e.src, e.dst))), []).append(e)
|
|
172
192
|
for es in pairs.values():
|
|
173
193
|
n = len(es)
|
|
174
194
|
for i, e in enumerate(es):
|
|
175
|
-
|
|
176
|
-
# opposite-direction edges get mirrored sign so they stay apart
|
|
177
|
-
self.offset[e.id] = k if e.src <= e.dst else -k
|
|
195
|
+
self.offset[e.id] = (i - (n - 1) / 2) * 12
|
|
178
196
|
|
|
179
197
|
def _spread_ports(self):
|
|
180
198
|
"""Several curved/bent edges on one side of a node: spread them symmetrically
|
|
181
|
-
around the side's middle
|
|
199
|
+
around the side's middle, ordered by where they go, so they don't cross.
|
|
200
|
+
Parallel edges (same two nodes) take part too, which keeps them apart and in
|
|
201
|
+
the same order at both ends."""
|
|
182
202
|
sides: dict[tuple[str, str], list] = {}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
203
|
+
saved, self.offset = self.offset, {} # order by the unshifted geometry
|
|
204
|
+
try:
|
|
205
|
+
for e in self.doc.edges:
|
|
206
|
+
if e.attrs.get("exit") or e.attrs.get("enter"):
|
|
207
|
+
continue
|
|
208
|
+
pts, axis = self.geometry(e)
|
|
209
|
+
if axis not in ("v", "h"):
|
|
210
|
+
continue
|
|
211
|
+
a, b = self.doc.items[e.src], self.doc.items[e.dst]
|
|
212
|
+
if flow_dir(lca(a, b, self.doc.root)) == "auto" or e.style.get("route", "curve") in ("straight", "ortho"):
|
|
213
|
+
continue # ortho fan-in/out merges into a bus instead
|
|
214
|
+
i = 0 if axis == "v" else 1
|
|
215
|
+
for end, node, here, there in ((0, a, pts[0], pts[1] if len(pts) > 2 else pts[-1]),
|
|
216
|
+
(1, b, pts[-1], pts[-2] if len(pts) > 2 else pts[0])):
|
|
217
|
+
side = ("bottom" if there[1] > here[1] else "top") if axis == "v" else \
|
|
218
|
+
("right" if there[0] > here[0] else "left")
|
|
219
|
+
sides.setdefault((node.id, side), []).append((there[i], e.id, end))
|
|
220
|
+
finally:
|
|
221
|
+
self.offset = saved
|
|
198
222
|
for (nid, side), lst in sides.items():
|
|
199
223
|
if len(lst) < 2:
|
|
200
224
|
continue
|
|
201
225
|
n = self.doc.items[nid]
|
|
202
|
-
if n.attrs.get("shape", "box") in ("diamond", "circle", "ellipse") and not isinstance(n, Group):
|
|
203
|
-
continue
|
|
204
226
|
span = n.w if side in ("top", "bottom") else n.h
|
|
227
|
+
if n.attrs.get("shape", "box") in ROUND_SHAPES and not isinstance(n, Group):
|
|
228
|
+
span *= 0.6 # stay on the part of the outline that faces the side
|
|
205
229
|
step = min(18.0, (span - 12) / (len(lst) - 1)) if span > 12 else 0.0
|
|
206
230
|
lst.sort()
|
|
207
231
|
for k, (_, eid, end) in enumerate(lst):
|
|
@@ -341,6 +365,19 @@ class Router:
|
|
|
341
365
|
if da.id in ri and db.id in ri and abs(ri[da.id] - ri[db.id]) == 1:
|
|
342
366
|
self.gapkey[e.id] = (owner.id, min(ri[da.id], ri[db.id]))
|
|
343
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
|
+
|
|
344
381
|
def geometry(self, e: Edge):
|
|
345
382
|
"""-> (points, axis). axis: v | h (flow axis), L (explicit L port) or free."""
|
|
346
383
|
a, b = self.doc.items[e.src], self.doc.items[e.dst]
|
|
@@ -359,6 +396,17 @@ class Router:
|
|
|
359
396
|
if v0 != v1: # L-shape: one corner
|
|
360
397
|
corner = (p0[0], p1[1]) if v0 else (p1[0], p0[1])
|
|
361
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"
|
|
362
410
|
return [p0] + wps + [p1], ("v" if v0 else "h")
|
|
363
411
|
if d in ("TB", "BT", "LR", "RL") and route != "straight":
|
|
364
412
|
vert = d in ("TB", "BT")
|
|
@@ -371,18 +419,21 @@ class Router:
|
|
|
371
419
|
elif not wps:
|
|
372
420
|
self._record_channel(e, owner, da, db, vert)
|
|
373
421
|
p0, p1 = self._ports(a, b, vert)
|
|
422
|
+
spread = (e.id, 0) in self.port_shift or (e.id, 1) in self.port_shift
|
|
374
423
|
s0, s1 = self.port_shift.get((e.id, 0), 0.0), self.port_shift.get((e.id, 1), 0.0)
|
|
375
424
|
if vert:
|
|
376
425
|
p0, p1 = (p0[0] + s0, p0[1]), (p1[0] + s1, p1[1])
|
|
377
426
|
else:
|
|
378
427
|
p0, p1 = (p0[0], p0[1] + s0), (p1[0], p1[1] + s1)
|
|
379
|
-
if
|
|
428
|
+
if spread:
|
|
429
|
+
p0, p1 = on_outline(a, p0, vert), on_outline(b, p1, vert)
|
|
380
430
|
return [p0] + wps + [p1], ("v" if vert else "h")
|
|
381
431
|
if off:
|
|
382
432
|
if vert:
|
|
383
433
|
p0, p1 = (p0[0] + off, p0[1]), (p1[0] + off, p1[1])
|
|
384
434
|
else:
|
|
385
435
|
p0, p1 = (p0[0], p0[1] + off), (p1[0], p1[1] + off)
|
|
436
|
+
p0, p1 = on_outline(a, p0, vert), on_outline(b, p1, vert)
|
|
386
437
|
# boxes overlap on the cross axis: one straight line, no jog / double trace
|
|
387
438
|
boxy = all(x.attrs.get("shape", "box") in RECT_SHAPES or isinstance(x, Group) for x in (a, b))
|
|
388
439
|
if not wps and not off and boxy:
|
|
@@ -16,6 +16,7 @@ import math
|
|
|
16
16
|
|
|
17
17
|
from .anim import BORDER_MODES, Track, border_tracks, dash_len, flow_tracks, seconds
|
|
18
18
|
from .ir import Elem, Scene
|
|
19
|
+
from .layout import SUB_GAP, accent_width
|
|
19
20
|
from .model import Document, Edge, Group, Node
|
|
20
21
|
from .route import Diagonal, Router, flow_dir, lca
|
|
21
22
|
from .style import box4, px
|
|
@@ -45,7 +46,53 @@ class SceneBuilder:
|
|
|
45
46
|
layers = [groups_layer, edges_layer, nodes_layer]
|
|
46
47
|
if self.title_layer.children:
|
|
47
48
|
layers.append(self.title_layer)
|
|
48
|
-
|
|
49
|
+
w, h = root.w, root.h
|
|
50
|
+
legend, lw, lh = self.legend(h)
|
|
51
|
+
if legend:
|
|
52
|
+
layers.append(legend)
|
|
53
|
+
w, h = max(w, lw), h + lh
|
|
54
|
+
cap = root.attrs.get("caption")
|
|
55
|
+
if cap:
|
|
56
|
+
st = root.part_styles.get("caption") or root.style
|
|
57
|
+
fs = px(st.get("font-size"), 12)
|
|
58
|
+
cw, ch, lines = measure(cap, st.get("font-family", "sans-serif"), fs)
|
|
59
|
+
w = max(w, cw + 28)
|
|
60
|
+
align = st.get("text-align", "center")
|
|
61
|
+
x, anchor = {"left": (14, "start"), "right": (w - 14, "end")}.get(align, (w / 2, "middle"))
|
|
62
|
+
layers.append(Elem("group", cls="pdg-caption", children=self.text(lines, x, h, fs, st, anchor)))
|
|
63
|
+
h += ch + 14
|
|
64
|
+
return Scene(w, h, root.style.get("background"), layers)
|
|
65
|
+
|
|
66
|
+
def legend(self, top: float) -> tuple[Elem | None, float, float]:
|
|
67
|
+
"""`legend .cls "text"` entries: a key box under the diagram, swatches styled like nodes."""
|
|
68
|
+
entries = self.doc.legend
|
|
69
|
+
if not entries:
|
|
70
|
+
return None, 0.0, 0.0
|
|
71
|
+
st = self.doc.root.part_styles.get("legend") or {}
|
|
72
|
+
fs = px(st.get("font-size"), 11)
|
|
73
|
+
fam = st.get("font-family", self.doc.root.style.get("font-family", "sans-serif"))
|
|
74
|
+
sw, gap, pad = 14.0, 16.0, 10.0
|
|
75
|
+
title = self.doc.legend_title
|
|
76
|
+
tw, th, _ = measure(title, fam, fs, True) if title else (0.0, 0.0, [])
|
|
77
|
+
x = pad + (tw + gap if title else 0.0)
|
|
78
|
+
kids, row_h = [], max(sw, fs * 1.25)
|
|
79
|
+
y0 = top + pad
|
|
80
|
+
if title:
|
|
81
|
+
kids += self.text([title], 14 + pad, y0 + (row_h - fs * 1.25) / 2, fs, {**st, "font-weight": "bold"}, "start")
|
|
82
|
+
for n in entries:
|
|
83
|
+
ew, _, _ = measure(n.label, fam, fs)
|
|
84
|
+
ns = n.style
|
|
85
|
+
r = min(px(ns.get("border-radius"), 3), 4)
|
|
86
|
+
kids.append(Elem("rect", cls=" ".join(["pdg-legend-swatch"] + n.classes),
|
|
87
|
+
geom={"x": 14 + x, "y": y0 + (row_h - sw) / 2, "w": sw, "h": sw, "rx": r},
|
|
88
|
+
paint={"fill": ns.get("fill", "#ffffff"), "stroke": ns.get("stroke", "#9ca3af"),
|
|
89
|
+
"stroke-width": 1.2}))
|
|
90
|
+
kids += self.text([n.label], 14 + x + sw + 6, y0 + (row_h - fs * 1.25) / 2, fs, st, "start")
|
|
91
|
+
x += sw + 6 + ew + gap
|
|
92
|
+
bw, bh = x - gap + pad, row_h + 2 * pad
|
|
93
|
+
box = Elem("rect", cls="pdg-legend-box", geom={"x": 14, "y": top, "w": bw, "h": bh, "rx": 6},
|
|
94
|
+
paint={"fill": st.get("fill", "#ffffff"), "stroke": st.get("stroke", "#d1d5db"), "stroke-width": 1})
|
|
95
|
+
return Elem("group", cls="pdg-legend", children=[box] + kids), bw + 28, bh + 14
|
|
49
96
|
|
|
50
97
|
# ------------------------------------------------------------ text
|
|
51
98
|
@staticmethod
|
|
@@ -113,6 +160,7 @@ class SceneBuilder:
|
|
|
113
160
|
st = n.style
|
|
114
161
|
shape = n.attrs.get("shape", "box")
|
|
115
162
|
out = Elem("group", id=n.id, cls=" ".join(["pdg-node"] + n.classes), children=self.shape(n, shape, st))
|
|
163
|
+
out.children += self.accent(n, st)
|
|
116
164
|
fs = px(st.get("font-size"), 14)
|
|
117
165
|
if n.image:
|
|
118
166
|
img = n.image
|
|
@@ -130,17 +178,45 @@ class SceneBuilder:
|
|
|
130
178
|
def _node_text(self, n, st, fs):
|
|
131
179
|
align = st.get("text-align", "center")
|
|
132
180
|
pt, pr, pb, pl = box4(st.get("padding"), 8)
|
|
181
|
+
pl += accent_width(st, "left")
|
|
182
|
+
pr += accent_width(st, "right")
|
|
133
183
|
top = n.cy - n.text_h / 2
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if
|
|
137
|
-
|
|
138
|
-
|
|
184
|
+
x, anchor = {"left": (n.ax + pl, "start"), "right": (n.ax + n.w - pr, "end")}.get(align, (n.cx, "middle"))
|
|
185
|
+
out = self.text(n.text_lines, x, top, fs, st, anchor)
|
|
186
|
+
if n.sub_lines:
|
|
187
|
+
sst = n.sub_style or st
|
|
188
|
+
sfs = px(sst.get("font-size"), 12)
|
|
189
|
+
stop = top + n.text_h - n.sub_h + SUB_GAP
|
|
190
|
+
out += self.text(n.sub_lines, x, stop, sfs, sst, anchor)
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
def accent(self, it, st) -> list[Elem]:
|
|
194
|
+
"""`accent-bar: 4px #22c55e` draws a strip inside the box edge (`accent-side`)."""
|
|
195
|
+
spec = st.get("accent-bar")
|
|
196
|
+
if not spec or spec.strip() == "none":
|
|
197
|
+
return []
|
|
198
|
+
parts = spec.split()
|
|
199
|
+
bw = px(parts[0], 4)
|
|
200
|
+
color = parts[1] if len(parts) > 1 else st.get("stroke", "#22c55e")
|
|
201
|
+
side = st.get("accent-side", "left")
|
|
202
|
+
x = it.ax if side == "left" else it.ax + it.w - bw
|
|
203
|
+
r = min(px(st.get("border-radius"), 6), bw)
|
|
204
|
+
return [Elem("rect", cls="pdg-accent", geom={"x": x, "y": it.ay, "w": bw, "h": it.h, "rx": r},
|
|
205
|
+
paint={"fill": color})]
|
|
139
206
|
|
|
140
207
|
def group(self, g: Group) -> Elem:
|
|
141
208
|
st = g.style
|
|
142
209
|
out = Elem("group", id=g.id, cls=" ".join(["pdg-group"] + g.classes),
|
|
143
210
|
children=self.shape(g, g.attrs.get("shape", "box"), st))
|
|
211
|
+
out.children += self.accent(g, st)
|
|
212
|
+
if g.label and st.get("title-fill") not in (None, "none") and st.get("label-position", "top") != "bottom":
|
|
213
|
+
pt = box4(st.get("padding"), 14)[0]
|
|
214
|
+
bh = pt + g.label_h - 3
|
|
215
|
+
r = px(st.get("border-radius"), 8)
|
|
216
|
+
out.children.append(Elem("path", cls="pdg-title-band", paint={"fill": st["title-fill"]}, geom={"segs": [
|
|
217
|
+
("M", g.ax, g.ay + bh), ("L", g.ax, g.ay + r), ("A", r, r, 0, 0, 1, g.ax + r, g.ay),
|
|
218
|
+
("L", g.ax + g.w - r, g.ay), ("A", r, r, 0, 0, 1, g.ax + g.w, g.ay + r),
|
|
219
|
+
("L", g.ax + g.w, g.ay + bh), ("Z",)]}))
|
|
144
220
|
if g.label:
|
|
145
221
|
fs = px(st.get("font-size"), 12)
|
|
146
222
|
pt, pr, pb, pl = box4(st.get("padding"), 14)
|
|
@@ -152,6 +228,10 @@ class SceneBuilder:
|
|
|
152
228
|
x0 = self._title_x(g, st, tw, pl, pr, top, th)
|
|
153
229
|
box = (x0 - TITLE_PAD, top - 2, x0 + tw + TITLE_PAD, top + th + 2)
|
|
154
230
|
texts = self.text(lines, x0, top, fs, st, "start")
|
|
231
|
+
if g.attrs.get("sub") and not bottom:
|
|
232
|
+
sst = g.sub_style or st
|
|
233
|
+
texts += self.text(g.attrs["sub"].split("\n"), x0, top + g.title_h + SUB_GAP,
|
|
234
|
+
px(sst.get("font-size"), 11), sst, "start")
|
|
155
235
|
xs = self._crossings(box[1], box[3], box[0], box[2])
|
|
156
236
|
if xs:
|
|
157
237
|
# ask the pipeline for a wider group: children are centred, so widening
|
|
@@ -288,9 +368,37 @@ class SceneBuilder:
|
|
|
288
368
|
out.children.append(self.head(kind, tip, dvec, hl, color, width,
|
|
289
369
|
self.doc.root.style.get("background", "#ffffff")))
|
|
290
370
|
if e.label:
|
|
291
|
-
|
|
371
|
+
lab = self.edge_label(e, self.edge_polys[e.id])
|
|
372
|
+
out.children += lab
|
|
373
|
+
if e.attrs.get("step"):
|
|
374
|
+
box = lab[0].geom if e.label else None
|
|
375
|
+
out.children += self.step_badge(e, self.edge_polys[e.id], box)
|
|
292
376
|
return out
|
|
293
377
|
|
|
378
|
+
def step_badge(self, e, poly, label_box=None) -> list[Elem]:
|
|
379
|
+
"""Numbered circle on the edge, a third of the way along its path. With a label the
|
|
380
|
+
badge sits just left of the label box, so neither covers the other."""
|
|
381
|
+
st = e.step_style or e.style
|
|
382
|
+
fs = px(st.get("font-size"), 11)
|
|
383
|
+
txt = e.attrs["step"][:3]
|
|
384
|
+
w, h, _ = measure(txt, st.get("font-family", "sans-serif"), fs, True)
|
|
385
|
+
r = max(w, h) / 2 + 3
|
|
386
|
+
lens = [math.dist(a, b) for a, b in zip(poly, poly[1:])]
|
|
387
|
+
goal, acc = sum(lens) / 3, 0.0
|
|
388
|
+
cx, cy = poly[0]
|
|
389
|
+
for (a, b), l in zip(zip(poly, poly[1:]), lens):
|
|
390
|
+
if acc + l >= goal and l:
|
|
391
|
+
t = (goal - acc) / l
|
|
392
|
+
cx, cy = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
393
|
+
break
|
|
394
|
+
acc += l
|
|
395
|
+
if label_box:
|
|
396
|
+
cx, cy = label_box["x"] - r - 3, label_box["y"] + label_box["h"] / 2
|
|
397
|
+
circ = Elem("circle", cls="pdg-step", geom={"cx": cx, "cy": cy, "r": r},
|
|
398
|
+
paint={"fill": st.get("fill", "#374151"), "stroke": self.doc.root.style.get("background", "#ffffff"),
|
|
399
|
+
"stroke-width": 1.5})
|
|
400
|
+
return [circ] + self.text([txt], cx, cy - fs * 1.25 / 2, fs, {**st, "font-weight": st.get("font-weight", "bold")})
|
|
401
|
+
|
|
294
402
|
@staticmethod
|
|
295
403
|
def head(kind, tip, d, hl, color, width, bg="#ffffff") -> Elem:
|
|
296
404
|
"""Arrowhead as plain geometry: tip sits exactly on the target outline.
|
|
@@ -134,18 +134,46 @@ class StyleSheet:
|
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
BOX_KEYS = ("fill", "stroke", "color", "font-size", "stroke-width", "border-radius", "font-weight",
|
|
137
|
-
"stroke-dasharray", "padding", "border", "border-speed")
|
|
137
|
+
"stroke-dasharray", "padding", "border", "border-speed", "accent-bar", "accent-side", "title-fill")
|
|
138
138
|
EDGE_KEYS = ("arrow", "head", "head-start", "head-size", "route", "radius", "stub", "flow", "flow-speed",
|
|
139
139
|
"diagonal", "diagonal-angle")
|
|
140
140
|
INHERITED_EDGE_KEYS = tuple(k for k in EDGE_KEYS if k not in ("flow", "head-start"))
|
|
141
|
+
TEXT_KEYS = ("font-family", "font-size", "color", "font-weight", "font-style", "text-align")
|
|
141
142
|
|
|
142
143
|
|
|
143
144
|
@dataclass
|
|
144
|
-
class
|
|
145
|
-
"""Pseudo item so `label`
|
|
145
|
+
class _Part:
|
|
146
|
+
"""Pseudo item so `label`, `sub`, `step`, `caption`, `legend` selectors match a piece
|
|
147
|
+
of text that belongs to an item (an edge label, a node subtitle, a step badge, ...)."""
|
|
148
|
+
kind: str
|
|
146
149
|
classes: list
|
|
147
150
|
id: str = ""
|
|
148
|
-
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def part_style(sheet: "StyleSheet", kind: str, owner, anc: list, inherit: dict) -> dict[str, str]:
|
|
154
|
+
"""Style for a text part: inherits text keys from its owner, then CSS for `kind`.
|
|
155
|
+
Subtitles drop the owner's weight: a bold title should not make the second line bold.
|
|
156
|
+
`em`/`%` font sizes are relative to the owner's size."""
|
|
157
|
+
base = {k: inherit[k] for k in TEXT_KEYS if k in inherit and not (kind == "sub" and k == "font-weight")}
|
|
158
|
+
st = sheet.resolve_vars({**base, **sheet.compute(_Part(kind, list(owner.classes)), [owner] + anc)})
|
|
159
|
+
fs = st.get("font-size", "").strip()
|
|
160
|
+
if fs.endswith("em"):
|
|
161
|
+
st["font-size"] = f"{px(inherit.get('font-size'), 14) * number(fs[:-2], 1, 0.1, 10):.2f}px"
|
|
162
|
+
elif fs.endswith("%"):
|
|
163
|
+
st["font-size"] = f"{px(inherit.get('font-size'), 14) * number(fs[:-1], 100, 10, 1000) / 100:.2f}px"
|
|
164
|
+
return st
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def number_steps(doc: Document) -> None:
|
|
168
|
+
"""`graph steps=auto`: number every edge without its own `step=` in file order."""
|
|
169
|
+
if doc.root.attrs.get("steps") != "auto":
|
|
170
|
+
return
|
|
171
|
+
n = 0
|
|
172
|
+
for e in doc.edges:
|
|
173
|
+
if "step" in e.attrs:
|
|
174
|
+
continue
|
|
175
|
+
n += 1
|
|
176
|
+
e.attrs["step"] = str(n)
|
|
149
177
|
|
|
150
178
|
|
|
151
179
|
def apply_styles(doc: Document, target: str | None = None) -> None:
|
|
@@ -154,15 +182,19 @@ def apply_styles(doc: Document, target: str | None = None) -> None:
|
|
|
154
182
|
for css, _ in doc.stylesheets:
|
|
155
183
|
sheet.add(css)
|
|
156
184
|
root = doc.root
|
|
185
|
+
number_steps(doc)
|
|
157
186
|
root.style = sheet.resolve_vars(sheet.compute(root, []))
|
|
187
|
+
root.part_styles = {k: part_style(sheet, k, root, [], root.style) for k in ("caption", "legend")}
|
|
158
188
|
|
|
159
|
-
for item in doc.walk():
|
|
189
|
+
for item in [*doc.walk(), *doc.legend]:
|
|
160
190
|
anc = item.ancestors() # nearest first; root last
|
|
161
191
|
parent = item.parent.style if item.parent else root.style
|
|
162
192
|
st = {k: parent[k] for k in ("font-family",) if k in parent}
|
|
163
193
|
st.update(sheet.compute(item, anc))
|
|
164
194
|
st.update({k: v for k, v in item.attrs.items() if k in BOX_KEYS})
|
|
165
195
|
item.style = sheet.resolve_vars(st)
|
|
196
|
+
if "sub" in item.attrs:
|
|
197
|
+
item.sub_style = part_style(sheet, "sub", item, anc, item.style)
|
|
166
198
|
ge = root.attrs
|
|
167
199
|
for e in doc.edges:
|
|
168
200
|
anc = [a for a in (([e.scope] + e.scope.ancestors()) if e.scope is not root else [root]) if a is not None]
|
|
@@ -181,9 +213,9 @@ def apply_styles(doc: Document, target: str | None = None) -> None:
|
|
|
181
213
|
elif k in EDGE_KEYS or k in ("stroke", "color", "stroke-dasharray", "font-size"):
|
|
182
214
|
st[k] = v
|
|
183
215
|
e.style = sheet.resolve_vars(st)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
216
|
+
e.label_style = part_style(sheet, "label", e, anc, e.style)
|
|
217
|
+
if "step" in e.attrs:
|
|
218
|
+
e.step_style = part_style(sheet, "step", e, anc, e.style)
|
|
187
219
|
|
|
188
220
|
|
|
189
221
|
def px(v: str | None, default: float = 0.0) -> float:
|
|
@@ -56,6 +56,8 @@ def load_theme(name: str, base_dir: str = ".", _seen=()) -> dict:
|
|
|
56
56
|
parent = load_theme(data["extends"], str(path.parent), _seen + (name,))
|
|
57
57
|
for sect in ("palette", "roles", "shape"):
|
|
58
58
|
parent.setdefault(sect, {}).update(data.get(sect, {}))
|
|
59
|
+
if "css" in data: # extra rules stack: parent's first, then the child's
|
|
60
|
+
parent["css"] = parent.get("css", "") + "\n" + data["css"]
|
|
59
61
|
parent["name"] = data.get("name", name)
|
|
60
62
|
parent["dark"] = data.get("dark", parent.get("dark", False))
|
|
61
63
|
data = parent
|
|
@@ -104,4 +106,6 @@ def theme_css(name: str, base_dir: str = ".") -> str:
|
|
|
104
106
|
lines.append("node.solid { fill: var(--fg); stroke: var(--fg); color: var(--bg); }")
|
|
105
107
|
if "edge-width" in shape:
|
|
106
108
|
lines.append(f"edge {{ stroke-width: {shape['edge-width']}; }}")
|
|
109
|
+
if t.get("css"): # free-form rules after the generated ones; var(--hue) works here
|
|
110
|
+
lines.append(t["css"])
|
|
107
111
|
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
# The look of the HAMi documentation diagrams: soft blue panels with a header band,
|
|
6
|
+
# white rounded boxes with a bold title and a grey second line (`sub=`), HAMi green
|
|
7
|
+
# for highlighted components (accent bar on the left), numbered steps.
|
|
8
|
+
schema = 1
|
|
9
|
+
name = "hami"
|
|
10
|
+
dark = false
|
|
11
|
+
|
|
12
|
+
# extra rules after the generated palette CSS (must sit before the first [table])
|
|
13
|
+
css = """
|
|
14
|
+
graph { font-family: "Source Sans 3", "Source Sans Pro", Arial, Helvetica, "Liberation Sans", sans-serif; }
|
|
15
|
+
node { font-weight: 600; padding: 10px 14px; }
|
|
16
|
+
sub { color: var(--muted); font-size: 0.85em; }
|
|
17
|
+
group { title-fill: #dbeafe; stroke: #bfdbfe; font-size: 13px; }
|
|
18
|
+
group.green { title-fill: #dcfce7; stroke: #bbf7d0; fill: #f0fdf4; }
|
|
19
|
+
node.green { fill: #ffffff; stroke: var(--green); accent-bar: 4px var(--green); }
|
|
20
|
+
node.blue { fill: #ffffff; stroke: var(--blue); accent-bar: 4px var(--blue); }
|
|
21
|
+
step { fill: var(--green); color: #ffffff; }
|
|
22
|
+
legend { stroke: var(--border); color: var(--fg); }
|
|
23
|
+
caption { color: var(--muted); }
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
[palette]
|
|
27
|
+
bg = "#ffffff"
|
|
28
|
+
bg-alt = "#f1f5f9"
|
|
29
|
+
surface = "#ffffff"
|
|
30
|
+
fg = "#1e293b"
|
|
31
|
+
muted = "#64748b"
|
|
32
|
+
border = "#cbd5e1"
|
|
33
|
+
red = "#dc2626"
|
|
34
|
+
orange = "#ea580c"
|
|
35
|
+
yellow = "#ca8a04"
|
|
36
|
+
green = "#16a34a"
|
|
37
|
+
cyan = "#0891b2"
|
|
38
|
+
blue = "#2563eb"
|
|
39
|
+
purple = "#7c3aed"
|
|
40
|
+
|
|
41
|
+
[roles]
|
|
42
|
+
group-fill = "bg-alt"
|
|
43
|
+
group-text = "fg"
|
|
44
|
+
edge = "muted"
|
|
45
|
+
|
|
46
|
+
[shape]
|
|
47
|
+
radius = 10
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Subtitles, step badges, legend, caption, accent bars and header bands."""
|
|
6
|
+
from predictygraph import build_scene
|
|
7
|
+
from predictygraph.parser import parse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def scene(src):
|
|
11
|
+
doc = parse(src)
|
|
12
|
+
return doc, build_scene(doc)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def classes(sc):
|
|
16
|
+
out, stack = [], list(sc.layers)
|
|
17
|
+
while stack:
|
|
18
|
+
e = stack.pop()
|
|
19
|
+
out.append(e.cls or "")
|
|
20
|
+
stack += e.children
|
|
21
|
+
return " ".join(out)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def texts(sc):
|
|
25
|
+
out, stack = [], list(sc.layers)
|
|
26
|
+
while stack:
|
|
27
|
+
e = stack.pop()
|
|
28
|
+
if e.kind == "text":
|
|
29
|
+
out.append(e.geom["text"])
|
|
30
|
+
stack += e.children
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_sub_adds_height_and_is_drawn():
|
|
35
|
+
plain, _ = scene('a "Title"\n')
|
|
36
|
+
doc, sc = scene('a "Title" sub="second line"\n')
|
|
37
|
+
assert doc.items["a"].h > plain.items["a"].h
|
|
38
|
+
assert "second line" in texts(sc)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_sub_is_not_bold_and_smaller():
|
|
42
|
+
doc, _ = scene('css { node { font-weight: bold; font-size: 20px; } }\na "T" sub="s"\n')
|
|
43
|
+
st = doc.items["a"].sub_style
|
|
44
|
+
assert "font-weight" not in st or st["font-weight"] == "normal"
|
|
45
|
+
assert float(st["font-size"].rstrip("px")) < 20
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_group_sub_drawn_under_title():
|
|
49
|
+
doc, sc = scene('group g "Panel" sub="what it does" {\n a\n}\n')
|
|
50
|
+
assert "what it does" in texts(sc)
|
|
51
|
+
assert doc.items["g"].label_h > doc.items["g"].title_h
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_steps_auto_and_explicit():
|
|
55
|
+
doc, sc = scene("graph steps=auto\na -> b\nb -> c step=9\nc -> d\n")
|
|
56
|
+
assert [e.attrs["step"] for e in doc.edges] == ["1", "9", "2"]
|
|
57
|
+
assert classes(sc).count("pdg-step") == 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_legend_and_caption_grow_canvas():
|
|
61
|
+
_, base = scene("a -> b\n")
|
|
62
|
+
_, sc = scene('graph caption="Figure 1"\na -> b\nlegend "Key"\nlegend .green "HAMi"\n')
|
|
63
|
+
assert sc.height > base.height
|
|
64
|
+
assert {"Key", "HAMi", "Figure 1"} <= set(texts(sc))
|
|
65
|
+
assert "pdg-legend-box" in classes(sc)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_accent_bar_widens_node_and_draws():
|
|
69
|
+
plain, _ = scene('a "a longer label"\n')
|
|
70
|
+
doc, sc = scene('css { node { accent-bar: 5px #0f0; } }\na "a longer label"\n')
|
|
71
|
+
assert doc.items["a"].w == plain.items["a"].w + 5
|
|
72
|
+
assert "pdg-accent" in classes(sc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_title_fill_band():
|
|
76
|
+
_, sc = scene('css { group { title-fill: #dbeafe; } }\ngroup g "G" {\n a\n}\n')
|
|
77
|
+
assert "pdg-title-band" in classes(sc)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Routing regressions: edges between the same nodes, and edges fanning out of a
|
|
6
|
+
round node, must stay apart and must not cross."""
|
|
7
|
+
from predictygraph import _style, parse
|
|
8
|
+
from predictygraph.layout import layout
|
|
9
|
+
from predictygraph.route import Router
|
|
10
|
+
|
|
11
|
+
STATES = """graph dir=LR
|
|
12
|
+
default node shape=circle
|
|
13
|
+
idle "Idle"
|
|
14
|
+
running "Running"
|
|
15
|
+
error "Error"
|
|
16
|
+
done "Done"
|
|
17
|
+
idle -> running "Start"
|
|
18
|
+
running -> error "Fail"
|
|
19
|
+
running -> done "OK"
|
|
20
|
+
error -> running "Retry"
|
|
21
|
+
done -> idle "Reset"
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ends():
|
|
26
|
+
doc = _style(parse(STATES), None, None, None)
|
|
27
|
+
layout(doc)
|
|
28
|
+
r = Router(doc).route()
|
|
29
|
+
ends = {}
|
|
30
|
+
for e in doc.edges:
|
|
31
|
+
pts, _ = r.geometry(e)
|
|
32
|
+
ends[(e.src, e.dst)] = (pts[0], pts[-1])
|
|
33
|
+
return ends
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_edge_and_its_reverse_do_not_overlap():
|
|
37
|
+
ends = _ends()
|
|
38
|
+
fail = ends[("running", "error")]
|
|
39
|
+
retry = ends[("error", "running")]
|
|
40
|
+
# Fail leaves Running at p0, Retry arrives at Running at p1 (and vice versa at Error)
|
|
41
|
+
assert abs(fail[0][1] - retry[1][1]) >= 8, "Fail and Retry share a port on Running"
|
|
42
|
+
assert abs(fail[1][1] - retry[0][1]) >= 8, "Fail and Retry share a port on Error"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_edges_leaving_one_side_keep_their_order():
|
|
46
|
+
ends = _ends()
|
|
47
|
+
# on Running's right side: Fail (to Error, top) above Retry above OK (to Done, bottom)
|
|
48
|
+
ys = [ends[("running", "error")][0][1], ends[("error", "running")][1][1], ends[("running", "done")][0][1]]
|
|
49
|
+
assert ys == sorted(ys), f"edges cross on Running's right side: {ys}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_shifted_ports_sit_on_the_circle():
|
|
53
|
+
doc = _style(parse(STATES), None, None, None)
|
|
54
|
+
layout(doc)
|
|
55
|
+
r = Router(doc).route()
|
|
56
|
+
for e in doc.edges:
|
|
57
|
+
pts, _ = r.geometry(e)
|
|
58
|
+
for n, p in ((doc.items[e.src], pts[0]), (doc.items[e.dst], pts[-1])):
|
|
59
|
+
d = ((p[0] - n.cx) / (n.w / 2)) ** 2 + ((p[1] - n.cy) / (n.h / 2)) ** 2
|
|
60
|
+
assert abs(d - 1) < 0.02, f"{e.src}->{e.dst} end is off the outline of {n.id}"
|
|
@@ -0,0 +1,41 @@
|
|
|
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"
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
-
#
|
|
3
|
-
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
-
|
|
5
|
-
"""APNG writer: Scene IR -> animated PNG (frames sampled from the animation tracks)."""
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import io
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
from ..anim import loop_length
|
|
12
|
-
from ..ir import Scene
|
|
13
|
-
from .svg import write_svg
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class ApngWriter:
|
|
17
|
-
def __init__(self, scene: Scene, fps: int = 20, scale: float = 2.0, seconds: float | None = None):
|
|
18
|
-
self.scene, self.fps, self.scale = scene, fps, scale
|
|
19
|
-
durs = scene.durations()
|
|
20
|
-
self.seconds = seconds or (loop_length(durs) if durs else 1.0)
|
|
21
|
-
|
|
22
|
-
def _raster(self, svg: str):
|
|
23
|
-
try:
|
|
24
|
-
import cairosvg
|
|
25
|
-
except ImportError as exc: # pragma: no cover
|
|
26
|
-
raise RuntimeError("APNG export needs cairosvg: install predictygraph[apng]") from exc
|
|
27
|
-
from PIL import Image
|
|
28
|
-
png = cairosvg.svg2png(bytestring=svg.encode(), scale=self.scale)
|
|
29
|
-
return Image.open(io.BytesIO(png)).convert("RGBA")
|
|
30
|
-
|
|
31
|
-
def frames(self):
|
|
32
|
-
n = max(1, round(self.seconds * self.fps))
|
|
33
|
-
return [self._raster(write_svg(self.scene, i / self.fps)) for i in range(n)]
|
|
34
|
-
|
|
35
|
-
def save(self, out: Path) -> Path:
|
|
36
|
-
frames = self.frames()
|
|
37
|
-
frames[0].save(out, format="PNG", save_all=True, append_images=frames[1:],
|
|
38
|
-
duration=round(1000 / self.fps), loop=0, disposal=1, blend=0, default_image=False)
|
|
39
|
-
return out
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def write_apng(scene: Scene, out: Path, fps: int = 20, scale: float = 2.0, seconds: float | None = None) -> Path:
|
|
43
|
-
return ApngWriter(scene, fps, scale, seconds).save(out)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationMono-Regular.ttf
RENAMED
|
File without changes
|
|
File without changes
|
{predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSans-Regular.ttf
RENAMED
|
File without changes
|
{predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSerif-Bold.ttf
RENAMED
|
File without changes
|
{predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/fonts/LiberationSerif-Regular.ttf
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{predictygraph-0.1.0 → predictygraph-0.2.1}/src/predictygraph/themes/catppuccin-macchiato.toml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|