predictygraph 0.1.0__py3-none-any.whl
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/__init__.py +108 -0
- predictygraph/__main__.py +7 -0
- predictygraph/anim.py +91 -0
- predictygraph/cli.py +62 -0
- predictygraph/default.css +16 -0
- predictygraph/fonts/Carlito-Bold.ttf +0 -0
- predictygraph/fonts/Carlito-Regular.ttf +0 -0
- predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- predictygraph/fonts/DejaVuSans.ttf +0 -0
- predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- predictygraph/fonts.py +167 -0
- predictygraph/ir.py +62 -0
- predictygraph/layout.py +534 -0
- predictygraph/model.py +98 -0
- predictygraph/parser.py +310 -0
- predictygraph/render/__init__.py +17 -0
- predictygraph/render/apng.py +43 -0
- predictygraph/render/svg.py +121 -0
- predictygraph/route.py +453 -0
- predictygraph/safe.py +41 -0
- predictygraph/scene.py +349 -0
- predictygraph/style.py +204 -0
- predictygraph/text.py +80 -0
- predictygraph/theme.py +107 -0
- predictygraph/themes/catppuccin-frappe.toml +27 -0
- predictygraph/themes/catppuccin-latte.toml +27 -0
- predictygraph/themes/catppuccin-macchiato.toml +27 -0
- predictygraph/themes/catppuccin-mocha.toml +27 -0
- predictygraph/themes/default.toml +22 -0
- predictygraph/themes/dracula.toml +25 -0
- predictygraph/themes/github-light.toml +25 -0
- predictygraph/themes/gruvbox-dark.toml +24 -0
- predictygraph/themes/nord.toml +25 -0
- predictygraph/themes/solarized-dark.toml +15 -0
- predictygraph/themes/solarized-light.toml +24 -0
- predictygraph-0.1.0.dist-info/METADATA +60 -0
- predictygraph-0.1.0.dist-info/RECORD +44 -0
- predictygraph-0.1.0.dist-info/WHEEL +4 -0
- predictygraph-0.1.0.dist-info/entry_points.txt +6 -0
predictygraph/parser.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Line-oriented parser for the Predictygraph DSL (see docs/SPEC.md)."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .model import Document, Edge, Group, Node
|
|
12
|
+
from .safe import MAX_EDGES, safe_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PdgSyntaxError(ValueError):
|
|
16
|
+
def __init__(self, msg: str, line: int):
|
|
17
|
+
super().__init__(f"line {line}: {msg}")
|
|
18
|
+
self.line = line
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
TOKEN_RE = re.compile(
|
|
22
|
+
r"""
|
|
23
|
+
(?P<ws>[ \t]+)
|
|
24
|
+
| (?P<string>"(?:[^"\\]|\\.)*")
|
|
25
|
+
| (?P<arrow><?[-=.]{1,64}>|<[-=.]{1,64}|[-=.]{2,64})
|
|
26
|
+
| (?P<attr>[A-Za-z_][\w-]*=(?:"(?:[^"\\]|\\.)*"|[^\s{}]+))
|
|
27
|
+
| (?P<cls>\.[A-Za-z_][\w-]*)
|
|
28
|
+
| (?P<ident>[A-Za-z_][\w-]*)
|
|
29
|
+
| (?P<punct>[{},;])
|
|
30
|
+
""",
|
|
31
|
+
re.VERBOSE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
BODIES = {"-": "solid", "=": "thick", ".": "dashed"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unquote(s: str) -> str:
|
|
38
|
+
if s.startswith('"') and s.endswith('"'):
|
|
39
|
+
s = s[1:-1]
|
|
40
|
+
return re.sub(r"\\(.)", lambda m: "\n" if m.group(1) == "n" else m.group(1), s)
|
|
41
|
+
return s
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def strip_comment(line: str) -> str:
|
|
45
|
+
out, in_str, esc = [], False, False
|
|
46
|
+
for i, ch in enumerate(line):
|
|
47
|
+
if in_str:
|
|
48
|
+
out.append(ch)
|
|
49
|
+
if esc:
|
|
50
|
+
esc = False
|
|
51
|
+
elif ch == "\\":
|
|
52
|
+
esc = True
|
|
53
|
+
elif ch == '"':
|
|
54
|
+
in_str = False
|
|
55
|
+
elif ch == '"':
|
|
56
|
+
in_str = True
|
|
57
|
+
out.append(ch)
|
|
58
|
+
elif ch == "#" and (not out or out[-1] in " \t") and (i + 1 == len(line) or line[i + 1] in " \t#!"):
|
|
59
|
+
break
|
|
60
|
+
else:
|
|
61
|
+
out.append(ch)
|
|
62
|
+
return "".join(out)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def tokenize(text: str, lineno: int) -> list[tuple[str, str]]:
|
|
66
|
+
pos, toks = 0, []
|
|
67
|
+
while pos < len(text):
|
|
68
|
+
m = TOKEN_RE.match(text, pos)
|
|
69
|
+
if not m:
|
|
70
|
+
raise PdgSyntaxError(f"unexpected {text[pos:pos + 12]!r}", lineno)
|
|
71
|
+
kind = m.lastgroup
|
|
72
|
+
if kind != "ws":
|
|
73
|
+
toks.append((kind, m.group()))
|
|
74
|
+
pos = m.end()
|
|
75
|
+
return toks
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def split_statements(toks):
|
|
79
|
+
"""Split on ';' and after '{' / around '}' so blocks may share a line."""
|
|
80
|
+
cur = []
|
|
81
|
+
for t in toks:
|
|
82
|
+
if t == ("punct", ";"):
|
|
83
|
+
if cur:
|
|
84
|
+
yield cur
|
|
85
|
+
cur = []
|
|
86
|
+
elif t == ("punct", "{"):
|
|
87
|
+
cur.append(t)
|
|
88
|
+
yield cur
|
|
89
|
+
cur = []
|
|
90
|
+
elif t == ("punct", "}"):
|
|
91
|
+
if cur:
|
|
92
|
+
yield cur
|
|
93
|
+
yield [t]
|
|
94
|
+
cur = []
|
|
95
|
+
else:
|
|
96
|
+
cur.append(t)
|
|
97
|
+
if cur:
|
|
98
|
+
yield cur
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_arrow(tok: str) -> tuple[str, str]:
|
|
102
|
+
start = tok.startswith("<")
|
|
103
|
+
end = tok.endswith(">")
|
|
104
|
+
body = tok.strip("<>")
|
|
105
|
+
arrow = {(False, False): "none", (False, True): "end", (True, False): "start", (True, True): "both"}[(start, end)]
|
|
106
|
+
return arrow, BODIES[body[0]]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Parser:
|
|
110
|
+
def __init__(self, base_dir: str = ".", allow_outside: bool = False):
|
|
111
|
+
self.root = Group(id="__root__", kind="graph")
|
|
112
|
+
self.doc = Document(root=self.root, base_dir=base_dir, allow_outside=allow_outside)
|
|
113
|
+
self.stack = [self.root]
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def scope(self) -> Group:
|
|
117
|
+
return self.stack[-1]
|
|
118
|
+
|
|
119
|
+
# -- helpers ---------------------------------------------------------
|
|
120
|
+
def _tail(self, toks, i, lineno):
|
|
121
|
+
label, classes, attrs = None, [], {}
|
|
122
|
+
for kind, val in toks[i:]:
|
|
123
|
+
if kind == "string" and label is None:
|
|
124
|
+
label = unquote(val)
|
|
125
|
+
elif kind == "cls":
|
|
126
|
+
classes.append(val[1:])
|
|
127
|
+
elif kind == "attr":
|
|
128
|
+
k, v = val.split("=", 1)
|
|
129
|
+
attrs[k] = unquote(v)
|
|
130
|
+
elif kind == "punct" and val == "{":
|
|
131
|
+
pass
|
|
132
|
+
else:
|
|
133
|
+
raise PdgSyntaxError(f"unexpected token {val!r}", lineno)
|
|
134
|
+
return label, classes, attrs
|
|
135
|
+
|
|
136
|
+
def _get_node(self, nid: str, lineno: int):
|
|
137
|
+
item = self.doc.items.get(nid)
|
|
138
|
+
if item is None:
|
|
139
|
+
item = Node(id=nid, implicit=True, parent=self.scope)
|
|
140
|
+
self.scope.children.append(item)
|
|
141
|
+
self.doc.items[nid] = item
|
|
142
|
+
return item
|
|
143
|
+
|
|
144
|
+
def _declare(self, item_id, lineno, cls=Node):
|
|
145
|
+
existing = self.doc.items.get(item_id)
|
|
146
|
+
if existing is not None:
|
|
147
|
+
if isinstance(existing, Node) and existing.implicit and cls is Node:
|
|
148
|
+
existing.parent.children.remove(existing)
|
|
149
|
+
existing.parent = self.scope
|
|
150
|
+
self.scope.children.append(existing)
|
|
151
|
+
existing.implicit = False
|
|
152
|
+
return existing
|
|
153
|
+
if cls is Node and isinstance(existing, Node):
|
|
154
|
+
return existing # re-declaration merges attributes
|
|
155
|
+
raise PdgSyntaxError(f"duplicate id {item_id!r}", lineno)
|
|
156
|
+
item = cls(id=item_id, parent=self.scope)
|
|
157
|
+
self.scope.children.append(item)
|
|
158
|
+
self.doc.items[item_id] = item
|
|
159
|
+
return item
|
|
160
|
+
|
|
161
|
+
# -- statements ------------------------------------------------------
|
|
162
|
+
def statement(self, toks, lineno):
|
|
163
|
+
kinds = [k for k, _ in toks]
|
|
164
|
+
head = toks[0][1]
|
|
165
|
+
if toks == [("punct", "}")]:
|
|
166
|
+
if len(self.stack) == 1:
|
|
167
|
+
raise PdgSyntaxError("unbalanced '}'", lineno)
|
|
168
|
+
self.stack.pop()
|
|
169
|
+
return
|
|
170
|
+
if "arrow" in kinds:
|
|
171
|
+
return self.edge(toks, lineno)
|
|
172
|
+
if toks[0][0] != "ident":
|
|
173
|
+
raise PdgSyntaxError(f"statement cannot start with {head!r}", lineno)
|
|
174
|
+
if head == "graph" and (len(toks) == 1 or toks[1][0] != "string"):
|
|
175
|
+
if self.scope is not self.root:
|
|
176
|
+
raise PdgSyntaxError("'graph' only allowed at top level", lineno)
|
|
177
|
+
_, classes, attrs = self._tail(toks, 1, lineno)
|
|
178
|
+
self.root.classes += classes
|
|
179
|
+
self.root.attrs.update(attrs)
|
|
180
|
+
return
|
|
181
|
+
if head == "css" and len(toks) == 2 and toks[1][0] == "string":
|
|
182
|
+
path = safe_path(self.doc.base_dir, unquote(toks[1][1]), self.doc.allow_outside)
|
|
183
|
+
self.doc.stylesheets.append((path.read_text(encoding="utf-8"), str(path.parent)))
|
|
184
|
+
return
|
|
185
|
+
if head == "default" and len(toks) >= 2 and toks[1][1] in ("node", "edge", "group"):
|
|
186
|
+
_, classes, attrs = self._tail(toks, 2, lineno)
|
|
187
|
+
d = self.scope.defaults.setdefault(toks[1][1], {})
|
|
188
|
+
d.update(attrs)
|
|
189
|
+
if classes:
|
|
190
|
+
d["__classes__"] = " ".join(classes)
|
|
191
|
+
return
|
|
192
|
+
if head == "group" and len(toks) >= 2 and toks[1][0] == "ident":
|
|
193
|
+
if toks[-1] != ("punct", "{"):
|
|
194
|
+
raise PdgSyntaxError("group needs '{'", lineno)
|
|
195
|
+
g = self._declare(toks[1][1], lineno, cls=Group)
|
|
196
|
+
g.label, g.classes, g.attrs = self._tail(toks[:-1], 2, lineno)
|
|
197
|
+
self._apply_defaults(g, "group")
|
|
198
|
+
self.stack.append(g)
|
|
199
|
+
return
|
|
200
|
+
# node declaration
|
|
201
|
+
n = self._declare(head, lineno)
|
|
202
|
+
label, classes, attrs = self._tail(toks, 1, lineno)
|
|
203
|
+
if label is not None:
|
|
204
|
+
n.label = label
|
|
205
|
+
n.classes += classes
|
|
206
|
+
n.attrs.update(attrs)
|
|
207
|
+
self._apply_defaults(n, "node")
|
|
208
|
+
|
|
209
|
+
def _apply_defaults(self, item, kind):
|
|
210
|
+
merged: dict[str, str] = {}
|
|
211
|
+
for g in reversed([self.scope] + self.scope.ancestors()):
|
|
212
|
+
merged.update(g.defaults.get(kind, {}))
|
|
213
|
+
extra = merged.pop("__classes__", "")
|
|
214
|
+
item.classes = extra.split() + [c for c in item.classes if c not in extra.split()]
|
|
215
|
+
item.attrs = {**merged, **item.attrs}
|
|
216
|
+
|
|
217
|
+
def edge(self, toks, lineno):
|
|
218
|
+
groups: list[list[str]] = [[]]
|
|
219
|
+
arrows: list[tuple[str, str]] = []
|
|
220
|
+
i = 0
|
|
221
|
+
while i < len(toks):
|
|
222
|
+
kind, val = toks[i]
|
|
223
|
+
if kind == "ident":
|
|
224
|
+
groups[-1].append(val)
|
|
225
|
+
elif kind == "punct" and val == ",":
|
|
226
|
+
pass
|
|
227
|
+
elif kind == "arrow":
|
|
228
|
+
if not groups[-1]:
|
|
229
|
+
raise PdgSyntaxError("arrow without source", lineno)
|
|
230
|
+
arrows.append(parse_arrow(val))
|
|
231
|
+
groups.append([])
|
|
232
|
+
else:
|
|
233
|
+
break
|
|
234
|
+
i += 1
|
|
235
|
+
if not groups[-1]:
|
|
236
|
+
raise PdgSyntaxError("arrow without target", lineno)
|
|
237
|
+
label, classes, attrs = self._tail(toks, i, lineno)
|
|
238
|
+
defaults: dict[str, str] = {}
|
|
239
|
+
for g in reversed([self.scope] + self.scope.ancestors()):
|
|
240
|
+
defaults.update(g.defaults.get("edge", {}))
|
|
241
|
+
dcls = defaults.pop("__classes__", "").split()
|
|
242
|
+
for a in groups:
|
|
243
|
+
for nid in a:
|
|
244
|
+
self._get_node(nid, lineno)
|
|
245
|
+
count = sum(len(s) * len(d) for s, d in zip(groups, groups[1:]))
|
|
246
|
+
if len(self.doc.edges) + count > MAX_EDGES:
|
|
247
|
+
raise PdgSyntaxError(f"too many edges (limit {MAX_EDGES})", lineno)
|
|
248
|
+
for (srcs, dsts), (arrow, body) in zip(zip(groups, groups[1:]), arrows):
|
|
249
|
+
for s in srcs:
|
|
250
|
+
for d in dsts:
|
|
251
|
+
e = Edge(src=s, dst=d, arrow=arrow, body=body, label=label,
|
|
252
|
+
classes=dcls + list(classes), attrs={**defaults, **attrs},
|
|
253
|
+
scope=self.scope, index=len(self.doc.edges))
|
|
254
|
+
e.id = f"e{e.index}"
|
|
255
|
+
self.doc.edges.append(e)
|
|
256
|
+
|
|
257
|
+
# -- driver ----------------------------------------------------------
|
|
258
|
+
def parse(self, text: str) -> Document:
|
|
259
|
+
lines = text.splitlines()
|
|
260
|
+
i = 0
|
|
261
|
+
while i < len(lines):
|
|
262
|
+
lineno = i + 1
|
|
263
|
+
raw = strip_comment(lines[i])
|
|
264
|
+
stripped = raw.strip()
|
|
265
|
+
m = re.match(r"^\s*css\s*\{(.*)$", lines[i])
|
|
266
|
+
if m: # raw inline css block
|
|
267
|
+
buf, depth, rest = [], 1, m.group(1)
|
|
268
|
+
while True:
|
|
269
|
+
for j, ch in enumerate(rest):
|
|
270
|
+
if ch == "{":
|
|
271
|
+
depth += 1
|
|
272
|
+
elif ch == "}":
|
|
273
|
+
depth -= 1
|
|
274
|
+
if depth == 0:
|
|
275
|
+
break
|
|
276
|
+
buf.append(ch)
|
|
277
|
+
if depth == 0:
|
|
278
|
+
break
|
|
279
|
+
buf.append("\n")
|
|
280
|
+
i += 1
|
|
281
|
+
if i >= len(lines):
|
|
282
|
+
raise PdgSyntaxError("unterminated css block", lineno)
|
|
283
|
+
rest = lines[i]
|
|
284
|
+
self.doc.stylesheets.append(("".join(buf), self.doc.base_dir))
|
|
285
|
+
tail = strip_comment(rest[j + 1:]).strip()
|
|
286
|
+
if tail: # statements after the closing brace on the same line
|
|
287
|
+
for stmt in split_statements(tokenize(tail, i + 1)):
|
|
288
|
+
self.statement(stmt, i + 1)
|
|
289
|
+
i += 1
|
|
290
|
+
continue
|
|
291
|
+
if stripped:
|
|
292
|
+
for stmt in split_statements(tokenize(stripped, lineno)):
|
|
293
|
+
self.statement(stmt, lineno)
|
|
294
|
+
i += 1
|
|
295
|
+
if len(self.stack) != 1:
|
|
296
|
+
raise PdgSyntaxError(f"unclosed group {self.stack[-1].id!r}", len(lines))
|
|
297
|
+
for e in self.doc.edges:
|
|
298
|
+
for nid in (e.src, e.dst):
|
|
299
|
+
if nid not in self.doc.items:
|
|
300
|
+
raise PdgSyntaxError(f"unknown id {nid!r}", 0)
|
|
301
|
+
return self.doc
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def parse(text: str, base_dir: str = ".", allow_outside: bool = False) -> Document:
|
|
305
|
+
return Parser(base_dir, allow_outside).parse(text)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def parse_file(path: str | Path, allow_outside: bool = False) -> Document:
|
|
309
|
+
p = Path(path)
|
|
310
|
+
return parse(p.read_text(encoding="utf-8"), str(p.parent), allow_outside)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Output handlers. Each one consumes a Scene IR and knows nothing about the DSL.
|
|
6
|
+
|
|
7
|
+
svg animated (SMIL) or frozen SVG
|
|
8
|
+
apng animated PNG, frames sampled from the scene's animation tracks
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .apng import write_apng
|
|
13
|
+
from .svg import write_svg
|
|
14
|
+
|
|
15
|
+
WRITERS = {"svg": write_svg, "apng": write_apng}
|
|
16
|
+
|
|
17
|
+
__all__ = ["WRITERS", "write_svg", "write_apng"]
|
|
@@ -0,0 +1,43 @@
|
|
|
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)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""SVG writer: Scene IR -> SVG text.
|
|
6
|
+
|
|
7
|
+
t=None animated SVG: static values are the first keyframe, SMIL <animate> drives tracks.
|
|
8
|
+
t=float frozen frame at that time (used for --static/--at and APNG frames).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from xml.sax.saxutils import escape, quoteattr
|
|
13
|
+
|
|
14
|
+
from ..ir import Elem, Scene
|
|
15
|
+
|
|
16
|
+
SHADOW = ('<filter id="pdg-shadow" x="-20%" y="-20%" width="140%" height="150%">'
|
|
17
|
+
'<feDropShadow dx="0" dy="2" stdDeviation="2.5" flood-opacity="0.18"/></filter>')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def num(v) -> str:
|
|
21
|
+
if isinstance(v, float):
|
|
22
|
+
s = f"{v:.2f}".rstrip("0").rstrip(".")
|
|
23
|
+
return s if s not in ("", "-0") else "0"
|
|
24
|
+
return str(v)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def path_d(segs) -> str:
|
|
28
|
+
out = []
|
|
29
|
+
for s in segs:
|
|
30
|
+
out.append(s[0] + (" " + " ".join(num(float(x)) for x in s[1:]) if len(s) > 1 else ""))
|
|
31
|
+
return " ".join(out)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SvgWriter:
|
|
35
|
+
def __init__(self, scene: Scene, t: float | None = None):
|
|
36
|
+
self.scene, self.t = scene, t
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------ attrs
|
|
39
|
+
def paint_attrs(self, el: Elem) -> dict[str, str]:
|
|
40
|
+
a = {}
|
|
41
|
+
for k, v in el.paint.items():
|
|
42
|
+
if k == "filter":
|
|
43
|
+
a["filter"] = f"url(#pdg-{v})"
|
|
44
|
+
else:
|
|
45
|
+
a[k] = num(v)
|
|
46
|
+
for tr in el.tracks:
|
|
47
|
+
v = tr.values[0] if self.t is None else tr.at(self.t)
|
|
48
|
+
a[tr.attr] = num(float(v))
|
|
49
|
+
return a
|
|
50
|
+
|
|
51
|
+
def smil(self, el: Elem) -> str:
|
|
52
|
+
if self.t is not None:
|
|
53
|
+
return ""
|
|
54
|
+
return "".join(f'<animate attributeName="{tr.attr}" values="{";".join(num(float(v)) for v in tr.values)}" '
|
|
55
|
+
f'dur="{num(float(tr.dur))}s" repeatCount="indefinite"/>' for tr in el.tracks)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def attrs_str(a: dict) -> str:
|
|
59
|
+
return " ".join(f"{k}={quoteattr(str(v))}" for k, v in a.items())
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------ elements
|
|
62
|
+
def elem(self, el: Elem) -> str:
|
|
63
|
+
g = el.geom
|
|
64
|
+
base = {}
|
|
65
|
+
if el.id:
|
|
66
|
+
base["id"] = el.id
|
|
67
|
+
if el.cls:
|
|
68
|
+
base["class"] = el.cls
|
|
69
|
+
if el.kind == "group":
|
|
70
|
+
inner = "\n".join(self.elem(c) for c in el.children)
|
|
71
|
+
return f"<g {self.attrs_str(base)}>\n{inner}\n</g>" if base else f"<g>\n{inner}\n</g>"
|
|
72
|
+
if el.kind == "text":
|
|
73
|
+
a = {"x": num(float(g["x"])), "y": num(float(g["y"])), "text-anchor": g.get("anchor", "middle")}
|
|
74
|
+
a.update(self.paint_attrs(el))
|
|
75
|
+
a.update(base)
|
|
76
|
+
return f"<text {self.attrs_str(a)}>{escape(g['text'])}</text>"
|
|
77
|
+
if el.kind == "rect":
|
|
78
|
+
a = {"x": g["x"], "y": g["y"], "width": g["w"], "height": g["h"]}
|
|
79
|
+
if g.get("rx"):
|
|
80
|
+
a["rx"] = g["rx"]
|
|
81
|
+
elif el.kind == "ellipse":
|
|
82
|
+
a = {"cx": g["cx"], "cy": g["cy"], "rx": g["rx"], "ry": g["ry"]}
|
|
83
|
+
elif el.kind == "circle":
|
|
84
|
+
a = {"cx": g["cx"], "cy": g["cy"], "r": g["r"]}
|
|
85
|
+
elif el.kind in ("polygon", "polyline"):
|
|
86
|
+
a = {"points": " ".join(f"{num(float(x))},{num(float(y))}" for x, y in g["points"])}
|
|
87
|
+
elif el.kind == "line":
|
|
88
|
+
a = {k: g[k] for k in ("x1", "y1", "x2", "y2")}
|
|
89
|
+
elif el.kind == "path":
|
|
90
|
+
a = {"d": path_d(g["segs"])}
|
|
91
|
+
elif el.kind == "image":
|
|
92
|
+
a = {"x": g["x"], "y": g["y"], "width": g["w"], "height": g["h"], "href": g["href"],
|
|
93
|
+
"xlink:href": g["href"], "preserveAspectRatio": "xMidYMid meet"}
|
|
94
|
+
else:
|
|
95
|
+
return ""
|
|
96
|
+
a = {k: num(float(v)) if isinstance(v, (int, float)) else v for k, v in a.items()}
|
|
97
|
+
a.update(self.paint_attrs(el))
|
|
98
|
+
a.update(base)
|
|
99
|
+
kids = self.smil(el)
|
|
100
|
+
tag = "path" if el.kind == "path" else el.kind
|
|
101
|
+
return f"<{tag} {self.attrs_str(a)}>{kids}</{tag}>" if kids else f"<{tag} {self.attrs_str(a)}/>"
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------ document
|
|
104
|
+
def write(self) -> str:
|
|
105
|
+
s = self.scene
|
|
106
|
+
body = [self.elem(layer) for layer in s.layers]
|
|
107
|
+
defs = []
|
|
108
|
+
if "shadow" in s.effects():
|
|
109
|
+
defs.append(SHADOW)
|
|
110
|
+
head = [f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
|
|
111
|
+
f'width="{s.width:.0f}" height="{s.height:.0f}" viewBox="0 0 {num(s.width)} {num(s.height)}" '
|
|
112
|
+
f'class="predictygraph">']
|
|
113
|
+
if defs:
|
|
114
|
+
head.append("<defs>" + "".join(defs) + "</defs>")
|
|
115
|
+
if s.background:
|
|
116
|
+
head.append(f'<rect x="0" y="0" width="{num(s.width)}" height="{num(s.height)}" fill="{s.background}"/>')
|
|
117
|
+
return "\n".join(head + body + ["</svg>"]) + "\n"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def write_svg(scene: Scene, t: float | None = None) -> str:
|
|
121
|
+
return SvgWriter(scene, t).write()
|