Files
2026-07-24 20:47:16 +00:00

738 lines
28 KiB
Python

#!/usr/bin/env python3
"""Render a SAP-style architecture diagram from a small semantic archetype.
This is the escape hatch for prompts where nearest-template copying is known to
hit a geometry ceiling. It deliberately supports only a few SAP BTP archetypes;
unknown prompts should still go through scaffold_diagram.py.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
THIS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(THIS_DIR))
try:
import extract_icon # type: ignore[import-not-found]
except Exception: # pragma: no cover - renderer still works without icons
extract_icon = None
try:
import extract_asset # type: ignore[import-not-found]
except Exception: # pragma: no cover - renderer still works without generic assets
extract_asset = None
BLUE = "#0070F2"
TEXT = "#1D2D3E"
MUTED = "#475E75"
AUTH_GREEN = "#188918"
INDIGO = "#5D36FF"
INDIGO_FILL = "#F1ECFF"
ZONE_FILL = "#EBF8FF"
NEUTRAL_FILL = "#F5F6F7"
WHITE = "#FFFFFF"
PILL_FILL = "#FCFCFC"
PAGE_W = 1169
PAGE_H = 827
@dataclass(frozen=True)
class Box:
id: str
label: str
x: int
y: int
w: int
h: int
icon: str | None = None
fill: str = WHITE
stroke: str = BLUE
@dataclass(frozen=True)
class Edge:
id: str
source: str
target: str
label: str = ""
stroke: str = MUTED
@dataclass(frozen=True)
class Pill:
id: str
label: str
x: int
y: int
w: int = 120
h: int = 24
@dataclass(frozen=True)
class DiagramPlan:
archetype: str
title: str
subtitle: str
width: int
height: int
boxes: list[Box]
edges: list[Edge]
pills: list[Pill]
zones: list[Box]
def norm(text: str) -> str:
return re.sub(r"\s+", " ", text.strip())
def words(text: str) -> set[str]:
return set(re.findall(r"[a-z0-9]+", text.lower()))
def snap(value: int | float) -> int:
return int(round(float(value) / 10.0) * 10)
def infer_archetype(description: str) -> str:
tokens = words(description)
joined = " ".join(tokens)
lower = description.lower()
if tokens & {"siem", "soar", "threat", "etd"} or "security operations" in description.lower():
return "security-operations"
if tokens & {"devops", "cicd", "pipeline", "transport"}:
return "devops"
cloud_connector = "cloud connector" in lower or "cloudconnector" in tokens or ({"cloud", "connector"} <= tokens)
on_premise = bool(tokens & {"onprem", "onpremise"}) or "on-premise" in lower or "on premise" in lower
backend_access = bool(tokens & {"s4", "s4hana", "destination", "connectivity", "vscode", "arc", "arc1"})
if cloud_connector or (on_premise and backend_access):
return "on-prem-connectivity"
if tokens & {"privatelink", "private"}:
return "private-connectivity"
if "event mesh" in lower or tokens & {"eventmesh", "kafka"}:
return "integration-flow"
data_integration = bool(tokens & {"databricks", "snowflake", "lakehouse", "warehouse", "datasphere"})
if data_integration or ("data integration" in lower and "hyperscaler" in lower):
return "data-integration"
if tokens & {"cap", "fiori", "hana", "html5", "approuter"}:
return "btp-application"
if tokens & {"a2a", "b2b", "b2g", "api", "integration", "eventmesh"}:
return "integration-flow"
if tokens & {"joule", "agentic", "agents", "mcp", "llm"} or "ai agent" in joined:
return "ai-agent"
return "generic-btp"
def title_from(description: str, archetype: str) -> str:
clean = norm(description)
if len(clean) <= 86:
return clean
defaults = {
"security-operations": "Security Operations with SAP ETD and SOAR",
"devops": "DevOps on SAP BTP",
"on-prem-connectivity": "On-Premise Connectivity on SAP BTP",
"private-connectivity": "Private Connectivity on SAP BTP",
"btp-application": "SAP BTP Application Architecture",
"data-integration": "Data Integration on SAP BTP",
"integration-flow": "Integration Flow on SAP BTP",
"ai-agent": "AI Agents on SAP BTP",
}
return defaults.get(archetype, "SAP BTP Architecture")
def security_operations(description: str) -> DiagramPlan:
title = title_from(description, "security-operations")
boxes = [
Box("s4", "SAP S/4HANA\nOn-Premise Solutions", 40, 145, 250, 72, "on-premise-sap"),
Box("rise", "RISE", 40, 235, 250, 72, None),
Box("cloud", "SAP Cloud\nSolutions", 40, 325, 250, 72, "cloud foundry"),
Box("btp", "SAP BTP", 40, 415, 250, 72, None),
Box("resources", "Cloud Enterprise Resources\n(Network Logs, EDR, IAM, etc)", 560, 40, 300, 70, None),
Box("etd", "SAP Domain Detection\n\nSAP Enterprise Threat Detection\n(Cloud Edition)", 440, 280, 240, 170, "audit log"),
Box("analytics", "Central Security\nAnalytics\n\nFortiSIEM", 800, 250, 220, 170, None, NEUTRAL_FILL, MUTED),
Box("response", "Security Orchestration\n& Response\n\nFortiSOAR", 800, 520, 220, 170, None, NEUTRAL_FILL, MUTED),
Box("notification", "Notification System", 720, 725, 170, 50, None, WHITE, MUTED),
Box("itsm", "ITSM System", 930, 725, 150, 50, None, WHITE, MUTED),
]
edges = [
Edge("e1", "s4", "etd"),
Edge("e2", "rise", "etd"),
Edge("e3", "cloud", "etd"),
Edge("e4", "btp", "etd"),
Edge("e5", "resources", "analytics"),
Edge("e6", "etd", "analytics"),
Edge("e7", "analytics", "response"),
Edge("e8", "response", "notification"),
Edge("e9", "response", "itsm"),
]
pills = [
Pill("p1", "Security Logs", 325, 335, 110, 24),
Pill("p2", "Alerts, findings &\nenriched events", 680, 335, 135, 36),
Pill("p3", "Correlated\nIncidents", 830, 455, 100, 38),
Pill("p4", "Status & closure\nupdates", 930, 455, 110, 38),
Pill("p5", "Notification", 740, 695, 125, 24),
Pill("p6", "Open Ticket", 940, 695, 125, 24),
]
return DiagramPlan(
"security-operations",
title,
"L2 event-to-response flow for SAP and enterprise security signals",
1100,
850,
boxes,
edges,
pills,
[],
)
def devops(description: str) -> DiagramPlan:
title = title_from(description, "devops")
zones = [
Box("z-source", "Source and Build", 60, 130, 325, 515, None, ZONE_FILL, BLUE),
Box("z-transport", "Transport and Release", 420, 130, 300, 515, None, ZONE_FILL, BLUE),
Box("z-runtime", "SAP BTP Runtime", 755, 130, 340, 515, None, ZONE_FILL, BLUE),
]
boxes = [
Box("dev", "Developer", 115, 210, 165, 62, None),
Box("git", "Git Repository", 115, 330, 165, 62, None),
Box("cicd", "SAP Continuous\nIntegration and Delivery", 80, 455, 250, 82, "continuous integration and delivery"),
Box("ctms", "SAP Cloud Transport\nManagement", 465, 300, 210, 82, "cloud transport management"),
Box("alm", "SAP Cloud ALM", 465, 465, 210, 72, None),
Box("cf", "Cloud Foundry\nRuntime", 815, 215, 200, 72, "cloud foundry"),
Box("kyma", "Kyma Runtime", 815, 350, 200, 72, None),
Box("abap", "ABAP Environment", 815, 485, 200, 72, "abap environment"),
]
edges = [
Edge("e1", "dev", "git"),
Edge("e2", "git", "cicd"),
Edge("e3", "cicd", "ctms"),
Edge("e4", "ctms", "cf"),
Edge("e5", "ctms", "kyma"),
Edge("e6", "ctms", "abap"),
Edge("e7", "alm", "ctms"),
]
pills = [
Pill("p1", "Commit", 155, 292, 82, 22),
Pill("p2", "Build & Test", 145, 418, 105, 22),
Pill("p3", "Release", 350, 420, 82, 22),
Pill("p4", "Deploy", 720, 245, 82, 22),
Pill("p5", "Deploy", 720, 380, 82, 22),
Pill("p6", "Deploy", 720, 515, 82, 22),
]
return DiagramPlan("devops", title, "L2 pipeline from source to SAP BTP runtimes", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def private_connectivity(description: str) -> DiagramPlan:
title = title_from(description, "private-connectivity")
zones = [
Box("z-btp", "SAP BTP", 170, 100, 700, 570, None, ZONE_FILL, BLUE),
Box("z-network", "Private Network / Hyperscaler", 905, 170, 225, 360, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("client", "Application Clients", 30, 325, 140, 62, None),
Box("app", "Extension Application\n(CAP / HTML5)", 245, 280, 225, 92, "cloud application programming"),
Box("dest", "SAP Destination\nservice", 540, 280, 155, 72, "destination"),
Box("identity", "SAP Cloud Identity\nServices", 335, 565, 250, 72, None),
Box("plink", "SAP Private Link\nservice", 710, 280, 150, 72, None),
Box("provider", "Provider Service\nor SAP workload", 945, 280, 160, 92, None),
]
edges = [
Edge("e1", "client", "app"),
Edge("e2", "app", "dest"),
Edge("e3", "dest", "plink"),
Edge("e4", "plink", "provider"),
Edge("e5", "identity", "app", stroke=AUTH_GREEN),
]
pills = [
Pill("p1", "HTTPS", 178, 345, 70, 22),
Pill("p2", "REST/OData", 452, 250, 95, 22),
Pill("p3", "Private Link", 855, 245, 105, 22),
Pill("p4", "Authenticate", 455, 505, 115, 22),
]
return DiagramPlan("private-connectivity", title, "L2 private connectivity pattern for SAP BTP", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def on_prem_connectivity(description: str) -> DiagramPlan:
title = title_from(description, "on-prem-connectivity")
zones = [
Box("z-dev", "Developer Workstation", 40, 220, 215, 240, None, NEUTRAL_FILL, MUTED),
Box("z-btp", "SAP BTP - Cloud Foundry", 300, 120, 520, 500, None, ZONE_FILL, BLUE),
Box("z-onprem", "Customer On-Premise Network", 875, 190, 250, 340, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("vscode", "Visual Studio\nCode", 75, 300, 150, 70, None),
Box("arc1", "ARC-1 Application\nCloud Foundry Runtime", 365, 235, 210, 90, "cloud foundry"),
Box("dest", "SAP Destination\nservice", 615, 235, 165, 72, "destination"),
Box("conn", "SAP Connectivity\nservice", 615, 385, 165, 72, "connectivity"),
Box("identity", "SAP Cloud Identity\nServices / XSUAA", 405, 535, 255, 72, "xsuaa"),
Box("cloudconn", "SAP Cloud\nConnector", 930, 235, 160, 72, "cloud connector"),
Box("s4", "SAP S/4HANA\nOn-Premise", 930, 400, 160, 72, "on-premise-sap"),
]
edges = [
Edge("e1", "vscode", "arc1"),
Edge("e2", "arc1", "dest"),
Edge("e3", "dest", "conn"),
Edge("e4", "conn", "cloudconn"),
Edge("e5", "cloudconn", "s4"),
Edge("e6", "identity", "arc1", stroke=AUTH_GREEN),
]
pills = [
Pill("p1", "HTTPS", 250, 325, 70, 22),
Pill("p2", "Destination", 545, 205, 100, 22),
Pill("p3", "Connectivity", 645, 335, 105, 22),
Pill("p4", "OData/REST", 955, 355, 105, 22),
Pill("p5", "Authenticate", 505, 495, 115, 22),
]
return DiagramPlan(
"on-prem-connectivity",
title,
"L2 developer-to-on-premise flow through SAP BTP Cloud Foundry",
PAGE_W,
PAGE_H,
boxes,
edges,
pills,
zones,
)
def btp_application(description: str) -> DiagramPlan:
title = title_from(description, "btp-application")
zones = [
Box("z-user", "Users and Channels", 55, 220, 240, 250, None, NEUTRAL_FILL, MUTED),
Box("z-btp", "SAP BTP", 350, 120, 720, 500, None, ZONE_FILL, BLUE),
]
boxes = [
Box("user", "Business User", 95, 290, 160, 62, None),
Box("fiori", "SAP Fiori /\nApp Router", 430, 205, 185, 72, None),
Box("cap", "CAP Service", 430, 350, 185, 72, "cloud application programming"),
Box("hana", "SAP HANA Cloud", 750, 350, 190, 72, "hana cloud"),
Box("identity", "SAP Cloud Identity\nServices / XSUAA", 500, 535, 255, 72, "xsuaa"),
]
edges = [
Edge("e1", "user", "fiori"),
Edge("e2", "fiori", "cap"),
Edge("e3", "cap", "hana"),
Edge("e4", "identity", "cap", stroke=AUTH_GREEN),
]
pills = [
Pill("p1", "HTTPS", 300, 235, 70, 22),
Pill("p2", "OData/REST", 475, 305, 105, 22),
Pill("p3", "SQL", 635, 375, 70, 22),
Pill("p4", "Authenticate", 560, 495, 115, 22),
]
return DiagramPlan("btp-application", title, "L2 application pattern with SAP BTP runtime and data service", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def data_integration(description: str) -> DiagramPlan:
title = title_from(description, "data-integration")
zones = [
Box("z-sources", "Enterprise Data Sources", 55, 170, 250, 410, None, NEUTRAL_FILL, MUTED),
Box("z-btp", "SAP BTP - Data Integration", 360, 120, 430, 510, None, ZONE_FILL, BLUE),
Box("z-platform", "Hyperscaler Data Platform", 850, 170, 260, 410, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("s4", "SAP S/4HANA", 95, 255, 170, 62, "on-premise-sap"),
Box("hana", "SAP HANA Cloud", 95, 395, 170, 62, "hana cloud"),
Box("datasphere", "SAP Datasphere", 470, 225, 210, 72, "sap datasphere"),
Box("integration", "Data Integration\nFlow", 470, 385, 210, 72, "integration suite"),
Box("databricks", "Databricks /\nLakehouse", 900, 300, 170, 82, None),
]
edges = [
Edge("e1", "s4", "datasphere"),
Edge("e2", "hana", "integration"),
Edge("e3", "datasphere", "databricks"),
Edge("e4", "integration", "databricks"),
]
pills = [
Pill("p1", "Data Federation", 305, 260, 120, 22),
Pill("p2", "Data Sync", 305, 410, 95, 22),
Pill("p3", "HTTPS", 785, 285, 70, 22),
Pill("p4", "Metadata", 785, 410, 85, 22),
]
return DiagramPlan("data-integration", title, "L2 data integration pattern between SAP and hyperscaler platforms", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def integration_flow(description: str) -> DiagramPlan:
title = title_from(description, "integration-flow")
zones = [
Box("z-senders", "Sender Systems", 55, 150, 255, 470, None, NEUTRAL_FILL, MUTED),
Box("z-btp", "SAP BTP - Integration Suite", 360, 110, 430, 550, None, ZONE_FILL, BLUE),
Box("z-receivers", "Receiver Systems", 850, 150, 255, 470, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("sender1", "SAP S/4HANA", 95, 250, 170, 62, "on-premise-sap"),
Box("sender2", "Third-party\nApplication", 95, 390, 170, 72, None),
Box("cpi", "Cloud Integration", 470, 230, 210, 72, "integration suite"),
Box("api", "API Management", 470, 370, 210, 72, None),
Box("event", "Event Mesh", 470, 510, 210, 72, "event mesh"),
Box("receiver1", "SAP Cloud\nSolution", 895, 260, 170, 72, None),
Box("receiver2", "Partner / B2B\nSystem", 895, 430, 170, 72, None),
]
edges = [
Edge("e1", "sender1", "cpi"),
Edge("e2", "sender2", "api"),
Edge("e3", "cpi", "receiver1"),
Edge("e4", "api", "receiver2"),
Edge("e5", "event", "receiver1"),
]
pills = [
Pill("p1", "OData/REST", 310, 260, 105, 22),
Pill("p2", "API", 315, 400, 70, 22),
Pill("p3", "Events", 720, 530, 82, 22),
]
return DiagramPlan("integration-flow", title, "L2 integration pattern with SAP Integration Suite", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def ai_agent(description: str) -> DiagramPlan:
title = title_from(description, "ai-agent")
zones = [
Box("z-user", "Users and Channels", 45, 155, 220, 430, None, NEUTRAL_FILL, MUTED),
Box("z-joule", "SAP Joule", 315, 120, 455, 210, None, INDIGO_FILL, INDIGO),
Box("z-btp", "SAP BTP", 315, 370, 455, 290, None, ZONE_FILL, BLUE),
Box("z-sap", "SAP Cloud and Enterprise Systems", 835, 155, 280, 430, None, NEUTRAL_FILL, MUTED),
]
boxes = [
Box("user", "Business User", 80, 265, 150, 62, None),
Box("joule", "SAP Joule", 455, 220, 180, 72, "joule"),
Box("agent", "AI Agent /\nOrchestrator", 385, 420, 180, 82, None),
Box("mcp", "MCP / Tool\nGateway", 575, 420, 160, 82, None),
Box("identity", "SAP Cloud Identity\nServices", 400, 555, 250, 72, None),
Box("s4", "SAP S/4HANA", 880, 240, 190, 62, None),
Box("sf", "SAP SuccessFactors", 880, 355, 190, 62, None),
Box("ext", "Third-party APIs", 880, 470, 190, 62, None),
]
edges = [
Edge("e1", "user", "joule"),
Edge("e2", "joule", "agent"),
Edge("e3", "agent", "mcp"),
Edge("e4", "mcp", "s4"),
Edge("e5", "mcp", "sf"),
Edge("e6", "mcp", "ext"),
Edge("e7", "identity", "agent", stroke=AUTH_GREEN),
]
pills = [
Pill("p1", "HTTPS", 270, 245, 80, 22),
Pill("p2", "MCP", 545, 390, 70, 22),
Pill("p3", "REST", 760, 492, 70, 22),
Pill("p4", "Authenticate", 545, 525, 115, 22),
]
return DiagramPlan("ai-agent", title, "L2 AI agent pattern with SAP BTP tools and identity", PAGE_W, PAGE_H, boxes, edges, pills, zones)
def generic_btp(description: str) -> DiagramPlan:
return btp_application(description)
PLANNERS = {
"security-operations": security_operations,
"devops": devops,
"on-prem-connectivity": on_prem_connectivity,
"private-connectivity": private_connectivity,
"btp-application": btp_application,
"data-integration": data_integration,
"integration-flow": integration_flow,
"ai-agent": ai_agent,
"generic-btp": generic_btp,
}
def icon_style(query: str | None) -> str | None:
if not query:
return None
if extract_icon is not None:
try:
index = extract_icon.load_index()
match = extract_icon.find(index, query)
except SystemExit:
match = None
except Exception:
match = None
if match:
return match[1].get("style")
if extract_asset is None:
return None
try:
asset_index = extract_asset.load_index()
match = extract_asset.find_asset(asset_index, query, None)
if not match:
return None
_, asset = match
if asset.get("kind") not in {"generic-icon", "sap-brand-name"}:
return None
library_entry = extract_asset.load_library_entry(asset)
except SystemExit:
return None
except Exception:
return None
data = library_entry.get("data")
if not data:
return None
return (
"shape=image;verticalLabelPosition=bottom;verticalAlign=top;aspect=fixed;"
f"imageAspect=0;image={data};"
)
def cell(parent: ET.Element, cell_id: str, value: str, style: str, x: int, y: int, w: int, h: int, *, parent_id: str = "1") -> ET.Element:
elem = ET.SubElement(
parent,
"mxCell",
{
"id": cell_id,
"value": value.replace("\n", "<br>"),
"style": style,
"vertex": "1",
"parent": parent_id,
},
)
ET.SubElement(elem, "mxGeometry", {"x": str(snap(x)), "y": str(snap(y)), "width": str(snap(w)), "height": str(snap(h)), "as": "geometry"})
return elem
def edge(parent: ET.Element, edge_id: str, value: str, source: Box, target: Box, stroke: str = MUTED) -> ET.Element:
source_cx = source.x + source.w / 2
source_cy = source.y + source.h / 2
target_cx = target.x + target.w / 2
target_cy = target.y + target.h / 2
dx = target_cx - source_cx
dy = target_cy - source_cy
if abs(dx) >= abs(dy):
if dx >= 0:
exit_x, exit_y, entry_x, entry_y = 1, 0.5, 0, 0.5
else:
exit_x, exit_y, entry_x, entry_y = 0, 0.5, 1, 0.5
else:
if dy >= 0:
exit_x, exit_y, entry_x, entry_y = 0.5, 1, 0.5, 0
else:
exit_x, exit_y, entry_x, entry_y = 0.5, 0, 0.5, 1
style = (
"edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;"
f"strokeColor={stroke};strokeWidth=1.5;labelBackgroundColor=default;"
"fontFamily=Helvetica;fontSize=11;fontColor=#1D2D3E;"
f"exitX={exit_x};exitY={exit_y};exitDx=0;exitDy=0;"
f"entryX={entry_x};entryY={entry_y};entryDx=0;entryDy=0;"
)
elem = ET.SubElement(
parent,
"mxCell",
{
"id": edge_id,
"value": value,
"style": style,
"edge": "1",
"parent": "1",
"source": source.id,
"target": target.id,
},
)
ET.SubElement(elem, "mxGeometry", {"relative": "1", "as": "geometry"})
return elem
def render_legend(root: ET.Element, plan: DiagramPlan) -> None:
"""Add the compact L2 legend required by SAP solution-diagram guidance."""
x = max(300, plan.width - 560)
y = plan.height - 70
title_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
f"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=10;fontStyle=1;fontColor={MUTED};"
)
text_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
f"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=9;fontColor={MUTED};"
)
cell(root, "legend-title", "Legend", title_style, x, y, 55, 16)
items = [
("legend-service", "Service", BLUE, ZONE_FILL, 65),
("legend-external", "External", MUTED, NEUTRAL_FILL, 75),
("legend-auth", "Auth flow", AUTH_GREEN, "#F5FAE5", 75),
("legend-step", "Flow step", MUTED, PILL_FILL, 75),
]
cursor = x + 60
for item_id, label, stroke, fill, width in items:
swatch_style = (
"rounded=1;whiteSpace=wrap;html=1;absoluteArcSize=1;arcSize=6;"
f"strokeColor={stroke};fillColor={fill};strokeWidth=1;"
)
cell(root, f"{item_id}-swatch", "", swatch_style, cursor, y + 3, 14, 10)
cell(root, f"{item_id}-label", label, text_style, cursor + 18, y, width, 16)
cursor += width + 28
def render(plan: DiagramPlan, out: Path) -> None:
mxfile = ET.Element("mxfile")
diagram = ET.SubElement(mxfile, "diagram", {"id": "sap-semantic", "name": plan.title[:80]})
model = ET.SubElement(
diagram,
"mxGraphModel",
{
"dx": "1200",
"dy": "900",
"grid": "1",
"gridSize": "10",
"guides": "1",
"tooltips": "1",
"connect": "1",
"arrows": "1",
"fold": "1",
"page": "1",
"pageScale": "1",
"pageWidth": str(plan.width),
"pageHeight": str(plan.height),
"math": "0",
"shadow": "0",
},
)
root = ET.SubElement(model, "root")
ET.SubElement(root, "mxCell", {"id": "0"})
ET.SubElement(root, "mxCell", {"id": "1", "parent": "0"})
title_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
f"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=22;fontStyle=1;fontColor={BLUE};"
)
subtitle_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=12;fontColor=#475E75;"
)
cell(root, "title", plan.title, title_style, 30, 20, plan.width - 60, 40)
cell(root, "subtitle", plan.subtitle, subtitle_style, 30, 60, plan.width - 60, 20)
zone_style = (
"rounded=1;whiteSpace=wrap;html=1;absoluteArcSize=1;arcSize=16;"
"strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontStyle=1;"
"align=left;verticalAlign=top;spacing=12;"
)
for zone in plan.zones:
style = f"{zone_style}strokeColor={zone.stroke};fillColor={zone.fill};fontColor={TEXT};"
cell(root, zone.id, zone.label, style, zone.x, zone.y, zone.w, zone.h)
def containing_zone(box: Box) -> Box | None:
for zone in plan.zones:
if (
box.x >= zone.x
and box.y >= zone.y
and box.x + box.w <= zone.x + zone.w
and box.y + box.h <= zone.y + zone.h
):
return zone
return None
card_style = (
"rounded=1;whiteSpace=wrap;html=1;absoluteArcSize=1;arcSize=12;"
"strokeWidth=1.5;fontFamily=Helvetica;fontSize=14;fontStyle=1;"
"align=center;verticalAlign=middle;spacing=8;"
)
icon_style_base = "ellipse;whiteSpace=wrap;html=1;aspect=fixed;strokeColor=#D5DADD;fillColor=#F5F6F7;"
for box in plan.boxes:
style = f"{card_style}strokeColor={box.stroke};fillColor={box.fill};fontColor={TEXT};"
if box.icon is not None:
style += "spacingLeft=36;"
zone = containing_zone(box)
parent_id = zone.id if zone else "1"
x = box.x - zone.x if zone else box.x
y = box.y - zone.y if zone else box.y
cell(root, box.id, box.label, style, x, y, box.w, box.h, parent_id=parent_id)
icon = icon_style(box.icon)
if icon:
icon_id = f"{box.id}-icon"
icon_cell = ET.SubElement(
root,
"mxCell",
{
"id": icon_id,
"value": "",
"style": icon,
"vertex": "1",
"parent": box.id,
},
)
ET.SubElement(icon_cell, "mxGeometry", {"x": "10", "y": "20", "width": "30", "height": "30", "as": "geometry"})
elif box.icon is not None:
cell(root, f"{box.id}-icon", "", icon_style_base, 10, 20, 30, 30, parent_id=box.id)
boxes_by_id = {box.id: box for box in plan.boxes}
for e in plan.edges:
source = boxes_by_id.get(e.source)
target = boxes_by_id.get(e.target)
if source is None or target is None:
continue
edge(root, e.id, e.label, source, target, e.stroke)
pill_style = (
"rounded=1;whiteSpace=wrap;html=1;absoluteArcSize=1;arcSize=50;"
"strokeColor=#475E75;fillColor=#FCFCFC;strokeWidth=1;fontFamily=Helvetica;"
"fontSize=10;fontStyle=1;fontColor=#475E75;align=center;verticalAlign=middle;"
)
for pill in plan.pills:
cell(root, pill.id, pill.label, pill_style, pill.x, pill.y, pill.w, pill.h)
render_legend(root, plan)
footer_style = (
"text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;"
"whiteSpace=wrap;rounded=0;fontFamily=Helvetica;fontSize=11;fontColor=#475E75;"
)
cell(root, "footer", "Diagram Level: 2", footer_style, 30, plan.height - 60, 180, 20)
tree = ET.ElementTree(mxfile)
ET.indent(tree, space=" ")
out.parent.mkdir(parents=True, exist_ok=True)
tree.write(out, encoding="unicode", xml_declaration=False)
def plan_for(description: str, archetype: str | None = None) -> DiagramPlan:
chosen = archetype or infer_archetype(description)
planner = PLANNERS.get(chosen)
if planner is None:
raise ValueError(f"unknown archetype {chosen!r}; choose one of {', '.join(sorted(PLANNERS))}")
return planner(description)
def main(argv: Iterable[str] | None = None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("description", nargs="*", help="diagram request; stdin if omitted")
ap.add_argument("-o", "--out", type=Path, required=True)
ap.add_argument("--archetype", choices=sorted(PLANNERS), help="override automatic archetype detection")
ap.add_argument("--json", action="store_true", help="print selected semantic plan summary")
args = ap.parse_args(list(argv) if argv is not None else None)
description = " ".join(args.description).strip() or sys.stdin.read().strip()
if not description:
print("description required", file=sys.stderr)
return 2
plan = plan_for(description, args.archetype)
render(plan, args.out)
if args.json:
print(
json.dumps(
{
"archetype": plan.archetype,
"title": plan.title,
"out": str(args.out),
"boxes": len(plan.boxes),
"edges": len(plan.edges),
"pills": len(plan.pills),
"zones": len(plan.zones),
},
indent=2,
)
)
else:
print(f"rendered {args.out} using {plan.archetype} archetype")
return 0
if __name__ == "__main__":
raise SystemExit(main())