738 lines
28 KiB
Python
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())
|