649 lines
24 KiB
Python
649 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
"""Extract a deep structural design profile from one .drawio file.
|
||
|
||
The fingerprint in compare.py is a similarity signature — good for scoring
|
||
but useless for *teaching* an LLM what makes a SAP template work. This
|
||
script produces a much richer per-template profile: zone inventory, card
|
||
inventory, icon size+library breakdown, pill vocabulary, edge
|
||
anchor/orthogonality stats, color usage, and detected layout patterns
|
||
(vertical network divider, identity-anchor-bottom-center, cloud-solutions
|
||
horizontal band, etc.).
|
||
|
||
When the LLM has chosen a SAP template via scaffold_diagram.py, it should
|
||
read this profile to understand the "design recipe" it's editing. The
|
||
recipe answers:
|
||
|
||
- How many zones, of what colors, in what arrangement?
|
||
- How many cards inside each zone?
|
||
- What sizes are the icons (so don't override default to 80×80)?
|
||
- What pill verbs does this scenario use?
|
||
- What edge styles are typical here (orthogonal vs straight, anchored vs naked)?
|
||
- What named layout patterns does this template exhibit?
|
||
|
||
Usage:
|
||
profile_template.py <file>.drawio # human-readable profile
|
||
profile_template.py <file>.drawio --json # machine-readable
|
||
profile_template.py --build-registry [--out FILE] # scan all bundled SAP refs and write
|
||
a single profiles.json registry
|
||
|
||
The registry is what `iterate.py` and `find_pattern.py` consult — it's
|
||
small (~250 KB for 71 templates) and shipped with the plugin.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import html
|
||
import json
|
||
import re
|
||
import sys
|
||
import xml.etree.ElementTree as ET
|
||
from collections import Counter
|
||
from dataclasses import asdict, dataclass, field
|
||
from pathlib import Path
|
||
|
||
THIS_DIR = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(THIS_DIR))
|
||
|
||
import select_reference as _sel # noqa: E402 for metadata lookup
|
||
|
||
|
||
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
|
||
DATA_URI_RE = re.compile(r"data:image/[^&\";]+")
|
||
SHAPE_RE = re.compile(r"(?:^|;)shape=([^;\"]+)")
|
||
|
||
|
||
# ---- Color → human name mapping (for the "zone colors" line in profiles)
|
||
COLOR_NAMES = {
|
||
"#0070F2": "SAP blue (border)",
|
||
"#EBF8FF": "SAP blue (fill)",
|
||
"#475E75": "non-SAP slate (border)",
|
||
"#F5F6F7": "non-SAP slate (fill)",
|
||
"#188918": "positive green (border)",
|
||
"#F5FAE5": "positive green (fill)",
|
||
"#C35500": "critical orange (border)",
|
||
"#FFF8D6": "critical orange (fill)",
|
||
"#D20A0A": "negative red (border)",
|
||
"#FFEAF4": "negative red (fill)",
|
||
"#5D36FF": "indigo accent (border)",
|
||
"#F1ECFF": "indigo accent (fill, Joule purple)",
|
||
"#CC00DC": "pink accent (trust)",
|
||
"#FFF0FA": "pink accent (fill)",
|
||
"#07838F": "teal accent (border, MCP)",
|
||
"#DAFDF5": "teal accent (fill)",
|
||
"#1D2D3E": "title text",
|
||
"#556B82": "body text",
|
||
"#1A2733": "near-black navy",
|
||
"#5B738B": "lighter slate",
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class ZoneInfo:
|
||
cell_id: str
|
||
label: str
|
||
fill: str
|
||
stroke: str
|
||
x: int
|
||
y: int
|
||
w: int
|
||
h: int
|
||
parent_id: str | None
|
||
color_role: str # "sap-blue" | "non-sap-slate" | "indigo" | "teal" | "pink" | "other"
|
||
|
||
|
||
@dataclass
|
||
class CardInfo:
|
||
cell_id: str
|
||
label: str
|
||
fill: str
|
||
stroke: str
|
||
x: int
|
||
y: int
|
||
w: int
|
||
h: int
|
||
parent_zone: str | None
|
||
|
||
|
||
@dataclass
|
||
class IconInfo:
|
||
cell_id: str
|
||
library_name: str # SAP service slug, "mxgraph.sap.icon", or "image"
|
||
label: str
|
||
w: int
|
||
h: int
|
||
parent_id: str | None
|
||
|
||
|
||
@dataclass
|
||
class PillInfo:
|
||
cell_id: str
|
||
label: str
|
||
fill: str
|
||
stroke: str
|
||
|
||
|
||
@dataclass
|
||
class EdgeInfo:
|
||
cell_id: str
|
||
source: str
|
||
target: str
|
||
stroke_color: str
|
||
has_anchors: bool
|
||
is_orthogonal: bool
|
||
is_dashed: bool
|
||
|
||
|
||
@dataclass
|
||
class TemplateProfile:
|
||
file: str
|
||
name: str
|
||
family: str
|
||
level: str
|
||
primary: bool
|
||
title: str
|
||
aliases: list[str] = field(default_factory=list)
|
||
tags: list[str] = field(default_factory=list)
|
||
domain: str = ""
|
||
canvas_w: int = 0
|
||
canvas_h: int = 0
|
||
background: str = ""
|
||
zones: list[ZoneInfo] = field(default_factory=list)
|
||
cards: list[CardInfo] = field(default_factory=list)
|
||
icons: list[IconInfo] = field(default_factory=list)
|
||
pills: list[PillInfo] = field(default_factory=list)
|
||
edges: list[EdgeInfo] = field(default_factory=list)
|
||
icon_sizes: dict[str, int] = field(default_factory=dict) # "32x32" → count
|
||
pill_vocab: list[str] = field(default_factory=list)
|
||
color_distribution: dict[str, int] = field(default_factory=dict) # hex → count
|
||
font_sizes: dict[str, int] = field(default_factory=dict)
|
||
fonts: list[str] = field(default_factory=list)
|
||
edge_color_distribution: dict[str, int] = field(default_factory=dict)
|
||
structure_summary: dict[str, int] = field(default_factory=dict)
|
||
edge_quality: dict[str, int] = field(default_factory=dict)
|
||
detected_patterns: list[str] = field(default_factory=list)
|
||
description: str = ""
|
||
|
||
def to_dict(self) -> dict:
|
||
d = asdict(self)
|
||
return d
|
||
|
||
|
||
# ---------- Helpers -----------------------------------------------------------
|
||
|
||
def _strip_label(raw: str) -> str:
|
||
s = html.unescape(raw or "")
|
||
s = re.sub(r"<br\s*/?>", " ", s, flags=re.I)
|
||
s = re.sub(r"<[^>]+>", "", s)
|
||
s = re.sub(r" ", " ", s)
|
||
return re.sub(r"\s+", " ", s).strip()
|
||
|
||
|
||
def _parse_style(style: str | None) -> dict[str, str]:
|
||
out: dict[str, str] = {}
|
||
if not style:
|
||
return out
|
||
for part in style.split(";"):
|
||
part = part.strip()
|
||
if not part or "=" not in part:
|
||
continue
|
||
k, v = part.split("=", 1)
|
||
out[k.strip()] = v.strip()
|
||
return out
|
||
|
||
|
||
def _color_role(stroke: str) -> str:
|
||
s = stroke.upper()
|
||
if s == "#0070F2":
|
||
return "sap-blue"
|
||
if s == "#475E75":
|
||
return "non-sap-slate"
|
||
if s == "#5D36FF":
|
||
return "indigo (Joule purple)"
|
||
if s == "#07838F":
|
||
return "teal accent"
|
||
if s == "#CC00DC":
|
||
return "pink accent (trust)"
|
||
if s == "#188918":
|
||
return "positive green"
|
||
if s == "#C35500":
|
||
return "critical orange"
|
||
if s == "#D20A0A":
|
||
return "negative red"
|
||
return f"other ({stroke})"
|
||
|
||
|
||
def _icon_library_name(style: str) -> str:
|
||
"""Extract a usable library / service name from an icon's style string."""
|
||
if "mxgraph.sap.icon" in style:
|
||
m = re.search(r"SAPIcon=([A-Za-z0-9_]+)", style)
|
||
if m:
|
||
return f"mxgraph.sap.icon/{m.group(1)}"
|
||
return "mxgraph.sap.icon"
|
||
# Inline-SVG icons sometimes carry an SAP-set hint in image-data URI; we can't easily
|
||
# decode the content, but we can flag image vs not.
|
||
if "shape=image" in style:
|
||
return "inline-svg"
|
||
return "unknown"
|
||
|
||
|
||
# ---------- Pattern detectors ------------------------------------------------
|
||
|
||
def _detect_patterns(profile: TemplateProfile) -> list[str]:
|
||
"""Heuristic detectors for common SAP layout patterns.
|
||
|
||
These run on the populated profile and produce short, grep-able pattern
|
||
tags the LLM can match against.
|
||
"""
|
||
patterns: list[str] = []
|
||
|
||
# Tri-zone Joule/BTP/3rd-party (RA0029 family signature)
|
||
has_joule_color = any("indigo" in z.color_role for z in profile.zones)
|
||
has_btp_color = any("sap-blue" in z.color_role for z in profile.zones if z.parent_id == "1")
|
||
has_slate = any("non-sap-slate" in z.color_role for z in profile.zones if z.parent_id == "1")
|
||
if has_joule_color and has_btp_color and has_slate:
|
||
patterns.append("tri-zone-joule-btp-third-party")
|
||
|
||
# Network divider — vertical bar, slate, very tall, very narrow
|
||
for c in profile.cards:
|
||
if c.h > 200 and c.w < 20 and "475E75" in (c.stroke + c.fill).upper():
|
||
patterns.append("vertical-network-divider")
|
||
break
|
||
|
||
# Cloud Solutions horizontal band — wide BTP-blue zone at bottom containing 4+ cards
|
||
btp_blue_zones = [z for z in profile.zones if z.color_role == "sap-blue"]
|
||
for z in btp_blue_zones:
|
||
if z.w > 400 and z.h < 250 and z.y > profile.canvas_h * 0.55:
|
||
children_count = sum(1 for c in profile.cards if c.parent_zone == z.cell_id)
|
||
if children_count >= 3:
|
||
patterns.append("cloud-solutions-bottom-band")
|
||
break
|
||
|
||
# Identity anchored at bottom center (IAS icon near bottom-center)
|
||
canvas_cx = profile.canvas_w / 2
|
||
for icon in profile.icons:
|
||
if "Identity" in (icon.library_name or "") or "identity" in icon.label.lower() or "ias" in icon.label.lower():
|
||
if abs((icon.parent_id and 0 or 0) + 0) < 0: # placeholder; check coordinates instead
|
||
pass
|
||
# Find geometry for this icon
|
||
# (icon doesn't carry geometry directly; would need separate lookup;
|
||
# approximation: if any icon's label contains "identity"/"ias" we tag it)
|
||
patterns.append("has-identity-services-icon")
|
||
break
|
||
|
||
# Many BTP service icons (>= 8) — full-blown landscape
|
||
if len(profile.icons) >= 8:
|
||
patterns.append("dense-icon-landscape")
|
||
elif len(profile.icons) <= 3:
|
||
patterns.append("sparse-icon-overview")
|
||
|
||
# Multiple-pill flow (>= 5 pills) → labeled-flow diagram
|
||
if len(profile.pills) >= 5:
|
||
patterns.append("labeled-flow-multi-pill")
|
||
|
||
# Dashed edges signal optional/async flows
|
||
if any(e.is_dashed for e in profile.edges):
|
||
patterns.append("uses-dashed-edges")
|
||
|
||
# Properly anchored edges — quality signal for the LLM to imitate
|
||
if profile.edges:
|
||
anchored_pct = sum(1 for e in profile.edges if e.has_anchors) / len(profile.edges)
|
||
if anchored_pct >= 0.7:
|
||
patterns.append("well-anchored-edges")
|
||
elif anchored_pct < 0.3:
|
||
patterns.append("naked-edges-style")
|
||
|
||
# Mostly orthogonal — SAP convention
|
||
if profile.edges:
|
||
ortho_pct = sum(1 for e in profile.edges if e.is_orthogonal) / len(profile.edges)
|
||
if ortho_pct >= 0.8:
|
||
patterns.append("orthogonal-edges-dominant")
|
||
|
||
# Subaccount nested inside SAP BTP zone (RA0029 + IAM templates pattern)
|
||
for z in profile.zones:
|
||
if z.label.lower() == "subaccount" and z.parent_id and z.parent_id != "1":
|
||
patterns.append("subaccount-nested-in-btp")
|
||
break
|
||
|
||
# Multi-zone pattern — reference families that use 4+ top-level zones
|
||
top_level_zones = [z for z in profile.zones if z.parent_id == "1" or z.parent_id is None]
|
||
if len(top_level_zones) >= 4:
|
||
patterns.append("multi-zone-layout-4plus")
|
||
elif len(top_level_zones) == 3:
|
||
patterns.append("tri-zone-layout")
|
||
elif len(top_level_zones) == 2:
|
||
patterns.append("dual-zone-layout")
|
||
|
||
return sorted(set(patterns))
|
||
|
||
|
||
# ---------- Description synthesizer ------------------------------------------
|
||
|
||
def _synthesize_description(profile: TemplateProfile) -> str:
|
||
"""One-paragraph plain-English summary of the template's design recipe."""
|
||
parts = []
|
||
parts.append(profile.title or profile.name)
|
||
if profile.canvas_w and profile.canvas_h:
|
||
parts.append(f"on a {profile.canvas_w}×{profile.canvas_h} canvas")
|
||
top_zones = [z for z in profile.zones if z.parent_id in (None, "1")]
|
||
zone_summary = ", ".join(
|
||
f"{z.label or '(unnamed)'} [{z.color_role}]"
|
||
for z in top_zones[:6]
|
||
)
|
||
if zone_summary:
|
||
parts.append(f"with top-level zones: {zone_summary}")
|
||
if profile.icons:
|
||
size_summary = ", ".join(f"{n}× {s}" for s, n in sorted(profile.icon_sizes.items(), key=lambda kv: -kv[1])[:3])
|
||
parts.append(f"and {len(profile.icons)} icons ({size_summary})")
|
||
if profile.pills:
|
||
vocab_summary = ", ".join(f"{lbl!r}" for lbl in list(dict.fromkeys(profile.pill_vocab))[:6])
|
||
parts.append(f"plus {len(profile.pills)} flow pills using verbs {vocab_summary}")
|
||
if profile.detected_patterns:
|
||
parts.append(f"Detected patterns: {', '.join(profile.detected_patterns[:5])}")
|
||
return ". ".join(parts) + "."
|
||
|
||
|
||
# ---------- Main extraction --------------------------------------------------
|
||
|
||
def profile_one(path: Path) -> TemplateProfile:
|
||
metadata = _sel.template_metadata(path)
|
||
profile = TemplateProfile(
|
||
file=str(path),
|
||
name=path.name,
|
||
family=str(metadata.get("family", "")),
|
||
level=str(metadata.get("level", "")),
|
||
primary=bool(metadata.get("primary", False)),
|
||
title=str(metadata.get("title", "")),
|
||
aliases=list(metadata.get("aliases", [])) if isinstance(metadata.get("aliases"), list) else [],
|
||
tags=list(metadata.get("tags", [])) if isinstance(metadata.get("tags"), list) else [],
|
||
domain=str(metadata.get("domain", "")),
|
||
)
|
||
|
||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||
palette_text = DATA_URI_RE.sub("", text)
|
||
profile.color_distribution = dict(Counter(h.upper() for h in HEX_RE.findall(palette_text)).most_common(20))
|
||
profile.fonts = sorted(set(re.findall(r"fontFamily=([^;\"]+)", palette_text)))
|
||
|
||
try:
|
||
root = ET.parse(path).getroot()
|
||
except ET.ParseError:
|
||
return profile
|
||
|
||
graph = root.find(".//mxGraphModel")
|
||
if graph is not None:
|
||
profile.canvas_w = int(graph.get("pageWidth") or "0")
|
||
profile.canvas_h = int(graph.get("pageHeight") or "0")
|
||
bg = graph.get("background") or graph.get("pageBackgroundColor") or ""
|
||
profile.background = bg.strip().lower()
|
||
|
||
parent_by_elem = {id(child): parent for parent in root.iter() for child in list(parent)}
|
||
|
||
# --- Pass 1: collect cell metadata
|
||
icon_geometries: dict[str, tuple[int, int, int, int]] = {}
|
||
cells = []
|
||
cell_lookup: dict[str, ET.Element] = {}
|
||
for c in root.iter("mxCell"):
|
||
cid = c.get("id")
|
||
if not cid:
|
||
parent_uo = parent_by_elem.get(id(c))
|
||
if parent_uo is not None and parent_uo.tag == "UserObject":
|
||
cid = parent_uo.get("id")
|
||
if not cid:
|
||
continue
|
||
cells.append((cid, c))
|
||
cell_lookup[cid] = c
|
||
|
||
# --- Pre-pass: count children per cell so we can distinguish zone (container)
|
||
# from card (leaf). Both can use rounded-rect style with strokeWidth=1.5; the
|
||
# only visual difference is whether other cells are parented inside.
|
||
children_count: dict[str, int] = {}
|
||
for _, c in cells:
|
||
parent = c.get("parent")
|
||
if parent:
|
||
children_count[parent] = children_count.get(parent, 0) + 1
|
||
|
||
# --- Pass 2: classify each cell
|
||
pill_labels: list[str] = []
|
||
font_size_counter: Counter[str] = Counter()
|
||
for cid, c in cells:
|
||
style = c.get("style") or ""
|
||
sd = _parse_style(style)
|
||
is_vertex = c.get("vertex") == "1"
|
||
is_edge = c.get("edge") == "1"
|
||
|
||
# Geometry
|
||
geo = c.find("mxGeometry")
|
||
if geo is None:
|
||
x = y = w = h = 0
|
||
else:
|
||
try:
|
||
x = int(float(geo.get("x", "0")))
|
||
y = int(float(geo.get("y", "0")))
|
||
w = int(float(geo.get("width", "0")))
|
||
h = int(float(geo.get("height", "0")))
|
||
except ValueError:
|
||
x = y = w = h = 0
|
||
|
||
raw_value = c.get("value") or ""
|
||
if not raw_value:
|
||
parent_uo = parent_by_elem.get(id(c))
|
||
if parent_uo is not None and parent_uo.tag == "UserObject":
|
||
raw_value = parent_uo.get("value") or parent_uo.get("label") or ""
|
||
label = _strip_label(raw_value)
|
||
|
||
# Font sizes from inline HTML — heuristic
|
||
for fs in re.findall(r"font-size\s*:\s*(\d+)", raw_value):
|
||
font_size_counter[fs] += 1
|
||
if sd.get("fontSize"):
|
||
font_size_counter[sd["fontSize"]] += 1
|
||
|
||
if is_edge:
|
||
stroke = sd.get("strokeColor", "").upper()
|
||
has_anchors = any(k in sd for k in ("entryX", "exitX", "entryY", "exitY"))
|
||
is_ortho = sd.get("edgeStyle") == "orthogonalEdgeStyle"
|
||
is_dashed = sd.get("dashed") == "1"
|
||
profile.edges.append(EdgeInfo(
|
||
cell_id=cid,
|
||
source=c.get("source", ""),
|
||
target=c.get("target", ""),
|
||
stroke_color=stroke,
|
||
has_anchors=has_anchors,
|
||
is_orthogonal=is_ortho,
|
||
is_dashed=is_dashed,
|
||
))
|
||
continue
|
||
|
||
if not is_vertex or w <= 0 or h <= 0:
|
||
continue
|
||
|
||
# Icon? — image with SVG or PNG, or mxgraph.sap.icon stencil
|
||
is_icon = False
|
||
image = sd.get("image", "")
|
||
if sd.get("shape") == "image" and image.startswith(("data:image/svg", "data:image/png")):
|
||
is_icon = True
|
||
elif "mxgraph.sap.icon" in style:
|
||
is_icon = True
|
||
|
||
if is_icon:
|
||
profile.icons.append(IconInfo(
|
||
cell_id=cid,
|
||
library_name=_icon_library_name(style),
|
||
label=label[:60],
|
||
w=w, h=h,
|
||
parent_id=c.get("parent"),
|
||
))
|
||
icon_geometries[cid] = (x, y, w, h)
|
||
continue
|
||
|
||
# Pill? — arcSize=50, small
|
||
try:
|
||
arc = int(float(sd.get("arcSize", "0")))
|
||
except ValueError:
|
||
arc = 0
|
||
if arc >= 40 and w <= 220 and h <= 60:
|
||
pill_labels.append(label.lower())
|
||
profile.pills.append(PillInfo(
|
||
cell_id=cid,
|
||
label=label,
|
||
fill=sd.get("fillColor", ""),
|
||
stroke=sd.get("strokeColor", ""),
|
||
))
|
||
continue
|
||
|
||
# Zone? — rounded container with strokeWidth=1.5. SAP uses the same
|
||
# rounded-rect style for zones and cards. We distinguish by either:
|
||
# (a) the cell has children parented inside it — definitively a zone
|
||
# (b) the cell is large enough to plausibly hold content
|
||
# (min dim >= 100 AND max dim >= 200 — cards are smaller)
|
||
n_children = children_count.get(cid, 0)
|
||
is_rounded_rect = (
|
||
12 <= arc <= 30
|
||
and sd.get("strokeWidth", "").rstrip(";") == "1.5"
|
||
)
|
||
is_zone = is_rounded_rect and (
|
||
n_children >= 1
|
||
or (min(w, h) >= 100 and max(w, h) >= 200)
|
||
)
|
||
if is_zone:
|
||
stroke = sd.get("strokeColor", "")
|
||
fill = sd.get("fillColor", "")
|
||
profile.zones.append(ZoneInfo(
|
||
cell_id=cid,
|
||
label=label[:80],
|
||
fill=fill,
|
||
stroke=stroke,
|
||
x=x, y=y, w=w, h=h,
|
||
parent_id=c.get("parent"),
|
||
color_role=_color_role(stroke),
|
||
))
|
||
continue
|
||
|
||
# Otherwise classify as a card if it has a fill and a label
|
||
if sd.get("fillColor", "").lower() not in ("", "none"):
|
||
# Determine which zone (if any) this card sits inside
|
||
parent_zone_id: str | None = None
|
||
for z in profile.zones:
|
||
if z.cell_id == c.get("parent"):
|
||
parent_zone_id = z.cell_id
|
||
break
|
||
profile.cards.append(CardInfo(
|
||
cell_id=cid,
|
||
label=label[:80],
|
||
fill=sd.get("fillColor", ""),
|
||
stroke=sd.get("strokeColor", ""),
|
||
x=x, y=y, w=w, h=h,
|
||
parent_zone=parent_zone_id,
|
||
))
|
||
|
||
profile.icon_sizes = dict(Counter(f"{i.w}x{i.h}" for i in profile.icons).most_common(10))
|
||
profile.pill_vocab = list(dict.fromkeys(pill_labels))
|
||
profile.font_sizes = dict(font_size_counter.most_common(8))
|
||
|
||
# Edge color distribution
|
||
profile.edge_color_distribution = dict(
|
||
Counter(e.stroke_color for e in profile.edges if e.stroke_color).most_common(8)
|
||
)
|
||
|
||
profile.edge_quality = {
|
||
"total": len(profile.edges),
|
||
"with_anchors": sum(1 for e in profile.edges if e.has_anchors),
|
||
"orthogonal": sum(1 for e in profile.edges if e.is_orthogonal),
|
||
"dashed": sum(1 for e in profile.edges if e.is_dashed),
|
||
}
|
||
|
||
profile.structure_summary = {
|
||
"top_level_zones": sum(1 for z in profile.zones if z.parent_id in (None, "1")),
|
||
"nested_zones": sum(1 for z in profile.zones if z.parent_id not in (None, "1")),
|
||
"cards": len(profile.cards),
|
||
"icons": len(profile.icons),
|
||
"pills": len(profile.pills),
|
||
"edges": len(profile.edges),
|
||
}
|
||
|
||
profile.detected_patterns = _detect_patterns(profile)
|
||
profile.description = _synthesize_description(profile)
|
||
|
||
return profile
|
||
|
||
|
||
def build_registry(refs_dir: Path, out_path: Path) -> int:
|
||
refs = sorted(refs_dir.rglob("*.drawio"))
|
||
profiles = {}
|
||
for p in refs:
|
||
try:
|
||
profiles[p.name] = profile_one(p).to_dict()
|
||
except Exception as e:
|
||
print(f"warning: failed to profile {p.name}: {e}", file=sys.stderr)
|
||
continue
|
||
payload = {
|
||
"version": 1,
|
||
"purpose": "Pre-computed deep design profiles for every bundled SAP reference template. Consulted by iterate.py and find_pattern.py so the LLM can study the design recipe of its chosen scaffold.",
|
||
"templates": profiles,
|
||
}
|
||
out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||
return len(profiles)
|
||
|
||
|
||
def render_human(profile: TemplateProfile) -> str:
|
||
lines: list[str] = []
|
||
lines.append(f"=== {profile.name} ===")
|
||
lines.append(f"Title : {profile.title or '(no metadata title)'}")
|
||
lines.append(f"Family : {profile.family} | Level: {profile.level} | Domain: {profile.domain} | Primary: {profile.primary}")
|
||
lines.append(f"Canvas : {profile.canvas_w}×{profile.canvas_h} bg={profile.background or '(white/none)'}")
|
||
lines.append(f"Structure: {profile.structure_summary}")
|
||
if profile.zones:
|
||
lines.append("Zones :")
|
||
for z in profile.zones:
|
||
depth = " " if z.parent_id in (None, "1") else " └ "
|
||
lines.append(f" {depth}{z.label or '(unnamed)':40s} {z.color_role:30s} @ ({z.x},{z.y}) {z.w}×{z.h}")
|
||
if profile.icons:
|
||
lines.append(f"Icons : {len(profile.icons)} total — sizes: {profile.icon_sizes}")
|
||
if profile.pills:
|
||
vocab_preview = ", ".join(f"{lbl!r}" for lbl in profile.pill_vocab[:8])
|
||
lines.append(f"Pills : {len(profile.pills)} — vocab: {vocab_preview}")
|
||
if profile.edges:
|
||
eq = profile.edge_quality
|
||
lines.append(f"Edges : {eq['total']} total — {eq['with_anchors']} anchored, {eq['orthogonal']} orthogonal, {eq['dashed']} dashed")
|
||
if profile.edge_color_distribution:
|
||
lines.append(f" colors: {profile.edge_color_distribution}")
|
||
if profile.detected_patterns:
|
||
lines.append("Patterns :")
|
||
for pat in profile.detected_patterns:
|
||
lines.append(f" • {pat}")
|
||
lines.append(f"Recipe : {profile.description}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("file", nargs="?", type=Path, help="single .drawio to profile")
|
||
ap.add_argument("--build-registry", action="store_true",
|
||
help="scan all bundled SAP refs and write template-profiles.json")
|
||
ap.add_argument("--refs-dir", type=Path, default=None,
|
||
help="reference dir (default: bundled assets)")
|
||
ap.add_argument("--out", type=Path, default=None,
|
||
help="output path for --build-registry (default: assets/reference-examples/template-profiles.json)")
|
||
ap.add_argument("--json", action="store_true", help="emit JSON for one file")
|
||
args = ap.parse_args()
|
||
|
||
if args.build_registry:
|
||
refs_dir = args.refs_dir or (THIS_DIR.parent / "assets" / "reference-examples")
|
||
out_path = args.out or (refs_dir / "template-profiles.json")
|
||
n = build_registry(refs_dir, out_path)
|
||
print(f"profiled {n} templates → {out_path}")
|
||
return 0
|
||
|
||
if not args.file:
|
||
print("either provide a .drawio path or pass --build-registry", file=sys.stderr)
|
||
return 2
|
||
|
||
profile = profile_one(args.file)
|
||
if args.json:
|
||
print(json.dumps(profile.to_dict(), indent=2))
|
||
else:
|
||
print(render_human(profile))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|