smjain commited on
Commit
ab700fc
·
verified ·
1 Parent(s): e53f02b

Upload layout_engine.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. layout_engine.py +311 -0
layout_engine.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Semantic spec -> clean, valid SAP .drawio. The model's job shrinks to emitting
3
+ typed components + zones + edges; THIS engine does deterministic graphviz layout
4
+ and snaps labels to real SAP icon keys. No pixel coords or style keys from the model.
5
+
6
+ spec = {
7
+ "title": str,
8
+ "zones": [{"id": str, "label": str, "children": [node_id,...]}],
9
+ "nodes": [{"id": str, "label": str, "icon": optional key}],
10
+ "edges": [{"source": id, "target": id, "label": optional}]
11
+ }
12
+ """
13
+ import json, re, subprocess, difflib
14
+ from xml.sax.saxutils import quoteattr
15
+
16
+ _LIB = json.load(open("dataset/style_library.json"))
17
+ _ICON_KEYS = [k for k in _LIB if not k.startswith(("box", "edge"))]
18
+
19
+ def slug(s):
20
+ return re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_")
21
+
22
+ def snap_icon(label, given=None):
23
+ """Return a library key for this label (given key wins, else fuzzy match)."""
24
+ if given and given in _LIB:
25
+ return given
26
+ s = slug(label)
27
+ if s in _LIB:
28
+ return s
29
+ m = difflib.get_close_matches(s, _ICON_KEYS, n=1, cutoff=0.72)
30
+ return m[0] if m else None
31
+
32
+ def _dot_layout(spec):
33
+ """Run graphviz dot, return {node_id: (x,y,w,h)} in pixels, top-left origin."""
34
+ lines = ['digraph G {', 'rankdir=LR; nodesep=0.5; ranksep=0.9;',
35
+ 'node [shape=box, fixedsize=true, width=1.1, height=0.7];']
36
+ inzone = set()
37
+ for z in spec.get("zones", []):
38
+ lines.append(f'subgraph cluster_{slug(z["id"])} {{ label={json.dumps(z.get("label",""))};')
39
+ for c in z.get("children", []):
40
+ lines.append(f'"{c}";'); inzone.add(c)
41
+ lines.append('}')
42
+ for n in spec["nodes"]:
43
+ lines.append(f'"{n["id"]}" [label={json.dumps(n["label"])}];')
44
+ for e in spec.get("edges", []):
45
+ lines.append(f'"{e["source"]}" -> "{e["target"]}";')
46
+ lines.append('}')
47
+ dot = "\n".join(lines)
48
+ plain = subprocess.run(["dot", "-Tplain"], input=dot, capture_output=True, text=True).stdout
49
+ scale = 1.0; H = 0.0; pos = {}
50
+ for ln in plain.splitlines():
51
+ p = ln.split()
52
+ if p[0] == "graph":
53
+ scale = float(p[1]); H = float(p[3])
54
+ elif p[0] == "node":
55
+ name = p[1].strip('"')
56
+ x, y, w, h = map(float, p[2:6])
57
+ # graphviz: inches, origin bottom-left -> pixels, top-left
58
+ PX = 72
59
+ pos[name] = (int((x - w/2)*PX), int((H - y - h/2)*PX), int(w*PX), int(h*PX))
60
+ return pos
61
+
62
+ def _grid_layout(spec):
63
+ """Zone-column layout with SMALLER icons + an OUTER wrapping block. zones =
64
+ columns L->R inside the outer block; nodes stacked. Returns node pos, zone
65
+ rects, and the outer-block rect."""
66
+ NW, NH, GAP, PAD, TITLE, COLGAP, TOP, MARGIN = 96, 54, 30, 18, 30, 46, 64, 30
67
+ zones = spec.get("zones", [])
68
+ zoned = set()
69
+ for z in zones:
70
+ zoned.update(z["children"])
71
+ unzoned = [n["id"] for n in spec["nodes"] if n["id"] not in zoned]
72
+ columns = ([{"id": None, "label": None, "children": unzoned}] if unzoned else []) + \
73
+ [{"id": z["id"], "label": z.get("label", "Zone"), "children": z["children"]} for z in zones]
74
+ pos, zrects = {}, []
75
+ x = TOP + MARGIN
76
+ maxy = TOP
77
+ for col in columns:
78
+ kids = [k for k in col["children"] if any(n["id"] == k for n in spec["nodes"])]
79
+ if not kids:
80
+ continue
81
+ col_w = NW + 2*PAD
82
+ y = TOP + MARGIN + (TITLE if col["id"] else 0) + PAD
83
+ for k in kids:
84
+ pos[k] = (x + PAD, y, NW, NH)
85
+ y += NH + GAP
86
+ if col["id"]:
87
+ zrects.append({"label": col["label"], "rect": [x, TOP + MARGIN, col_w, y - (TOP+MARGIN) - GAP + PAD]})
88
+ maxy = max(maxy, y)
89
+ x += col_w + COLGAP
90
+ outer = [TOP, TOP, x - COLGAP - TOP + MARGIN, maxy - TOP - GAP + PAD + MARGIN]
91
+ return pos, zrects, outer
92
+
93
+ def build(spec, out_path="spec_output.drawio"):
94
+ pos, zrects, outer = _grid_layout(spec)
95
+ cells = ['<mxCell id="0" />', '<mxCell id="1" parent="0" />']
96
+ # outer surrounding block (behind everything)
97
+ ox, oy, ow, oh = outer
98
+ cells.append(
99
+ f'<mxCell id="outer" value={quoteattr(spec.get("title",""))} '
100
+ f'style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fbfdff;strokeColor=#7a9bbe;'
101
+ f'verticalAlign=top;fontSize=16;fontStyle=1;fontColor=#13334f;dashed=0;" '
102
+ f'vertex="1" parent="1"><mxGeometry x="{ox}" y="{oy}" width="{ow}" height="{oh}" as="geometry" /></mxCell>')
103
+ zid = 1000
104
+ for z in zrects:
105
+ x0, y0, w0, h0 = z["rect"]; zid += 1
106
+ cells.append(
107
+ f'<mxCell id="z{zid}" value={quoteattr(z.get("label") or "")} '
108
+ f'style="rounded=0;whiteSpace=wrap;html=1;fillColor=#eef5fc;strokeColor=#9fc5e8;'
109
+ f'verticalAlign=top;fontSize=13;fontStyle=1;fontColor=#1a3b5c;" vertex="1" parent="1">'
110
+ f'<mxGeometry x="{x0}" y="{y0}" width="{w0}" height="{h0}" as="geometry" /></mxCell>')
111
+ idmap = {}; nid = 1
112
+ for n in spec["nodes"]:
113
+ if n["id"] not in pos:
114
+ continue
115
+ x, y, w, h = pos[n["id"]]
116
+ nid += 1; idmap[n["id"]] = f"n{nid}"
117
+ key = snap_icon(n["label"], n.get("icon"))
118
+ if key:
119
+ style = _LIB[key].rstrip(";") + ";verticalLabelPosition=bottom;verticalAlign=top;"
120
+ else:
121
+ style = "rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#5b7a9c;"
122
+ cells.append(
123
+ f'<mxCell id="n{nid}" value={quoteattr(n["label"])} style={quoteattr(style)} '
124
+ f'vertex="1" parent="1"><mxGeometry x="{x}" y="{y}" width="{w}" height="{h}" '
125
+ f'as="geometry" /></mxCell>')
126
+ # edges
127
+ eid = 5000
128
+ for e in spec.get("edges", []):
129
+ if e["source"] not in idmap or e["target"] not in idmap:
130
+ continue
131
+ eid += 1
132
+ lab = e.get("label", "")
133
+ va = f' value={quoteattr(lab)}' if lab else ''
134
+ cells.append(
135
+ f'<mxCell id="e{eid}"{va} style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;'
136
+ f'strokeColor=#5b7a9c;" edge="1" parent="1" source={quoteattr(idmap[e["source"]])} '
137
+ f'target={quoteattr(idmap[e["target"]])}><mxGeometry relative="1" as="geometry" /></mxCell>')
138
+ xml = (f'<mxfile host="archgen"><diagram name={quoteattr(spec.get("title","Architecture"))}>'
139
+ f'<mxGraphModel dx="1200" dy="800" grid="0" guides="1" tooltips="1" connect="1" '
140
+ f'arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1100" '
141
+ f'math="0" shadow="0"><root>' + "".join(cells) +
142
+ '</root></mxGraphModel></diagram></mxfile>')
143
+ open(out_path, "w").write(xml)
144
+ return out_path
145
+
146
+ # ---------------- recursive nested-block layout (hierarchical spec) ----------------
147
+ # wide cell (gives the label room) + SMALL icon; row pitch leaves space for the
148
+ # bottom-positioned 2-line label so it never overlaps the next icon.
149
+ ICON_W, ICON_H, BPAD, BTITLE = 124, 40, 20, 34
150
+ COLGAP, ROWGAP, LABEL_H = 34, 26, 34
151
+ BGAP = COLGAP # block-to-block spacing
152
+ ROW_PITCH = ICON_H + LABEL_H + ROWGAP
153
+
154
+ def _measure(block):
155
+ cw, ch = [], []
156
+ for sub in block.get("blocks", []):
157
+ _measure(sub); cw.append(sub["_w"]); ch.append(sub["_h"])
158
+ comps = block.get("components", [])
159
+ if comps:
160
+ ncol = 1 if len(comps) <= 5 else 2
161
+ rows = (len(comps) + ncol - 1) // ncol
162
+ clw = ncol*ICON_W + (ncol-1)*COLGAP
163
+ clh = rows*ROW_PITCH - ROWGAP # no trailing gap; last row keeps its label
164
+ block["_cluster"] = (ncol, clw, clh); cw.append(clw); ch.append(clh)
165
+ inner_w = (sum(cw) + COLGAP*(len(cw)-1)) if cw else ICON_W
166
+ inner_h = max(ch) if ch else ICON_H
167
+ block["_w"] = inner_w + 2*BPAD
168
+ block["_h"] = inner_h + BTITLE + BPAD
169
+
170
+ def _place(block, x, y, pos, rects, depth=0):
171
+ rects.append({"label": block.get("label", ""), "rect": [x, y, block["_w"], block["_h"]], "depth": depth})
172
+ cx = x + BPAD; cy = y + BTITLE
173
+ for sub in block.get("blocks", []):
174
+ _place(sub, cx, cy, pos, rects, depth+1); cx += sub["_w"] + COLGAP
175
+ comps = block.get("components", [])
176
+ if comps:
177
+ ncol = block["_cluster"][0]
178
+ for i, comp in enumerate(comps):
179
+ r, c = divmod(i, ncol)
180
+ pos[comp["id"]] = (cx + c*(ICON_W+COLGAP), cy + r*ROW_PITCH, ICON_W, ICON_H)
181
+
182
+ def compute_layout(spec):
183
+ """Return {component_id: (x, y, w, h)} icon-cell positions (no file written)."""
184
+ return compute_full(spec)[0]
185
+
186
+ def compute_full(spec):
187
+ """Return (pos {id:(x,y,w,h)}, block_rects [{label,rect,depth}]) — for streaming/render."""
188
+ import copy as _copy
189
+ blocks = _copy.deepcopy(spec.get("blocks", []))
190
+ for b in blocks: _measure(b)
191
+ pos, rects = {}, []; x = 50
192
+ for b in blocks:
193
+ _place(b, x, 60, pos, rects, 1); x += b["_w"] + 50
194
+ return pos, rects
195
+
196
+ def layout_issues(spec, tol=4):
197
+ """Visual sanity check: flag any two component boxes (icon + label area) that overlap.
198
+ The engine is overlap-free by construction, so this should always pass."""
199
+ pos = compute_layout(spec)
200
+ # each node occupies icon cell + the label strip below it (LABEL_H tall)
201
+ boxes = {cid: (x, y, w, ICON_H + LABEL_H) for cid, (x, y, w, h) in pos.items()}
202
+ ids = list(boxes); out = []
203
+ for i in range(len(ids)):
204
+ ax, ay, aw, ah = boxes[ids[i]]
205
+ for j in range(i + 1, len(ids)):
206
+ bx, by, bw, bh = boxes[ids[j]]
207
+ if ax < bx+bw-tol and bx < ax+aw-tol and ay < by+bh-tol and by < ay+ah-tol:
208
+ out.append(f"layout overlap: {ids[i]} & {ids[j]}")
209
+ return out[:6]
210
+
211
+ def _fix_img(style):
212
+ # browsers/mxGraph need ;base64 for base64 data URIs (draw.io tolerates the comma form)
213
+ return re.sub(r"data:image/(svg\+xml|png|jpeg),", r"data:image/\1;base64,", style)
214
+
215
+ def stream_cells(spec):
216
+ """Real draw.io cells (blocks → nodes → edges) in build order, with EXACT styles +
217
+ geometry — for incremental insertion into an mxGraph (pixel-identical to build_hier)."""
218
+ pos, rects = compute_full(spec)
219
+ fills = ["#eef5fc", "#e4eefb", "#dbe8f8"]
220
+ idlabel = {}
221
+ def walk(bs):
222
+ for b in bs:
223
+ for c in b.get("components", []): idlabel[c["id"]] = c["label"]
224
+ walk(b.get("blocks", []))
225
+ walk(spec.get("blocks", []))
226
+ cells = []
227
+ for i, z in enumerate(sorted(rects, key=lambda r: r["depth"])):
228
+ x, y, w, h = z["rect"]; d = z["depth"]
229
+ fc = fills[min(d-1, len(fills)-1)] if d >= 1 else "#ffffff"
230
+ cells.append({"id": f"b{i}", "vertex": True, "value": z.get("label") or "",
231
+ "x": x, "y": y, "w": w, "h": h,
232
+ "style": f"rounded=0;whiteSpace=wrap;html=1;fillColor={fc};strokeColor=#9fc5e8;"
233
+ f"verticalAlign=top;fontSize=12;fontStyle=1;fontColor=#1a3b5c;"})
234
+ nmap = {}
235
+ for cid, (x, y, w, h) in pos.items():
236
+ lbl = idlabel.get(cid, ""); key = snap_icon(lbl)
237
+ # use the EXACT style build_hier uses (draw.io renders the original comma data-URI form)
238
+ style = (_LIB[key].rstrip(";") + ";verticalLabelPosition=bottom;verticalAlign=top;"
239
+ "imageAlign=center;align=center;whiteSpace=wrap;") if key \
240
+ else "rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#5b7a9c;align=center;"
241
+ nmap[cid] = f"n{cid}"
242
+ cells.append({"id": nmap[cid], "vertex": True, "value": lbl,
243
+ "x": x, "y": y, "w": w, "h": ICON_H, "style": style})
244
+ for i, e in enumerate(spec.get("connections", [])):
245
+ if e["source"] in nmap and e["target"] in nmap:
246
+ cells.append({"id": f"e{i}", "edge": True, "value": e.get("label", ""),
247
+ "source": nmap[e["source"]], "target": nmap[e["target"]],
248
+ "style": "edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#42607d;endArrow=block;"})
249
+ return cells
250
+
251
+ def build_hier(spec, out_path="hier_output.drawio"):
252
+ import copy as _copy
253
+ blocks = _copy.deepcopy(spec.get("blocks", [])) # don't pollute caller's spec with _w/_h
254
+ for b in blocks: _measure(b)
255
+ pos, rects = {}, []
256
+ x = 50
257
+ maxh = 0
258
+ placed = []
259
+ for b in blocks:
260
+ _place(b, x, 60, pos, rects, 1); x += b["_w"] + 50; maxh = max(maxh, b["_h"])
261
+ # outer wrapper
262
+ ow = x - 50 - 50 + 2*30; oh = maxh + 2*30
263
+ cells = ['<mxCell id="0" />', '<mxCell id="1" parent="0" />']
264
+ cells.append(f'<mxCell id="outer" value={quoteattr(spec.get("title",""))} '
265
+ f'style="rounded=0;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#5b7a9c;'
266
+ f'verticalAlign=top;fontSize=17;fontStyle=1;fontColor=#13334f;" vertex="1" parent="1">'
267
+ f'<mxGeometry x="20" y="20" width="{max(ow, x-50)}" height="{oh+60}" as="geometry" /></mxCell>')
268
+ fills = ["#eef5fc", "#e4eefb", "#dbe8f8"]
269
+ for i, z in enumerate(sorted(rects, key=lambda r: r["depth"])):
270
+ x0, y0, w0, h0 = z["rect"]
271
+ fc = fills[min(z["depth"]-1, len(fills)-1)] if z["depth"] >= 1 else "#ffffff"
272
+ cells.append(f'<mxCell id="blk{i}" value={quoteattr(z.get("label") or "")} '
273
+ f'style="rounded=0;whiteSpace=wrap;html=1;fillColor={fc};strokeColor=#9fc5e8;'
274
+ f'verticalAlign=top;fontSize=12;fontStyle=1;fontColor=#1a3b5c;" vertex="1" parent="1">'
275
+ f'<mxGeometry x="{x0}" y="{y0}" width="{w0}" height="{h0}" as="geometry" /></mxCell>')
276
+ idmap = {}; nid = 1
277
+ def walk(b):
278
+ for sub in b.get("blocks", []): walk(sub)
279
+ for comp in b.get("components", []):
280
+ if comp["id"] not in pos: continue
281
+ nonlocal nid; nid += 1; idmap[comp["id"]] = f"n{nid}"
282
+ xx, yy, ww, hh = pos[comp["id"]]
283
+ key = snap_icon(comp["label"], comp.get("icon"))
284
+ # small icon centered in the wide cell; label wraps below within the full width
285
+ style = (_LIB[key].rstrip(";") + ";verticalLabelPosition=bottom;verticalAlign=top;"
286
+ "imageAlign=center;labelPosition=center;align=center;whiteSpace=wrap;") if key \
287
+ else "rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#5b7a9c;align=center;"
288
+ cells.append(f'<mxCell id="n{nid}" value={quoteattr(comp["label"])} style={quoteattr(style)} '
289
+ f'vertex="1" parent="1"><mxGeometry x="{xx}" y="{yy}" width="{ww}" height="{hh}" as="geometry" /></mxCell>')
290
+ for b in blocks: walk(b)
291
+ eid = 5000
292
+ for e in spec.get("connections", []):
293
+ if e["source"] not in idmap or e["target"] not in idmap: continue
294
+ eid += 1; lab = e.get("label", ""); va = f' value={quoteattr(lab)}' if lab else ''
295
+ cells.append(f'<mxCell id="e{eid}"{va} style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;'
296
+ f'strokeColor=#42607d;endArrow=block;" edge="1" parent="1" '
297
+ f'source={quoteattr(idmap[e["source"]])} target={quoteattr(idmap[e["target"]])}>'
298
+ f'<mxGeometry relative="1" as="geometry" /></mxCell>')
299
+ xml = (f'<mxfile host="archgen"><diagram name={quoteattr(spec.get("title","Architecture"))}>'
300
+ f'<mxGraphModel dx="1200" dy="800" grid="0" guides="1" tooltips="1" connect="1" arrows="1" '
301
+ f'fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1100" math="0" shadow="0"><root>'
302
+ + "".join(cells) + '</root></mxGraphModel></diagram></mxfile>')
303
+ open(out_path, "w").write(xml)
304
+ return out_path
305
+
306
+ if __name__ == "__main__":
307
+ import sys
308
+ spec = json.load(open(sys.argv[1])) if len(sys.argv) > 1 else json.load(open("demo_spec.json"))
309
+ fn = build_hier if "blocks" in spec else build
310
+ p = fn(spec, sys.argv[2] if len(sys.argv) > 2 else "spec_output.drawio")
311
+ print("wrote", p)