frameplot 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.
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from frameplot.layout.types import ValidatedPipeline
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class SccResult:
10
+ components: tuple[tuple[str, ...], ...]
11
+ node_to_component: dict[str, int]
12
+
13
+
14
+ def strongly_connected_components(validated: ValidatedPipeline) -> SccResult:
15
+ adjacency: dict[str, list[str]] = {node.id: [] for node in validated.nodes}
16
+ for edge in validated.edges:
17
+ adjacency[edge.source].append(edge.target)
18
+
19
+ for neighbors in adjacency.values():
20
+ neighbors.sort(key=validated.node_index.__getitem__)
21
+
22
+ index = 0
23
+ stack: list[str] = []
24
+ active: set[str] = set()
25
+ indices: dict[str, int] = {}
26
+ lowlinks: dict[str, int] = {}
27
+ components: list[list[str]] = []
28
+
29
+ def visit(node_id: str) -> None:
30
+ nonlocal index
31
+ indices[node_id] = index
32
+ lowlinks[node_id] = index
33
+ index += 1
34
+ stack.append(node_id)
35
+ active.add(node_id)
36
+
37
+ for neighbor in adjacency[node_id]:
38
+ if neighbor not in indices:
39
+ visit(neighbor)
40
+ lowlinks[node_id] = min(lowlinks[node_id], lowlinks[neighbor])
41
+ elif neighbor in active:
42
+ lowlinks[node_id] = min(lowlinks[node_id], indices[neighbor])
43
+
44
+ if lowlinks[node_id] == indices[node_id]:
45
+ component: list[str] = []
46
+ while True:
47
+ member = stack.pop()
48
+ active.remove(member)
49
+ component.append(member)
50
+ if member == node_id:
51
+ break
52
+ component.sort(key=validated.node_index.__getitem__)
53
+ components.append(component)
54
+
55
+ for node in validated.nodes:
56
+ if node.id not in indices:
57
+ visit(node.id)
58
+
59
+ components.sort(key=lambda members: min(validated.node_index[node_id] for node_id in members))
60
+
61
+ node_to_component: dict[str, int] = {}
62
+ for component_id, component in enumerate(components):
63
+ for node_id in component:
64
+ node_to_component[node_id] = component_id
65
+
66
+ return SccResult(
67
+ components=tuple(tuple(component) for component in components),
68
+ node_to_component=node_to_component,
69
+ )
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import textwrap
5
+
6
+ from frameplot.layout.types import MeasuredText, ValidatedPipeline
7
+
8
+
9
+ TITLE_CHAR_WIDTH = 0.58
10
+ SUBTITLE_CHAR_WIDTH = 0.54
11
+ LINE_HEIGHT_RATIO = 1.25
12
+
13
+
14
+ def measure_text(validated: ValidatedPipeline) -> dict[str, MeasuredText]:
15
+ theme = validated.theme
16
+ title_char_limit = max(10, int(theme.max_text_width / (theme.title_font_size * TITLE_CHAR_WIDTH)))
17
+ subtitle_char_limit = max(
18
+ 10, int(theme.max_text_width / (theme.subtitle_font_size * SUBTITLE_CHAR_WIDTH))
19
+ )
20
+ measurements: dict[str, MeasuredText] = {}
21
+
22
+ for node in validated.nodes:
23
+ title_lines = tuple(_wrap(node.title, title_char_limit))
24
+ subtitle_lines = tuple(_wrap(node.subtitle, subtitle_char_limit)) if node.subtitle else ()
25
+
26
+ title_width = _max_line_width(title_lines, theme.title_font_size, TITLE_CHAR_WIDTH)
27
+ subtitle_width = _max_line_width(
28
+ subtitle_lines, theme.subtitle_font_size, SUBTITLE_CHAR_WIDTH
29
+ )
30
+ text_width = max(title_width, subtitle_width, theme.min_node_width - theme.node_padding_x * 2)
31
+
32
+ title_line_height = theme.title_font_size * LINE_HEIGHT_RATIO
33
+ subtitle_line_height = theme.subtitle_font_size * LINE_HEIGHT_RATIO
34
+
35
+ content_height = len(title_lines) * title_line_height
36
+ if subtitle_lines:
37
+ content_height += theme.inter_text_gap + len(subtitle_lines) * subtitle_line_height
38
+
39
+ auto_width = theme.node_padding_x * 2 + text_width
40
+ auto_height = theme.node_padding_y * 2 + content_height
41
+
42
+ width = max(node.width or 0.0, auto_width, theme.min_node_width)
43
+ height = max(node.height or 0.0, auto_height, theme.min_node_height)
44
+
45
+ measurements[node.id] = MeasuredText(
46
+ title_lines=title_lines,
47
+ subtitle_lines=subtitle_lines,
48
+ title_line_height=title_line_height,
49
+ subtitle_line_height=subtitle_line_height,
50
+ content_height=content_height,
51
+ width=round(width, 2),
52
+ height=round(height, 2),
53
+ )
54
+
55
+ return measurements
56
+
57
+
58
+ def _wrap(value: str | None, width: int) -> list[str]:
59
+ if not value:
60
+ return []
61
+ wrapped = textwrap.wrap(
62
+ value,
63
+ width=width,
64
+ break_long_words=True,
65
+ break_on_hyphens=False,
66
+ drop_whitespace=True,
67
+ )
68
+ return wrapped or [value]
69
+
70
+
71
+ def _max_line_width(lines: tuple[str, ...], font_size: float, ratio: float) -> float:
72
+ if not lines:
73
+ return 0.0
74
+ return math.ceil(max(len(line) for line in lines) * font_size * ratio)
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from frameplot.model import DetailPanel, Edge, Group, Node
6
+ from frameplot.theme import Theme
7
+
8
+
9
+ @dataclass(slots=True, frozen=True)
10
+ class Point:
11
+ x: float
12
+ y: float
13
+
14
+
15
+ @dataclass(slots=True, frozen=True)
16
+ class Bounds:
17
+ x: float
18
+ y: float
19
+ width: float
20
+ height: float
21
+
22
+ @property
23
+ def right(self) -> float:
24
+ return self.x + self.width
25
+
26
+ @property
27
+ def bottom(self) -> float:
28
+ return self.y + self.height
29
+
30
+ def expand(self, padding: float) -> "Bounds":
31
+ return Bounds(
32
+ x=self.x - padding,
33
+ y=self.y - padding,
34
+ width=self.width + padding * 2,
35
+ height=self.height + padding * 2,
36
+ )
37
+
38
+
39
+ def union_bounds(bounds: list[Bounds]) -> Bounds:
40
+ min_x = min(bound.x for bound in bounds)
41
+ min_y = min(bound.y for bound in bounds)
42
+ max_x = max(bound.right for bound in bounds)
43
+ max_y = max(bound.bottom for bound in bounds)
44
+ return Bounds(x=min_x, y=min_y, width=max_x - min_x, height=max_y - min_y)
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class ValidatedPipeline:
49
+ nodes: tuple[Node, ...]
50
+ edges: tuple[Edge, ...]
51
+ groups: tuple[Group, ...]
52
+ node_lookup: dict[str, Node]
53
+ edge_lookup: dict[str, Edge]
54
+ node_index: dict[str, int]
55
+ edge_index: dict[str, int]
56
+ theme: Theme
57
+ detail_panel: "ValidatedDetailPanel | None" = None
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class ValidatedDetailPanel:
62
+ panel: DetailPanel
63
+ nodes: tuple[Node, ...]
64
+ edges: tuple[Edge, ...]
65
+ groups: tuple[Group, ...]
66
+ node_lookup: dict[str, Node]
67
+ edge_lookup: dict[str, Edge]
68
+ node_index: dict[str, int]
69
+ edge_index: dict[str, int]
70
+ theme: Theme
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class MeasuredText:
75
+ title_lines: tuple[str, ...]
76
+ subtitle_lines: tuple[str, ...]
77
+ title_line_height: float
78
+ subtitle_line_height: float
79
+ content_height: float
80
+ width: float
81
+ height: float
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class LayoutNode:
86
+ node: Node
87
+ rank: int
88
+ order: int
89
+ component_id: int
90
+ width: float
91
+ height: float
92
+ x: float
93
+ y: float
94
+ title_lines: tuple[str, ...]
95
+ subtitle_lines: tuple[str, ...]
96
+ title_line_height: float
97
+ subtitle_line_height: float
98
+ content_height: float
99
+
100
+ @property
101
+ def bounds(self) -> Bounds:
102
+ return Bounds(self.x, self.y, self.width, self.height)
103
+
104
+ @property
105
+ def right(self) -> float:
106
+ return self.x + self.width
107
+
108
+ @property
109
+ def center_x(self) -> float:
110
+ return self.x + self.width / 2
111
+
112
+ @property
113
+ def center_y(self) -> float:
114
+ return self.y + self.height / 2
115
+
116
+
117
+ @dataclass(slots=True)
118
+ class RoutedEdge:
119
+ edge: Edge
120
+ points: tuple[Point, ...]
121
+ bounds: Bounds
122
+ stroke: str
123
+
124
+
125
+ @dataclass(slots=True)
126
+ class GroupOverlay:
127
+ group: Group
128
+ bounds: Bounds
129
+ stroke: str
130
+ fill: str
131
+
132
+
133
+ @dataclass(slots=True)
134
+ class GuideLine:
135
+ points: tuple[Point, ...]
136
+ bounds: Bounds
137
+ stroke: str
138
+
139
+
140
+ @dataclass(slots=True)
141
+ class GraphLayout:
142
+ nodes: dict[str, LayoutNode]
143
+ edges: tuple[RoutedEdge, ...]
144
+ groups: tuple[GroupOverlay, ...]
145
+ content_bounds: Bounds
146
+ width: float
147
+ height: float
148
+
149
+
150
+ @dataclass(slots=True)
151
+ class DetailPanelLayout:
152
+ panel: DetailPanel
153
+ graph: GraphLayout
154
+ bounds: Bounds
155
+ stroke: str
156
+ fill: str
157
+ guide_lines: tuple[GuideLine, ...]
158
+
159
+
160
+ @dataclass(slots=True)
161
+ class LayoutResult:
162
+ main: GraphLayout
163
+ detail_panel: DetailPanelLayout | None
164
+ width: float
165
+ height: float
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from frameplot.layout.types import ValidatedDetailPanel, ValidatedPipeline
4
+
5
+
6
+ def validate_pipeline(pipeline: "Pipeline") -> ValidatedPipeline:
7
+ from frameplot.api import Pipeline
8
+
9
+ if not isinstance(pipeline, Pipeline):
10
+ raise TypeError("pipeline must be a Pipeline instance.")
11
+
12
+ validated_graph = _validate_graph_parts(
13
+ nodes=tuple(pipeline.nodes),
14
+ edges=tuple(pipeline.edges),
15
+ groups=tuple(pipeline.groups),
16
+ owner_label="Pipeline",
17
+ )
18
+
19
+ detail_panel = None
20
+ if pipeline.detail_panel is not None:
21
+ if pipeline.detail_panel.focus_node_id not in validated_graph["node_lookup"]:
22
+ raise ValueError(
23
+ f"DetailPanel {pipeline.detail_panel.id} references missing focus node: "
24
+ f"{pipeline.detail_panel.focus_node_id}"
25
+ )
26
+ validated_panel = _validate_graph_parts(
27
+ nodes=tuple(pipeline.detail_panel.nodes),
28
+ edges=tuple(pipeline.detail_panel.edges),
29
+ groups=tuple(pipeline.detail_panel.groups),
30
+ owner_label=f"DetailPanel {pipeline.detail_panel.id}",
31
+ )
32
+ detail_panel = ValidatedDetailPanel(
33
+ panel=pipeline.detail_panel,
34
+ nodes=validated_panel["nodes"],
35
+ edges=validated_panel["edges"],
36
+ groups=validated_panel["groups"],
37
+ node_lookup=validated_panel["node_lookup"],
38
+ edge_lookup=validated_panel["edge_lookup"],
39
+ node_index=validated_panel["node_index"],
40
+ edge_index=validated_panel["edge_index"],
41
+ theme=pipeline.theme,
42
+ )
43
+
44
+ return ValidatedPipeline(
45
+ nodes=validated_graph["nodes"],
46
+ edges=validated_graph["edges"],
47
+ groups=validated_graph["groups"],
48
+ node_lookup=validated_graph["node_lookup"],
49
+ edge_lookup=validated_graph["edge_lookup"],
50
+ node_index=validated_graph["node_index"],
51
+ edge_index=validated_graph["edge_index"],
52
+ theme=pipeline.theme,
53
+ detail_panel=detail_panel,
54
+ )
55
+
56
+
57
+ def _validate_graph_parts(
58
+ *,
59
+ nodes: tuple["Node", ...],
60
+ edges: tuple["Edge", ...],
61
+ groups: tuple["Group", ...],
62
+ owner_label: str,
63
+ ) -> dict[str, object]:
64
+ if not nodes:
65
+ raise ValueError(f"{owner_label} must contain at least one node.")
66
+
67
+ node_lookup: dict[str, object] = {}
68
+ edge_lookup: dict[str, object] = {}
69
+ node_index: dict[str, int] = {}
70
+ edge_index: dict[str, int] = {}
71
+
72
+ for index, node in enumerate(nodes):
73
+ if node.id in node_lookup:
74
+ raise ValueError(f"Duplicate node id: {node.id}")
75
+ node_lookup[node.id] = node
76
+ node_index[node.id] = index
77
+
78
+ for index, edge in enumerate(edges):
79
+ if edge.id in edge_lookup:
80
+ raise ValueError(f"Duplicate edge id: {edge.id}")
81
+ if edge.source not in node_lookup:
82
+ raise ValueError(f"Edge {edge.id} references missing source node: {edge.source}")
83
+ if edge.target not in node_lookup:
84
+ raise ValueError(f"Edge {edge.id} references missing target node: {edge.target}")
85
+ edge_lookup[edge.id] = edge
86
+ edge_index[edge.id] = index
87
+
88
+ for group in groups:
89
+ missing_nodes = [node_id for node_id in group.node_ids if node_id not in node_lookup]
90
+ missing_edges = [edge_id for edge_id in group.edge_ids if edge_id not in edge_lookup]
91
+ if missing_nodes:
92
+ raise ValueError(
93
+ f"Group {group.id} references missing node ids: {', '.join(sorted(missing_nodes))}"
94
+ )
95
+ if missing_edges:
96
+ raise ValueError(
97
+ f"Group {group.id} references missing edge ids: {', '.join(sorted(missing_edges))}"
98
+ )
99
+
100
+ return {
101
+ "nodes": nodes,
102
+ "edges": edges,
103
+ "groups": groups,
104
+ "node_lookup": node_lookup,
105
+ "edge_lookup": edge_lookup,
106
+ "node_index": node_index,
107
+ "edge_index": edge_index,
108
+ }
frameplot/model.py ADDED
@@ -0,0 +1,129 @@
1
+ """Public data models used to describe frameplot diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class Node:
11
+ """Describe a rendered node in the main graph or a detail panel.
12
+
13
+ `id` and `title` are required. Optional colors override the active theme for
14
+ this node only, and `width` or `height` can pin the rendered box size.
15
+ Surrounding whitespace is stripped from text fields.
16
+ """
17
+
18
+ id: str
19
+ title: str
20
+ subtitle: str | None = None
21
+ fill: str | None = None
22
+ stroke: str | None = None
23
+ text_color: str | None = None
24
+ metadata: dict[str, Any] = field(default_factory=dict)
25
+ width: float | None = None
26
+ height: float | None = None
27
+
28
+ def __post_init__(self) -> None:
29
+ self.id = self.id.strip()
30
+ self.title = self.title.strip()
31
+ if self.subtitle is not None:
32
+ self.subtitle = self.subtitle.strip() or None
33
+ if not self.id:
34
+ raise ValueError("Node id must not be empty.")
35
+ if not self.title:
36
+ raise ValueError("Node title must not be empty.")
37
+ if self.width is not None and self.width <= 0:
38
+ raise ValueError("Node width must be positive.")
39
+ if self.height is not None and self.height <= 0:
40
+ raise ValueError("Node height must be positive.")
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class Edge:
45
+ """Connect two nodes with a directional edge.
46
+
47
+ `source` and `target` must reference node identifiers in the same graph.
48
+ Set `dashed=True` to draw a secondary or conditional flow.
49
+ """
50
+
51
+ id: str
52
+ source: str
53
+ target: str
54
+ color: str | None = None
55
+ dashed: bool = False
56
+ metadata: dict[str, Any] = field(default_factory=dict)
57
+
58
+ def __post_init__(self) -> None:
59
+ self.id = self.id.strip()
60
+ self.source = self.source.strip()
61
+ self.target = self.target.strip()
62
+ if not self.id:
63
+ raise ValueError("Edge id must not be empty.")
64
+ if not self.source or not self.target:
65
+ raise ValueError("Edge source and target must not be empty.")
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class Group:
70
+ """Highlight related nodes or edges with a labeled overlay.
71
+
72
+ Groups are visual only. They do not constrain layout or change routing.
73
+ At least one node or edge reference is required.
74
+ """
75
+
76
+ id: str
77
+ label: str
78
+ node_ids: tuple[str, ...]
79
+ edge_ids: tuple[str, ...] = ()
80
+ stroke: str | None = None
81
+ fill: str | None = None
82
+ metadata: dict[str, Any] = field(default_factory=dict)
83
+
84
+ def __post_init__(self) -> None:
85
+ self.id = self.id.strip()
86
+ self.label = self.label.strip()
87
+ self.node_ids = tuple(node_id.strip() for node_id in self.node_ids if node_id.strip())
88
+ self.edge_ids = tuple(edge_id.strip() for edge_id in self.edge_ids if edge_id.strip())
89
+ if not self.id:
90
+ raise ValueError("Group id must not be empty.")
91
+ if not self.label:
92
+ raise ValueError("Group label must not be empty.")
93
+ if not self.node_ids and not self.edge_ids:
94
+ raise ValueError("Group must reference at least one node or edge.")
95
+
96
+
97
+ @dataclass(slots=True)
98
+ class DetailPanel:
99
+ """Expand a focus node into a lower inset with its own mini-graph.
100
+
101
+ The focus node must exist in the main pipeline graph, and the panel must
102
+ contain at least one node.
103
+ """
104
+
105
+ id: str
106
+ focus_node_id: str
107
+ label: str
108
+ nodes: tuple[Node, ...]
109
+ edges: tuple[Edge, ...]
110
+ groups: tuple[Group, ...] = ()
111
+ stroke: str | None = None
112
+ fill: str | None = None
113
+ metadata: dict[str, Any] = field(default_factory=dict)
114
+
115
+ def __post_init__(self) -> None:
116
+ self.id = self.id.strip()
117
+ self.focus_node_id = self.focus_node_id.strip()
118
+ self.label = self.label.strip()
119
+ self.nodes = tuple(self.nodes)
120
+ self.edges = tuple(self.edges)
121
+ self.groups = tuple(self.groups)
122
+ if not self.id:
123
+ raise ValueError("DetailPanel id must not be empty.")
124
+ if not self.focus_node_id:
125
+ raise ValueError("DetailPanel focus_node_id must not be empty.")
126
+ if not self.label:
127
+ raise ValueError("DetailPanel label must not be empty.")
128
+ if not self.nodes:
129
+ raise ValueError("DetailPanel must contain at least one node.")
@@ -0,0 +1,4 @@
1
+ from frameplot.render.png import save_png, svg_to_png_bytes
2
+ from frameplot.render.svg import render_svg
3
+
4
+ __all__ = ["render_svg", "save_png", "svg_to_png_bytes"]
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def svg_to_png_bytes(svg: str) -> bytes:
7
+ """Convert an SVG string into PNG bytes with CairoSVG."""
8
+
9
+ try:
10
+ import cairosvg
11
+ except ImportError as exc: # pragma: no cover - dependency issue
12
+ raise RuntimeError("CairoSVG is required for PNG export.") from exc
13
+ return cairosvg.svg2png(bytestring=svg.encode("utf-8"))
14
+
15
+
16
+ def save_png(svg: str, path: str | Path) -> None:
17
+ """Write PNG bytes generated from `svg` to `path`."""
18
+
19
+ output = svg_to_png_bytes(svg)
20
+ Path(path).write_bytes(output)