spolvero 0.3.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.
- spolvero/__init__.py +7 -0
- spolvero/builder.py +200 -0
- spolvero/cli.py +118 -0
- spolvero/constants.py +55 -0
- spolvero/engines/__init__.py +102 -0
- spolvero/engines/data_visualizer.py +56 -0
- spolvero/engines/package_only.py +28 -0
- spolvero/engines/visio_com.py +118 -0
- spolvero/engines/vsdx_template.py +251 -0
- spolvero/hygiene.py +205 -0
- spolvero/inspection.py +222 -0
- spolvero/ir.py +248 -0
- spolvero/models.py +19 -0
- spolvero/render.py +564 -0
- spolvero/reports.py +180 -0
- spolvero/schema.py +65 -0
- spolvero/schemas/__init__.py +2 -0
- spolvero/schemas/ir.schema.json +83 -0
- spolvero/schemas/manifest.schema.json +74 -0
- spolvero/schemas/topology.schema.json +85 -0
- spolvero/source.py +160 -0
- spolvero/templates/LICENSE.txt +42 -0
- spolvero/templates/minimal_flowchart.vsdx +0 -0
- spolvero/topology/__init__.py +27 -0
- spolvero/topology/model.py +275 -0
- spolvero/utils.py +57 -0
- spolvero/workbook.py +200 -0
- spolvero-0.3.0.dist-info/METADATA +106 -0
- spolvero-0.3.0.dist-info/RECORD +31 -0
- spolvero-0.3.0.dist-info/WHEEL +4 -0
- spolvero-0.3.0.dist-info/entry_points.txt +2 -0
spolvero/render.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Preview rendering for normalized diagrams and references."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import struct
|
|
9
|
+
import subprocess
|
|
10
|
+
import zlib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from spolvero.ir import is_flowchart_ir
|
|
15
|
+
from spolvero.models import DiagramSource
|
|
16
|
+
from spolvero.topology import STENCIL_MAP, is_topology_ir
|
|
17
|
+
from spolvero.utils import escape, wrap_label, write_text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_preview(diagram: DiagramSource, ir: dict[str, Any], svg_path: Path, png_path: Path) -> None:
|
|
21
|
+
if try_mmdc_render(diagram, svg_path, png_path):
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
if is_flowchart_ir(ir):
|
|
25
|
+
layout = compute_layout(ir)
|
|
26
|
+
write_text(svg_path, flowchart_svg(ir, layout))
|
|
27
|
+
write_flowchart_png(png_path, ir, layout)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if is_topology_ir(ir):
|
|
31
|
+
layout = compute_layout(ir)
|
|
32
|
+
write_text(svg_path, topology_svg(ir, layout))
|
|
33
|
+
write_basic_flowchart_png(png_path, ir, layout)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
if try_external_reference_render(diagram, svg_path, png_path):
|
|
37
|
+
return
|
|
38
|
+
write_text(svg_path, reference_svg(diagram, ir))
|
|
39
|
+
write_reference_png(png_path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def try_mmdc_render(diagram: DiagramSource, svg_path: Path, png_path: Path) -> bool:
|
|
43
|
+
"""Render a Mermaid diagram with the official ``mmdc`` CLI, if available.
|
|
44
|
+
|
|
45
|
+
Activation requires:
|
|
46
|
+
- ``diagram.language == "mermaid"``
|
|
47
|
+
- ``mmdc`` resolvable via ``shutil.which`` (or the override
|
|
48
|
+
``SPOLVERO_MMDC_BIN`` environment variable)
|
|
49
|
+
- the env var ``SPOLVERO_DISABLE_MMDC`` is not set to ``1``
|
|
50
|
+
|
|
51
|
+
Returns True if both SVG and PNG were produced; on any failure the caller
|
|
52
|
+
should fall back to the built-in renderer.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
if diagram.language != "mermaid":
|
|
56
|
+
return False
|
|
57
|
+
if os.environ.get("SPOLVERO_DISABLE_MMDC") == "1":
|
|
58
|
+
return False
|
|
59
|
+
bin_override = os.environ.get("SPOLVERO_MMDC_BIN")
|
|
60
|
+
mmdc = bin_override or shutil.which("mmdc")
|
|
61
|
+
if not mmdc:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
tmp_input = svg_path.with_suffix(".mmd.tmp")
|
|
65
|
+
write_text(tmp_input, diagram.text)
|
|
66
|
+
try:
|
|
67
|
+
subprocess.run(
|
|
68
|
+
[mmdc, "-i", str(tmp_input), "-o", str(svg_path)],
|
|
69
|
+
check=True,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
)
|
|
72
|
+
subprocess.run(
|
|
73
|
+
[mmdc, "-i", str(tmp_input), "-o", str(png_path)],
|
|
74
|
+
check=True,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
)
|
|
77
|
+
except Exception:
|
|
78
|
+
return False
|
|
79
|
+
finally:
|
|
80
|
+
tmp_input.unlink(missing_ok=True)
|
|
81
|
+
return svg_path.exists() and png_path.exists()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def try_external_reference_render(diagram: DiagramSource, svg_path: Path, png_path: Path) -> bool:
|
|
85
|
+
if diagram.language == "graphviz" and shutil.which("dot"):
|
|
86
|
+
tmp_dot = svg_path.with_suffix(".dot.tmp")
|
|
87
|
+
write_text(tmp_dot, diagram.text)
|
|
88
|
+
try:
|
|
89
|
+
subprocess.run(["dot", "-Tsvg", str(tmp_dot), "-o", str(svg_path)], check=True, capture_output=True)
|
|
90
|
+
subprocess.run(["dot", "-Tpng", str(tmp_dot), "-o", str(png_path)], check=True, capture_output=True)
|
|
91
|
+
tmp_dot.unlink(missing_ok=True)
|
|
92
|
+
return True
|
|
93
|
+
except Exception:
|
|
94
|
+
tmp_dot.unlink(missing_ok=True)
|
|
95
|
+
return False
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compute_layout(ir: dict[str, Any]) -> dict[str, Any]:
|
|
100
|
+
nodes = ir.get("nodes", [])
|
|
101
|
+
edges = ir.get("edges", [])
|
|
102
|
+
node_ids = [node["id"] for node in nodes]
|
|
103
|
+
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
|
|
104
|
+
incoming_count: dict[str, int] = {node_id: 0 for node_id in node_ids}
|
|
105
|
+
for edge in edges:
|
|
106
|
+
if edge["from"] in outgoing and edge["to"] in incoming_count:
|
|
107
|
+
outgoing[edge["from"]].append(edge["to"])
|
|
108
|
+
incoming_count[edge["to"]] += 1
|
|
109
|
+
|
|
110
|
+
queue = [node_id for node_id in node_ids if incoming_count[node_id] == 0]
|
|
111
|
+
rank = {node_id: 0 for node_id in node_ids}
|
|
112
|
+
seen: set[str] = set()
|
|
113
|
+
while queue:
|
|
114
|
+
node_id = queue.pop(0)
|
|
115
|
+
seen.add(node_id)
|
|
116
|
+
for target in outgoing.get(node_id, []):
|
|
117
|
+
rank[target] = max(rank[target], rank[node_id] + 1)
|
|
118
|
+
incoming_count[target] -= 1
|
|
119
|
+
if incoming_count[target] == 0:
|
|
120
|
+
queue.append(target)
|
|
121
|
+
if len(seen) < len(node_ids):
|
|
122
|
+
fallback_rank = max(rank.values(), default=0) + 1
|
|
123
|
+
for node_id in node_ids:
|
|
124
|
+
if node_id not in seen:
|
|
125
|
+
rank[node_id] = fallback_rank
|
|
126
|
+
|
|
127
|
+
groups: dict[int, list[dict[str, Any]]] = {}
|
|
128
|
+
for node in nodes:
|
|
129
|
+
groups.setdefault(rank[node["id"]], []).append(node)
|
|
130
|
+
|
|
131
|
+
orientation = ir.get("orientation", "left_to_right")
|
|
132
|
+
positions: dict[str, dict[str, float]] = {}
|
|
133
|
+
margin = 60
|
|
134
|
+
x_gap = 240
|
|
135
|
+
y_gap = 120
|
|
136
|
+
for rank_value in sorted(groups):
|
|
137
|
+
group = groups[rank_value]
|
|
138
|
+
for index, node in enumerate(group):
|
|
139
|
+
width = min(260, max(130, max((len(line) for line in wrap_label(node["label"])), default=8) * 8 + 44))
|
|
140
|
+
height = max(58, len(wrap_label(node["label"])) * 16 + 30)
|
|
141
|
+
if orientation == "left_to_right":
|
|
142
|
+
x = margin + rank_value * x_gap
|
|
143
|
+
y = margin + index * y_gap
|
|
144
|
+
else:
|
|
145
|
+
x = margin + index * x_gap
|
|
146
|
+
y = margin + rank_value * y_gap
|
|
147
|
+
positions[node["id"]] = {"x": x, "y": y, "w": width, "h": height}
|
|
148
|
+
|
|
149
|
+
max_x = max((p["x"] + p["w"] for p in positions.values()), default=600) + margin
|
|
150
|
+
max_y = max((p["y"] + p["h"] for p in positions.values()), default=360) + margin
|
|
151
|
+
return {"positions": positions, "width": int(max_x), "height": int(max_y)}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def flowchart_svg(ir: dict[str, Any], layout: dict[str, Any]) -> str:
|
|
155
|
+
width = layout["width"]
|
|
156
|
+
height = layout["height"]
|
|
157
|
+
positions = layout["positions"]
|
|
158
|
+
parts = [
|
|
159
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
160
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
|
|
161
|
+
"<defs>",
|
|
162
|
+
'<marker id="arrow" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="strokeWidth">',
|
|
163
|
+
'<path d="M0,0 L10,4 L0,8 z" fill="#4b5563"/>',
|
|
164
|
+
"</marker>",
|
|
165
|
+
"</defs>",
|
|
166
|
+
'<rect width="100%" height="100%" fill="#ffffff"/>',
|
|
167
|
+
f'<text x="24" y="32" font-family="Segoe UI, Arial, sans-serif" font-size="18" font-weight="600" fill="#111827">{escape(ir.get("title", "Diagram"))}</text>',
|
|
168
|
+
]
|
|
169
|
+
for edge in ir.get("edges", []):
|
|
170
|
+
src = positions.get(edge["from"])
|
|
171
|
+
dst = positions.get(edge["to"])
|
|
172
|
+
if not src or not dst:
|
|
173
|
+
continue
|
|
174
|
+
x1, y1 = src["x"] + src["w"], src["y"] + src["h"] / 2
|
|
175
|
+
x2, y2 = dst["x"], dst["y"] + dst["h"] / 2
|
|
176
|
+
if abs(x2 - x1) < 10:
|
|
177
|
+
x1, y1 = src["x"] + src["w"] / 2, src["y"] + src["h"]
|
|
178
|
+
x2, y2 = dst["x"] + dst["w"] / 2, dst["y"]
|
|
179
|
+
parts.append(
|
|
180
|
+
f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="#4b5563" stroke-width="1.8" marker-end="url(#arrow)"/>'
|
|
181
|
+
)
|
|
182
|
+
if edge.get("label"):
|
|
183
|
+
mx, my = (x1 + x2) / 2, (y1 + y2) / 2 - 7
|
|
184
|
+
parts.append(
|
|
185
|
+
f'<text x="{mx:.1f}" y="{my:.1f}" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="12" fill="#374151">{escape(edge["label"])}</text>'
|
|
186
|
+
)
|
|
187
|
+
for node in ir.get("nodes", []):
|
|
188
|
+
pos = positions[node["id"]]
|
|
189
|
+
parts.append(node_shape_svg(node, pos))
|
|
190
|
+
parts.append("</svg>")
|
|
191
|
+
return "\n".join(parts) + "\n"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def node_shape_svg(node: dict[str, Any], pos: dict[str, float]) -> str:
|
|
195
|
+
x, y, w, h = pos["x"], pos["y"], pos["w"], pos["h"]
|
|
196
|
+
label = node.get("label", node["id"])
|
|
197
|
+
shape_kind = node.get("shape_kind", "process")
|
|
198
|
+
fill = "#eef6ff"
|
|
199
|
+
stroke = "#2563eb"
|
|
200
|
+
if shape_kind == "decision":
|
|
201
|
+
points = f"{x + w / 2:.1f},{y:.1f} {x + w:.1f},{y + h / 2:.1f} {x + w / 2:.1f},{y + h:.1f} {x:.1f},{y + h / 2:.1f}"
|
|
202
|
+
shape = f'<polygon points="{points}" fill="#fff7ed" stroke="#f97316" stroke-width="1.8"/>'
|
|
203
|
+
elif shape_kind == "terminator":
|
|
204
|
+
shape = f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" rx="{h / 2:.1f}" fill="#ecfdf5" stroke="#059669" stroke-width="1.8"/>'
|
|
205
|
+
elif shape_kind == "database":
|
|
206
|
+
shape = (
|
|
207
|
+
f'<rect x="{x:.1f}" y="{y + 8:.1f}" width="{w:.1f}" height="{h - 16:.1f}" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.8"/>'
|
|
208
|
+
f'<ellipse cx="{x + w / 2:.1f}" cy="{y + 8:.1f}" rx="{w / 2:.1f}" ry="8" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.8"/>'
|
|
209
|
+
f'<ellipse cx="{x + w / 2:.1f}" cy="{y + h - 8:.1f}" rx="{w / 2:.1f}" ry="8" fill="none" stroke="#7c3aed" stroke-width="1.8"/>'
|
|
210
|
+
)
|
|
211
|
+
elif shape_kind == "document":
|
|
212
|
+
shape = f'<path d="M{x:.1f},{y:.1f} H{x + w:.1f} V{y + h - 10:.1f} Q{x + w * .75:.1f},{y + h + 8:.1f} {x + w * .5:.1f},{y + h - 2:.1f} Q{x + w * .25:.1f},{y + h - 12:.1f} {x:.1f},{y + h - 2:.1f} Z" fill="#fdf2f8" stroke="#db2777" stroke-width="1.8"/>'
|
|
213
|
+
elif shape_kind == "subprocess":
|
|
214
|
+
shape = (
|
|
215
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" rx="4" fill="{fill}" stroke="{stroke}" stroke-width="1.8"/>'
|
|
216
|
+
f'<line x1="{x + 12:.1f}" y1="{y:.1f}" x2="{x + 12:.1f}" y2="{y + h:.1f}" stroke="{stroke}" stroke-width="1.2"/>'
|
|
217
|
+
f'<line x1="{x + w - 12:.1f}" y1="{y:.1f}" x2="{x + w - 12:.1f}" y2="{y + h:.1f}" stroke="{stroke}" stroke-width="1.2"/>'
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
shape = f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" rx="6" fill="{fill}" stroke="{stroke}" stroke-width="1.8"/>'
|
|
221
|
+
|
|
222
|
+
text_lines = wrap_label(label)
|
|
223
|
+
start_y = y + h / 2 - (len(text_lines) - 1) * 7
|
|
224
|
+
text = ['<text font-family="Segoe UI, Arial, sans-serif" font-size="13" fill="#111827" text-anchor="middle">']
|
|
225
|
+
for index, line in enumerate(text_lines):
|
|
226
|
+
dy = 0 if index == 0 else 16
|
|
227
|
+
text.append(f'<tspan x="{x + w / 2:.1f}" y="{start_y + dy * index:.1f}">{escape(line)}</tspan>')
|
|
228
|
+
text.append("</text>")
|
|
229
|
+
return f'<g data-node-id="{escape(node["id"])}">\n{shape}\n' + "\n".join(text) + "\n</g>"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def topology_svg(ir: dict[str, Any], layout: dict[str, Any]) -> str:
|
|
233
|
+
"""Render a topology IR with simple geometric glyphs per node kind."""
|
|
234
|
+
|
|
235
|
+
width = layout["width"]
|
|
236
|
+
height = layout["height"]
|
|
237
|
+
positions = layout["positions"]
|
|
238
|
+
parts = [
|
|
239
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
240
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
|
|
241
|
+
"<defs>",
|
|
242
|
+
'<marker id="arrow" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="strokeWidth">',
|
|
243
|
+
'<path d="M0,0 L10,4 L0,8 z" fill="#4b5563"/>',
|
|
244
|
+
"</marker>",
|
|
245
|
+
"</defs>",
|
|
246
|
+
'<rect width="100%" height="100%" fill="#ffffff"/>',
|
|
247
|
+
f'<text x="24" y="32" font-family="Segoe UI, Arial, sans-serif" font-size="18" font-weight="600" fill="#111827">{escape(ir.get("title", "Topology"))}</text>',
|
|
248
|
+
]
|
|
249
|
+
# Container rectangles drawn first so nodes overlay them.
|
|
250
|
+
container_nodes: dict[str, list[str]] = {}
|
|
251
|
+
for node in ir.get("nodes", []):
|
|
252
|
+
cid = node.get("container") or ""
|
|
253
|
+
if cid:
|
|
254
|
+
container_nodes.setdefault(cid, []).append(node["id"])
|
|
255
|
+
for container in ir.get("containers", []):
|
|
256
|
+
ids = container_nodes.get(container["id"], [])
|
|
257
|
+
if not ids:
|
|
258
|
+
continue
|
|
259
|
+
xs = [positions[i]["x"] for i in ids if i in positions]
|
|
260
|
+
ys = [positions[i]["y"] for i in ids if i in positions]
|
|
261
|
+
ws = [positions[i]["w"] for i in ids if i in positions]
|
|
262
|
+
hs = [positions[i]["h"] for i in ids if i in positions]
|
|
263
|
+
if not xs:
|
|
264
|
+
continue
|
|
265
|
+
pad = 18
|
|
266
|
+
bx = min(xs) - pad
|
|
267
|
+
by = min(ys) - pad - 14
|
|
268
|
+
bw = max(x + w for x, w in zip(xs, ws)) - bx + pad
|
|
269
|
+
bh = max(y + h for y, h in zip(ys, hs)) - by + pad
|
|
270
|
+
parts.append(
|
|
271
|
+
f'<rect x="{bx:.1f}" y="{by:.1f}" width="{bw:.1f}" height="{bh:.1f}" '
|
|
272
|
+
f'rx="10" fill="#f9fafb" stroke="#9ca3af" stroke-dasharray="6 4" stroke-width="1.5"/>'
|
|
273
|
+
)
|
|
274
|
+
parts.append(
|
|
275
|
+
f'<text x="{bx + 12:.1f}" y="{by + 18:.1f}" font-family="Segoe UI, Arial, sans-serif" '
|
|
276
|
+
f'font-size="12" font-weight="600" fill="#6b7280">{escape(container.get("label", container["id"]))}</text>'
|
|
277
|
+
)
|
|
278
|
+
for edge in ir.get("edges", []):
|
|
279
|
+
src = positions.get(edge["from"])
|
|
280
|
+
dst = positions.get(edge["to"])
|
|
281
|
+
if not src or not dst:
|
|
282
|
+
continue
|
|
283
|
+
x1, y1 = src["x"] + src["w"], src["y"] + src["h"] / 2
|
|
284
|
+
x2, y2 = dst["x"], dst["y"] + dst["h"] / 2
|
|
285
|
+
parts.append(
|
|
286
|
+
f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
|
|
287
|
+
f'stroke="#4b5563" stroke-width="1.8" marker-end="url(#arrow)"/>'
|
|
288
|
+
)
|
|
289
|
+
if edge.get("label"):
|
|
290
|
+
mx, my = (x1 + x2) / 2, (y1 + y2) / 2 - 7
|
|
291
|
+
parts.append(
|
|
292
|
+
f'<text x="{mx:.1f}" y="{my:.1f}" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" '
|
|
293
|
+
f'font-size="12" fill="#374151">{escape(edge["label"])}</text>'
|
|
294
|
+
)
|
|
295
|
+
for node in ir.get("nodes", []):
|
|
296
|
+
pos = positions[node["id"]]
|
|
297
|
+
parts.append(topology_node_svg(node, pos))
|
|
298
|
+
parts.append("</svg>")
|
|
299
|
+
return "\n".join(parts) + "\n"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def topology_node_svg(node: dict[str, Any], pos: dict[str, float]) -> str:
|
|
303
|
+
x, y, w, h = pos["x"], pos["y"], pos["w"], pos["h"]
|
|
304
|
+
kind = node.get("kind", "generic")
|
|
305
|
+
glyph = STENCIL_MAP.get(kind, STENCIL_MAP["generic"])["glyph"]
|
|
306
|
+
fill = "#eef6ff"
|
|
307
|
+
stroke = "#2563eb"
|
|
308
|
+
if glyph == "diamond":
|
|
309
|
+
points = (
|
|
310
|
+
f"{x + w / 2:.1f},{y:.1f} {x + w:.1f},{y + h / 2:.1f} "
|
|
311
|
+
f"{x + w / 2:.1f},{y + h:.1f} {x:.1f},{y + h / 2:.1f}"
|
|
312
|
+
)
|
|
313
|
+
shape = f'<polygon points="{points}" fill="#fff7ed" stroke="#f97316" stroke-width="1.8"/>'
|
|
314
|
+
elif glyph == "rounded":
|
|
315
|
+
shape = (
|
|
316
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
|
317
|
+
f'rx="{h / 2:.1f}" fill="#ecfdf5" stroke="#059669" stroke-width="1.8"/>'
|
|
318
|
+
)
|
|
319
|
+
elif glyph == "cylinder":
|
|
320
|
+
shape = (
|
|
321
|
+
f'<rect x="{x:.1f}" y="{y + 8:.1f}" width="{w:.1f}" height="{h - 16:.1f}" '
|
|
322
|
+
f'fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.8"/>'
|
|
323
|
+
f'<ellipse cx="{x + w / 2:.1f}" cy="{y + 8:.1f}" rx="{w / 2:.1f}" ry="8" '
|
|
324
|
+
f'fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.8"/>'
|
|
325
|
+
f'<ellipse cx="{x + w / 2:.1f}" cy="{y + h - 8:.1f}" rx="{w / 2:.1f}" ry="8" '
|
|
326
|
+
f'fill="none" stroke="#7c3aed" stroke-width="1.8"/>'
|
|
327
|
+
)
|
|
328
|
+
elif glyph == "cloud":
|
|
329
|
+
cx = x + w / 2
|
|
330
|
+
cy = y + h / 2
|
|
331
|
+
rx = w / 2
|
|
332
|
+
ry = h / 2
|
|
333
|
+
shape = (
|
|
334
|
+
f'<ellipse cx="{cx:.1f}" cy="{cy:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" '
|
|
335
|
+
f'fill="#fdf4ff" stroke="#a855f7" stroke-width="1.8"/>'
|
|
336
|
+
)
|
|
337
|
+
elif glyph == "circle":
|
|
338
|
+
r = min(w, h) / 2
|
|
339
|
+
shape = (
|
|
340
|
+
f'<circle cx="{x + w / 2:.1f}" cy="{y + h / 2:.1f}" r="{r:.1f}" '
|
|
341
|
+
f'fill="#fffbeb" stroke="#d97706" stroke-width="1.8"/>'
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
shape = (
|
|
345
|
+
f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
|
346
|
+
f'rx="6" fill="{fill}" stroke="{stroke}" stroke-width="1.8"/>'
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
label = node.get("label", node["id"])
|
|
350
|
+
text_lines = wrap_label(label)
|
|
351
|
+
start_y = y + h / 2 - (len(text_lines) - 1) * 7
|
|
352
|
+
text_parts = [
|
|
353
|
+
'<text font-family="Segoe UI, Arial, sans-serif" font-size="13" fill="#111827" text-anchor="middle">'
|
|
354
|
+
]
|
|
355
|
+
for index, line in enumerate(text_lines):
|
|
356
|
+
dy = 0 if index == 0 else 16
|
|
357
|
+
text_parts.append(
|
|
358
|
+
f'<tspan x="{x + w / 2:.1f}" y="{start_y + dy * index:.1f}">{escape(line)}</tspan>'
|
|
359
|
+
)
|
|
360
|
+
text_parts.append("</text>")
|
|
361
|
+
kind_label = STENCIL_MAP.get(kind, STENCIL_MAP["generic"])["label"]
|
|
362
|
+
return (
|
|
363
|
+
f'<g data-node-id="{escape(node["id"])}" data-kind="{escape(kind)}" '
|
|
364
|
+
f'data-stencil="{escape(kind_label)}">\n{shape}\n'
|
|
365
|
+
+ "\n".join(text_parts)
|
|
366
|
+
+ "\n</g>"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def reference_svg(diagram: DiagramSource, ir: dict[str, Any]) -> str:
|
|
371
|
+
title = escape(diagram.title)
|
|
372
|
+
source_type = escape(diagram.source_type)
|
|
373
|
+
warning = escape("; ".join(ir.get("warnings", diagram.warnings)) or "Reference output only.")
|
|
374
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
375
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="360" viewBox="0 0 900 360">
|
|
376
|
+
<rect width="100%" height="100%" fill="#ffffff"/>
|
|
377
|
+
<rect x="36" y="42" width="828" height="250" rx="8" fill="#f9fafb" stroke="#9ca3af" stroke-width="1.5"/>
|
|
378
|
+
<text x="72" y="100" font-family="Segoe UI, Arial, sans-serif" font-size="24" font-weight="600" fill="#111827">{title}</text>
|
|
379
|
+
<text x="72" y="142" font-family="Segoe UI, Arial, sans-serif" font-size="16" fill="#374151">Source type: {source_type}</text>
|
|
380
|
+
<text x="72" y="182" font-family="Segoe UI, Arial, sans-serif" font-size="15" fill="#6b7280">Native parsing/rendering is not available.</text>
|
|
381
|
+
<text x="72" y="222" font-family="Segoe UI, Arial, sans-serif" font-size="14" fill="#6b7280">{warning}</text>
|
|
382
|
+
</svg>
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def write_flowchart_png(png_path: Path, ir: dict[str, Any], layout: dict[str, Any]) -> None:
|
|
387
|
+
try:
|
|
388
|
+
from PIL import Image, ImageDraw, ImageFont # type: ignore
|
|
389
|
+
|
|
390
|
+
width, height = layout["width"], layout["height"]
|
|
391
|
+
image = Image.new("RGB", (width, height), "white")
|
|
392
|
+
draw = ImageDraw.Draw(image)
|
|
393
|
+
font = ImageFont.load_default()
|
|
394
|
+
draw.text((24, 18), ir.get("title", "Diagram"), fill=(17, 24, 39), font=font)
|
|
395
|
+
positions = layout["positions"]
|
|
396
|
+
for edge in ir.get("edges", []):
|
|
397
|
+
src = positions.get(edge["from"])
|
|
398
|
+
dst = positions.get(edge["to"])
|
|
399
|
+
if not src or not dst:
|
|
400
|
+
continue
|
|
401
|
+
x1, y1 = src["x"] + src["w"], src["y"] + src["h"] / 2
|
|
402
|
+
x2, y2 = dst["x"], dst["y"] + dst["h"] / 2
|
|
403
|
+
draw.line((x1, y1, x2, y2), fill=(75, 85, 99), width=2)
|
|
404
|
+
draw_arrowhead(draw, x1, y1, x2, y2)
|
|
405
|
+
if edge.get("label"):
|
|
406
|
+
draw.text(((x1 + x2) / 2, (y1 + y2) / 2 - 14), edge["label"], fill=(55, 65, 81), font=font)
|
|
407
|
+
for node in ir.get("nodes", []):
|
|
408
|
+
pos = positions[node["id"]]
|
|
409
|
+
box = (pos["x"], pos["y"], pos["x"] + pos["w"], pos["y"] + pos["h"])
|
|
410
|
+
if node.get("shape_kind") == "decision":
|
|
411
|
+
points = [
|
|
412
|
+
(pos["x"] + pos["w"] / 2, pos["y"]),
|
|
413
|
+
(pos["x"] + pos["w"], pos["y"] + pos["h"] / 2),
|
|
414
|
+
(pos["x"] + pos["w"] / 2, pos["y"] + pos["h"]),
|
|
415
|
+
(pos["x"], pos["y"] + pos["h"] / 2),
|
|
416
|
+
]
|
|
417
|
+
draw.polygon(points, fill=(255, 247, 237), outline=(249, 115, 22))
|
|
418
|
+
else:
|
|
419
|
+
draw.rounded_rectangle(box, radius=10, fill=(238, 246, 255), outline=(37, 99, 235), width=2)
|
|
420
|
+
lines = wrap_label(node.get("label", node["id"]))
|
|
421
|
+
text_y = pos["y"] + pos["h"] / 2 - len(lines) * 7
|
|
422
|
+
for line in lines:
|
|
423
|
+
try:
|
|
424
|
+
bbox = draw.textbbox((0, 0), line, font=font)
|
|
425
|
+
text_w = bbox[2] - bbox[0]
|
|
426
|
+
except Exception:
|
|
427
|
+
text_w = len(line) * 6
|
|
428
|
+
draw.text((pos["x"] + pos["w"] / 2 - text_w / 2, text_y), line, fill=(17, 24, 39), font=font)
|
|
429
|
+
text_y += 14
|
|
430
|
+
image.save(png_path)
|
|
431
|
+
except Exception:
|
|
432
|
+
write_basic_flowchart_png(png_path, ir, layout)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def draw_arrowhead(draw: Any, x1: float, y1: float, x2: float, y2: float) -> None:
|
|
436
|
+
angle = math.atan2(y2 - y1, x2 - x1)
|
|
437
|
+
size = 8
|
|
438
|
+
points = [
|
|
439
|
+
(x2, y2),
|
|
440
|
+
(x2 - size * math.cos(angle - 0.5), y2 - size * math.sin(angle - 0.5)),
|
|
441
|
+
(x2 - size * math.cos(angle + 0.5), y2 - size * math.sin(angle + 0.5)),
|
|
442
|
+
]
|
|
443
|
+
draw.polygon(points, fill=(75, 85, 99))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def write_basic_flowchart_png(png_path: Path, ir: dict[str, Any], layout: dict[str, Any]) -> None:
|
|
447
|
+
width = min(max(int(layout["width"]), 320), 2400)
|
|
448
|
+
height = min(max(int(layout["height"]), 240), 1800)
|
|
449
|
+
canvas = new_canvas(width, height, (255, 255, 255))
|
|
450
|
+
positions = layout["positions"]
|
|
451
|
+
for edge in ir.get("edges", []):
|
|
452
|
+
src = positions.get(edge["from"])
|
|
453
|
+
dst = positions.get(edge["to"])
|
|
454
|
+
if not src or not dst:
|
|
455
|
+
continue
|
|
456
|
+
draw_line(canvas, int(src["x"] + src["w"]), int(src["y"] + src["h"] / 2), int(dst["x"]), int(dst["y"] + dst["h"] / 2), (75, 85, 99))
|
|
457
|
+
for node in ir.get("nodes", []):
|
|
458
|
+
pos = positions[node["id"]]
|
|
459
|
+
x, y, w, h = int(pos["x"]), int(pos["y"]), int(pos["w"]), int(pos["h"])
|
|
460
|
+
if node.get("shape_kind") == "decision":
|
|
461
|
+
draw_polygon(canvas, [(x + w // 2, y), (x + w, y + h // 2), (x + w // 2, y + h), (x, y + h // 2)], (255, 247, 237), (249, 115, 22))
|
|
462
|
+
else:
|
|
463
|
+
draw_rect(canvas, x, y, x + w, y + h, (238, 246, 255), (37, 99, 235))
|
|
464
|
+
write_png(png_path, canvas)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def write_reference_png(png_path: Path) -> None:
|
|
468
|
+
canvas = new_canvas(900, 360, (255, 255, 255))
|
|
469
|
+
draw_rect(canvas, 36, 42, 864, 292, (249, 250, 251), (156, 163, 175))
|
|
470
|
+
draw_rect(canvas, 72, 92, 520, 130, (238, 246, 255), (37, 99, 235))
|
|
471
|
+
draw_rect(canvas, 72, 160, 720, 198, (255, 247, 237), (249, 115, 22))
|
|
472
|
+
draw_rect(canvas, 72, 226, 620, 264, (236, 253, 245), (5, 150, 105))
|
|
473
|
+
write_png(png_path, canvas)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def new_canvas(width: int, height: int, color: tuple[int, int, int]) -> list[list[tuple[int, int, int]]]:
|
|
477
|
+
return [[color for _ in range(width)] for _ in range(height)]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def set_pixel(canvas: list[list[tuple[int, int, int]]], x: int, y: int, color: tuple[int, int, int]) -> None:
|
|
481
|
+
if 0 <= y < len(canvas) and 0 <= x < len(canvas[0]):
|
|
482
|
+
canvas[y][x] = color
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def draw_line(canvas: list[list[tuple[int, int, int]]], x1: int, y1: int, x2: int, y2: int, color: tuple[int, int, int]) -> None:
|
|
486
|
+
dx = abs(x2 - x1)
|
|
487
|
+
dy = -abs(y2 - y1)
|
|
488
|
+
sx = 1 if x1 < x2 else -1
|
|
489
|
+
sy = 1 if y1 < y2 else -1
|
|
490
|
+
err = dx + dy
|
|
491
|
+
while True:
|
|
492
|
+
for ox in range(-1, 2):
|
|
493
|
+
for oy in range(-1, 2):
|
|
494
|
+
set_pixel(canvas, x1 + ox, y1 + oy, color)
|
|
495
|
+
if x1 == x2 and y1 == y2:
|
|
496
|
+
break
|
|
497
|
+
e2 = 2 * err
|
|
498
|
+
if e2 >= dy:
|
|
499
|
+
err += dy
|
|
500
|
+
x1 += sx
|
|
501
|
+
if e2 <= dx:
|
|
502
|
+
err += dx
|
|
503
|
+
y1 += sy
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def draw_rect(
|
|
507
|
+
canvas: list[list[tuple[int, int, int]]],
|
|
508
|
+
x1: int,
|
|
509
|
+
y1: int,
|
|
510
|
+
x2: int,
|
|
511
|
+
y2: int,
|
|
512
|
+
fill: tuple[int, int, int],
|
|
513
|
+
stroke: tuple[int, int, int],
|
|
514
|
+
) -> None:
|
|
515
|
+
for y in range(max(0, y1), min(len(canvas), y2)):
|
|
516
|
+
for x in range(max(0, x1), min(len(canvas[0]), x2)):
|
|
517
|
+
canvas[y][x] = fill
|
|
518
|
+
for x in range(x1, x2 + 1):
|
|
519
|
+
set_pixel(canvas, x, y1, stroke)
|
|
520
|
+
set_pixel(canvas, x, y2, stroke)
|
|
521
|
+
for y in range(y1, y2 + 1):
|
|
522
|
+
set_pixel(canvas, x1, y, stroke)
|
|
523
|
+
set_pixel(canvas, x2, y, stroke)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def draw_polygon(
|
|
527
|
+
canvas: list[list[tuple[int, int, int]]],
|
|
528
|
+
points: list[tuple[int, int]],
|
|
529
|
+
fill: tuple[int, int, int],
|
|
530
|
+
stroke: tuple[int, int, int],
|
|
531
|
+
) -> None:
|
|
532
|
+
min_y = max(0, min(y for _, y in points))
|
|
533
|
+
max_y = min(len(canvas) - 1, max(y for _, y in points))
|
|
534
|
+
for y in range(min_y, max_y + 1):
|
|
535
|
+
intersections = []
|
|
536
|
+
for i, (x1, y1) in enumerate(points):
|
|
537
|
+
x2, y2 = points[(i + 1) % len(points)]
|
|
538
|
+
if (y1 <= y < y2) or (y2 <= y < y1):
|
|
539
|
+
x = int(x1 + (y - y1) * (x2 - x1) / (y2 - y1))
|
|
540
|
+
intersections.append(x)
|
|
541
|
+
intersections.sort()
|
|
542
|
+
for i in range(0, len(intersections), 2):
|
|
543
|
+
if i + 1 < len(intersections):
|
|
544
|
+
for x in range(max(0, intersections[i]), min(len(canvas[0]), intersections[i + 1])):
|
|
545
|
+
canvas[y][x] = fill
|
|
546
|
+
for i, (x1, y1) in enumerate(points):
|
|
547
|
+
x2, y2 = points[(i + 1) % len(points)]
|
|
548
|
+
draw_line(canvas, x1, y1, x2, y2, stroke)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def write_png(path: Path, canvas: list[list[tuple[int, int, int]]]) -> None:
|
|
552
|
+
height = len(canvas)
|
|
553
|
+
width = len(canvas[0]) if height else 0
|
|
554
|
+
raw = b"".join(b"\x00" + bytes(channel for pixel in row for channel in pixel) for row in canvas)
|
|
555
|
+
|
|
556
|
+
def chunk(kind: bytes, data: bytes) -> bytes:
|
|
557
|
+
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
|
|
558
|
+
|
|
559
|
+
png = b"\x89PNG\r\n\x1a\n"
|
|
560
|
+
png += chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
|
|
561
|
+
png += chunk(b"IDAT", zlib.compress(raw, 9))
|
|
562
|
+
png += chunk(b"IEND", b"")
|
|
563
|
+
path.write_bytes(png)
|
|
564
|
+
|