[verified] feat: add semantic diagram lifecycle

This commit is contained in:
2026-09-03 19:11:50 +00:00
parent 5146b14d43
commit db4129168b
37 changed files with 1626 additions and 58 deletions
@@ -0,0 +1,237 @@
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 };
}