import type { DiagramEdge, DiagramIR, DiagramNode, DiagramPage } from "../model/diagram-ir.js"; import { normalizeDiagramIR } from "../model/diagram-ir.js"; import { routePageEdges } from "../services/authoring-router/orthogonal-router.js"; import { layoutPage } from "../services/layout/layout-engine.js"; const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;"; const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;"; const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;"; const FIXED_MODEL_ATTRIBUTES = new Set(["grid", "gridSize", "page", "pageWidth", "pageHeight"]); type Preservation = { host?: string; attributes?: Record; holderTag?: string; holderAttributes?: Record; childXml?: string[]; geometryAttributes?: Record; geometryChildXml?: string[]; styleAbsent?: boolean; modelAttributes?: Record; modelChildXml?: string[]; unknownCells?: string[]; }; function escapeXml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } function stableJson(value: unknown): string { if (value === undefined) return "null"; if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { const record = value as Record; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; } return JSON.stringify(value); } function semanticExtensions(value: Record | undefined): Record | undefined { if (!value) return undefined; const result = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$drawio")); return Object.keys(result).length ? result : undefined; } function preservation(value: Record | undefined): Preservation { const raw = value?.$drawio; return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Preservation : {}; } function dataAttributes(values: Record): string { return Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`).join(""); } function rawAttributes(values: Record | undefined, prefix?: string, reserved = new Set()): string { return Object.entries(values ?? {}) .filter(([key]) => !prefix || key.startsWith(prefix)) .map(([key, value]) => [prefix ? key.slice(prefix.length) : key, value] as const) .filter(([key]) => !reserved.has(key) && /^[A-Za-z_][\w:.-]*$/.test(key)) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`) .join(""); } function unprefixedAttributes(values: Record | undefined): string { return Object.entries(values ?? {}).filter(([key]) => !key.includes(":")).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`).join(""); } function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } { if (node.geometry) return node.geometry; const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal"; return { x: horizontal ? 40 + index * 200 : 40, y: horizontal ? 80 : 80 + index * 120, width: node.width ?? 120, height: node.height ?? 60 }; } function wrapHolder(cell: string, id: string, label: string, ext: Record | undefined, p: Preservation): string { if (!p.holderTag) return cell; const metadata = dataAttributes({ kind: undefined, properties: undefined, provenance: undefined, extensions: semanticExtensions(ext) }); return `<${p.holderTag} id="${escapeXml(id)}" label="${escapeXml(label)}"${rawAttributes(p.holderAttributes)}${metadata}>${cell}`; } function serializeNode(node: DiagramNode, index: number, page: DiagramPage): string { const geometry = geometryForNode(node, index, page); const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE); const parent = node.parentId ?? "1"; const p = preservation(node.extensions); const renderedLabel = p.attributes?.["semantic:label"] ?? node.label; const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: semanticExtensions(node.extensions) }); const geometryChildren = (p.geometryChildXml ?? []).join(""); const relative = p.geometryAttributes?.relative === undefined ? "" : ` relative="${escapeXml(p.geometryAttributes.relative)}"`; const geometryXml = `${geometryChildren}` : "/>"}`; const styleAttribute = p.styleAbsent === true ? "" : ` style="${escapeXml(style)}"`; const cell = `${geometryXml}${(p.childXml ?? []).join("")}`; return ` ${wrapHolder(cell, node.id, renderedLabel, node.extensions, p)}`; } function serializeEdge(edge: DiagramEdge): string { const p = preservation(edge.extensions); const points = edge.waypoints?.length ? `${edge.waypoints.map((point) => ``).join("")}` : ""; const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: semanticExtensions(edge.extensions) }); const geometryChildren = `${points}${(p.geometryChildXml ?? []).join("")}`; const relative = p.geometryAttributes?.relative === undefined ? "1" : p.geometryAttributes.relative; const geometry = `${geometryChildren}`; const cell = `${geometry}${(p.childXml ?? []).join("")}`; return ` ${wrapHolder(cell, edge.id, edge.label ?? "", edge.extensions, p)}`; } function contentExtent(page: DiagramPage): { width: number; height: number } { const byId = new Map(page.nodes.map((node) => [node.id, node])); const absolute = new Map(); const resolve = (id: string): { x: number; y: number; width: number; height: number } | undefined => { const cached = absolute.get(id); if (cached) return cached; const node = byId.get(id); if (!node?.geometry) return undefined; const parent = node.parentId ? resolve(node.parentId) : undefined; const geometry = { ...node.geometry, x: node.geometry.x + (parent?.x ?? 0), y: node.geometry.y + (parent?.y ?? 0) }; absolute.set(id, geometry); return geometry; }; const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined); const points = page.edges.flatMap((edge) => edge.waypoints ?? []); return { width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)), height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)) }; } function serializePage(input: DiagramPage): string { const page = input.layout?.type === "manual" ? input : routePageEdges(layoutPage(input)); const nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page)); const edgeCells = page.edges.map(serializeEdge); const extent = contentExtent(page); const grid = page.layout?.gridSize ?? 10; const fit = (value: number) => Math.ceil((value + 40) / grid) * grid; const pageWidth = Math.max(page.width ?? 1169, fit(extent.width)); const pageHeight = Math.max(page.height ?? 827, fit(extent.height)); const p = preservation(page.extensions); const metadata = dataAttributes({ properties: page.properties, extensions: semanticExtensions(page.extensions) }); const diagramChildren = (p.childXml ?? []).map((xml) => ` ${xml}`); const modelChildren = (p.modelChildXml ?? []).map((xml) => ` ${xml}`); const modelAttributes = p.modelAttributes ?? {}; return ` \n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} \n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} \n \n \n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n \n \n `; } export function diagramIRToDrawio(ir: DiagramIR): string { const normalized = normalizeDiagramIR(ir); const p = preservation(normalized.extensions); const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: semanticExtensions(normalized.extensions) }); const rootChildren = (p.childXml ?? []).map((xml) => ` ${xml}`); return `\n\n${rootChildren.length ? `${rootChildren.join("\n")}\n` : ""}${normalized.pages.map(serializePage).join("\n")}\n\n`; }