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.
- frameplot/__init__.py +7 -0
- frameplot/api.py +61 -0
- frameplot/layout/__init__.py +268 -0
- frameplot/layout/order.py +169 -0
- frameplot/layout/place.py +193 -0
- frameplot/layout/rank.py +54 -0
- frameplot/layout/route.py +1031 -0
- frameplot/layout/scc.py +69 -0
- frameplot/layout/text.py +74 -0
- frameplot/layout/types.py +165 -0
- frameplot/layout/validate.py +108 -0
- frameplot/model.py +129 -0
- frameplot/render/__init__.py +4 -0
- frameplot/render/png.py +20 -0
- frameplot/render/svg.py +306 -0
- frameplot/theme.py +59 -0
- frameplot-0.1.0.dist-info/METADATA +132 -0
- frameplot-0.1.0.dist-info/RECORD +21 -0
- frameplot-0.1.0.dist-info/WHEEL +5 -0
- frameplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- frameplot-0.1.0.dist-info/top_level.txt +1 -0
frameplot/render/svg.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
|
|
6
|
+
from frameplot.layout.types import (
|
|
7
|
+
DetailPanelLayout,
|
|
8
|
+
GraphLayout,
|
|
9
|
+
GuideLine,
|
|
10
|
+
LayoutNode,
|
|
11
|
+
LayoutResult,
|
|
12
|
+
Point,
|
|
13
|
+
RoutedEdge,
|
|
14
|
+
)
|
|
15
|
+
from frameplot.theme import Theme
|
|
16
|
+
|
|
17
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
18
|
+
ET.register_namespace("", SVG_NS)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_svg(layout: LayoutResult, theme: Theme) -> str:
|
|
22
|
+
"""Serialize a computed layout into an SVG document string."""
|
|
23
|
+
|
|
24
|
+
root = ET.Element(
|
|
25
|
+
ET.QName(SVG_NS, "svg"),
|
|
26
|
+
attrib={
|
|
27
|
+
"width": _fmt(layout.width),
|
|
28
|
+
"height": _fmt(layout.height),
|
|
29
|
+
"viewBox": f"0 0 {_fmt(layout.width)} {_fmt(layout.height)}",
|
|
30
|
+
"fill": "none",
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
defs = ET.SubElement(root, ET.QName(SVG_NS, "defs"))
|
|
35
|
+
marker_ids = _build_markers(defs, _all_edges(layout), theme)
|
|
36
|
+
|
|
37
|
+
ET.SubElement(
|
|
38
|
+
root,
|
|
39
|
+
ET.QName(SVG_NS, "rect"),
|
|
40
|
+
attrib={
|
|
41
|
+
"x": "0",
|
|
42
|
+
"y": "0",
|
|
43
|
+
"width": _fmt(layout.width),
|
|
44
|
+
"height": _fmt(layout.height),
|
|
45
|
+
"fill": theme.background_color,
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
group_layer = ET.SubElement(root, ET.QName(SVG_NS, "g"), attrib={"id": "groups"})
|
|
50
|
+
_render_groups(group_layer, layout.main, theme)
|
|
51
|
+
|
|
52
|
+
if layout.detail_panel is not None:
|
|
53
|
+
guide_layer = ET.SubElement(root, ET.QName(SVG_NS, "g"), attrib={"id": "detail-panel-guides"})
|
|
54
|
+
_render_guide_lines(guide_layer, layout.detail_panel.guide_lines, theme)
|
|
55
|
+
|
|
56
|
+
panel_layer = ET.SubElement(
|
|
57
|
+
root,
|
|
58
|
+
ET.QName(SVG_NS, "g"),
|
|
59
|
+
attrib={"id": f"detail-panel-{layout.detail_panel.panel.id}"},
|
|
60
|
+
)
|
|
61
|
+
_render_detail_panel_container(panel_layer, layout.detail_panel, theme)
|
|
62
|
+
_render_groups(panel_layer, layout.detail_panel.graph, theme)
|
|
63
|
+
|
|
64
|
+
edge_layer = ET.SubElement(root, ET.QName(SVG_NS, "g"), attrib={"id": "edges"})
|
|
65
|
+
_render_edges(edge_layer, layout.main.edges, marker_ids, theme)
|
|
66
|
+
if layout.detail_panel is not None:
|
|
67
|
+
_render_edges(edge_layer, layout.detail_panel.graph.edges, marker_ids, theme)
|
|
68
|
+
|
|
69
|
+
node_layer = ET.SubElement(root, ET.QName(SVG_NS, "g"), attrib={"id": "nodes"})
|
|
70
|
+
_render_nodes(node_layer, layout.main.nodes, theme)
|
|
71
|
+
if layout.detail_panel is not None:
|
|
72
|
+
_render_nodes(
|
|
73
|
+
node_layer,
|
|
74
|
+
layout.detail_panel.graph.nodes,
|
|
75
|
+
theme,
|
|
76
|
+
id_prefix=f"detail-panel-{layout.detail_panel.panel.id}-",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return ET.tostring(root, encoding="unicode")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _all_edges(layout: LayoutResult) -> tuple[RoutedEdge, ...]:
|
|
83
|
+
if layout.detail_panel is None:
|
|
84
|
+
return layout.main.edges
|
|
85
|
+
return layout.main.edges + layout.detail_panel.graph.edges
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_groups(parent: ET.Element, graph: GraphLayout, theme: Theme) -> None:
|
|
89
|
+
for overlay in graph.groups:
|
|
90
|
+
ET.SubElement(
|
|
91
|
+
parent,
|
|
92
|
+
ET.QName(SVG_NS, "rect"),
|
|
93
|
+
attrib={
|
|
94
|
+
"x": _fmt(overlay.bounds.x),
|
|
95
|
+
"y": _fmt(overlay.bounds.y),
|
|
96
|
+
"width": _fmt(overlay.bounds.width),
|
|
97
|
+
"height": _fmt(overlay.bounds.height),
|
|
98
|
+
"rx": _fmt(theme.group_corner_radius),
|
|
99
|
+
"ry": _fmt(theme.group_corner_radius),
|
|
100
|
+
"stroke": overlay.stroke,
|
|
101
|
+
"stroke-width": _fmt(theme.group_stroke_width),
|
|
102
|
+
"stroke-dasharray": "8 6",
|
|
103
|
+
"fill": overlay.fill,
|
|
104
|
+
"fill-opacity": _fmt(theme.group_fill_opacity),
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
ET.SubElement(
|
|
108
|
+
parent,
|
|
109
|
+
ET.QName(SVG_NS, "text"),
|
|
110
|
+
attrib={
|
|
111
|
+
"x": _fmt(overlay.bounds.x + theme.group_padding),
|
|
112
|
+
"y": _fmt(overlay.bounds.y + theme.subtitle_font_size + 6),
|
|
113
|
+
"fill": theme.group_label_color,
|
|
114
|
+
"font-family": theme.title_font_family,
|
|
115
|
+
"font-size": _fmt(theme.subtitle_font_size),
|
|
116
|
+
"font-weight": str(theme.title_font_weight),
|
|
117
|
+
},
|
|
118
|
+
).text = overlay.group.label
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _render_guide_lines(parent: ET.Element, guide_lines: tuple[GuideLine, ...], theme: Theme) -> None:
|
|
122
|
+
for guide_line in guide_lines:
|
|
123
|
+
ET.SubElement(
|
|
124
|
+
parent,
|
|
125
|
+
ET.QName(SVG_NS, "path"),
|
|
126
|
+
attrib={
|
|
127
|
+
"d": _path_data(guide_line.points),
|
|
128
|
+
"stroke": guide_line.stroke,
|
|
129
|
+
"stroke-width": _fmt(theme.detail_panel_guide_width),
|
|
130
|
+
"stroke-linecap": "round",
|
|
131
|
+
"stroke-linejoin": "round",
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _render_detail_panel_container(
|
|
137
|
+
parent: ET.Element,
|
|
138
|
+
detail_panel: DetailPanelLayout,
|
|
139
|
+
theme: Theme,
|
|
140
|
+
) -> None:
|
|
141
|
+
ET.SubElement(
|
|
142
|
+
parent,
|
|
143
|
+
ET.QName(SVG_NS, "rect"),
|
|
144
|
+
attrib={
|
|
145
|
+
"x": _fmt(detail_panel.bounds.x),
|
|
146
|
+
"y": _fmt(detail_panel.bounds.y),
|
|
147
|
+
"width": _fmt(detail_panel.bounds.width),
|
|
148
|
+
"height": _fmt(detail_panel.bounds.height),
|
|
149
|
+
"rx": _fmt(theme.detail_panel_corner_radius),
|
|
150
|
+
"ry": _fmt(theme.detail_panel_corner_radius),
|
|
151
|
+
"stroke": detail_panel.stroke,
|
|
152
|
+
"stroke-width": _fmt(theme.detail_panel_stroke_width),
|
|
153
|
+
"fill": detail_panel.fill,
|
|
154
|
+
"fill-opacity": _fmt(theme.detail_panel_fill_opacity),
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
ET.SubElement(
|
|
158
|
+
parent,
|
|
159
|
+
ET.QName(SVG_NS, "text"),
|
|
160
|
+
attrib={
|
|
161
|
+
"x": _fmt(detail_panel.bounds.x + theme.detail_panel_padding),
|
|
162
|
+
"y": _fmt(detail_panel.bounds.y + theme.subtitle_font_size + 8),
|
|
163
|
+
"fill": theme.detail_panel_title_color,
|
|
164
|
+
"font-family": theme.title_font_family,
|
|
165
|
+
"font-size": _fmt(theme.subtitle_font_size),
|
|
166
|
+
"font-weight": str(theme.title_font_weight),
|
|
167
|
+
},
|
|
168
|
+
).text = detail_panel.panel.label
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _render_edges(
|
|
172
|
+
parent: ET.Element,
|
|
173
|
+
edges: tuple[RoutedEdge, ...],
|
|
174
|
+
marker_ids: dict[str, str],
|
|
175
|
+
theme: Theme,
|
|
176
|
+
) -> None:
|
|
177
|
+
for routed_edge in edges:
|
|
178
|
+
attributes = {
|
|
179
|
+
"d": _path_data(routed_edge.points),
|
|
180
|
+
"stroke": routed_edge.stroke,
|
|
181
|
+
"stroke-width": _fmt(theme.stroke_width),
|
|
182
|
+
"stroke-linecap": "round",
|
|
183
|
+
"stroke-linejoin": "round",
|
|
184
|
+
"marker-end": f"url(#{marker_ids[routed_edge.stroke]})",
|
|
185
|
+
}
|
|
186
|
+
if routed_edge.edge.dashed:
|
|
187
|
+
attributes["stroke-dasharray"] = "8 6"
|
|
188
|
+
ET.SubElement(parent, ET.QName(SVG_NS, "path"), attrib=attributes)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _render_nodes(
|
|
192
|
+
parent: ET.Element,
|
|
193
|
+
nodes: dict[str, LayoutNode],
|
|
194
|
+
theme: Theme,
|
|
195
|
+
*,
|
|
196
|
+
id_prefix: str = "",
|
|
197
|
+
) -> None:
|
|
198
|
+
for node_id in sorted(nodes, key=lambda item: (nodes[item].rank, nodes[item].order, item)):
|
|
199
|
+
_render_node(parent, nodes[node_id], theme, element_id=f"{id_prefix}{node_id}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _render_node(
|
|
203
|
+
parent: ET.Element,
|
|
204
|
+
layout_node: LayoutNode,
|
|
205
|
+
theme: Theme,
|
|
206
|
+
*,
|
|
207
|
+
element_id: str,
|
|
208
|
+
) -> None:
|
|
209
|
+
node = layout_node.node
|
|
210
|
+
node_group = ET.SubElement(parent, ET.QName(SVG_NS, "g"), attrib={"id": element_id})
|
|
211
|
+
ET.SubElement(
|
|
212
|
+
node_group,
|
|
213
|
+
ET.QName(SVG_NS, "rect"),
|
|
214
|
+
attrib={
|
|
215
|
+
"x": _fmt(layout_node.x),
|
|
216
|
+
"y": _fmt(layout_node.y),
|
|
217
|
+
"width": _fmt(layout_node.width),
|
|
218
|
+
"height": _fmt(layout_node.height),
|
|
219
|
+
"rx": _fmt(theme.corner_radius),
|
|
220
|
+
"ry": _fmt(theme.corner_radius),
|
|
221
|
+
"fill": node.fill or theme.node_fill,
|
|
222
|
+
"stroke": node.stroke or theme.node_stroke,
|
|
223
|
+
"stroke-width": _fmt(theme.stroke_width),
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
text_x = layout_node.x + theme.node_padding_x
|
|
228
|
+
content_top = layout_node.y + (layout_node.height - layout_node.content_height) / 2
|
|
229
|
+
current_top = content_top
|
|
230
|
+
title_ascent = theme.title_font_size * 0.8
|
|
231
|
+
subtitle_ascent = theme.subtitle_font_size * 0.8
|
|
232
|
+
text_color = node.text_color or theme.node_text_color
|
|
233
|
+
|
|
234
|
+
for line in layout_node.title_lines:
|
|
235
|
+
current_top += layout_node.title_line_height
|
|
236
|
+
ET.SubElement(
|
|
237
|
+
node_group,
|
|
238
|
+
ET.QName(SVG_NS, "text"),
|
|
239
|
+
attrib={
|
|
240
|
+
"x": _fmt(text_x),
|
|
241
|
+
"y": _fmt(current_top - layout_node.title_line_height + title_ascent),
|
|
242
|
+
"fill": text_color,
|
|
243
|
+
"font-family": theme.title_font_family,
|
|
244
|
+
"font-size": _fmt(theme.title_font_size),
|
|
245
|
+
"font-weight": str(theme.title_font_weight),
|
|
246
|
+
},
|
|
247
|
+
).text = line
|
|
248
|
+
|
|
249
|
+
if layout_node.subtitle_lines:
|
|
250
|
+
current_top += theme.inter_text_gap
|
|
251
|
+
for line in layout_node.subtitle_lines:
|
|
252
|
+
current_top += layout_node.subtitle_line_height
|
|
253
|
+
ET.SubElement(
|
|
254
|
+
node_group,
|
|
255
|
+
ET.QName(SVG_NS, "text"),
|
|
256
|
+
attrib={
|
|
257
|
+
"x": _fmt(text_x),
|
|
258
|
+
"y": _fmt(current_top - layout_node.subtitle_line_height + subtitle_ascent),
|
|
259
|
+
"fill": text_color,
|
|
260
|
+
"font-family": theme.title_font_family,
|
|
261
|
+
"font-size": _fmt(theme.subtitle_font_size),
|
|
262
|
+
"font-weight": str(theme.subtitle_font_weight),
|
|
263
|
+
},
|
|
264
|
+
).text = line
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _build_markers(defs: ET.Element, edges: tuple[RoutedEdge, ...], theme: Theme) -> dict[str, str]:
|
|
268
|
+
marker_ids: dict[str, str] = {}
|
|
269
|
+
for color in sorted({routed_edge.stroke for routed_edge in edges}):
|
|
270
|
+
marker_id = f"arrow-{hashlib.sha1(color.encode('utf-8')).hexdigest()[:10]}"
|
|
271
|
+
marker_ids[color] = marker_id
|
|
272
|
+
marker = ET.SubElement(
|
|
273
|
+
defs,
|
|
274
|
+
ET.QName(SVG_NS, "marker"),
|
|
275
|
+
attrib={
|
|
276
|
+
"id": marker_id,
|
|
277
|
+
"markerWidth": _fmt(theme.arrow_size),
|
|
278
|
+
"markerHeight": _fmt(theme.arrow_size),
|
|
279
|
+
"refX": _fmt(theme.arrow_size),
|
|
280
|
+
"refY": _fmt(theme.arrow_size / 2),
|
|
281
|
+
"orient": "auto",
|
|
282
|
+
"markerUnits": "userSpaceOnUse",
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
ET.SubElement(
|
|
286
|
+
marker,
|
|
287
|
+
ET.QName(SVG_NS, "path"),
|
|
288
|
+
attrib={
|
|
289
|
+
"d": f"M0,0 L{_fmt(theme.arrow_size)},{_fmt(theme.arrow_size / 2)} L0,{_fmt(theme.arrow_size)} z",
|
|
290
|
+
"fill": color,
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
return marker_ids
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _path_data(points: tuple[Point, ...]) -> str:
|
|
297
|
+
commands = [f"M {_fmt(points[0].x)} {_fmt(points[0].y)}"]
|
|
298
|
+
commands.extend(f"L {_fmt(point.x)} {_fmt(point.y)}" for point in points[1:])
|
|
299
|
+
return " ".join(commands)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _fmt(value: float) -> str:
|
|
303
|
+
text = f"{value:.2f}"
|
|
304
|
+
if "." in text:
|
|
305
|
+
text = text.rstrip("0").rstrip(".")
|
|
306
|
+
return text or "0"
|
frameplot/theme.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Styling defaults for frameplot layout and rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class Theme:
|
|
10
|
+
"""Control colors, typography, spacing, and routing defaults.
|
|
11
|
+
|
|
12
|
+
A theme applies global defaults to the whole diagram. Per-node, per-edge,
|
|
13
|
+
per-group, and per-detail-panel overrides still win when provided.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
background_color: str = "#FFFFFF"
|
|
17
|
+
node_fill: str = "#F8FAFC"
|
|
18
|
+
node_stroke: str = "#0F172A"
|
|
19
|
+
node_text_color: str = "#0F172A"
|
|
20
|
+
edge_color: str = "#334155"
|
|
21
|
+
group_stroke: str = "#94A3B8"
|
|
22
|
+
group_fill: str = "#E2E8F0"
|
|
23
|
+
group_label_color: str = "#475569"
|
|
24
|
+
title_font_family: str = "DejaVu Sans, Arial, sans-serif"
|
|
25
|
+
title_font_size: float = 16.0
|
|
26
|
+
subtitle_font_size: float = 12.0
|
|
27
|
+
title_font_weight: int = 600
|
|
28
|
+
subtitle_font_weight: int = 400
|
|
29
|
+
outer_margin: float = 32.0
|
|
30
|
+
node_padding_x: float = 18.0
|
|
31
|
+
node_padding_y: float = 14.0
|
|
32
|
+
inter_text_gap: float = 8.0
|
|
33
|
+
rank_gap: float = 96.0
|
|
34
|
+
node_gap: float = 32.0
|
|
35
|
+
component_gap: float = 64.0
|
|
36
|
+
group_padding: float = 18.0
|
|
37
|
+
max_text_width: float = 220.0
|
|
38
|
+
min_node_width: float = 140.0
|
|
39
|
+
min_node_height: float = 72.0
|
|
40
|
+
corner_radius: float = 16.0
|
|
41
|
+
group_corner_radius: float = 20.0
|
|
42
|
+
stroke_width: float = 2.0
|
|
43
|
+
group_stroke_width: float = 1.5
|
|
44
|
+
group_fill_opacity: float = 0.14
|
|
45
|
+
detail_panel_gap: float = 48.0
|
|
46
|
+
detail_panel_padding: float = 20.0
|
|
47
|
+
detail_panel_header_height: float = 30.0
|
|
48
|
+
detail_panel_fill: str = "#FFFFFF"
|
|
49
|
+
detail_panel_stroke: str = "#CBD5E1"
|
|
50
|
+
detail_panel_title_color: str = "#475569"
|
|
51
|
+
detail_panel_stroke_width: float = 1.2
|
|
52
|
+
detail_panel_fill_opacity: float = 1.0
|
|
53
|
+
detail_panel_corner_radius: float = 18.0
|
|
54
|
+
detail_panel_guide_color: str = "#CBD5E1"
|
|
55
|
+
detail_panel_guide_width: float = 1.2
|
|
56
|
+
arrow_size: float = 8.0
|
|
57
|
+
route_track_gap: float = 18.0
|
|
58
|
+
back_edge_gap: float = 26.0
|
|
59
|
+
self_loop_size: float = 28.0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: frameplot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
|
|
5
|
+
Author: Small Turtle 2
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/smturtle2/frameplot
|
|
8
|
+
Project-URL: Repository, https://github.com/smturtle2/frameplot
|
|
9
|
+
Project-URL: Issues, https://github.com/smturtle2/frameplot/issues
|
|
10
|
+
Project-URL: Releases, https://github.com/smturtle2/frameplot/releases
|
|
11
|
+
Keywords: diagram,graph,pipeline,png,svg,visualization
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: CairoSVG>=2.7
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# frameplot
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/frameplot/)
|
|
33
|
+
[](https://pypi.org/project/frameplot/)
|
|
34
|
+
[](https://github.com/smturtle2/frameplot/actions/workflows/workflow.yml)
|
|
35
|
+
[](https://github.com/smturtle2/frameplot/blob/main/LICENSE)
|
|
36
|
+
|
|
37
|
+
Turn Python-defined pipeline graphs into presentation-ready SVG and PNG diagrams.
|
|
38
|
+
|
|
39
|
+
[한국어 README](https://github.com/smturtle2/frameplot/blob/main/README.ko.md)
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
`frameplot` is a compact Python library for rendering left-to-right pipeline diagrams with clean defaults. Define nodes, edges, groups, and optional detail panels in plain Python, then export polished SVG for documentation or PNG for slides and papers.
|
|
44
|
+
|
|
45
|
+
## Why frameplot?
|
|
46
|
+
|
|
47
|
+
- Clean left-to-right layout for architecture diagrams, data pipelines, and model overviews
|
|
48
|
+
- SVG-first output with optional PNG export through CairoSVG
|
|
49
|
+
- Detail panels for expanding a summary node into a lower inset mini-graph
|
|
50
|
+
- Themeable typography, spacing, colors, and routing defaults
|
|
51
|
+
- Deterministic rendering from simple dataclass-based inputs
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m pip install frameplot
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
PNG export depends on CairoSVG and may require Cairo or libffi packages from the host OS.
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from frameplot import Edge, Group, Node, Pipeline
|
|
65
|
+
|
|
66
|
+
pipeline = Pipeline(
|
|
67
|
+
nodes=[
|
|
68
|
+
Node("start", "Start", "Receive request"),
|
|
69
|
+
Node("fetch", "Fetch Data", "Load source tables"),
|
|
70
|
+
Node("retry", "Retry", "Loop on transient failure", fill="#FFF2CC"),
|
|
71
|
+
Node("done", "Done", "Return result", fill="#D9EAD3"),
|
|
72
|
+
],
|
|
73
|
+
edges=[
|
|
74
|
+
Edge("e1", "start", "fetch"),
|
|
75
|
+
Edge("e2", "fetch", "retry", dashed=True),
|
|
76
|
+
Edge("e3", "retry", "fetch", color="#C0504D"),
|
|
77
|
+
Edge("e4", "fetch", "done"),
|
|
78
|
+
],
|
|
79
|
+
groups=[
|
|
80
|
+
Group("g1", "Execution", ["start", "fetch", "retry"], edge_ids=["e2"]),
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
svg = pipeline.to_svg()
|
|
85
|
+
pipeline.save_svg("pipeline.svg")
|
|
86
|
+
pipeline.save_png("pipeline.png")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Public API
|
|
90
|
+
|
|
91
|
+
Top-level imports are the supported public API:
|
|
92
|
+
|
|
93
|
+
- `Node(id, title, subtitle=None, fill=None, stroke=None, text_color=None, metadata=None, width=None, height=None)`
|
|
94
|
+
- `Edge(id, source, target, color=None, dashed=False, metadata=None)`
|
|
95
|
+
- `Group(id, label, node_ids, edge_ids=(), stroke=None, fill=None, metadata=None)`
|
|
96
|
+
- `DetailPanel(id, focus_node_id, label, nodes, edges, groups=(), stroke=None, fill=None, metadata=None)`
|
|
97
|
+
- `Theme(...)`
|
|
98
|
+
- `Pipeline(nodes, edges, groups=(), detail_panel=None, theme=None)`
|
|
99
|
+
|
|
100
|
+
`Pipeline` exposes:
|
|
101
|
+
|
|
102
|
+
- `to_svg() -> str`
|
|
103
|
+
- `save_svg(path) -> None`
|
|
104
|
+
- `to_png_bytes() -> bytes`
|
|
105
|
+
- `save_png(path) -> None`
|
|
106
|
+
|
|
107
|
+
## Advanced Example
|
|
108
|
+
|
|
109
|
+
The hero image above is generated from [`examples/sar_backbone_example.py`](https://github.com/smturtle2/frameplot/blob/main/examples/sar_backbone_example.py), which demonstrates:
|
|
110
|
+
|
|
111
|
+
- custom `Theme` values
|
|
112
|
+
- split decoder branches
|
|
113
|
+
- grouped overlays
|
|
114
|
+
- a `DetailPanel` attached to a summary node
|
|
115
|
+
|
|
116
|
+
## Design Notes
|
|
117
|
+
|
|
118
|
+
- Layout is intentionally left-to-right in v0.x.
|
|
119
|
+
- Edge labels are not supported yet.
|
|
120
|
+
- Groups are visual overlays and do not constrain layout.
|
|
121
|
+
- Detail panels render as separate lower insets attached to a focus node in the main flow.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
python -m venv .venv
|
|
127
|
+
source .venv/bin/activate
|
|
128
|
+
python -m pip install -e '.[dev]'
|
|
129
|
+
python -m pytest -q
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Release publishing is automated through GitHub Actions and PyPI Trusted Publishing. Bump the version in `pyproject.toml`, create a tag like `v0.1.0`, and push the tag to trigger a release from `.github/workflows/workflow.yml`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
frameplot/__init__.py,sha256=sFaxPWRSlnA0HjBf-1XCL2CI9GSMVD3AjBop5Ec1Lio,266
|
|
2
|
+
frameplot/api.py,sha256=LTjY3qg1nY0rhB4PtNi1IrD4jypneaLQvdoFrDY9dWE,1865
|
|
3
|
+
frameplot/model.py,sha256=3ruaNf36JfaQcxtAUYQtY0jupbLaf2wxxxHoAvVbuCQ,4371
|
|
4
|
+
frameplot/theme.py,sha256=D2ujyFIuVf7I7AN-iZYaZN2KJxsw-ySnneusce6bZU4,1984
|
|
5
|
+
frameplot/layout/__init__.py,sha256=AfmzjPLX_Vqv_Rvm-SlhNVGZmMc2gHVim2Eupl0BV6w,10040
|
|
6
|
+
frameplot/layout/order.py,sha256=pRvH6Yitem_29slUAbblcXPPV0UTccAwoLGaB-QZ9Uk,5585
|
|
7
|
+
frameplot/layout/place.py,sha256=_hM92R0LS5-rcdLvF427VxnAxWwlnnkfmkCboRmBzZc,7091
|
|
8
|
+
frameplot/layout/rank.py,sha256=T95P10lAV-3hc-T-uQeBJ7zxnLILg7ihBVpPzzsILHM,2279
|
|
9
|
+
frameplot/layout/route.py,sha256=nuu64g7E1byBvyVoyGHMBdQZglVIUFJ1QNCmk64MfbQ,33086
|
|
10
|
+
frameplot/layout/scc.py,sha256=D6fWWvbcb62eKDxDhqJoxuPGQj93tPNV8x6i5jYHvo0,2216
|
|
11
|
+
frameplot/layout/text.py,sha256=pT9EYm0vtcr1ipuOhHzchHjh2wYY4yRyyuGzkrzjOd0,2587
|
|
12
|
+
frameplot/layout/types.py,sha256=v_4Ld_Eb4zBP5P-1q3DH89o9eGlaGeuWYWPvwPIqBqw,3524
|
|
13
|
+
frameplot/layout/validate.py,sha256=rLhjzoqMtXY7nfwDbwIHJoqkAqEFmzxad1uZGP8jxW8,3977
|
|
14
|
+
frameplot/render/__init__.py,sha256=-HFtKujLTvZKcY9ZFIn9-5zRWJTnnmDheUkvC2Ln9qA,162
|
|
15
|
+
frameplot/render/png.py,sha256=p-aGIa0hTJTOfLm70pNqdTDHVqwNBDYiHDKqo0ZGvy4,587
|
|
16
|
+
frameplot/render/svg.py,sha256=-oHav_7S8bart9vGIPa6UmF3X5ws5SJsK5Hmo0kWkG8,10701
|
|
17
|
+
frameplot-0.1.0.dist-info/licenses/LICENSE,sha256=K6I_4F9xoqE32M-CpGHLV7WmetFHWZwj60bJ5X4bbR0,1071
|
|
18
|
+
frameplot-0.1.0.dist-info/METADATA,sha256=nuh72IvnII6txTM7neEBi2apTRskwRJO2VXOTZ2xxf0,5148
|
|
19
|
+
frameplot-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
20
|
+
frameplot-0.1.0.dist-info/top_level.txt,sha256=yQ12_9t2dlReyty9T1ahzWzWNPywPZ6WZ3zwBrlPsxg,10
|
|
21
|
+
frameplot-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Small Turtle 2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
frameplot
|