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,108 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Predictygraph: readable diagram DSL -> Scene IR -> SVG / APNG.
6
+
7
+ Pipeline (mirrors slidr):
8
+
9
+ text --parse--> AST (model.Document)
10
+ --style--> styled AST (CSS, theme, @target blocks)
11
+ --layout + route (iterated until channels fit)--> laid-out AST
12
+ --build--> Scene IR (ir.Scene)
13
+ --writers--> render/svg.py, render/apng.py
14
+
15
+ Labels are measured with the fonts of the render *target* (see fonts.py): the platform
16
+ or viewer the SVG is made for. Showing it somewhere else can make labels overflow.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from .fonts import TARGETS, FontWarning
23
+ from .ir import Scene
24
+ from .layout import layout
25
+ from .parser import PdgSyntaxError, parse, parse_file
26
+ from .render import write_apng, write_svg
27
+ from .route import Router
28
+ from .scene import SceneBuilder
29
+ from .style import apply_styles
30
+ from .text import use_target
31
+ from .theme import ThemeError, list_themes, theme_css
32
+
33
+ __all__ = ["render", "render_file", "build_scene", "scene_from_file", "parse", "parse_file",
34
+ "write_svg", "write_apng", "Scene", "PdgSyntaxError", "ThemeError", "FontWarning",
35
+ "TARGETS", "list_themes"]
36
+
37
+ MAX_PASSES = 4
38
+
39
+
40
+ def resolve_target(doc, target: str | None) -> str | None:
41
+ """Explicit target wins over `graph target=`; None means measure with this machine's fonts."""
42
+ name = target or doc.root.attrs.get("target")
43
+ if name and name not in TARGETS:
44
+ raise ValueError(f"unknown target {name!r} (choose from: {', '.join(TARGETS)})")
45
+ return None if name in (None, "host") else name
46
+
47
+
48
+ def _style(doc, extra_css, theme, target):
49
+ name = theme or doc.root.attrs.get("theme")
50
+ if name:
51
+ doc.stylesheets.insert(0, (theme_css(name, doc.base_dir), doc.base_dir))
52
+ for css in extra_css or []:
53
+ doc.stylesheets.append((css, doc.base_dir))
54
+ apply_styles(doc, target)
55
+ return doc
56
+
57
+
58
+ def _layout_and_route(doc) -> Router:
59
+ """Lay out, route; if a channel between two ranks is too narrow for its lanes,
60
+ widen exactly that gap and lay out again."""
61
+ router = None
62
+ for _ in range(MAX_PASSES):
63
+ layout(doc)
64
+ router = Router(doc).route()
65
+ if not router.needs:
66
+ break
67
+ for (gid, rank), need in router.needs.items():
68
+ g = doc.root if gid == doc.root.id else doc.items[gid]
69
+ extra = getattr(g, "extra_gap", {})
70
+ extra[rank] = extra.get(rank, 0.0) + need
71
+ g.extra_gap = extra
72
+ return router
73
+
74
+
75
+ def build_scene(doc, extra_css: list[str] | None = None, theme: str | None = None,
76
+ target: str | None = None) -> Scene:
77
+ """Style, then iterate layout -> route -> scene until edges clear the group titles
78
+ (a crossed title that cannot slide asks for a wider group)."""
79
+ target = resolve_target(doc, target)
80
+ with use_target(target):
81
+ _style(doc, extra_css, theme, target)
82
+ scene = None
83
+ for _ in range(MAX_PASSES):
84
+ router = _layout_and_route(doc)
85
+ builder = SceneBuilder(doc, router)
86
+ scene = builder.build()
87
+ if not builder.title_needs:
88
+ break
89
+ for gid, need in builder.title_needs.items():
90
+ g = doc.items[gid]
91
+ g.extra_w = getattr(g, "extra_w", 0.0) + need
92
+ return scene
93
+
94
+
95
+ def scene_from_file(path: str | Path, extra_css: list[str] | None = None, theme: str | None = None,
96
+ allow_outside: bool = False, target: str | None = None) -> Scene:
97
+ return build_scene(parse_file(path, allow_outside), extra_css, theme, target)
98
+
99
+
100
+ def render(text: str, base_dir: str = ".", extra_css: list[str] | None = None, theme: str | None = None,
101
+ t: float | None = None, allow_outside: bool = False, target: str | None = None) -> str:
102
+ """SVG text. t=None: animated (SMIL) SVG; t=seconds: static frame at that time."""
103
+ return write_svg(build_scene(parse(text, base_dir, allow_outside), extra_css, theme, target), t)
104
+
105
+
106
+ def render_file(path: str | Path, extra_css: list[str] | None = None, theme: str | None = None,
107
+ t: float | None = None, allow_outside: bool = False, target: str | None = None) -> str:
108
+ return write_svg(scene_from_file(path, extra_css, theme, allow_outside, target), t)
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from .cli import app
6
+
7
+ app()
predictygraph/anim.py ADDED
@@ -0,0 +1,91 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Animation tracks: format-independent keyframe descriptions.
6
+
7
+ Every animated property is a looping linear keyframe track ``(attr, values, dur)``
8
+ attached to a Scene IR element. The first keyframe always equals the static value, so
9
+ writers that cannot animate (PDF, PNG, ODP) just use it. Writers that can animate
10
+ (SVG via SMIL) emit the track; frame writers (APNG) sample it with ``Track.at(t)``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import re
16
+
17
+ from .safe import NUM, numbers
18
+
19
+ FLOW_MODES = ("forward", "reverse", "both", "auto")
20
+ BORDER_MODES = ("march", "pulse", "glow")
21
+
22
+
23
+ def seconds(v, default: float = 1.0) -> float:
24
+ if v is None:
25
+ return default
26
+ m = re.match(rf"\s*({NUM})\s*(ms|s)?\s*$", str(v))
27
+ if not m:
28
+ return default
29
+ n = float(m.group(1))
30
+ n = n / 1000 if m.group(2) == "ms" else n
31
+ return min(3600.0, max(0.05, n)) if n == n else default
32
+
33
+
34
+ def dash_len(dash: str) -> float:
35
+ nums = [abs(x) for x in numbers(dash)]
36
+ if not nums:
37
+ return 0.0
38
+ if len(nums) % 2:
39
+ nums = nums * 2 # SVG repeats odd-length dash lists
40
+ return sum(nums)
41
+
42
+
43
+ class Track:
44
+ """A looping linear keyframe track over evenly spaced keyframes."""
45
+
46
+ def __init__(self, attr: str, values: list[float], dur: float):
47
+ if not values:
48
+ raise ValueError("track needs at least one value")
49
+ values = values if len(values) > 1 else values * 2
50
+ dur = dur if dur > 0 else 1.0
51
+ self.attr, self.values, self.dur = attr, values, dur
52
+
53
+ def at(self, t: float) -> float:
54
+ vs = self.values
55
+ ph = (t % self.dur) / self.dur * (len(vs) - 1)
56
+ i = min(int(ph), len(vs) - 2)
57
+ f = ph - i
58
+ return vs[i] + (vs[i + 1] - vs[i]) * f
59
+
60
+ def shifted(self, delta: float) -> "Track":
61
+ return Track(self.attr, [v + delta for v in self.values], self.dur)
62
+
63
+
64
+ def flow_tracks(dash: str, dur: float, reverse: bool) -> list[Track]:
65
+ L = dash_len(dash) or 10.0
66
+ # offset L -> 0 moves the dash pattern towards the path's end
67
+ return [Track("stroke-dashoffset", [0.0, L] if reverse else [L, 0.0], dur)]
68
+
69
+
70
+ def border_tracks(mode: str, sw: float, dash: str | None, dur: float) -> list[Track]:
71
+ if mode == "march":
72
+ return [Track("stroke-dashoffset", [dash_len(dash) or 10.0, 0.0], dur)]
73
+ if mode == "pulse":
74
+ return [Track("stroke-opacity", [1.0, 0.35, 1.0], dur),
75
+ Track("stroke-width", [sw, sw * 2.2, sw], dur)]
76
+ if mode == "glow": # applied to a halo copy behind the shape
77
+ return [Track("stroke-width", [sw, sw + 10, sw + 10], dur),
78
+ Track("stroke-opacity", [0.7, 0.0, 0.0], dur)]
79
+ return []
80
+
81
+
82
+ def loop_length(durations: list[float], cap: float = 12.0) -> float:
83
+ """Shortest time after which every track is back at its start (LCM on a 10 ms grid)."""
84
+ if not durations:
85
+ return 1.0
86
+ l = 1
87
+ for d in durations:
88
+ c = max(1, round(d * 100))
89
+ l = l * c // math.gcd(l, c)
90
+ lcm = l / 100
91
+ return lcm if lcm <= cap else min(max(durations), cap)
predictygraph/cli.py ADDED
@@ -0,0 +1,62 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """predictygraph CLI: .pdg -> Scene IR -> .svg (animated or static) or .png (APNG)."""
6
+ from __future__ import annotations
7
+
8
+ import warnings
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from . import FontWarning, list_themes, scene_from_file, write_apng, write_svg
14
+
15
+ app = typer.Typer(add_completion=False, help="Render Predictygraph diagrams to SVG or APNG.")
16
+
17
+
18
+ @app.command()
19
+ def main(
20
+ src: Path = typer.Argument(None, help="Input .pdg file"),
21
+ out: Path | None = typer.Option(None, "-o", "--out", help="Output .svg or .png (default: <src>.svg)"),
22
+ css: list[Path] = typer.Option([], "--css", help="Extra stylesheet(s), applied after the file's own"),
23
+ theme: str | None = typer.Option(None, "--theme", help="Theme name or .toml path (overrides graph theme=)"),
24
+ target: str | None = typer.Option(None, "--target",
25
+ help="Where the SVG will be shown: web, linux, macos, windows or host "
26
+ "(overrides graph target=). Labels are measured with that "
27
+ "platform's fonts"),
28
+ themes: bool = typer.Option(False, "--list-themes", help="List bundled themes and exit"),
29
+ check: bool = typer.Option(False, "--check", help="Parse, lay out and route only; report errors"),
30
+ static: bool = typer.Option(False, "--static", help="SVG without animation (first frame)"),
31
+ at: float | None = typer.Option(None, "--at", help="SVG frozen at this time (seconds)"),
32
+ fps: int = typer.Option(20, "--fps", help="APNG frames per second"),
33
+ seconds: float | None = typer.Option(None, "--seconds", help="APNG length (default: one full loop)"),
34
+ scale: float = typer.Option(2.0, "--scale", help="APNG pixel scale"),
35
+ allow_outside: bool = typer.Option(False, "--allow-outside",
36
+ help="Let image= and css \"...\" read files outside the input's directory"),
37
+ ) -> None:
38
+ if themes:
39
+ typer.echo("\n".join(list_themes()))
40
+ return
41
+ if src is None or not src.exists():
42
+ typer.echo("missing input file", err=True)
43
+ raise typer.Exit(2)
44
+ dest = out or src.with_suffix(".svg")
45
+ try:
46
+ with warnings.catch_warnings(record=True) as caught:
47
+ warnings.simplefilter("always", FontWarning)
48
+ scene = scene_from_file(src, [p.read_text() for p in css], theme, allow_outside, target)
49
+ for w in caught:
50
+ typer.echo(f"{src}: warning: {w.message}", err=True)
51
+ if check:
52
+ typer.echo(f"{src}: ok")
53
+ return
54
+ if dest.suffix.lower() in (".png", ".apng"):
55
+ write_apng(scene, dest, fps, scale, seconds)
56
+ else:
57
+ t = at if at is not None else (0.0 if static else None)
58
+ dest.write_text(write_svg(scene, t), encoding="utf-8")
59
+ except (ValueError, OSError, RuntimeError) as exc:
60
+ typer.echo(f"{src}: {exc}", err=True)
61
+ raise typer.Exit(1) from exc
62
+ typer.echo(str(dest))
@@ -0,0 +1,16 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: 2026 The predictygraph Authors
3
+ *
4
+ * SPDX-License-Identifier: GPL-3.0-or-later
5
+ *
6
+ * Base layer. Cascade: default.css < theme < css in the .pdg file < --css.
7
+ */
8
+
9
+ graph { font-family: Roboto, Arial, "Helvetica Neue", sans-serif; font-size: 14px; color: #1f2937;
10
+ fill: none; padding: 16px; }
11
+ node { fill: #ffffff; stroke: #374151; stroke-width: 1.5; border-radius: 6px;
12
+ padding: 8px 14px; color: #111827; min-width: 40px; min-height: 28px; }
13
+ group { fill: #f3f4f6; stroke: #9ca3af; stroke-width: 1; border-radius: 10px;
14
+ padding: 14px; color: #374151; font-size: 12px; font-weight: bold;
15
+ label-position: top; }
16
+ edge { stroke: #4b5563; stroke-width: 1.5; color: #374151; font-size: 11px; }
Binary file
Binary file
Binary file
Binary file
predictygraph/fonts.py ADDED
@@ -0,0 +1,167 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Pick the font file used to measure a label, for a given render target.
6
+
7
+ Text is measured to size boxes, but drawn later by whatever displays the SVG. If the two
8
+ use different fonts, labels overflow or leave gaps. A *target* says where the SVG will be
9
+ shown, so we can measure with the font that viewer will actually use:
10
+
11
+ - ``linux`` / ``macos`` / ``windows``: the first font in the CSS list that this platform ships.
12
+ - ``web``: every font in the list; boxes are sized for the widest, so any choice fits.
13
+ - ``host`` (default when no target is set): whatever this machine resolves via fontconfig.
14
+
15
+ Bundled metric fonts (no outlines, widths and kerning only) stand in for common fonts,
16
+ including proprietary ones with identical widths (Liberation Sans = Arial/Helvetica,
17
+ Liberation Serif = Times New Roman, Carlito = Calibri).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import functools
22
+ import subprocess
23
+ import warnings
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ BUNDLED = Path(__file__).parent / "fonts"
28
+
29
+ # family (lower case) -> bundled file stem (regular, bold)
30
+ METRIC = {
31
+ "dejavu sans": ("DejaVuSans", "DejaVuSans-Bold"),
32
+ "liberation sans": ("LiberationSans-Regular", "LiberationSans-Bold"),
33
+ "liberation serif": ("LiberationSerif-Regular", "LiberationSerif-Bold"),
34
+ "liberation mono": ("LiberationMono-Regular", "LiberationMono-Bold"),
35
+ "carlito": ("Carlito-Regular", "Carlito-Bold"),
36
+ }
37
+ # Proprietary or common fonts with identical advance widths to a bundled one.
38
+ ALIASES = {
39
+ "arial": "liberation sans", "helvetica": "liberation sans", "helvetica neue": "liberation sans",
40
+ "arimo": "liberation sans",
41
+ "times new roman": "liberation serif", "times": "liberation serif", "tinos": "liberation serif",
42
+ "courier new": "liberation mono", "courier": "liberation mono", "cousine": "liberation mono",
43
+ "calibri": "carlito",
44
+ "bitstream vera sans": "dejavu sans", "verdana": "dejavu sans",
45
+ }
46
+
47
+ # Fonts each platform ships, and what the generic CSS families resolve to there.
48
+ PLATFORMS = {
49
+ "linux": {
50
+ "has": {"dejavu sans", "liberation sans", "liberation serif", "liberation mono", "carlito"},
51
+ "generic": {"sans-serif": "dejavu sans", "serif": "liberation serif", "monospace": "liberation mono",
52
+ "system-ui": "dejavu sans"},
53
+ },
54
+ "macos": {
55
+ "has": {"helvetica", "helvetica neue", "arial", "times", "times new roman", "courier", "courier new",
56
+ "verdana"},
57
+ "generic": {"sans-serif": "helvetica", "serif": "times", "monospace": "courier",
58
+ "system-ui": "helvetica neue"},
59
+ },
60
+ "windows": {
61
+ "has": {"arial", "times new roman", "courier new", "calibri", "verdana"},
62
+ "generic": {"sans-serif": "arial", "serif": "times new roman", "monospace": "courier new",
63
+ "system-ui": "arial"},
64
+ },
65
+ }
66
+ TARGETS = ("host", "web", *PLATFORMS)
67
+ GENERIC = {"sans-serif", "serif", "monospace", "system-ui", "cursive", "fantasy", "ui-sans-serif"}
68
+
69
+
70
+ class FontWarning(UserWarning):
71
+ """A requested font could not be measured exactly; widths may not match the viewer."""
72
+
73
+
74
+ def families(css: str) -> list[str]:
75
+ """CSS font-family list -> lower-case names, quotes stripped."""
76
+ out = [f.strip().strip("\"'").strip().lower() for f in (css or "").split(",")]
77
+ return [f for f in out if f and not f.startswith("-")] or ["sans-serif"]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class FontChoice:
82
+ path: str | None # file to measure with; None = Pillow's built-in default
83
+ family: str # the family the viewer is expected to draw with
84
+ exact: bool # True if widths are known to match that family
85
+
86
+
87
+ def bundled(family: str, bold: bool) -> str | None:
88
+ key = ALIASES.get(family, family)
89
+ stems = METRIC.get(key)
90
+ if not stems:
91
+ return None
92
+ p = BUNDLED / f"{stems[1 if bold else 0]}.ttf"
93
+ return str(p) if p.exists() else None
94
+
95
+
96
+ @functools.lru_cache(maxsize=64)
97
+ def fontconfig(family: str, bold: bool) -> tuple[str | None, str | None]:
98
+ """(file, resolved family) from fc-match, or (None, None) without fontconfig."""
99
+ pattern = family + (":bold" if bold else "")
100
+ try:
101
+ out = subprocess.run(["fc-match", "-f", "%{file}\t%{family[0]}", "--", pattern],
102
+ capture_output=True, text=True, timeout=5, check=False).stdout.strip()
103
+ except (OSError, subprocess.SubprocessError):
104
+ return None, None
105
+ path, _, fam = out.partition("\t")
106
+ return (path or None), (fam.lower() or None)
107
+
108
+
109
+ class FontResolver:
110
+ def __init__(self, target: str = "host"):
111
+ if target not in TARGETS:
112
+ raise ValueError(f"unknown target {target!r} (choose from: {', '.join(TARGETS)})")
113
+ self.target = target
114
+ self.warned: set[str] = set()
115
+
116
+ def warn(self, msg: str) -> None:
117
+ if msg not in self.warned:
118
+ self.warned.add(msg)
119
+ warnings.warn(msg, FontWarning, stacklevel=4)
120
+
121
+ def platform(self, name: str, fams: list[str], bold: bool) -> FontChoice:
122
+ plat = PLATFORMS[name]
123
+ for fam in fams:
124
+ fam = plat["generic"].get(fam, fam)
125
+ if fam in plat["has"]:
126
+ path = bundled(fam, bold)
127
+ if path:
128
+ return FontChoice(path, fam, True)
129
+ return self.approximate(fam, bold, f"{name} ships {fam!r}, but predictygraph has no metric copy")
130
+ return self.approximate(fams[0], bold, f"none of {fams} is available on {name}")
131
+
132
+ def approximate(self, fam: str, bold: bool, why: str) -> FontChoice:
133
+ """Widest bundled sans as a safe stand-in (text then fits, possibly with spare room)."""
134
+ self.warn(f"font {fam!r}: {why}; measuring with DejaVu Sans, labels may not fit exactly")
135
+ return FontChoice(bundled("dejavu sans", bold), fam, False)
136
+
137
+ def host(self, fams: list[str], bold: bool) -> FontChoice:
138
+ for fam in fams:
139
+ if fam in GENERIC:
140
+ path, got = fontconfig(fam, bold)
141
+ return FontChoice(path, got or fam, path is not None)
142
+ path, got = fontconfig(fam, bold)
143
+ if path and got == fam:
144
+ return FontChoice(path, fam, True)
145
+ if (b := bundled(fam, bold)):
146
+ return FontChoice(b, fam, True)
147
+ path, got = fontconfig(fams[0], bold)
148
+ self.warn(f"font {fams[0]!r} is not installed; measuring with {got or 'a default font'} instead. "
149
+ f"Set a target (e.g. --target web) or install the font")
150
+ return FontChoice(path, got or fams[0], False)
151
+
152
+ @functools.lru_cache(maxsize=256)
153
+ def choose(self, css_family: str, bold: bool) -> list[FontChoice]:
154
+ """Fonts to measure with; the caller takes the widest result."""
155
+ fams = families(css_family)
156
+ if self.target == "host":
157
+ return [self.host(fams, bold)]
158
+ if self.target in PLATFORMS:
159
+ return [self.platform(self.target, fams, bold)]
160
+ # web: any font in the list may be used by some browser; generic families mean "anything"
161
+ out = [FontChoice(bundled(f, bold), f, True) for f in fams if bundled(f, bold)]
162
+ unknown = [f for f in fams if not bundled(f, bold) and f not in GENERIC]
163
+ for f in unknown:
164
+ self.warn(f"font {f!r} has no metric copy; web target sizes boxes without it")
165
+ if any(f in GENERIC for f in fams) or not out:
166
+ out += [FontChoice(bundled(f, bold), f, True) for f in ("dejavu sans", "liberation sans")]
167
+ return out
predictygraph/ir.py ADDED
@@ -0,0 +1,62 @@
1
+ # SPDX-FileCopyrightText: 2026 The predictygraph Authors
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ """Scene IR: a laid-out, routed, fully styled diagram, independent of output format.
6
+
7
+ Pipeline (same shape as slidr): .pdg text → AST (model.Document) → layout + routing
8
+ → Scene IR (this module) → output handlers (render/svg.py, render/apng.py, ...).
9
+
10
+ Everything a writer needs is resolved here: absolute geometry, paint, text lines and
11
+ animation tracks. Writers never look at the AST and never do layout or routing.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ from .anim import Track
18
+
19
+ # Path segments: ("M", x, y) | ("L", x, y) | ("Q", cx, cy, x, y) | ("C", c1x, c1y, c2x, c2y, x, y)
20
+ Seg = tuple
21
+
22
+
23
+ @dataclass
24
+ class Elem:
25
+ """One drawable (or a container of drawables).
26
+
27
+ kind group | rect | ellipse | circle | polygon | polyline | line | path | text | image
28
+ geom kind-specific geometry, e.g. rect {x,y,w,h,rx}, path {segs}, text {x,y,text,anchor}
29
+ paint presentation properties (fill, stroke, stroke-width, stroke-dasharray, opacity,
30
+ font-family, ...). filter="shadow" refers to a scene-level effect.
31
+ tracks animation tracks; the first keyframe equals the static value.
32
+ """
33
+ kind: str
34
+ id: str = ""
35
+ cls: str = ""
36
+ geom: dict = field(default_factory=dict)
37
+ paint: dict = field(default_factory=dict)
38
+ tracks: list[Track] = field(default_factory=list)
39
+ children: list["Elem"] = field(default_factory=list)
40
+
41
+ def walk(self):
42
+ yield self
43
+ for c in self.children:
44
+ yield from c.walk()
45
+
46
+
47
+ @dataclass
48
+ class Scene:
49
+ width: float
50
+ height: float
51
+ background: str | None = None
52
+ layers: list[Elem] = field(default_factory=list) # painter's order: groups, edges, nodes
53
+
54
+ def walk(self):
55
+ for e in self.layers:
56
+ yield from e.walk()
57
+
58
+ def durations(self) -> list[float]:
59
+ return [t.dur for e in self.walk() for t in e.tracks]
60
+
61
+ def effects(self) -> set[str]:
62
+ return {e.paint["filter"] for e in self.walk() if e.paint.get("filter")}