238 lines
14 KiB
TypeScript
238 lines
14 KiB
TypeScript
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<string, string>;
|
|
holderTag?: string;
|
|
holderAttributes?: Record<string, string>;
|
|
childXml?: string[];
|
|
geometryAttributes?: Record<string, string>;
|
|
geometryChildXml?: string[];
|
|
styleAbsent?: boolean;
|
|
modelAttributes?: Record<string, string>;
|
|
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 (/<!DOCTYPE/i.test(xml)) throw new Error("Invalid XML: DOCTYPE is not allowed");
|
|
const Parser = new JSDOM("<root/>", { 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("<root/>", { contentType: "text/xml" }).window.XMLSerializer;
|
|
return new XMLSerializer().serializeToString(element);
|
|
}
|
|
|
|
function attrs(element: Element, known: Set<string>): Record<string, string> | undefined {
|
|
const result: Record<string, string> = {};
|
|
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<string>): Record<string, string> | undefined {
|
|
const result: Record<string, string> = {};
|
|
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<string, unknown> | 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<string, unknown>;
|
|
} 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<string, unknown> | undefined, envelope: PreservationEnvelope): Record<string, unknown> | 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 <root>");
|
|
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 (/^<mxfile/i.test(raw)) {
|
|
const document = parseDocument(raw, "mxfile");
|
|
root = document.documentElement;
|
|
const diagrams = Array.from(root.children).filter((child) => child.tagName === "diagram");
|
|
if (!diagrams.length) throw new Error("No <diagram> 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<string, string>();
|
|
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 };
|
|
}
|