import pako from "pako" /** What changed on the first page between two versions of a diagram */ export interface ChangeSummary { shapesAdded: number shapesRemoved: number shapesChanged: number edgesAdded: number edgesRemoved: number edgesChanged: number } export interface DiagramDiff { summary: ChangeSummary /** Ids of cells that were added or changed (for highlighting) */ touchedIds: string[] } export const EMPTY_SUMMARY: ChangeSummary = { shapesAdded: 0, shapesRemoved: 0, shapesChanged: 0, edgesAdded: 0, edgesRemoved: 0, edgesChanged: 0, } interface CellInfo { isEdge: boolean signature: string } /** Inflate a compressed payload (base64, raw deflate, URI-encoded) */ function inflateDiagram(text: string): string | null { try { const binary = atob(text.trim()) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) const inflated = pako.inflateRaw(bytes, { to: "string" }) return decodeURIComponent(inflated) } catch { return null } } /** A page's mxGraphModel element, inflated when the page is compressed */ function modelOfPage(diagram: Element): Element | null { const model = diagram.querySelector("mxGraphModel") if (model) return model const inflated = inflateDiagram(diagram.textContent || "") if (!inflated) return null const inner = new DOMParser().parseFromString(inflated, "text/xml") return inner.querySelector("mxGraphModel") } function parse(xml: string): Document | null { if (!xml?.trim()) return null const doc = new DOMParser().parseFromString(xml, "text/xml") return doc.querySelector("parsererror") ? null : doc } /** The first page's mxGraphModel element, whatever wrapper the XML has */ function firstPageModel(xml: string): Element | null { const doc = parse(xml) if (!doc) return null const diagram = doc.querySelector("diagram") if (diagram) return modelOfPage(diagram) return doc.querySelector("mxGraphModel") ?? doc.documentElement } /** * The document with every compressed page inflated in place, as draw.io * saves it after an edit. Returned unchanged when there is nothing to do. */ export function inflatePages(xml: string): string { const doc = parse(xml) if (!doc) return xml let changed = false for (const diagram of Array.from(doc.getElementsByTagName("diagram"))) { if (diagram.querySelector("mxGraphModel")) continue const model = modelOfPage(diagram) if (!model) continue diagram.textContent = "" diagram.appendChild(doc.importNode(model, true)) changed = true } return changed ? new XMLSerializer().serializeToString(doc) : xml } /** * What a cell is, independent of how the XML was written: the same cell * saved by draw.io and written by the model can order attributes and style * entries differently */ function signatureOf(node: Element): string { const attrs = Array.from(node.attributes) .map((a) => { if (a.name !== "style") return `${a.name}=${a.value}` const entries = a.value .split(";") .map((e) => e.trim()) .filter(Boolean) .sort() return `style=${entries.join(";")}` }) .sort() const children = Array.from(node.children).map(signatureOf) return `<${node.tagName} ${attrs.join(" ")}>${children.join("")}` } function collectCells(xml: string): Map { return cellsOf(firstPageModel(xml)) } function cellsOf(model: Element | null): Map { const cells = new Map() if (!model) return cells for (const cell of Array.from(model.getElementsByTagName("mxCell"))) { // Cells with links or custom data are wrapped; the wrapper holds the id const wrapper = cell.parentElement const isWrapped = wrapper?.tagName === "object" || wrapper?.tagName === "UserObject" const id = cell.getAttribute("id") || wrapper?.getAttribute("id") if (!id || id === "0" || id === "1") continue const node = isWrapped && wrapper ? wrapper : cell cells.set(id, { isEdge: cell.getAttribute("edge") === "1", signature: signatureOf(node), }) } return cells } export function diffDiagrams(beforeXml: string, afterXml: string): DiagramDiff { const before = collectCells(beforeXml) const after = collectCells(afterXml) const summary: ChangeSummary = { ...EMPTY_SUMMARY } const touchedIds: string[] = [] for (const [id, cell] of after) { const old = before.get(id) if (!old) { touchedIds.push(id) if (cell.isEdge) summary.edgesAdded++ else summary.shapesAdded++ } else if (old.signature !== cell.signature) { touchedIds.push(id) if (cell.isEdge) summary.edgesChanged++ else summary.shapesChanged++ } } for (const [id, cell] of before) { if (after.has(id)) continue if (cell.isEdge) summary.edgesRemoved++ else summary.shapesRemoved++ } return { summary, touchedIds } } export function isEmptySummary(summary: ChangeSummary): boolean { return Object.values(summary).every((n) => n === 0) } /** Count of shapes and connectors on the first page */ export function countCells(xml: string): { shapes: number; edges: number } { let shapes = 0 let edges = 0 for (const cell of collectCells(xml).values()) { if (cell.isEdge) edges++ else shapes++ } return { shapes, edges } } // Where the view was scrolled to, saved with the page; not a change const VIEW_ATTRIBUTES = new Set(["dx", "dy"]) /** * Whether two documents hold the same pages (names and cells) and page * settings. A setting written on one side only is draw.io's default being * filled in, so only settings both sides have are compared. */ export function isSameDocument(a: string, b: string): boolean { const pagesOf = (xml: string) => { const doc = parse(xml) if (!doc) return null const diagrams = Array.from(doc.getElementsByTagName("diagram")) if (diagrams.length === 0) { return [{ name: null, model: doc.querySelector("mxGraphModel") }] } return diagrams.map((d) => ({ name: d.getAttribute("name"), model: modelOfPage(d), })) } const pagesA = pagesOf(a) const pagesB = pagesOf(b) if (!pagesA || !pagesB) return a === b if (pagesA.length !== pagesB.length) return false return pagesA.every((pageA, i) => { const pageB = pagesB[i] if (pageA.name !== null && pageB.name !== null) { if (pageA.name !== pageB.name) return false } if (pageA.model && pageB.model) { for (const attr of Array.from(pageA.model.attributes)) { if (VIEW_ATTRIBUTES.has(attr.name)) continue const other = pageB.model.getAttribute(attr.name) if (other !== null && other !== attr.value) return false } } const cellsA = cellsOf(pageA.model) const cellsB = cellsOf(pageB.model) if (cellsA.size !== cellsB.size) return false for (const [id, cell] of cellsA) { if (cellsB.get(id)?.signature !== cell.signature) return false } return true }) }