#!/usr/bin/env python3
"""Semantic spec -> clean, valid SAP .drawio. The model's job shrinks to emitting
typed components + zones + edges; THIS engine does deterministic graphviz layout
and snaps labels to real SAP icon keys. No pixel coords or style keys from the model.
spec = {
"title": str,
"zones": [{"id": str, "label": str, "children": [node_id,...]}],
"nodes": [{"id": str, "label": str, "icon": optional key}],
"edges": [{"source": id, "target": id, "label": optional}]
}
"""
import json, re, subprocess, difflib
from xml.sax.saxutils import quoteattr
_LIB = json.load(open("dataset/style_library.json"))
_ICON_KEYS = [k for k in _LIB if not k.startswith(("box", "edge"))]
def slug(s):
return re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_")
def snap_icon(label, given=None):
"""Return a library key for this label (given key wins, else fuzzy match)."""
if given and given in _LIB:
return given
s = slug(label)
if s in _LIB:
return s
m = difflib.get_close_matches(s, _ICON_KEYS, n=1, cutoff=0.72)
return m[0] if m else None
def _dot_layout(spec):
"""Run graphviz dot, return {node_id: (x,y,w,h)} in pixels, top-left origin."""
lines = ['digraph G {', 'rankdir=LR; nodesep=0.5; ranksep=0.9;',
'node [shape=box, fixedsize=true, width=1.1, height=0.7];']
inzone = set()
for z in spec.get("zones", []):
lines.append(f'subgraph cluster_{slug(z["id"])} {{ label={json.dumps(z.get("label",""))};')
for c in z.get("children", []):
lines.append(f'"{c}";'); inzone.add(c)
lines.append('}')
for n in spec["nodes"]:
lines.append(f'"{n["id"]}" [label={json.dumps(n["label"])}];')
for e in spec.get("edges", []):
lines.append(f'"{e["source"]}" -> "{e["target"]}";')
lines.append('}')
dot = "\n".join(lines)
plain = subprocess.run(["dot", "-Tplain"], input=dot, capture_output=True, text=True).stdout
scale = 1.0; H = 0.0; pos = {}
for ln in plain.splitlines():
p = ln.split()
if p[0] == "graph":
scale = float(p[1]); H = float(p[3])
elif p[0] == "node":
name = p[1].strip('"')
x, y, w, h = map(float, p[2:6])
# graphviz: inches, origin bottom-left -> pixels, top-left
PX = 72
pos[name] = (int((x - w/2)*PX), int((H - y - h/2)*PX), int(w*PX), int(h*PX))
return pos
def _grid_layout(spec):
"""Zone-column layout with SMALLER icons + an OUTER wrapping block. zones =
columns L->R inside the outer block; nodes stacked. Returns node pos, zone
rects, and the outer-block rect."""
NW, NH, GAP, PAD, TITLE, COLGAP, TOP, MARGIN = 96, 54, 30, 18, 30, 46, 64, 30
zones = spec.get("zones", [])
zoned = set()
for z in zones:
zoned.update(z["children"])
unzoned = [n["id"] for n in spec["nodes"] if n["id"] not in zoned]
columns = ([{"id": None, "label": None, "children": unzoned}] if unzoned else []) + \
[{"id": z["id"], "label": z.get("label", "Zone"), "children": z["children"]} for z in zones]
pos, zrects = {}, []
x = TOP + MARGIN
maxy = TOP
for col in columns:
kids = [k for k in col["children"] if any(n["id"] == k for n in spec["nodes"])]
if not kids:
continue
col_w = NW + 2*PAD
y = TOP + MARGIN + (TITLE if col["id"] else 0) + PAD
for k in kids:
pos[k] = (x + PAD, y, NW, NH)
y += NH + GAP
if col["id"]:
zrects.append({"label": col["label"], "rect": [x, TOP + MARGIN, col_w, y - (TOP+MARGIN) - GAP + PAD]})
maxy = max(maxy, y)
x += col_w + COLGAP
outer = [TOP, TOP, x - COLGAP - TOP + MARGIN, maxy - TOP - GAP + PAD + MARGIN]
return pos, zrects, outer
def build(spec, out_path="spec_output.drawio"):
pos, zrects, outer = _grid_layout(spec)
cells = ['', '']
# outer surrounding block (behind everything)
ox, oy, ow, oh = outer
cells.append(
f'')
zid = 1000
for z in zrects:
x0, y0, w0, h0 = z["rect"]; zid += 1
cells.append(
f''
f'')
idmap = {}; nid = 1
for n in spec["nodes"]:
if n["id"] not in pos:
continue
x, y, w, h = pos[n["id"]]
nid += 1; idmap[n["id"]] = f"n{nid}"
key = snap_icon(n["label"], n.get("icon"))
if key:
style = _LIB[key].rstrip(";") + ";verticalLabelPosition=bottom;verticalAlign=top;"
else:
style = "rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#5b7a9c;"
cells.append(
f'')
# edges
eid = 5000
for e in spec.get("edges", []):
if e["source"] not in idmap or e["target"] not in idmap:
continue
eid += 1
lab = e.get("label", "")
va = f' value={quoteattr(lab)}' if lab else ''
cells.append(
f'')
xml = (f''
f'' + "".join(cells) +
'')
open(out_path, "w").write(xml)
return out_path
# ---------------- recursive nested-block layout (hierarchical spec) ----------------
# wide cell (gives the label room) + SMALL icon; row pitch leaves space for the
# bottom-positioned 2-line label so it never overlaps the next icon.
ICON_W, ICON_H, BPAD, BTITLE = 124, 40, 20, 34
COLGAP, ROWGAP, LABEL_H = 34, 26, 34
BGAP = COLGAP # block-to-block spacing
ROW_PITCH = ICON_H + LABEL_H + ROWGAP
def _measure(block):
cw, ch = [], []
for sub in block.get("blocks", []):
_measure(sub); cw.append(sub["_w"]); ch.append(sub["_h"])
comps = block.get("components", [])
if comps:
ncol = 1 if len(comps) <= 5 else 2
rows = (len(comps) + ncol - 1) // ncol
clw = ncol*ICON_W + (ncol-1)*COLGAP
clh = rows*ROW_PITCH - ROWGAP # no trailing gap; last row keeps its label
block["_cluster"] = (ncol, clw, clh); cw.append(clw); ch.append(clh)
inner_w = (sum(cw) + COLGAP*(len(cw)-1)) if cw else ICON_W
inner_h = max(ch) if ch else ICON_H
block["_w"] = inner_w + 2*BPAD
block["_h"] = inner_h + BTITLE + BPAD
def _place(block, x, y, pos, rects, depth=0):
rects.append({"label": block.get("label", ""), "rect": [x, y, block["_w"], block["_h"]], "depth": depth})
cx = x + BPAD; cy = y + BTITLE
for sub in block.get("blocks", []):
_place(sub, cx, cy, pos, rects, depth+1); cx += sub["_w"] + COLGAP
comps = block.get("components", [])
if comps:
ncol = block["_cluster"][0]
for i, comp in enumerate(comps):
r, c = divmod(i, ncol)
pos[comp["id"]] = (cx + c*(ICON_W+COLGAP), cy + r*ROW_PITCH, ICON_W, ICON_H)
def compute_layout(spec):
"""Return {component_id: (x, y, w, h)} icon-cell positions (no file written)."""
return compute_full(spec)[0]
def compute_full(spec):
"""Return (pos {id:(x,y,w,h)}, block_rects [{label,rect,depth}]) — for streaming/render."""
import copy as _copy
blocks = _copy.deepcopy(spec.get("blocks", []))
for b in blocks: _measure(b)
pos, rects = {}, []; x = 50
for b in blocks:
_place(b, x, 60, pos, rects, 1); x += b["_w"] + 50
return pos, rects
def layout_issues(spec, tol=4):
"""Visual sanity check: flag any two component boxes (icon + label area) that overlap.
The engine is overlap-free by construction, so this should always pass."""
pos = compute_layout(spec)
# each node occupies icon cell + the label strip below it (LABEL_H tall)
boxes = {cid: (x, y, w, ICON_H + LABEL_H) for cid, (x, y, w, h) in pos.items()}
ids = list(boxes); out = []
for i in range(len(ids)):
ax, ay, aw, ah = boxes[ids[i]]
for j in range(i + 1, len(ids)):
bx, by, bw, bh = boxes[ids[j]]
if ax < bx+bw-tol and bx < ax+aw-tol and ay < by+bh-tol and by < ay+ah-tol:
out.append(f"layout overlap: {ids[i]} & {ids[j]}")
return out[:6]
def _fix_img(style):
# browsers/mxGraph need ;base64 for base64 data URIs (draw.io tolerates the comma form)
return re.sub(r"data:image/(svg\+xml|png|jpeg),", r"data:image/\1;base64,", style)
def stream_cells(spec):
"""Real draw.io cells (blocks → nodes → edges) in build order, with EXACT styles +
geometry — for incremental insertion into an mxGraph (pixel-identical to build_hier)."""
pos, rects = compute_full(spec)
fills = ["#eef5fc", "#e4eefb", "#dbe8f8"]
idlabel = {}
def walk(bs):
for b in bs:
for c in b.get("components", []): idlabel[c["id"]] = c["label"]
walk(b.get("blocks", []))
walk(spec.get("blocks", []))
cells = []
for i, z in enumerate(sorted(rects, key=lambda r: r["depth"])):
x, y, w, h = z["rect"]; d = z["depth"]
fc = fills[min(d-1, len(fills)-1)] if d >= 1 else "#ffffff"
cells.append({"id": f"b{i}", "vertex": True, "value": z.get("label") or "",
"x": x, "y": y, "w": w, "h": h,
"style": f"rounded=0;whiteSpace=wrap;html=1;fillColor={fc};strokeColor=#9fc5e8;"
f"verticalAlign=top;fontSize=12;fontStyle=1;fontColor=#1a3b5c;"})
nmap = {}
for cid, (x, y, w, h) in pos.items():
lbl = idlabel.get(cid, ""); key = snap_icon(lbl)
# use the EXACT style build_hier uses (draw.io renders the original comma data-URI form)
style = (_LIB[key].rstrip(";") + ";verticalLabelPosition=bottom;verticalAlign=top;"
"imageAlign=center;align=center;whiteSpace=wrap;") if key \
else "rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#5b7a9c;align=center;"
nmap[cid] = f"n{cid}"
cells.append({"id": nmap[cid], "vertex": True, "value": lbl,
"x": x, "y": y, "w": w, "h": ICON_H, "style": style})
for i, e in enumerate(spec.get("connections", [])):
if e["source"] in nmap and e["target"] in nmap:
cells.append({"id": f"e{i}", "edge": True, "value": e.get("label", ""),
"source": nmap[e["source"]], "target": nmap[e["target"]],
"style": "edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#42607d;endArrow=block;"})
return cells
def build_hier(spec, out_path="hier_output.drawio"):
import copy as _copy
blocks = _copy.deepcopy(spec.get("blocks", [])) # don't pollute caller's spec with _w/_h
for b in blocks: _measure(b)
pos, rects = {}, []
x = 50
maxh = 0
placed = []
for b in blocks:
_place(b, x, 60, pos, rects, 1); x += b["_w"] + 50; maxh = max(maxh, b["_h"])
# outer wrapper
ow = x - 50 - 50 + 2*30; oh = maxh + 2*30
cells = ['', '']
cells.append(f''
f'')
fills = ["#eef5fc", "#e4eefb", "#dbe8f8"]
for i, z in enumerate(sorted(rects, key=lambda r: r["depth"])):
x0, y0, w0, h0 = z["rect"]
fc = fills[min(z["depth"]-1, len(fills)-1)] if z["depth"] >= 1 else "#ffffff"
cells.append(f''
f'')
idmap = {}; nid = 1
def walk(b):
for sub in b.get("blocks", []): walk(sub)
for comp in b.get("components", []):
if comp["id"] not in pos: continue
nonlocal nid; nid += 1; idmap[comp["id"]] = f"n{nid}"
xx, yy, ww, hh = pos[comp["id"]]
key = snap_icon(comp["label"], comp.get("icon"))
# small icon centered in the wide cell; label wraps below within the full width
style = (_LIB[key].rstrip(";") + ";verticalLabelPosition=bottom;verticalAlign=top;"
"imageAlign=center;labelPosition=center;align=center;whiteSpace=wrap;") if key \
else "rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#5b7a9c;align=center;"
cells.append(f'')
for b in blocks: walk(b)
eid = 5000
for e in spec.get("connections", []):
if e["source"] not in idmap or e["target"] not in idmap: continue
eid += 1; lab = e.get("label", ""); va = f' value={quoteattr(lab)}' if lab else ''
cells.append(f''
f'')
xml = (f''
f''
+ "".join(cells) + '')
open(out_path, "w").write(xml)
return out_path
if __name__ == "__main__":
import sys
spec = json.load(open(sys.argv[1])) if len(sys.argv) > 1 else json.load(open("demo_spec.json"))
fn = build_hier if "blocks" in spec else build
p = fn(spec, sys.argv[2] if len(sys.argv) > 2 else "spec_output.drawio")
print("wrote", p)