import { readFileSync } from "node:fs"; import { JSDOM } from "jsdom"; import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js"; import { validateDiagramIR } from "../../model/diagram-ir.js"; import { decodeDiagramContent, normalizeDrawioDocument } from "../drawio-parser/parser.js"; export interface PreservationEnvelope { host?: string; attributes?: Record; holderTag?: string; holderAttributes?: Record; childXml?: string[]; geometryAttributes?: Record; geometryChildXml?: string[]; styleAbsent?: boolean; modelAttributes?: Record; modelChildXml?: string[]; unknownCells?: string[]; } export interface ImportLossReport { lossy: boolean; preserved: string[]; warnings: string[]; } export interface ImportDrawioOptions { sourceIsXml?: boolean; } const CELL_KNOWN = new Set(["id", "value", "label", "style", "vertex", "edge", "parent", "source", "target", "data-kind", "data-properties", "data-provenance", "data-extensions"]); const GEOMETRY_KNOWN = new Set(["x", "y", "width", "height", "relative", "as"]); const PAGE_KNOWN = new Set(["id", "name", "data-properties", "data-extensions"]); const MODEL_KNOWN = new Set(["pageWidth", "pageHeight", "grid", "gridSize", "page"]); const ROOT_KNOWN = new Set(["host", "data-version", "data-title", "data-theme", "data-provenance", "data-assets", "data-properties", "data-extensions"]); function parseDocument(xml: string, expected: "mxfile" | "mxGraphModel"): Document { if (/", { contentType: "text/xml" }).window.DOMParser; const parsed = new Parser().parseFromString(xml, "text/xml"); if (parsed.querySelector("parsererror") || parsed.documentElement.tagName !== expected) { throw new Error(`Invalid XML: expected complete <${expected}> document`); } return parsed; } function serializeElement(element: Element): string { const XMLSerializer = new JSDOM("", { contentType: "text/xml" }).window.XMLSerializer; return new XMLSerializer().serializeToString(element); } function attrs(element: Element, known: Set): Record | undefined { const result: Record = {}; for (const attribute of Array.from(element.attributes).sort((a, b) => a.name.localeCompare(b.name))) { if (!known.has(attribute.name)) result[attribute.name] = attribute.value; } return Object.keys(result).length ? result : undefined; } function selectedAttrs(element: Element, names: Set): Record | undefined { const result: Record = {}; for (const name of names) if (element.hasAttribute(name)) result[name] = element.getAttribute(name)!; return Object.keys(result).length ? result : undefined; } function jsonObject(element: Element, name: string): Record | undefined { const raw = element.getAttribute(name); if (!raw) return undefined; try { const value: unknown = JSON.parse(raw); if (value && typeof value === "object" && !Array.isArray(value)) return value as Record; } catch { // Invalid semantic JSON remains preserved as an unknown attribute below. } return undefined; } function numberAttr(element: Element | null, name: string, fallback = 0): number { const raw = element?.getAttribute(name); if (raw === null || raw === undefined || raw === "") return fallback; const value = Number(raw); if (!Number.isFinite(value)) throw new Error(`Invalid geometry ${name}: ${raw}`); return value; } function mergePreservation(extensions: Record | undefined, envelope: PreservationEnvelope): Record | undefined { const clean = Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined && (!Array.isArray(value) || value.length > 0))); if (!Object.keys(clean).length) return extensions; return { ...(extensions ?? {}), $drawio: clean }; } function cellRows(model: Element): Array<{ holder: Element; cell: Element }> { const root = Array.from(model.children).find((child) => child.tagName === "root"); if (!root) throw new Error("Draw.io page is missing "); const rows: Array<{ holder: Element; cell: Element }> = []; for (const child of Array.from(root.children)) { if (child.tagName === "mxCell") rows.push({ holder: child, cell: child }); else if (child.tagName === "object" || child.tagName === "UserObject") { const cell = Array.from(child.children).find((nested) => nested.tagName === "mxCell"); if (cell) rows.push({ holder: child, cell }); } } return rows; } function parsePage(modelXml: string, pageElement: Element | undefined, index: number, report: ImportLossReport): DiagramPage { const document = parseDocument(modelXml, "mxGraphModel"); const model = document.documentElement; const rows = cellRows(model); const nodeIds = new Set(rows.filter(({ cell }) => cell.getAttribute("vertex") === "1").map(({ holder, cell }) => holder.getAttribute("id") ?? cell.getAttribute("id") ?? "")); const root = Array.from(model.children).find((child) => child.tagName === "root")!; const knownElements = new Set(rows.map(({ holder }) => holder)); const unknownCells = Array.from(root.children).filter((child) => !knownElements.has(child) && !["0", "1"].includes(child.getAttribute("id") ?? "")).map((child) => serializeElement(child)); const nodes: DiagramNode[] = []; const edges: DiagramEdge[] = []; for (const { holder, cell } of rows) { const id = holder.getAttribute("id") ?? cell.getAttribute("id") ?? ""; if (!id || id === "0" || id === "1") continue; const semantic = holder === cell ? cell : holder; const geometry = Array.from(cell.children).find((child) => child.tagName === "mxGeometry") ?? null; const envelope: PreservationEnvelope = { attributes: attrs(cell, CELL_KNOWN), holderTag: holder === cell ? undefined : holder.tagName, holderAttributes: holder === cell ? undefined : attrs(holder, new Set(["id", "label", "value", "data-kind", "data-properties", "data-provenance", "data-extensions"])), childXml: Array.from(cell.children).filter((child) => child.tagName !== "mxGeometry").map((child) => serializeElement(child)), geometryAttributes: geometry ? selectedAttrs(geometry, new Set(["relative", "as"])) : undefined, geometryChildXml: geometry ? Array.from(geometry.children).filter((child) => !(cell.getAttribute("edge") === "1" && child.matches("Array[as='points']"))).map((child) => serializeElement(child)) : undefined, styleAbsent: cell.getAttribute("vertex") === "1" && !cell.hasAttribute("style") ? true : undefined, }; for (const name of ["data-properties", "data-provenance", "data-extensions"]) { const owner = semantic.hasAttribute(name) ? semantic : cell; if (owner.hasAttribute(name) && jsonObject(owner, name) === undefined) { envelope.attributes = { ...(envelope.attributes ?? {}), [name]: owner.getAttribute(name)! }; } } const geometryAttrs = geometry ? attrs(geometry, GEOMETRY_KNOWN) : undefined; if (geometryAttrs) envelope.attributes = { ...(envelope.attributes ?? {}), ...Object.fromEntries(Object.entries(geometryAttrs).map(([key, value]) => [`geometry:${key}`, value])) }; const rawLabel = semantic.getAttribute("label") ?? semantic.getAttribute("value") ?? cell.getAttribute("value") ?? id; if (rawLabel.trim() === "") envelope.attributes = { ...(envelope.attributes ?? {}), "semantic:label": rawLabel }; const extensions = mergePreservation(jsonObject(semantic, "data-extensions") ?? jsonObject(cell, "data-extensions"), envelope); const label = rawLabel.trim() === "" ? id : rawLabel; if (cell.getAttribute("vertex") === "1") { nodes.push({ id, label, kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? (nodeIds.has(id) && rows.some((row) => row.cell.getAttribute("parent") === id) ? "container" : "service"), ...(cell.getAttribute("parent") && cell.getAttribute("parent") !== "1" ? { parentId: cell.getAttribute("parent")! } : {}), geometry: { x: numberAttr(geometry, "x"), y: numberAttr(geometry, "y"), width: numberAttr(geometry, "width"), height: numberAttr(geometry, "height") }, ...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}), ...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}), ...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}), ...(extensions ? { extensions } : {}), }); } else if (cell.getAttribute("edge") === "1") { const source = cell.getAttribute("source") ?? ""; const target = cell.getAttribute("target") ?? ""; const missing = [source, target].filter((endpoint) => !nodeIds.has(endpoint)); if (missing.length) throw new Error(`Unsupported dangling endpoint on edge ${id}: ${missing.join(", ")}`); const points = geometry ? Array.from(geometry.querySelectorAll("Array[as='points'] > mxPoint")).map((point) => ({ x: numberAttr(point, "x"), y: numberAttr(point, "y") })) : []; edges.push({ id, source, target, label: rawLabel, kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? "relation", ...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}), ...(points.length ? { waypoints: points } : {}), ...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}), ...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}), ...(extensions ? { extensions } : {}), }); } else { unknownCells.push(serializeElement(holder)); } } const pageExtensions = mergePreservation(jsonObject(pageElement ?? model, "data-extensions"), { attributes: { ...(pageElement ? attrs(pageElement, PAGE_KNOWN) : {}), ...Object.fromEntries(Object.entries(attrs(model, MODEL_KNOWN) ?? {}).map(([key, value]) => [`model:${key}`, value])) }, modelAttributes: selectedAttrs(model, MODEL_KNOWN), childXml: pageElement ? Array.from(pageElement.children).filter((child) => child.tagName !== "mxGraphModel").map(serializeElement) : undefined, modelChildXml: Array.from(model.children).filter((child) => child !== root).map(serializeElement), unknownCells, }); if (unknownCells.length) report.preserved.push(`page ${index + 1}: ${unknownCells.length} unknown cell(s)`); return { id: pageElement?.getAttribute("id") || `page-${index + 1}`, title: pageElement?.getAttribute("name") || `Page ${index + 1}`, nodes, edges, layout: { type: "manual", gridSize: numberAttr(model, "gridSize", 10) }, ...(numberAttr(model, "pageWidth") > 0 ? { width: numberAttr(model, "pageWidth") } : {}), ...(numberAttr(model, "pageHeight") > 0 ? { height: numberAttr(model, "pageHeight") } : {}), ...(jsonObject(pageElement ?? model, "data-properties") ? { properties: jsonObject(pageElement ?? model, "data-properties") } : {}), ...(pageExtensions ? { extensions: pageExtensions } : {}), }; } export function importDrawioToIR(source: string, options: ImportDrawioOptions = {}): { ir: DiagramIRV2; lossReport: ImportLossReport } { const raw = normalizeDrawioDocument(options.sourceIsXml ? source : readFileSync(source, "utf8")); const report: ImportLossReport = { lossy: false, preserved: [], warnings: [] }; let pages: DiagramPage[]; let root: Element | undefined; if (/^ child.tagName === "diagram"); if (!diagrams.length) throw new Error("No elements found in mxfile"); pages = diagrams.map((diagram, index) => { const model = Array.from(diagram.children).find((child) => child.tagName === "mxGraphModel"); return parsePage(model ? serializeElement(model) : decodeDiagramContent(diagram.textContent ?? ""), diagram, index, report); }); } else { pages = [parsePage(raw, undefined, 0, report)]; } const nodePages = new Map(); for (const page of pages) for (const node of page.nodes) { const priorPage = nodePages.get(node.id); if (priorPage !== undefined) throw new Error(`Duplicate semantic node ID ${node.id} across pages ${priorPage} and ${page.id}`); nodePages.set(node.id, page.id); } const extensions = root ? mergePreservation(jsonObject(root, "data-extensions"), { host: root.getAttribute("host") ?? undefined, attributes: attrs(root, ROOT_KNOWN), childXml: Array.from(root.children).filter((child) => child.tagName !== "diagram").map(serializeElement), }) : undefined; const ir: DiagramIRV2 = { version: 2, ...(root?.getAttribute("data-title") ? { title: root.getAttribute("data-title")! } : {}), pages, ...(root ? (jsonObject(root, "data-provenance") ? { provenance: jsonObject(root, "data-provenance") } : {}) : {}), ...(root ? (jsonObject(root, "data-assets") ? { assets: jsonObject(root, "data-assets") } : {}) : {}), ...(root?.getAttribute("data-theme") ? { theme: root.getAttribute("data-theme")! } : {}), ...(root ? (jsonObject(root, "data-properties") ? { properties: jsonObject(root, "data-properties") } : {}) : {}), ...(extensions ? { extensions } : {}), }; validateDiagramIR(ir); return { ir, lossReport: report }; }