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/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
+