predictygraph 0.1.0__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/PKG-INFO +60 -0
- predictygraph-0.1.0/README.md +46 -0
- predictygraph-0.1.0/pyproject.toml +46 -0
- predictygraph-0.1.0/src/predictygraph/__init__.py +108 -0
- predictygraph-0.1.0/src/predictygraph/__main__.py +7 -0
- predictygraph-0.1.0/src/predictygraph/anim.py +91 -0
- predictygraph-0.1.0/src/predictygraph/cli.py +62 -0
- predictygraph-0.1.0/src/predictygraph/default.css +16 -0
- predictygraph-0.1.0/src/predictygraph/fonts/Carlito-Bold.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/Carlito-Regular.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/DejaVuSans.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- predictygraph-0.1.0/src/predictygraph/fonts.py +167 -0
- predictygraph-0.1.0/src/predictygraph/ir.py +62 -0
- predictygraph-0.1.0/src/predictygraph/layout.py +534 -0
- predictygraph-0.1.0/src/predictygraph/model.py +98 -0
- predictygraph-0.1.0/src/predictygraph/parser.py +310 -0
- predictygraph-0.1.0/src/predictygraph/render/__init__.py +17 -0
- predictygraph-0.1.0/src/predictygraph/render/apng.py +43 -0
- predictygraph-0.1.0/src/predictygraph/render/svg.py +121 -0
- predictygraph-0.1.0/src/predictygraph/route.py +453 -0
- predictygraph-0.1.0/src/predictygraph/safe.py +41 -0
- predictygraph-0.1.0/src/predictygraph/scene.py +349 -0
- predictygraph-0.1.0/src/predictygraph/style.py +204 -0
- predictygraph-0.1.0/src/predictygraph/text.py +80 -0
- predictygraph-0.1.0/src/predictygraph/theme.py +107 -0
- predictygraph-0.1.0/src/predictygraph/themes/catppuccin-frappe.toml +27 -0
- predictygraph-0.1.0/src/predictygraph/themes/catppuccin-latte.toml +27 -0
- predictygraph-0.1.0/src/predictygraph/themes/catppuccin-macchiato.toml +27 -0
- predictygraph-0.1.0/src/predictygraph/themes/catppuccin-mocha.toml +27 -0
- predictygraph-0.1.0/src/predictygraph/themes/default.toml +22 -0
- predictygraph-0.1.0/src/predictygraph/themes/dracula.toml +25 -0
- predictygraph-0.1.0/src/predictygraph/themes/github-light.toml +25 -0
- predictygraph-0.1.0/src/predictygraph/themes/gruvbox-dark.toml +24 -0
- predictygraph-0.1.0/src/predictygraph/themes/nord.toml +25 -0
- predictygraph-0.1.0/src/predictygraph/themes/solarized-dark.toml +15 -0
- predictygraph-0.1.0/src/predictygraph/themes/solarized-light.toml +24 -0
- predictygraph-0.1.0/tests/test_diagonal.py +54 -0
- predictygraph-0.1.0/tests/test_fixes.py +35 -0
- predictygraph-0.1.0/tests/test_hardening.py +75 -0
- predictygraph-0.1.0/tests/test_predictygraph.py +76 -0
- predictygraph-0.1.0/tests/test_scene.py +90 -0
- predictygraph-0.1.0/tests/test_targets.py +118 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: predictygraph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Predictable graphs: a readable, CSS-styled diagram DSL with per-group layouts, rendered to SVG/APNG
|
|
5
|
+
Author-Email: Reza Jelveh <git@jelveh.me>
|
|
6
|
+
License: GPL-3.0-or-later
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: tinycss2>=1.5.1
|
|
9
|
+
Requires-Dist: typer>=0.26.8
|
|
10
|
+
Requires-Dist: pillow>=11.0.0
|
|
11
|
+
Provides-Extra: apng
|
|
12
|
+
Requires-Dist: cairosvg>=2.7; extra == "apng"
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
<!--
|
|
16
|
+
SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
17
|
+
|
|
18
|
+
SPDX-License-Identifier: GPL-3.0-or-later
|
|
19
|
+
-->
|
|
20
|
+
|
|
21
|
+
# predictygraph
|
|
22
|
+
|
|
23
|
+
**Predictable graphs.** A readable diagram DSL with per-group layouts ("balancing styles"),
|
|
24
|
+
plain-CSS styling, and static or animated SVG/APNG output (no browser).
|
|
25
|
+
Spec: [docs/SPEC.md](docs/SPEC.md). Docs: [predictygraph.github.io](https://predictygraph.github.io/).
|
|
26
|
+
Editor support: [tree-sitter-pdg](https://github.com/predictygraph/tree-sitter-pdg).
|
|
27
|
+
Rendered examples and the gallery: [predictygraph.github.io](https://predictygraph.github.io/). This repo keeps
|
|
28
|
+
only `.pdg` sources; images live in the docs repo (git-lfs).
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv run pdg examples/hami_flow.pdg # -> examples/hami_flow.svg (not committed)
|
|
32
|
+
uv run pdg diagram.pdg --css theme.css -o out.svg
|
|
33
|
+
uv run pdg diagram.pdg -o out.png # animated PNG (APNG)
|
|
34
|
+
uv run pdg diagram.pdg --check # validate only
|
|
35
|
+
uv run pdg diagram.pdg --target web # size labels to fit any browser font
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`pdg` is the short alias of the `predictygraph` command.
|
|
39
|
+
|
|
40
|
+
## Credits
|
|
41
|
+
|
|
42
|
+
This project stands on the shoulders of two giants.
|
|
43
|
+
|
|
44
|
+
**[Graphviz](https://graphviz.org/) (dot)** taught a generation that diagrams can be
|
|
45
|
+
text. Its layered layout algorithm is still the reference for graph drawing, and
|
|
46
|
+
we are grateful for decades of work on it. But dot optimises for the whole graph
|
|
47
|
+
at once: you describe nodes and edges, and the engine decides where they go.
|
|
48
|
+
Nudging one cluster without disturbing the rest means fighting `rank`, `weight`
|
|
49
|
+
and invisible edges. Styling lives in attributes rather than CSS, and the output
|
|
50
|
+
often looks like a paper figure rather than a slide.
|
|
51
|
+
|
|
52
|
+
**[Mermaid](https://mermaid.js.org/)** made text diagrams mainstream by putting
|
|
53
|
+
them in Markdown, READMEs and docs sites. Its syntax is friendly and we borrowed
|
|
54
|
+
from its spirit. But it renders through a browser and JavaScript, leaves little
|
|
55
|
+
control over layout, and the same source can render differently across versions
|
|
56
|
+
and hosts.
|
|
57
|
+
|
|
58
|
+
predictygraph keeps what we love from both (diagrams as readable text) and adds what we
|
|
59
|
+
missed: per-group layouts, plain-CSS styling, and deterministic static SVG with no
|
|
60
|
+
browser in the loop.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
3
|
+
|
|
4
|
+
SPDX-License-Identifier: GPL-3.0-or-later
|
|
5
|
+
-->
|
|
6
|
+
|
|
7
|
+
# predictygraph
|
|
8
|
+
|
|
9
|
+
**Predictable graphs.** A readable diagram DSL with per-group layouts ("balancing styles"),
|
|
10
|
+
plain-CSS styling, and static or animated SVG/APNG output (no browser).
|
|
11
|
+
Spec: [docs/SPEC.md](docs/SPEC.md). Docs: [predictygraph.github.io](https://predictygraph.github.io/).
|
|
12
|
+
Editor support: [tree-sitter-pdg](https://github.com/predictygraph/tree-sitter-pdg).
|
|
13
|
+
Rendered examples and the gallery: [predictygraph.github.io](https://predictygraph.github.io/). This repo keeps
|
|
14
|
+
only `.pdg` sources; images live in the docs repo (git-lfs).
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv run pdg examples/hami_flow.pdg # -> examples/hami_flow.svg (not committed)
|
|
18
|
+
uv run pdg diagram.pdg --css theme.css -o out.svg
|
|
19
|
+
uv run pdg diagram.pdg -o out.png # animated PNG (APNG)
|
|
20
|
+
uv run pdg diagram.pdg --check # validate only
|
|
21
|
+
uv run pdg diagram.pdg --target web # size labels to fit any browser font
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`pdg` is the short alias of the `predictygraph` command.
|
|
25
|
+
|
|
26
|
+
## Credits
|
|
27
|
+
|
|
28
|
+
This project stands on the shoulders of two giants.
|
|
29
|
+
|
|
30
|
+
**[Graphviz](https://graphviz.org/) (dot)** taught a generation that diagrams can be
|
|
31
|
+
text. Its layered layout algorithm is still the reference for graph drawing, and
|
|
32
|
+
we are grateful for decades of work on it. But dot optimises for the whole graph
|
|
33
|
+
at once: you describe nodes and edges, and the engine decides where they go.
|
|
34
|
+
Nudging one cluster without disturbing the rest means fighting `rank`, `weight`
|
|
35
|
+
and invisible edges. Styling lives in attributes rather than CSS, and the output
|
|
36
|
+
often looks like a paper figure rather than a slide.
|
|
37
|
+
|
|
38
|
+
**[Mermaid](https://mermaid.js.org/)** made text diagrams mainstream by putting
|
|
39
|
+
them in Markdown, READMEs and docs sites. Its syntax is friendly and we borrowed
|
|
40
|
+
from its spirit. But it renders through a browser and JavaScript, leaves little
|
|
41
|
+
control over layout, and the same source can render differently across versions
|
|
42
|
+
and hosts.
|
|
43
|
+
|
|
44
|
+
predictygraph keeps what we love from both (diagrams as readable text) and adds what we
|
|
45
|
+
missed: per-group layouts, plain-CSS styling, and deterministic static SVG with no
|
|
46
|
+
browser in the loop.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "predictygraph"
|
|
3
|
+
dynamic = []
|
|
4
|
+
description = "Predictable graphs: a readable, CSS-styled diagram DSL with per-group layouts, rendered to SVG/APNG"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Reza Jelveh", email = "git@jelveh.me" },
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"tinycss2>=1.5.1",
|
|
10
|
+
"typer>=0.26.8",
|
|
11
|
+
"pillow>=11.0.0",
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
version = "0.1.0"
|
|
16
|
+
|
|
17
|
+
[project.license]
|
|
18
|
+
text = "GPL-3.0-or-later"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
apng = [
|
|
22
|
+
"cairosvg>=2.7",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
predictygraph = "predictygraph.cli:app"
|
|
27
|
+
pdg = "predictygraph.cli:app"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = [
|
|
31
|
+
"pdm-backend",
|
|
32
|
+
]
|
|
33
|
+
build-backend = "pdm.backend"
|
|
34
|
+
|
|
35
|
+
[tool.pdm.version]
|
|
36
|
+
source = "scm"
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = [
|
|
40
|
+
"tests",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=9.1.1",
|
|
46
|
+
]
|
|
@@ -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,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)
|
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|