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.
Files changed (44) hide show
  1. predictygraph/__init__.py +108 -0
  2. predictygraph/__main__.py +7 -0
  3. predictygraph/anim.py +91 -0
  4. predictygraph/cli.py +62 -0
  5. predictygraph/default.css +16 -0
  6. predictygraph/fonts/Carlito-Bold.ttf +0 -0
  7. predictygraph/fonts/Carlito-Regular.ttf +0 -0
  8. predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
  9. predictygraph/fonts/DejaVuSans.ttf +0 -0
  10. predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
  11. predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
  12. predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
  13. predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
  14. predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
  15. predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
  16. predictygraph/fonts.py +167 -0
  17. predictygraph/ir.py +62 -0
  18. predictygraph/layout.py +534 -0
  19. predictygraph/model.py +98 -0
  20. predictygraph/parser.py +310 -0
  21. predictygraph/render/__init__.py +17 -0
  22. predictygraph/render/apng.py +43 -0
  23. predictygraph/render/svg.py +121 -0
  24. predictygraph/route.py +453 -0
  25. predictygraph/safe.py +41 -0
  26. predictygraph/scene.py +349 -0
  27. predictygraph/style.py +204 -0
  28. predictygraph/text.py +80 -0
  29. predictygraph/theme.py +107 -0
  30. predictygraph/themes/catppuccin-frappe.toml +27 -0
  31. predictygraph/themes/catppuccin-latte.toml +27 -0
  32. predictygraph/themes/catppuccin-macchiato.toml +27 -0
  33. predictygraph/themes/catppuccin-mocha.toml +27 -0
  34. predictygraph/themes/default.toml +22 -0
  35. predictygraph/themes/dracula.toml +25 -0
  36. predictygraph/themes/github-light.toml +25 -0
  37. predictygraph/themes/gruvbox-dark.toml +24 -0
  38. predictygraph/themes/nord.toml +25 -0
  39. predictygraph/themes/solarized-dark.toml +15 -0
  40. predictygraph/themes/solarized-light.toml +24 -0
  41. predictygraph-0.1.0.dist-info/METADATA +60 -0
  42. predictygraph-0.1.0.dist-info/RECORD +44 -0
  43. predictygraph-0.1.0.dist-info/WHEEL +4 -0
  44. predictygraph-0.1.0.dist-info/entry_points.txt +6 -0
@@ -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()