[verified] feat: add semantic diagram lifecycle
This commit is contained in:
@@ -7,13 +7,22 @@ const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;s
|
||||
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;";
|
||||
|
||||
type Preservation = {
|
||||
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[];
|
||||
};
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
@@ -26,38 +35,65 @@ function stableJson(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function semanticExtensions(value: Record<string, unknown> | undefined): Record<string, unknown> | 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<string, unknown> | undefined): Preservation {
|
||||
const raw = value?.$drawio;
|
||||
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Preservation : {};
|
||||
}
|
||||
|
||||
function dataAttributes(values: Record<string, unknown>): string {
|
||||
return Object.entries(values)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`)
|
||||
.join("");
|
||||
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<string, string> | undefined, prefix?: string): string {
|
||||
return Object.entries(values ?? {}).filter(([key]) => !prefix || key.startsWith(prefix)).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(prefix ? key.slice(prefix.length) : key)}="${escapeXml(value)}"`).join("");
|
||||
}
|
||||
|
||||
function unprefixedAttributes(values: Record<string, string> | 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,
|
||||
};
|
||||
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<string, unknown> | 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}</${p.holderTag}>`;
|
||||
}
|
||||
|
||||
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 metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: node.extensions });
|
||||
return ` <mxCell id="${escapeXml(node.id)}" value="${escapeXml(node.label)}" style="${escapeXml(style)}" vertex="1" parent="${escapeXml(parent)}"${metadata}><mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}" as="geometry"/></mxCell>`;
|
||||
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 = `<mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}"${relative} as="${escapeXml(p.geometryAttributes?.as ?? "geometry")}"${rawAttributes(p.attributes, "geometry:")}${geometryChildren ? `>${geometryChildren}</mxGeometry>` : "/>"}`;
|
||||
const styleAttribute = p.styleAbsent === true ? "" : ` style="${escapeXml(style)}"`;
|
||||
const cell = `<mxCell id="${escapeXml(node.id)}" value="${escapeXml(renderedLabel)}"${styleAttribute} vertex="1" parent="${escapeXml(parent)}"${metadata}${unprefixedAttributes(p.attributes)}>${geometryXml}${(p.childXml ?? []).join("")}</mxCell>`;
|
||||
return ` ${wrapHolder(cell, node.id, renderedLabel, node.extensions, p)}`;
|
||||
}
|
||||
|
||||
function serializeEdge(edge: DiagramEdge): string {
|
||||
const points = edge.waypoints?.length
|
||||
? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>`
|
||||
: "";
|
||||
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions });
|
||||
return ` <mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}><mxGeometry relative="1" as="geometry">${points}</mxGeometry></mxCell>`;
|
||||
const p = preservation(edge.extensions);
|
||||
const points = edge.waypoints?.length ? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>` : "";
|
||||
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 = `<mxGeometry relative="${escapeXml(relative)}" as="${escapeXml(p.geometryAttributes?.as ?? "geometry")}"${rawAttributes(p.attributes, "geometry:")}>${geometryChildren}</mxGeometry>`;
|
||||
const cell = `<mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}${unprefixedAttributes(p.attributes)}>${geometry}${(p.childXml ?? []).join("")}</mxCell>`;
|
||||
return ` ${wrapHolder(cell, edge.id, edge.label ?? "", edge.extensions, p)}`;
|
||||
}
|
||||
|
||||
function contentExtent(page: DiagramPage): { width: number; height: number } {
|
||||
@@ -75,14 +111,11 @@ function contentExtent(page: DiagramPage): { width: number; height: number } {
|
||||
};
|
||||
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)),
|
||||
};
|
||||
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 = routePageEdges(layoutPage(input));
|
||||
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);
|
||||
@@ -90,12 +123,18 @@ function serializePage(input: DiagramPage): string {
|
||||
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 metadata = dataAttributes({ properties: page.properties, extensions: page.extensions });
|
||||
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}>\n <mxGraphModel grid="1" gridSize="${page.layout?.gridSize ?? 10}" page="1" pageWidth="${pageWidth}" pageHeight="${pageHeight}">\n <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
|
||||
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 ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}${unprefixedAttributes(p.attributes)}>\n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} <mxGraphModel grid="${escapeXml(modelAttributes.grid ?? "1")}" gridSize="${escapeXml(modelAttributes.gridSize ?? String(grid))}" page="${escapeXml(modelAttributes.page ?? "1")}" pageWidth="${escapeXml(modelAttributes.pageWidth ?? String(pageWidth))}" pageHeight="${escapeXml(modelAttributes.pageHeight ?? String(pageHeight))}"${rawAttributes(p.attributes, "model:")}>\n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
|
||||
}
|
||||
|
||||
export function diagramIRToDrawio(ir: DiagramIR): string {
|
||||
const normalized = normalizeDiagramIR(ir);
|
||||
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: normalized.extensions });
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="drawio-tools"${metadata}>\n${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
|
||||
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 `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="${escapeXml(p.host ?? "drawio-tools")}"${metadata}${unprefixedAttributes(p.attributes)}>\n${rootChildren.length ? `${rootChildren.join("\n")}\n` : ""}${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user