From a2f892ca82c69bc042153f7e8e2a8c53044be903 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 9 Aug 2026 11:56:08 +0900 Subject: [PATCH] feat(diagram-engine): layout + XML renderer, verified end to end in draw.io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e. --- lib/diagram-engine/layout.ts | 279 ++++++ lib/diagram-engine/markers.ts | 46 +- lib/diagram-engine/parse.ts | 936 +++++++++++++++----- lib/diagram-engine/render.ts | 254 ++++++ tests/e2e/diagram-engine-roundtrip.spec.ts | 208 +++++ tests/unit/diagram-engine-layout.test.ts | 466 ++++++++++ tests/unit/diagram-engine-parse.test.ts | 17 +- tests/unit/diagram-engine-roundtrip.test.ts | 541 +++++++++++ 8 files changed, 2525 insertions(+), 222 deletions(-) create mode 100644 lib/diagram-engine/layout.ts create mode 100644 lib/diagram-engine/render.ts create mode 100644 tests/e2e/diagram-engine-roundtrip.spec.ts create mode 100644 tests/unit/diagram-engine-layout.test.ts create mode 100644 tests/unit/diagram-engine-roundtrip.test.ts diff --git a/lib/diagram-engine/layout.ts b/lib/diagram-engine/layout.ts new file mode 100644 index 0000000..45ab767 --- /dev/null +++ b/lib/diagram-engine/layout.ts @@ -0,0 +1,279 @@ +/** + * Layout: tree → coordinates. + * + * Two passes, the same shape as a flexbox implementation: + * + * measure — bottom-up. A leaf reports its intrinsic size; a container sums its + * children along the flow axis, takes the maximum across it, and adds + * padding and its title strip. A container therefore always ends up big + * enough to hold what is inside it, which is why "child spills out of its + * frame" cannot happen by construction. + * + * place — top-down. Each container distributes its now-known interior among its + * children. + * + * The model never supplies a coordinate. It declares nesting, direction and gap; every + * x/y/width/height comes from here. + * + * Ported from drawio-ai-kit (MIT) — see NOTICE. + */ + +import type { ContainerNode, DiagramNode, Rect } from "./types" +import { isContainer } from "./types" + +/** Default glyph size for a catalog icon. */ +export const ICON_SIZE = 48 +/** Interior padding of a container. */ +const PAD = 24 +/** Height of a container's title strip. Zero when it has no label — an empty strip + * reads as a dead band at the top of the frame. */ +const HEADER = 36 +/** Approximate width of one label character at the engine's font size. */ +const CHAR_W = 6.6 + +/** A node with its computed box. Layout works on this, leaving the tree untouched. */ +export interface Placed { + node: DiagramNode + rect: Rect + children: Placed[] +} + +/** Intrinsic size of a text box: widest wrapped line by line count. */ +export function autoBoxSize(label: string): { w: number; h: number } { + const lines = String(label ?? "").split("\n") + const longest = Math.max(1, ...lines.map((l) => l.length)) + return { + w: Math.min(260, Math.max(120, Math.round(longest * CHAR_W + 28))), + h: Math.max(44, lines.length * 18 + 26), + } +} + +/** + * Intrinsic size of an icon cell: the glyph, plus room for the label underneath, and + * wide enough that a long label does not overflow the cell it is centred in. + */ +function iconSize(label: string, glyph: number): { w: number; h: number } { + return { + w: Math.max(96, glyph + 20, Math.min(200, label.length * 7 + 24)), + h: glyph + 34, + } +} + +/** A container is never narrower than its own title. */ +function titleFloor(label: string, pad: number): number { + return label ? Math.ceil(label.length * CHAR_W) + pad * 2 : 0 +} + +function headerFor(n: ContainerNode): number { + return n.label ? HEADER : 0 +} + +/** + * measure: give every node a size, bottom-up. + * + * Siblings are equalised across the cross axis — frames in a row share a bottom edge, + * frames in a column share left and right edges. Only containers stretch; a leaf keeps + * its natural size, because stretching an icon would distort the glyph. + */ +function measure(n: DiagramNode, defaultGlyph: number): Placed { + if (n.kind === "icon") { + const glyph = n.size ?? defaultGlyph + const s = iconSize(n.label, glyph) + return { node: n, rect: { x: 0, y: 0, ...s }, children: [] } + } + if (n.kind === "box") { + const auto = autoBoxSize(n.label) + return { + node: n, + rect: { x: 0, y: 0, w: n.w ?? auto.w, h: n.h ?? auto.h }, + children: [], + } + } + if (n.kind === "title") { + return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] } + } + + const kids = n.children.map((c) => measure(c, defaultGlyph)) + const head = headerFor(n) + const gap = n.gap + + if (n.kind === "grid") { + const cols = Math.max(1, n.cols) + const rows = Math.ceil(kids.length / cols) || 1 + const cellW = Math.max(0, ...kids.map((k) => k.rect.w)) + const cellH = Math.max(0, ...kids.map((k) => k.rect.h)) + const w = PAD * 2 + cols * cellW + gap * (cols - 1) + const h = head + PAD * 2 + rows * cellH + gap * (rows - 1) + return { + node: n, + rect: { + x: 0, + y: 0, + w: Math.max(w, titleFloor(n.label, PAD)), + h, + }, + children: kids, + } + } + + // group: row or col + if (n.dir === "row") { + const tallest = Math.max(0, ...kids.map((k) => k.rect.h)) + for (const k of kids) + if (isContainer(k.node)) k.rect.h = Math.max(k.rect.h, tallest) + const w = + PAD * 2 + + kids.reduce((s, k) => s + k.rect.w, 0) + + gap * Math.max(0, kids.length - 1) + const h = head + PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.h)) + return { + node: n, + rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, PAD)), h }, + children: kids, + } + } + + const widest = Math.max(0, ...kids.map((k) => k.rect.w)) + // Only groups stretch: a grid computes its own interior, so forcing it wider would + // leave a gap inside it rather than filling the space. + for (const k of kids) + if (k.node.kind === "group") k.rect.w = Math.max(k.rect.w, widest) + const w = PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.w)) + const h = + head + + PAD * 2 + + kids.reduce((s, k) => s + k.rect.h, 0) + + gap * Math.max(0, kids.length - 1) + return { + node: n, + rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, PAD)), h }, + children: kids, + } +} + +/** + * place: assign absolute positions, top-down. + * + * When a container ended up larger than its content — because a sibling forced it + * wider, or its own title did — the slack is shared between the children rather than + * left as dead margin on one side. The extra spacing is capped at one base gap so a + * stretched frame reads as deliberately spaced instead of sparse, and the resulting + * cluster is centred. + */ +function place(p: Placed, x: number, y: number): void { + p.rect.x = Math.round(x) + p.rect.y = Math.round(y) + const n = p.node + if (!isContainer(n)) return + + const head = headerFor(n) + const innerX = p.rect.x + PAD + const innerTop = p.rect.y + head + PAD + const innerW = p.rect.w - PAD * 2 + const innerH = p.rect.h - head - PAD * 2 + const kids = p.children + + if (n.kind === "grid") { + const cols = Math.max(1, n.cols) + const cellW = Math.max(0, ...kids.map((k) => k.rect.w)) + const cellH = Math.max(0, ...kids.map((k) => k.rect.h)) + kids.forEach((k, i) => { + const r = Math.floor(i / cols) + const c = i % cols + const cx = innerX + c * (cellW + n.gap) + const cy = innerTop + r * (cellH + n.gap) + // centre each child in its cell so a short label does not sit off-axis + place(k, cx + (cellW - k.rect.w) / 2, cy + (cellH - k.rect.h) / 2) + }) + return + } + + const alongRow = n.dir === "row" + const sizes = kids.map((k) => (alongRow ? k.rect.w : k.rect.h)) + const content = sizes.reduce((s, v) => s + v, 0) + const extent = alongRow ? innerW : innerH + const k = kids.length + const slack = Math.max(0, extent - content - n.gap * (k - 1)) + const gap = k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap + const span = content + gap * Math.max(0, k - 1) + let cur = (alongRow ? innerX : innerTop) + Math.max(0, (extent - span) / 2) + + for (const kid of kids) { + if (alongRow) { + place(kid, cur, innerTop + (innerH - kid.rect.h) / 2) + cur += kid.rect.w + gap + } else { + place(kid, innerX + (innerW - kid.rect.w) / 2, cur) + cur += kid.rect.h + gap + } + } +} + +export interface LayoutResult { + /** Placed roots, in the order given. */ + roots: Placed[] + /** Page size that fits everything, with a margin. */ + page: { w: number; h: number } +} + +/** Where the tree starts on the page. Leaves room for a title above it. */ +const ORIGIN = { x: 40, y: 90 } +const MARGIN = { right: 40, bottom: 50 } + +/** + * Lay out a forest of roots side by side and report the page size that fits them. + * + * A pinned node keeps the position it already had: the user moved it deliberately, and + * the whole point of the pin is that a re-layout does not undo that. + */ +export function layoutForest( + roots: DiagramNode[], + opts: { iconSize?: number; gap?: number } = {}, +): LayoutResult { + const glyph = opts.iconSize ?? ICON_SIZE + const gap = opts.gap ?? 70 + const placed = roots.map((r) => measure(r, glyph)) + + let cur = ORIGIN.x + for (const p of placed) { + const n = p.node + const held = + n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null + if (held) { + place(p, held.x, held.y) + } else { + place(p, cur, ORIGIN.y) + cur += p.rect.w + gap + } + } + + let maxX = 0 + let maxY = 0 + const visit = (p: Placed) => { + maxX = Math.max(maxX, p.rect.x + p.rect.w) + maxY = Math.max(maxY, p.rect.y + p.rect.h) + p.children.forEach(visit) + } + placed.forEach(visit) + + return { + roots: placed, + page: { + w: Math.round(maxX + MARGIN.right), + h: Math.round(maxY + MARGIN.bottom), + }, + } +} + +/** Flatten a placed forest into (node, rect, parentId) triples in document order. */ +export function flatten( + roots: Placed[], +): { node: DiagramNode; rect: Rect; parent: string }[] { + const out: { node: DiagramNode; rect: Rect; parent: string }[] = [] + const walk = (p: Placed, parent: string) => { + out.push({ node: p.node, rect: p.rect, parent }) + for (const c of p.children) walk(c, p.node.id) + } + for (const r of roots) walk(r, "1") + return out +} diff --git a/lib/diagram-engine/markers.ts b/lib/diagram-engine/markers.ts index 1c0b3c1..2bb2cb3 100644 --- a/lib/diagram-engine/markers.ts +++ b/lib/diagram-engine/markers.ts @@ -36,6 +36,11 @@ export const MARKER = { cols: "dai_cols", /** Set by the user to freeze a node's position across re-layouts. */ pin: "dai_pin", + /** + * A catalog icon's name. Needed because an Azure or GCP icon's style is an embedded + * base64 image with no name anywhere in it, so the style alone cannot identify it. + */ + name: "dai_name", } as const export type NodeKind = "group" | "grid" | "icon" | "box" | "title" @@ -54,6 +59,22 @@ export type Direction = "row" | "col" | "grid" const CONTAINER_TOKENS = "container=1;pointerEvents=0;collapsible=0;recursiveResize=0;" +/** + * A container that groups children for layout but should not be visible. + * + * The reference project solves this with a "phantom": a wrapper that participates in + * layout and then emits NO cell, reparenting its children onto the nearest visible + * ancestor. That makes the round-trip lossy by construction — the wrapper's direction + * and grouping are simply absent from the XML, so re-deriving the tree cannot recover + * them. Measured on the reference project's own build_vpc.mjs: a phantom erased a + * container's "col" direction, leaving children in a 2-D arrangement that can only be + * read back as a grid. + * + * So we emit a real cell and make it invisible instead. One extra cell per wrapper, + * in exchange for structure that survives being read back. + */ +const INVISIBLE_TOKENS = "fillColor=none;strokeColor=none;" + /** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */ export function readMarker(style: string, key: string): string | null { // Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution. @@ -127,10 +148,13 @@ export function stampContainer( dir: Direction gap: number cols?: number + /** Layout-only wrapper: emit a real cell, but draw nothing. */ + invisible?: boolean }, ): string { let s = style.endsWith(";") || style === "" ? style : `${style};` s += CONTAINER_TOKENS + if (opts.invisible) s += INVISIBLE_TOKENS s = append(s, MARKER.kind, opts.kind) s = append(s, MARKER.dir, opts.dir) s = append(s, MARKER.gap, Math.round(opts.gap)) @@ -139,12 +163,30 @@ export function stampContainer( return s } -/** Stamp a leaf (icon or box) with its kind, so the parser need not infer it. */ +/** + * Is this an invisible layout wrapper? Both colours set to `none` and no group + * stencil — a visible frame always has a stroke or a stencil. + */ +export function isInvisible(style: string): boolean { + if (/grIcon=/.test(style)) return false + const fill = readMarker(style, "fillColor") + const stroke = readMarker(style, "strokeColor") + return fill === "none" && stroke === "none" +} + +/** + * Stamp a leaf with its kind, so the parser need not infer it. + * + * For an icon, also record the catalog name: an Azure or GCP icon's style is an embedded + * base64 image with no name in it, so the style alone cannot identify which icon it is. + */ export function stampLeaf( style: string, kind: "icon" | "box" | "title", + opts: { name?: string } = {}, ): string { - return append(style, MARKER.kind, kind) + const s = append(style, MARKER.kind, kind) + return opts.name ? append(s, MARKER.name, opts.name) : s } /** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */ diff --git a/lib/diagram-engine/parse.ts b/lib/diagram-engine/parse.ts index 89b3e6a..e72fd00 100644 --- a/lib/diagram-engine/parse.ts +++ b/lib/diagram-engine/parse.ts @@ -15,10 +15,10 @@ * - draw.io preserves unknown style keys, so the `dai_*` markers written at emit time * are still there on the way back. * - * When a container lacks `container=1` (an imported file, or output from the old - * hand-written-XML path) the `parent` attribute and the visual nesting can disagree: a - * shape sits inside a frame on screen but its parent is still the root layer. We trust - * GEOMETRY over `parent` in exactly that case, and only that case — see resolveNesting. + * THE ONE INVARIANT THIS FILE OWES ITS CALLER: every vertex on the page comes back + * either as a node in the tree or as a verbatim entry in `tree.foreign`. A re-layout + * re-emits both, so nothing a user drew can be deleted by a round-trip. Every branch + * that declines to interpret a cell calls `keep()`; the final sweep asserts the count. */ import { extractDiagramXML } from "@/lib/utils" @@ -26,9 +26,11 @@ import { type Direction, hasMarkers, isPinned, + MARKER, readDir, readIntMarker, readKind, + readMarker, } from "./markers" import type { BoxNode, @@ -42,22 +44,31 @@ import type { Rect, } from "./types" -/** draw.io's own layer/root cells, plus the boundaries layer the engine emits. */ -const LAYER_IDS = new Set(["0", "1", "boundaries"]) +/** Hard cap on nesting depth, matching the reference project's 50-hop guard. */ +const MAX_DEPTH = 50 + +/** + * How much of a cell's area must fall inside a frame before we accept that the user + * meant to put it there. A shape merely clipping a frame's border is not "inside". + */ +const INSIDE_AREA_RATIO = 0.9 /** A flattened cell, before it becomes a node. */ interface RawCell { id: string + /** The `parent` attribute, verbatim. */ parent: string style: string value: string isEdge: boolean source: string | null target: string | null - /** Geometry as written: relative to the parent for a nested cell. */ + /** Geometry as written: relative to the declared parent for a nested cell. */ geo: Rect | null - /** Geometry resolved to page coordinates through the parent chain. */ + /** Geometry resolved to page coordinates through the declared parent chain. */ abs: Rect | null + /** Document position, used as the tie-break for every ordering decision. */ + seq: number /** The cell's serialised XML, kept so unrecognised cells survive verbatim. */ xml: string } @@ -66,28 +77,26 @@ export interface ParseResult { tree: DiagramTree /** True when no cell carried a `dai_*` marker — an imported or legacy diagram. */ needsAdoption: boolean - /** Non-fatal problems worth surfacing (cycles broken, cells dropped). */ + /** Non-fatal problems worth surfacing (cycles broken, direction guessed, cells kept aside). */ warnings: string[] } +// ============================================================================ +// 1. Multi-page document → the cells of ONE page +// ============================================================================ + /** - * Pull the mxGraphModel body of one page out of whatever the caller has: an mxfile - * document, a bare mxGraphModel, or the XML embedded in an exported SVG. + * The name of every page, in document order. + * + * The canvas XML this repo holds is a multi-page `` (see the note at + * contexts/diagram-context.tsx around line 230). A caller that wants to restructure + * "the diagram the user is looking at" has to say WHICH page, so we expose the list + * rather than quietly assuming page 0. */ -export function extractPage(xml: string, pageIndex = 0): string | null { - let doc = xml.trim() - if (doc.startsWith("]*>([\s\S]*?)<\/diagram>/g)] - if (diagrams.length > 0) { - const body = diagrams[Math.min(pageIndex, diagrams.length - 1)][1] - // A compressed page is base64 with no markup — nothing to parse. - if (!/]*)>/g)].map( + (m, i) => attr(m[1], "name") ?? `Page-${i + 1}`, + ) } /** How many pages the document has. */ @@ -95,42 +104,110 @@ export function countPages(xml: string): number { return [...xml.matchAll(/]*>/g)].length || 1 } +/** Index of the page with this name, or -1. */ +export function findPageIndex(xml: string, name: string): number { + return listPages(xml).indexOf(name) +} + +/** + * Pull the `` body of one page out of whatever the caller has: an mxfile + * document, a bare mxGraphModel, or the XML embedded in an exported SVG. + * + * Returns null — never throws, and never an empty-looking success — when the page + * cannot be read. The distinction matters: an empty page and a compressed page look + * identical to a regex, and treating a compressed page as empty would let a re-layout + * wipe a real diagram. + */ +export function extractPage(xml: string, pageIndex = 0): string | null { + let doc = xml.trim() + + // An exported SVG: the model is in the root element's `content` attribute, either as + // a base64 data URI (what the embed API hands us) or as escaped markup. + if (doc.startsWith("data:image/") || doc.startsWith("]*>([\s\S]*?)<\/diagram>/g)] + if (diagrams.length > 0) { + const idx = Math.min(Math.max(0, pageIndex), diagrams.length - 1) + const body = diagrams[idx][1] + // A compressed page is deflate+base64 with no markup — nothing to parse. + if (!/") .replace(/"/g, '"') - .replace(/'/g, "'") + .replace(/�?39;/g, "'") + .replace(/'/g, "'") .replace(/&/g, "&") } /** * Split the page into cells with a regex rather than a DOM parser. * - * The DOM route needs a real parser (browser DOMParser or @xmldom/xmldom) and gives us - * nothing extra here: we want each cell's raw XML preserved byte-for-byte so foreign - * cells can be re-emitted untouched, and re-serialising a DOM node changes attribute - * order and whitespace. The reference project's validator parses the same way. + * @xmldom/xmldom is available, but the DOM route gives us nothing here and costs us the + * one thing we need: each cell's raw XML preserved byte-for-byte, so a cell the engine + * does not understand can be re-emitted untouched. Re-serialising a DOM node reorders + * attributes and normalises whitespace. The reference project's own validator + * (core.mjs `parseCells`) reads cells the same way. */ function splitCells(page: string): RawCell[] { const out: RawCell[] = [] - // Match a full or . + // A full or . mxCell never nests inside mxCell, so + // the lazy body match cannot run past the right closing tag. const re = /]*?(?:\/>|>[\s\S]*?<\/mxCell>)/g + let seq = 0 for (const m of page.matchAll(re)) { const xml = m[0] const head = xml.slice(0, xml.indexOf(">") + 1) const id = attr(head, "id") if (!id) continue + // The FIRST mxGeometry is the cell's own; a later one would belong to a nested + // construct, and edge label offsets live in . const geoTag = xml.match(/]*?(?:\/>|>)/)?.[0] ?? "" const num = (n: string) => { const v = attr(geoTag, n) - return v === null ? null : Number(v) + if (v === null) return null + const f = Number(v) + return Number.isFinite(f) ? f : null } const x = num("x") const y = num("y") @@ -149,6 +226,7 @@ function splitCells(page: string): RawCell[] { ? { x: x ?? 0, y: y ?? 0, w, h } : null, abs: null, + seq: seq++, xml, }) } @@ -158,19 +236,28 @@ function splitCells(page: string): RawCell[] { /** * Resolve every cell's geometry into page coordinates. * - * A nested cell's geometry is relative to its parent, so absolute position is the sum - * down the parent chain. The hop limit breaks a cycle in a malformed file instead of - * hanging. + * A nested cell's geometry is relative to its DECLARED parent, so this walks the + * `parent` attribute — not the corrected nesting computed later, which would double- + * count the offsets of a cell we re-homed. A `visited` set breaks a cycle in a + * malformed file and names the cells involved, so the caller learns the file is broken + * instead of getting a plausible-looking wrong answer. */ -function resolveAbsolute(cells: RawCell[], warnings: string[]): void { +function resolveAbsolute(cells: RawCell[]): Set { const byId = new Map(cells.map((c) => [c.id, c])) + const cyclic = new Set() for (const c of cells) { if (!c.geo) continue let x = c.geo.x let y = c.geo.y + const seen = new Set([c.id]) let p = byId.get(c.parent) let hops = 0 - while (p && hops < 50) { + while (p && hops < MAX_DEPTH) { + if (seen.has(p.id)) { + cyclic.add(c.id) + break + } + seen.add(p.id) if (p.geo) { x += p.geo.x y += p.geo.y @@ -178,29 +265,99 @@ function resolveAbsolute(cells: RawCell[], warnings: string[]): void { p = byId.get(p.parent) hops++ } - if (hops >= 50) - warnings.push( - `Parent chain of "${c.id}" exceeded 50 hops — possible cycle; geometry may be wrong.`, - ) + if (hops >= MAX_DEPTH) cyclic.add(c.id) c.abs = { x, y, w: c.geo.w, h: c.geo.h } } + return cyclic } -/** Does this style declare a draw.io container? Last duplicate wins, as draw.io does. */ +// ============================================================================ +// 3. Style reading +// ============================================================================ + +/** Read a style key's effective value. Last duplicate wins, as draw.io resolves them. */ +function styleValue(style: string, key: string): string | undefined { + const all = [...style.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g"))] + return all.length ? all[all.length - 1][1] : undefined +} + +/** Does this style declare a draw.io container? */ function declaresContainer(style: string): boolean { - const matches = [...style.matchAll(/(?:^|;)container=([^;]*)/g)] - if (matches.length === 0) return false - return matches[matches.length - 1][1] === "1" + return styleValue(style, "container") === "1" } /** - * Classify a cell. + * The catalog name of an icon. * - * The marker wins when present. Without one we fall back to the shape, which has to - * cover four different icon encodings: `resIcon=mxgraph.aws4.` (554 of the 983 - * AWS icons), a bare `shape=mxgraph.aws4.` (the other 429), `shape=image` with an - * embedded data URI (all 626 Azure and 216 GCP icons), and `grIcon=` for group frames. - * Keying only on `resIcon=` would misread well over a thousand icons as plain boxes. + * Four encodings exist in the catalogs and all four have to work: + * 1. `shape=mxgraph.aws4.resourceIcon` + `resIcon=mxgraph.aws4.` — 554 of 983 + * AWS icons. The name is in resIcon; the shape is the generic tile. + * 2. `shape=mxgraph.aws4.` with NO resIcon — the other 429 AWS icons. + * 3. `shape=image;image=data:image/png,` — all 626 Azure and 216 GCP icons. + * There is no token to read, so the name is unrecoverable and we return null; the + * verbatim style on the node is what re-emits it faithfully. + * 4. `shape=mxgraph.aws4.group` + `grIcon=mxgraph.aws4.group_` — group frames, + * handled by `groupName` instead. + * + * resIcon is checked first because encoding 1 carries BOTH tokens and the bare shape + * there is the meaningless `resourceIcon` tile. + */ +function iconName(style: string): string | null { + // The marker wins: an Azure or GCP icon is an embedded base64 image whose style + // contains no name at all, so nothing else can identify it. + const marked = readMarker(style, MARKER.name) + if (marked) return marked + const res = style.match(/(?:^|;)resIcon=mxgraph\.[a-z0-9_]+\.([\w]+)/) + if (res) return res[1] + const shape = style.match(/(?:^|;)shape=mxgraph\.[a-z0-9_]+\.([\w]+)/) + if (shape && shape[1] !== "resourceIcon" && shape[1] !== "group") + return shape[1] + return null +} + +/** The group stencil name of a container, when it has one. */ +function groupName(style: string): string | null { + return ( + style.match(/(?:^|;)grIcon=mxgraph\.[a-z0-9_]+\.([\w]+)/)?.[1] ?? null + ) +} + +/** Any of the four icon encodings, without needing the name to be recoverable. */ +function looksLikeIcon(style: string): boolean { + if (/(?:^|;)resIcon=/.test(style)) return true + if (/(?:^|;)image=data:image\//.test(style)) return true + const shape = styleValue(style, "shape") + if (shape === "image") return true + if (shape && shape !== "group" && /^mxgraph\./.test(shape)) return true + return false +} + +/** A `text;`-styled cell: draw.io's label-only shape, no border and no fill. */ +function looksLikeText(style: string): boolean { + return /(?:^|;)text;/.test(style) || styleValue(style, "text") === "1" +} + +/** + * Classify a cell into a node kind. + * + * Discrimination order, most authoritative first. Each step is only reached because + * every step above it declined: + * + * 1. `dai_kind` — written by our own emitter and preserved by draw.io. Nothing else + * can be as reliable, because it records intent rather than appearance. + * 2. Holds children → a container, whatever it is styled as. A cell with children is + * structurally a container no matter what draw.io calls it. + * 3. `grIcon=` (a group stencil) or `container=1` → a container, even when empty. An + * empty VPC frame the user just dropped is still a VPC frame. + * 4. Any of the four icon encodings → icon. Checked BEFORE the text test, because an + * icon's style also carries label-positioning tokens. + * 5. `text;` → title/annotation. Only the first becomes `tree.title`; the rest are + * the user's own notes and are preserved verbatim (see `parseDiagram`). + * 6. Anything left → box, a plain labelled rectangle. + * + * The old ordering put the icon test last and keyed it on `resIcon=` alone, which reads + * 429 AWS + 842 Azure/GCP icons as plain boxes: a re-layout would then re-emit them as + * grey rectangles and the stencils would be gone. */ function classify( c: RawCell, @@ -208,216 +365,494 @@ function classify( ): "group" | "grid" | "icon" | "box" | "title" { const marked = readKind(c.style) if (marked) return marked - - if (/(?:^|;)text;/.test(c.style) || c.id === "__title") return "title" - // A group stencil, or anything draw.io treats as a container, or anything that - // actually holds children — all are containers regardless of how they were styled. - if (/grIcon=/.test(c.style) || declaresContainer(c.style) || hasChildren) + if (hasChildren) return "group" + if (groupName(c.style) !== null || declaresContainer(c.style)) return "group" - if ( - /resIcon=/.test(c.style) || - /shape=mxgraph\.[a-z0-9_]+\./.test(c.style) || - /shape=image/.test(c.style) - ) - return "icon" + if (looksLikeIcon(c.style)) return "icon" + if (looksLikeText(c.style)) return "title" return "box" } -/** The catalog name of an icon, when it can be recovered from the style. */ -function iconName(style: string): string | null { - return ( - style.match(/resIcon=mxgraph\.[a-z0-9_]+\.([a-zA-Z0-9_]+)/)?.[1] ?? - style.match(/shape=mxgraph\.[a-z0-9_]+\.([a-zA-Z0-9_]+)/)?.[1] ?? - null - ) +// ============================================================================ +// 4. Deciding each cell's true parent +// ============================================================================ + +/** Area of a rect, 0 when degenerate. */ +function area(r: Rect): number { + return Math.max(0, r.w) * Math.max(0, r.h) } -/** The group stencil name of a container, when it has one. */ -function groupName(style: string): string | null { - return ( - style.match(/grIcon=mxgraph\.[a-z0-9_]+\.([a-zA-Z0-9_]+)/)?.[1] ?? null +/** Fraction of `inner`'s area that lies inside `outer`. */ +function insideRatio(outer: Rect, inner: Rect): number { + const a = area(inner) + if (a === 0) return 0 + const ox = Math.max( + 0, + Math.min(outer.x + outer.w, inner.x + inner.w) - + Math.max(outer.x, inner.x), ) -} - -function styleValue(style: string, key: string): string | undefined { - const all = [...style.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g"))] - return all.length ? all[all.length - 1][1] : undefined + const oy = Math.max( + 0, + Math.min(outer.y + outer.h, inner.y + inner.h) - + Math.max(outer.y, inner.y), + ) + return (ox * oy) / a } /** - * Decide each cell's true parent. + * Decide each cell's true parent, resolving the one case where the `parent` attribute + * and the visual nesting can disagree. * - * Normally `parent` is authoritative: with `container=1` in place draw.io maintains it - * as the user drags things around. The exception is a container WITHOUT `container=1` - * — draw.io will not reparent into it, so a shape the user dropped inside it visually - * still claims the root layer as its parent. There, and only there, we believe the - * geometry: if a cell sits geometrically inside such a frame and its declared parent is - * a layer, we re-home it into the smallest frame that contains it. + * WE TRUST `parent`, with a single narrow exception. The reasoning: * - * Trusting geometry unconditionally would be wrong the other way round: a legitimately - * reparented cell whose geometry got stale for a frame, or a deliberately overlapping - * badge, would be silently moved. + * `parent` is the only place draw.io records a structural DECISION. When a container + * carries `container=1` — verified in a browser — dragging a shape into it rewrites + * `parent` to the frame and converts the geometry to parent-relative. So on engine + * output, `parent` is a live record of what the user did, and geometry is derived from + * it. Preferring geometry there would be strictly worse: it would swallow the things + * that legitimately overlap a frame without belonging to it — a legend sitting on top + * of a region box, a status badge pinned to a subnet's corner, a callout note. Each + * would get pulled into the frame and then re-laid-out into its child flow. + * + * The exception is a frame WITHOUT `container=1`. draw.io refuses to reparent into it, + * so a shape the user dropped inside keeps `parent="1"` while sitting visually within + * the frame (verified: parent stayed "1", geometry stayed absolute at 260,186; the same + * stencil with `container=1;pointerEvents=0;collapsible=0;recursiveResize=0` reparented + * to "frame" with geometry 140,106). Here `parent="1"` is not a decision at all — it is + * the default, the absence of one. Geometry is the only signal that exists, so we use + * it. That is the whole asymmetry: geometry fills a vacuum, it never overrules a + * statement. + * + * This matters because the AWS catalog is inconsistent about it: group_region, + * group_vpc, group_subnet, group_availability_zone, group_aws_cloud and + * group_on_premise ship without `container=1`, while group_account, group_aws_cloud_alt, + * group_vpc2, group_security_group and group_corporate_data_center ship with it. Our + * emitter appends the container tokens unconditionally, so this path is for imported + * files and output from the older hand-written-XML path — and `needsAdoption` tells the + * caller to run an adoption pass that stamps markers, which retires the ambiguity for + * that diagram permanently. + * + * Three guards keep the geometric inference safe: + * + * - Only cells on the DEFAULT content layer are re-homed. A cell on another layer is + * an overlay by construction; overlays are meant to sit on top of things. + * - The frame must be STRICTLY LARGER in area than the cell. This makes the relation + * a strict partial order, so the result cannot contain a cycle. Without it, two + * frames of identical size each "contain" the other and the whole page vanishes + * into an unreachable cycle. + * - At least 90% of the cell's area must be inside the frame, so a shape clipping a + * border is left alone. */ -function resolveNesting(cells: RawCell[]): Map { +function resolveNesting( + cells: RawCell[], + layers: Set, + defaultLayer: string, +): { parentOf: Map; rehomed: string[] } { const byId = new Map(cells.map((c) => [c.id, c])) const parentOf = new Map() + const rehomed: string[] = [] - const contains = (outer: Rect, inner: Rect) => - inner.x >= outer.x - 2 && - inner.y >= outer.y - 2 && - inner.x + inner.w <= outer.x + outer.w + 2 && - inner.y + inner.h <= outer.y + outer.h + 2 - - // Frames that draw.io will NOT reparent into, so their contents may be mis-parented. + // Frames draw.io will NOT reparent into, so their contents may be mis-parented. const looseFrames = cells.filter( (c) => !c.isEdge && c.abs !== null && !declaresContainer(c.style) && - (/grIcon=/.test(c.style) || readKind(c.style) === "group"), + (groupName(c.style) !== null || + readKind(c.style) === "group" || + readKind(c.style) === "grid"), ) for (const c of cells) { let p = c.parent - const declaredIsLayer = LAYER_IDS.has(p) || !byId.has(p) - if (declaredIsLayer && c.abs && !c.isEdge && looseFrames.length > 0) { + const onDefaultLayer = p === defaultLayer || !byId.has(p) + if ( + onDefaultLayer && + !c.isEdge && + c.abs !== null && + !layers.has(c.id) && + looseFrames.length > 0 + ) { + const mine = c.abs let best: RawCell | null = null for (const f of looseFrames) { if (f.id === c.id || !f.abs) continue - if (!contains(f.abs, c.abs)) continue - // smallest containing frame — the innermost one the user dropped into - if (!best?.abs || f.abs.w * f.abs.h < best.abs.w * best.abs.h) + // strict area growth ⇒ acyclic by construction + if (area(f.abs) <= area(mine)) continue + if (insideRatio(f.abs, mine) < INSIDE_AREA_RATIO) continue + // innermost = smallest containing frame; document order breaks ties + if ( + best?.abs == null || + area(f.abs) < area(best.abs) || + (area(f.abs) === area(best.abs) && f.seq < best.seq) + ) best = f } - if (best) p = best.id + if (best) { + p = best.id + rehomed.push(`${c.id}→${best.id}`) + } } parentOf.set(c.id, p) } - return parentOf + return { parentOf, rehomed } +} + +// ============================================================================ +// 5. Recovering layout direction and order +// ============================================================================ + +interface LayoutGuess { + dir: Direction + gap: number + cols: number + /** True when no single direction describes the arrangement — worth telling the user. */ + ambiguous: boolean +} + +type Span = [number, number] + +const spanX = (r: Rect): Span => [r.x, r.x + r.w] +const spanY = (r: Rect): Span => [r.y, r.y + r.h] + +/** + * Are two spans in the same band? + * + * Overlap of more than half the SHORTER span, which is scale-free: two 48px icons need + * to overlap by 24px, two 614px availability-zone columns by 307px. This replaces a + * fixed pixel tolerance, which cannot be right for both at once — 20px is a third of an + * icon but 3% of a zone. + */ +function sameBand(a: Span, b: Span): boolean { + const overlap = Math.min(a[1], b[1]) - Math.max(a[0], b[0]) + if (overlap <= 0) return false + const shorter = Math.min(a[1] - a[0], b[1] - b[0]) + return shorter <= 0 ? true : overlap > shorter / 2 +} + +/** Group rects into bands along one axis, comparing each against its band's first member. */ +function bandsAlong(rects: Rect[], span: (r: Rect) => Span): Rect[][] { + const sorted = [...rects].sort((a, b) => span(a)[0] - span(b)[0]) + const bands: Rect[][] = [] + for (const r of sorted) { + const band = bands.find((b) => sameBand(span(b[0]), span(r))) + if (band) band.push(r) + else bands.push([r]) + } + return bands +} + +/** Median, or `fallback` for an empty list. */ +function median(xs: number[], fallback: number): number { + if (xs.length === 0) return fallback + const s = [...xs].sort((a, b) => a - b) + return s[Math.floor(s.length / 2)] } /** - * Recover a container's stacking direction and gap from its children's positions. + * Recover a container's stacking direction, gap and column count from its children's + * positions. Only used when the `dai_dir` marker is absent. * - * Used only when the style marker is missing. Compares how much the children spread - * along each axis: a row varies in x and shares y, a column the reverse. Ties and - * genuinely two-dimensional arrangements fall back to a row, which is what an - * unlabelled cluster of icons most often is. + * The test is SEPARATION, not spread. Children stacked in a row occupy disjoint + * intervals on x and overlapping intervals on y; a column is the mirror image. Spread + * (max origin minus min origin) gets this wrong whenever child sizes differ — three + * subnet frames 187px wide stacked vertically have a y-spread of 364 and an x-spread of + * 0, which spread reads correctly, but one wide frame beside two narrow ones defeats it. + * + * A 2-D GRID is only reported when the arrangement is actually rectangular: at least + * two bands on each axis, every row band the same size, and the row bands account for + * every child. That deliberately excludes the case where the user dragged ONE child out + * of line — three icons in a row plus one below gives row bands of size 3 and 1, which + * is a row with an outlier, not a 2×2 grid. The outlier keeps its place in the flow + * order (sorted by x) and a re-layout pulls it back into line, which is what a user who + * asks to restructure a diagram wants. A user who wanted it left where it is says so + * with `dai_pin`. + * + * When neither axis fully separates and the shape is not a grid, the arrangement is + * genuinely 2-D and no single direction can express it. We pick the axis with the + * larger extent and set `ambiguous`, so the caller can warn that a re-layout will move + * things. This happens on output from the reference project's `phantom` wrapper, which + * emits no cell and so leaves its children flattened onto their grandparent — the + * reason our own engine must not have phantoms. */ -function inferLayout(children: RawCell[]): { dir: Direction; gap: number } { - const boxes = children +function inferLayout(children: RawCell[]): LayoutGuess { + const rects = children .map((c) => c.abs) - .filter((r): r is Rect => r !== null) - if (boxes.length < 2) return { dir: "row", gap: 20 } + .filter((r): r is Rect => r !== null && area(r) > 0) + if (rects.length < 2) + return { dir: "row", gap: 20, cols: 1, ambiguous: false } - const xs = boxes.map((b) => b.x) - const ys = boxes.map((b) => b.y) - const spreadX = Math.max(...xs) - Math.min(...xs) - const spreadY = Math.max(...ys) - Math.min(...ys) + const rows = bandsAlong(rects, spanY) + const cols = bandsAlong(rects, spanX) - // Distinct row/column bands, to notice a real grid. - const bands = (vals: number[], tol: number) => { - const sorted = [...vals].sort((a, b) => a - b) - let n = 1 - for (let i = 1; i < sorted.length; i++) - if (sorted[i] - sorted[i - 1] > tol) n++ + // A real rectangular grid: uniform row sizes, ≥2 per row, ≥2 rows, all accounted for. + const rowSize = rows[0].length + const isGrid = + rows.length >= 2 && + cols.length >= 2 && + rowSize >= 2 && + rows.every((r) => r.length === rowSize) && + rows.length * rowSize === rects.length + if (isGrid) { + const inRow = [...rows[0]].sort((a, b) => a.x - b.x) + return { + dir: "grid", + gap: Math.max(0, Math.round(gapsBetween(inRow, "row"))), + cols: rowSize, + ambiguous: false, + } + } + + // How many consecutive pairs are fully separated along each axis? + const separated = (span: (r: Rect) => Span) => { + const s = [...rects].sort((a, b) => span(a)[0] - span(b)[0]) + let n = 0 + for (let i = 1; i < s.length; i++) + if (!sameBand(span(s[i - 1]), span(s[i]))) n++ return n } - const rows = bands(ys, 20) - const cols = bands(xs, 20) - if (rows > 1 && cols > 1) return { dir: "grid", gap: 20 } + const need = rects.length - 1 + const sepX = separated(spanX) + const sepY = separated(spanY) - const dir: Direction = spreadX >= spreadY ? "row" : "col" + let dir: Direction + if (sepX === need && sepY < need) dir = "row" + else if (sepY === need && sepX < need) dir = "col" + else { + // Both fully separated (a diagonal staircase) or neither (a ragged 2-D cluster): + // fall back to the longer extent. + const extentX = + Math.max(...rects.map((r) => r.x + r.w)) - + Math.min(...rects.map((r) => r.x)) + const extentY = + Math.max(...rects.map((r) => r.y + r.h)) - + Math.min(...rects.map((r) => r.y)) + dir = extentX >= extentY ? "row" : "col" + } + const ambiguous = sepX !== need && sepY !== need - // Gap = median edge-to-edge distance between neighbours along the flow axis. - const sorted = [...boxes].sort((a, b) => + const ordered = [...rects].sort((a, b) => dir === "row" ? a.x - b.x : a.y - b.y, ) - const gaps: number[] = [] - for (let i = 1; i < sorted.length; i++) { - const prev = sorted[i - 1] - const cur = sorted[i] - gaps.push( - dir === "row" - ? cur.x - (prev.x + prev.w) - : cur.y - (prev.y + prev.h), - ) + return { + dir, + gap: Math.max(0, Math.round(gapsBetween(ordered, dir))), + cols: 1, + ambiguous, } - gaps.sort((a, b) => a - b) - const median = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 20 - return { dir, gap: Math.max(0, Math.round(median)) } } -/** Turn an edge cell into a link spec. */ -function toLink(c: RawCell): LinkSpec | null { - if (!c.source || !c.target) return null - let label = c.value - let step: number | undefined - const m = label.match(/^(\d+)\.\s*(.*)$/) - if (m) { - step = Number(m[1]) - label = m[2] +/** Median edge-to-edge distance between neighbours along the flow axis. */ +function gapsBetween(ordered: Rect[], dir: Direction): number { + const gaps: number[] = [] + for (let i = 1; i < ordered.length; i++) { + const a = ordered[i - 1] + const b = ordered[i] + gaps.push(dir === "col" ? b.y - (a.y + a.h) : b.x - (a.x + a.w)) } + return median( + gaps.filter((g) => g >= 0), + 20, + ) +} + +/** + * Put a container's children into layout order. + * + * Document order is the wrong answer on its own. The engine emits children in layout + * order, but as soon as the user drags one past another the two disagree, and re- + * emitting in document order silently undoes the user's reordering — a change they made + * on purpose, reverted by the tool that was supposed to be reading their edits. + * + * So: sort by position along the flow axis (row-major for a grid), and use document + * order only to break exact ties, which keeps engine output byte-stable through a + * round-trip. Children with no geometry keep their document position. + */ +function orderChildren(kids: RawCell[], dir: Direction): RawCell[] { + const key = (c: RawCell): number => { + if (!c.abs) return Number.POSITIVE_INFINITY + return dir === "col" ? c.abs.y : c.abs.x + } + if (dir === "grid") { + const rects = kids + .map((c) => c.abs) + .filter((r): r is Rect => r !== null) + const rows = bandsAlong(rects, spanY) + const rowOf = new Map() + rows.forEach((band, i) => { + for (const r of band) rowOf.set(r, i) + }) + return [...kids].sort((a, b) => { + const ra = a.abs ? (rowOf.get(a.abs) ?? 0) : Number.MAX_SAFE_INTEGER + const rb = b.abs ? (rowOf.get(b.abs) ?? 0) : Number.MAX_SAFE_INTEGER + if (ra !== rb) return ra - rb + const dx = (a.abs?.x ?? 0) - (b.abs?.x ?? 0) + return dx !== 0 ? dx : a.seq - b.seq + }) + } + return [...kids].sort((a, b) => { + const d = key(a) - key(b) + return d !== 0 ? d : a.seq - b.seq + }) +} + +// ============================================================================ +// 6. Edges → link specs +// ============================================================================ + +/** + * Split a leading step number off an edge label. + * + * The emitter writes `"3. route"`, so the parser has to take it back off or the number + * doubles on the next round-trip. The dot must be followed by whitespace and the number + * must be short, because plain labels look like this too: `"3.5x throughput"` and + * `"10.0.0.0/16 peering"` both parse as a step number under a laxer pattern, which + * silently truncates the user's text to `"5x throughput"` and `"0.0.0/16 peering"`. + */ +function splitStep(label: string): { label: string; step?: number } { + const m = label.match(/^(\d{1,2})\.\s+(\S[\s\S]*)$/) + if (!m) return { label } + return { label: m[2], step: Number(m[1]) } +} + +/** + * Turn an edge cell into a link spec. + * + * `style` is kept verbatim so a caller that does not re-route can re-emit it exactly. + * Note for the emitter: it still contains `exitX`/`entryX` pins and the edge's old + * waypoints are in the cell XML, both of which are stale once the layout moves — a + * re-layout must drop them, not reuse them. + */ +function toLink(c: RawCell, labelOverride?: string): LinkSpec | null { + if (!c.source || !c.target) return null + const raw = (labelOverride ?? c.value).trim() + const { label, step } = splitStep(raw) return { id: c.id, source: c.source, target: c.target, label: label || undefined, - dashed: /(?:^|;)dashed=1/.test(c.style) || undefined, + dashed: styleValue(c.style, "dashed") === "1" || undefined, step, style: c.style, } } +// ============================================================================ +// 7. The parse +// ============================================================================ + /** - * Re-derive the node tree from a page of canvas XML. + * Is this cell one of the engine's own decorations rather than a real child? * - * Cells the classifier cannot place — a shape whose parent is a foreign cell, anything - * on the boundaries layer — are returned in `tree.foreign` and re-emitted verbatim, so - * a re-layout never deletes a user's annotations. + * A container with no group stencil gets a corner icon emitted as a child cell named + * `__ci`, sitting flush in the frame's top-left. It is part of the container's + * chrome; if it enters the child flow, a re-layout puts a 22px glyph in the middle of + * the row and the frame loses its corner icon. + */ +function isDecoration(c: RawCell, parentId: string): boolean { + if (c.id === `${parentId}__ci`) return true + if (c.value !== "" || !c.geo) return false + // small, unlabelled, flush to the parent's top-left corner + return c.geo.w <= 26 && c.geo.h <= 26 && c.geo.x <= 12 && c.geo.y <= 12 +} + +/** + * Re-derive the node tree from one page of canvas XML. + * + * Everything the tree cannot express comes back in `tree.foreign` with its XML + * verbatim: cells on a non-default layer, the engine's boundary frames, the user's own + * text annotations, container chrome, labels attached to edges, and anything caught by + * the depth or cycle guards. A re-layout re-emits them untouched. */ export function parseDiagram(xml: string, pageIndex = 0): ParseResult { const warnings: string[] = [] + const pages = countPages(xml) + if (pages > 1) + warnings.push( + `Document has ${pages} pages (${listPages(xml).join(", ")}); parsed page ${pageIndex + 1} only.`, + ) + const page = extractPage(xml, pageIndex) if (!page) return { tree: { roots: [], links: [], foreign: [] }, needsAdoption: true, warnings: [ - "No cells found — the page is empty or the .drawio is compressed.", + ...warnings, + "No cells found — the page is empty, or the .drawio is compressed and must be decompressed first.", ], } const cells = splitCells(page) - resolveAbsolute(cells, warnings) + const cyclic = resolveAbsolute(cells) + if (cyclic.size > 0) + warnings.push( + `Parent cycle involving ${[...cyclic].join(", ")} — geometry for those cells is unreliable; they were detached to the top level.`, + ) + + /** + * Layers are the cells parented to the root cell "0". draw.io's default deck has + * "0" (the root) and "1" (the default layer), the engine adds a locked + * "boundaries" layer, and a user who adds layers in the editor gets cells with + * generated ids. Reading `parent="0"` finds all of them; a hardcoded + * {"0","1","boundaries"} set does not, and a missed layer gets built as a + * borderless zero-size "group" node that a re-layout then emits as a shape. + */ + const layers = new Set(["0"]) + for (const c of cells) if (c.parent === "0") layers.add(c.id) + // "1" is draw.io's default content layer even in a file that omits its cell. + const defaultLayer = cells.find((c) => c.parent === "0")?.id ?? "1" + layers.add("1") const marked = cells.some((c) => hasMarkers(c.style)) - const parentOf = resolveNesting(cells) + const { parentOf, rehomed } = resolveNesting(cells, layers, defaultLayer) + if (rehomed.length > 0) + warnings.push( + `Re-homed ${rehomed.length} cell(s) by geometry because their frame lacks container=1: ${rehomed.join(", ")}.`, + ) - const vertices = cells.filter((c) => !c.isEdge && !LAYER_IDS.has(c.id)) + const byId = new Map(cells.map((c) => [c.id, c])) + const vertices = cells.filter((c) => !c.isEdge && !layers.has(c.id)) const edges = cells.filter((c) => c.isEdge) + const edgeIds = new Set(edges.map((e) => e.id)) - // children, in document order — which is layout order for engine output const childrenOf = new Map() for (const c of vertices) { const p = parentOf.get(c.id) ?? "" - if (!childrenOf.has(p)) childrenOf.set(p, []) - childrenOf.get(p)?.push(c) + const list = childrenOf.get(p) + if (list) list.push(c) + else childrenOf.set(p, [c]) } - const byId = new Map(cells.map((c) => [c.id, c])) const kindOf = new Map>() for (const c of vertices) kindOf.set(c.id, classify(c, (childrenOf.get(c.id)?.length ?? 0) > 0)) const foreign: ForeignCell[] = [] - let title: string | undefined + const accounted = new Set() + /** Carry a cell through the round-trip without interpreting it. */ + const keep = (c: RawCell, parent: string) => { + if (accounted.has(c.id)) return + accounted.add(c.id) + foreign.push({ id: c.id, xml: c.xml, parent }) + } - const build = (c: RawCell, depth: number): DiagramNode | null => { - if (depth > 50) { + let title: string | undefined + const ambiguousContainers: string[] = [] + + const build = ( + c: RawCell, + depth: number, + path: Set, + ): DiagramNode | null => { + if (depth > MAX_DEPTH || path.has(c.id)) { warnings.push( - `Nesting deeper than 50 at "${c.id}" — subtree dropped.`, + path.has(c.id) + ? `Cycle at "${c.id}" — kept as-is instead of recursing.` + : `Nesting deeper than ${MAX_DEPTH} at "${c.id}" — kept as-is instead of recursing.`, ) + keep(c, parentOf.get(c.id) ?? defaultLayer) return null } const kind = kindOf.get(c.id) ?? "box" @@ -425,10 +860,19 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult { const rect = c.abs ?? undefined if (kind === "title") { - if (title === undefined) title = c.value - return null // laid out separately, not part of the flow + // The first text cell is the page title. Every other one is the user's own + // annotation — a caption, a legend entry, a note — and must survive. + if (title === undefined) { + title = c.value + accounted.add(c.id) + } else { + keep(c, parentOf.get(c.id) ?? defaultLayer) + } + return null } + accounted.add(c.id) + if (kind === "icon") { const node: IconNode = { kind: "icon", @@ -436,7 +880,9 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult { name: iconName(c.style) ?? "", label: c.value, style: c.style, - size: c.geo ? Math.round(c.geo.w) : undefined, + size: c.geo + ? Math.round(Math.max(c.geo.w, c.geo.h)) + : undefined, pinned, rect, } @@ -459,44 +905,35 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult { return node } - // container - const kids = childrenOf.get(c.id) ?? [] - const built = kids - .map((k) => build(k, depth + 1)) - .filter((n): n is DiagramNode => n !== null) - const markedDir = readDir(c.style) - const markedGap = readIntMarker(c.style, "dai_gap") - const inferred = markedDir === null ? inferLayout(kids) : null - const dir = markedDir ?? inferred?.dir ?? "row" - const gap = markedGap ?? inferred?.gap ?? 20 - - if (kind === "grid" || dir === "grid") { - const cols = - readIntMarker(c.style, "dai_cols") ?? - Math.max(1, Math.round(Math.sqrt(built.length))) - const node: GridNode = { - kind: "grid", - id: c.id, - gname: groupName(c.style), - label: c.value, - cols, - gap, - children: built, - fill: styleValue(c.style, "fillColor"), - stroke: styleValue(c.style, "strokeColor"), - style: c.style, - pinned, - rect, - } - return node + // ---- container ---- + const all = childrenOf.get(c.id) ?? [] + const kids: RawCell[] = [] + for (const k of all) { + if (isDecoration(k, c.id)) keep(k, c.id) + else kids.push(k) } - const node: GroupNode = { - kind: "group", + const markedDir = readDir(c.style) + const markedGap = readIntMarker(c.style, MARKER.gap) + const markedCols = readIntMarker(c.style, MARKER.cols) + const guess = + markedDir === null || markedGap === null || markedCols === null + ? inferLayout(kids) + : null + const dir: Direction = markedDir ?? guess?.dir ?? "row" + const gap = markedGap ?? guess?.gap ?? 20 + if (markedDir === null && guess?.ambiguous) + ambiguousContainers.push(c.id) + + const nextPath = new Set(path).add(c.id) + const built = orderChildren(kids, dir) + .map((k) => build(k, depth + 1, nextPath)) + .filter((n): n is DiagramNode => n !== null) + + const common = { id: c.id, gname: groupName(c.style), label: c.value, - dir: dir === "col" ? "col" : "row", gap, children: built, fill: styleValue(c.style, "fillColor"), @@ -505,29 +942,94 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult { pinned, rect, } + + if (kind === "grid" || dir === "grid") { + const node: GridNode = { + kind: "grid", + ...common, + cols: Math.max( + 1, + markedCols ?? + guess?.cols ?? + Math.ceil(Math.sqrt(built.length)), + ), + } + return node + } + const node: GroupNode = { + kind: "group", + ...common, + dir: dir === "col" ? "col" : "row", + } return node } - // Roots: cells parented to a layer. The boundaries layer holds engine-drawn - // cluster frames, which are decoration over the real nesting — keep them verbatim. - const roots: DiagramNode[] = [] + // ---- roots ---- + // A cell is a root when its resolved parent is a layer or does not exist. Cells on + // a layer other than the default one are overlays (a locked annotation layer, the + // engine's "boundaries" frames) and are preserved verbatim rather than restructured. + // A cell parented to an EDGE is that edge's label; it never belongs in the forest. + const rootCells: RawCell[] = [] for (const c of vertices) { const p = parentOf.get(c.id) ?? "" - if (byId.has(p) && !LAYER_IDS.has(p)) continue // not a root - if (p === "boundaries") { - foreign.push({ id: c.id, xml: c.xml, parent: p }) + if (edgeIds.has(p)) continue // handled with the edges below + if (byId.has(p) && !layers.has(p)) continue // a real child + if (p !== defaultLayer && layers.has(p)) { + keep(c, p) continue } - const n = build(c, 0) - if (n) roots.push(n) + rootCells.push(c) + } + const roots = orderChildren(rootCells, "row") + .map((c) => build(c, 0, new Set())) + .filter((n): n is DiagramNode => n !== null) + + // ---- links ---- + // draw.io stores a repositioned edge label as a child vertex of the edge. If the + // edge itself has no value, that child holds the real label and we lift it into the + // spec; otherwise we cannot merge the two and the child is preserved verbatim. + const labelCellsOf = new Map() + for (const c of vertices) { + const p = parentOf.get(c.id) ?? "" + if (!edgeIds.has(p)) continue + const list = labelCellsOf.get(p) + if (list) list.push(c) + else labelCellsOf.set(p, [c]) + } + const links: LinkSpec[] = [] + for (const e of edges) { + const labelCells = labelCellsOf.get(e.id) ?? [] + let override: string | undefined + for (const lc of labelCells) { + if (override === undefined && e.value.trim() === "" && lc.value) { + override = lc.value + accounted.add(lc.id) + } else { + keep(lc, e.id) + } + } + const link = toLink(e, override) + if (link) links.push(link) + else + warnings.push( + `Edge "${e.id}" has no source or target — dropped from the link list.`, + ) } - const links = edges.map(toLink).filter((l): l is LinkSpec => l !== null) - - const pages = countPages(xml) - if (pages > 1) + // ---- the invariant ---- + // Anything not turned into a node, lifted into the title, or explicitly kept aside + // is a parser bug. Sweep it into `foreign` so the bug costs a warning, not a cell. + for (const c of vertices) { + if (accounted.has(c.id)) continue + keep(c, parentOf.get(c.id) ?? defaultLayer) warnings.push( - `Document has ${pages} pages; parsed page ${pageIndex + 1} only.`, + `Cell "${c.id}" could not be placed in the tree — kept verbatim.`, + ) + } + + if (ambiguousContainers.length > 0) + warnings.push( + `Children of ${ambiguousContainers.join(", ")} are arranged in two dimensions, which no single direction describes; a re-layout will move them.`, ) return { diff --git a/lib/diagram-engine/render.ts b/lib/diagram-engine/render.ts new file mode 100644 index 0000000..37178d8 --- /dev/null +++ b/lib/diagram-engine/render.ts @@ -0,0 +1,254 @@ +/** + * tree → XML. Takes a laid-out forest and writes the mxCell elements draw.io reads. + * + * Every cell it emits carries `container=1` on containers and `dai_*` markers recording + * the layout parameters, so parse.ts can read the structure back. That round-trip is + * what lets the canvas stay the single source of truth. + * + * Ported from drawio-ai-kit (MIT) — see NOTICE. + */ + +import { flatten, ICON_SIZE, layoutForest, type Placed } from "./layout" +import { stampContainer, stampLeaf } from "./markers" +import type { DiagramNode, DiagramTree, LinkSpec, Rect } from "./types" + +/** Escape the five characters that would break an XML attribute. */ +export function esc(s: string): string { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +/** Resolve a catalog name to a style. Injected so the engine does not own the catalog. */ +export type StyleResolver = ( + name: string, + kind: "icon" | "group", +) => string | null + +const FALLBACK_BOX = + "rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#5A6B7B;fontColor=#1A1A1A;fontSize=11;verticalAlign=middle;" +const FALLBACK_FRAME = + "rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#999999;fontColor=#1A1A1A;fontSize=12;fontStyle=1;verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;" +const TITLE_STYLE = + "text;html=1;align=center;fontStyle=1;fontSize=14;fontColor=light-dark(#232F3E,#E8E8E8);" +const EDGE_STYLE = + "edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;jettySize=auto;orthogonalLoop=1;fontSize=10;fontColor=light-dark(#1B2733,#CFE0F0);strokeColor=light-dark(#1A1A1A,#E0E0E0);strokeWidth=1;" + +export interface RenderOptions { + /** Resolves a catalog icon/group name to its verbatim draw.io style. */ + resolveStyle?: StyleResolver + /** Diagram-wide glyph size. */ + iconSize?: number + /** Gap between top-level roots. */ + rootGap?: number +} + +/** + * Build the style for one node. + * + * A style recovered from XML is preferred over re-resolving the catalog name: it is + * what is already on the canvas, including any colour the user changed by hand. We only + * re-stamp the markers on top, so layout parameters stay current. + */ +function styleFor(n: DiagramNode, resolve: StyleResolver | undefined): string { + if (n.kind === "title") return TITLE_STYLE + + if (n.kind === "icon") { + const base = + n.style ?? + (n.name ? resolve?.(n.name, "icon") : null) ?? + FALLBACK_BOX + return stampLeaf(base, "icon", { name: n.name }) + } + + if (n.kind === "box") { + let base = n.style ?? FALLBACK_BOX + if (!n.style) { + if (n.fill) base += `fillColor=${n.fill};` + if (n.stroke) base += `strokeColor=${n.stroke};` + if (n.bold) base += "fontStyle=1;" + } + return stampLeaf(base, "box") + } + + // container + const fromCatalog = n.gname ? resolve?.(n.gname, "group") : null + // An unlabelled frame with no stencil is a layout-only wrapper: emit a real cell so + // the structure survives a round-trip, but draw nothing. This replaces the + // reference project's "phantom", which emitted no cell and therefore lost the + // wrapper's direction and grouping on the way back. + const invisible = !n.gname && !n.label && !n.fill && !n.stroke + let base = n.style ?? fromCatalog ?? FALLBACK_FRAME + if (!n.style && !fromCatalog) { + if (n.fill) base += `fillColor=${n.fill};` + if (n.stroke) base += `strokeColor=${n.stroke};` + } + return stampContainer(base, { + kind: n.kind, + dir: n.kind === "grid" ? "grid" : n.dir, + gap: n.gap, + cols: n.kind === "grid" ? n.cols : undefined, + invisible, + }) +} + +/** + * One `` for a vertex, with geometry relative to its parent. + * + * An icon's cell is the glyph square, not the measured slot. Layout reserves a wider, + * taller slot so the label underneath has room, but the cell itself must stay square: + * the stencil scales to the cell, and `verticalLabelPosition=bottom` renders the label + * outside it. Emitting the padded slot would both stretch the glyph and — because the + * padding depends on the label length — make the size grow on every round-trip. + */ +function vertexXml( + n: DiagramNode, + rect: Rect, + parent: string, + parentRect: Rect | null, + resolve: StyleResolver | undefined, + defaultGlyph: number, +): string { + const ox = parentRect?.x ?? 0 + const oy = parentRect?.y ?? 0 + let box = rect + if (n.kind === "icon") { + const glyph = n.size ?? defaultGlyph + box = { + x: Math.round(rect.x + (rect.w - glyph) / 2), + y: rect.y, + w: glyph, + h: glyph, + } + } + return ( + `` + + `` + + `` + ) +} + +/** + * One `` for an edge. + * + * No waypoints: draw.io's own orthogonal router recomputes the route from the terminals + * on every edit, so a user who moves a node never has to re-link an arrow. Freezing a + * pre-computed route would look better on first open and then deform the moment anyone + * touched the diagram — the wrong trade for an editor. + */ +function edgeXml(l: LinkSpec, index: number): string { + const label = + l.step != null + ? l.label + ? `${l.step}. ${l.label}` + : `${l.step}.` + : (l.label ?? "") + let style = l.style ?? EDGE_STYLE + if (!l.style) { + if (l.dashed) style += "dashed=1;" + if (label) style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);" + } + const id = l.id ?? `ed${index + 1}` + return ( + `` + + `` + + `` + ) +} + +export interface RenderResult { + /** A complete `` document, ready for the editor. */ + xml: string + page: { w: number; h: number } + /** Ids the links referenced that no node provides — these edges were dropped. */ + danglingLinks: string[] +} + +/** + * Render a tree to a complete draw.io document. + * + * Links whose endpoints do not exist are dropped rather than emitted: draw.io renders a + * dangling edge as an arrow floating in space, which looks like a bug in the diagram. + * The dropped ids are reported so the caller can tell the model what happened. + */ +export function renderDiagram( + tree: DiagramTree, + opts: RenderOptions = {}, +): RenderResult { + const { roots, page } = layoutForest(tree.roots, { + iconSize: opts.iconSize, + gap: opts.rootGap, + }) + + const flat = flatten(roots) + const rectById = new Map() + for (const f of flat) rectById.set(f.node.id, f.rect) + + const cells: string[] = [] + + // Title spans the page width, above the content. + if (tree.title) + cells.push( + `` + + ``, + ) + + // Parents come before children (flatten guarantees it), which draw.io requires. + const glyph = opts.iconSize ?? ICON_SIZE + for (const f of flat) { + const parentRect = + f.parent === "1" ? null : (rectById.get(f.parent) ?? null) + cells.push( + vertexXml( + f.node, + f.rect, + f.parent, + parentRect, + opts.resolveStyle, + glyph, + ), + ) + } + + // Cells the parser could not interpret — user annotations, imported shapes — go back + // verbatim. A re-layout must not delete work the engine does not understand. + const foreignLayer = tree.foreign.some((c) => c.parent === "boundaries") + if (foreignLayer) + cells.push( + ``, + ) + for (const c of tree.foreign) cells.push(c.xml) + + const known = new Set(flat.map((f) => f.node.id)) + for (const c of tree.foreign) known.add(c.id) + const dangling: string[] = [] + let emitted = 0 + for (const l of tree.links) { + if (!known.has(l.source) || !known.has(l.target)) { + if (!known.has(l.source)) dangling.push(l.source) + if (!known.has(l.target)) dangling.push(l.target) + continue + } + cells.push(edgeXml(l, emitted++)) + } + + const model = + `` + + `${cells.join("")}` + + return { + xml: `${model}`, + page, + danglingLinks: [...new Set(dangling)], + } +} + +/** Re-export so callers can lay out without rendering. */ +export type { Placed } diff --git a/tests/e2e/diagram-engine-roundtrip.spec.ts b/tests/e2e/diagram-engine-roundtrip.spec.ts new file mode 100644 index 0000000..637a1e6 --- /dev/null +++ b/tests/e2e/diagram-engine-roundtrip.spec.ts @@ -0,0 +1,208 @@ +/** + * The closed loop, in a real browser: engine → draw.io → user drags something → engine + * reads the structure back. + * + * The unit tests prove render and parse agree with each other. This proves they agree + * with the actual editor: that the XML the engine writes renders, that a frame really + * accepts a drop, and that the structure recovered afterwards matches what the user did + * on screen. + * + * The engine runs in the test process (Playwright transpiles the spec, so the TypeScript + * imports resolve); only XML strings cross into the page. + */ +import { expect, test } from "@playwright/test" +import { parseDiagram } from "../../lib/diagram-engine/parse" +import { renderDiagram } from "../../lib/diagram-engine/render" +import { type DiagramTree, findParent } from "../../lib/diagram-engine/types" + +/** Two frames side by side; MOVER starts in the left one. */ +function twoFrames(extraLeft: string[] = []): DiagramTree { + return { + roots: [ + { + kind: "group", + id: "root", + gname: null, + label: "Root", + dir: "row", + gap: 60, + children: [ + { + kind: "group", + id: "left", + gname: null, + label: "Left", + dir: "col", + gap: 20, + children: [ + { kind: "box", id: "mover", label: "MOVER" }, + ...extraLeft.map((id) => ({ + kind: "box" as const, + id, + label: id.toUpperCase(), + })), + ], + }, + { + kind: "group", + id: "right", + gname: null, + label: "Right", + dir: "col", + gap: 20, + children: [ + { kind: "box", id: "anchor", label: "ANCHOR" }, + ], + }, + ], + }, + ], + links: [], + foreign: [], + } +} + +async function loadAndWatch( + page: import("@playwright/test").Page, + xml: string, +) { + await page.evaluate(() => { + const w = window as unknown as { __xml?: string[] } + w.__xml = [] + window.addEventListener("message", (e: MessageEvent) => { + if (typeof e.data !== "string") return + try { + const m = JSON.parse(e.data) + if ((m.event === "autosave" || m.event === "save") && m.xml) + w.__xml?.push(m.xml) + } catch { + /* not our message */ + } + }) + }) + await page.evaluate((x) => { + const iframe = document.querySelector("iframe") as HTMLIFrameElement + iframe.contentWindow?.postMessage( + JSON.stringify({ action: "load", xml: x, autosave: 1 }), + "*", + ) + }, xml) + await page.waitForTimeout(4000) +} + +const lastXml = (page: import("@playwright/test").Page) => + page.evaluate(() => { + const w = window as unknown as { __xml?: string[] } + const a = w.__xml ?? [] + return a.length ? a[a.length - 1] : null + }) + +const parentAttr = (xml: string, id: string) => + xml + .match(new RegExp(`]*\\bid="${id}"[^>]*>`))?.[0] + .match(/\bparent="([^"]*)"/)?.[1] ?? null + +/** Drag the cell labelled `from` into the frame that holds the cell labelled `into`. */ +async function dragInto( + page: import("@playwright/test").Page, + from: string, + into: string, +) { + const canvas = page.frameLocator("iframe") + const src = canvas.getByText(from, { exact: true }).first() + await src.waitFor({ state: "visible", timeout: 30000 }) + const dst = canvas.getByText(into, { exact: true }).first() + await dst.waitFor({ state: "visible", timeout: 30000 }) + const sb = await src.boundingBox() + const db = await dst.boundingBox() + if (!sb || !db) throw new Error("cells not rendered") + + // Drop below the anchor: inside the target frame, but not on top of the anchor. + await page.mouse.move(sb.x + sb.width / 2, sb.y + sb.height / 2) + await page.mouse.down() + await page.mouse.move(sb.x + sb.width / 2 + 20, sb.y + 10, { steps: 8 }) + await page.mouse.move(db.x + db.width / 2, db.y + db.height + 30, { + steps: 30, + }) + await page.waitForTimeout(800) + await page.mouse.up() + await page.waitForTimeout(3000) +} + +test.describe("diagram engine, end to end", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await page + .locator("iframe") + .waitFor({ state: "visible", timeout: 60000 }) + await page.waitForTimeout(6000) + }) + + test("engine output renders, and a drop into a frame becomes structure", async ({ + page, + }) => { + test.setTimeout(180000) + + const { xml } = renderDiagram(twoFrames()) + expect(xml).toContain("container=1") + await loadAndWatch(page, xml) + + await dragInto(page, "MOVER", "ANCHOR") + + const after = await lastXml(page) + expect(after, "editor emitted no autosave after the drag").toBeTruthy() + if (!after) return + + // draw.io reparented it, because the frame carries container=1. + expect(parentAttr(after, "mover")).toBe("right") + + // ...and the engine reads the user's change back as structure. There is no + // second copy of the state, so nothing to reconcile. + const { tree, needsAdoption } = parseDiagram(after) + expect(findParent(tree, "mover")?.id).toBe("right") + expect(findParent(tree, "anchor")?.id).toBe("right") + expect(needsAdoption).toBe(false) // markers survived the editor + }) + + test("a re-layout after the drag keeps the node in its new frame", async ({ + page, + }) => { + test.setTimeout(180000) + + const { xml } = renderDiagram(twoFrames(["stay"])) + await loadAndWatch(page, xml) + await dragInto(page, "MOVER", "ANCHOR") + + const after = await lastXml(page) + expect(after).toBeTruthy() + if (!after) return + + const afterDrag = parseDiagram(after).tree + expect(findParent(afterDrag, "mover")?.id).toBe("right") + + // Re-lay-out from what the canvas says, then read it back: the user's move is + // preserved rather than undone, and the node they did not touch stays put. + const relaid = parseDiagram(renderDiagram(afterDrag).xml).tree + expect(findParent(relaid, "mover")?.id).toBe("right") + expect(findParent(relaid, "stay")?.id).toBe("left") + }) + + test("the re-laid-out diagram still renders in the editor", async ({ + page, + }) => { + test.setTimeout(180000) + + // Guards against the engine emitting XML that parses fine but the editor + // rejects — geometry the wrong side of a parent, a forward reference, and so on. + const first = renderDiagram(twoFrames(["stay"])) + const relaid = renderDiagram(parseDiagram(first.xml).tree) + + await loadAndWatch(page, relaid.xml) + const canvas = page.frameLocator("iframe") + for (const label of ["MOVER", "STAY", "ANCHOR", "Left", "Right"]) { + await expect( + canvas.getByText(label, { exact: true }).first(), + ).toBeVisible({ timeout: 20000 }) + } + }) +}) diff --git a/tests/unit/diagram-engine-layout.test.ts b/tests/unit/diagram-engine-layout.test.ts new file mode 100644 index 0000000..5da6ad5 --- /dev/null +++ b/tests/unit/diagram-engine-layout.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, it } from "vitest" +import { + autoBoxSize, + flatten, + layoutForest, + type Placed, +} from "@/lib/diagram-engine/layout" +import type { + BoxNode, + DiagramNode, + GridNode, + GroupNode, + IconNode, + Rect, +} from "@/lib/diagram-engine/types" + +const icon = (id: string, label = "", size?: number): IconNode => ({ + kind: "icon", + id, + name: "ec2", + label, + size, +}) +const box = (id: string, label = "", w?: number, h?: number): BoxNode => ({ + kind: "box", + id, + label, + w, + h, +}) +const group = ( + id: string, + dir: "row" | "col", + children: DiagramNode[], + label = "", + gap = 20, +): GroupNode => ({ kind: "group", id, gname: null, label, dir, gap, children }) +const grid = ( + id: string, + cols: number, + children: DiagramNode[], + label = "", + gap = 20, +): GridNode => ({ kind: "grid", id, gname: null, label, cols, gap, children }) + +/** Look a rect up by node id in a placed forest. */ +function rects(roots: Placed[]): Map { + const m = new Map() + for (const f of flatten(roots)) m.set(f.node.id, f.rect) + return m +} +const contains = (outer: Rect, inner: Rect) => + inner.x >= outer.x && + inner.y >= outer.y && + inner.x + inner.w <= outer.x + outer.w && + inner.y + inner.h <= outer.y + outer.h +const overlaps = (a: Rect, b: Rect) => + Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0 && + Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0 + +describe("autoBoxSize", () => { + it("widens with the label but stops at a maximum", () => { + expect(autoBoxSize("hi").w).toBe(120) // floor + expect(autoBoxSize("x".repeat(200)).w).toBe(260) // ceiling + expect(autoBoxSize("a medium length label").w).toBeGreaterThan(120) + }) + + it("grows taller with each line", () => { + expect(autoBoxSize("one\ntwo\nthree").h).toBeGreaterThan( + autoBoxSize("one").h, + ) + }) + + it("never returns a degenerate size for an empty label", () => { + const s = autoBoxSize("") + expect(s.w).toBeGreaterThan(0) + expect(s.h).toBeGreaterThan(0) + }) +}) + +describe("containers always fit their children", () => { + it("holds a row of icons", () => { + const tree = group( + "f", + "row", + [icon("a"), icon("b"), icon("c")], + "Frame", + ) + const r = rects(layoutForest([tree]).roots) + const frame = r.get("f") as Rect + for (const id of ["a", "b", "c"]) + expect(contains(frame, r.get(id) as Rect)).toBe(true) + }) + + it("holds a column of icons", () => { + const tree = group("f", "col", [icon("a"), icon("b")], "Frame") + const r = rects(layoutForest([tree]).roots) + for (const id of ["a", "b"]) + expect(contains(r.get("f") as Rect, r.get(id) as Rect)).toBe(true) + }) + + it("holds a grid", () => { + const tree = grid( + "g", + 3, + [icon("a"), icon("b"), icon("c"), icon("d")], + "G", + ) + const r = rects(layoutForest([tree]).roots) + for (const id of ["a", "b", "c", "d"]) + expect(contains(r.get("g") as Rect, r.get(id) as Rect)).toBe(true) + }) + + it("holds a deeply nested structure at every level", () => { + // Region → VPC → AZ → Subnet → icon, the shape of a real cloud diagram + const tree = group( + "region", + "row", + [ + group( + "vpc", + "col", + [ + group( + "az", + "col", + [group("subnet", "col", [icon("ec2")], "Subnet")], + "AZ", + ), + ], + "VPC", + ), + ], + "Region", + ) + const r = rects(layoutForest([tree]).roots) + const chain = ["region", "vpc", "az", "subnet", "ec2"] + for (let i = 1; i < chain.length; i++) + expect( + contains(r.get(chain[i - 1]) as Rect, r.get(chain[i]) as Rect), + ).toBe(true) + }) + + it("holds a child whose label is far wider than the frame's own", () => { + const tree = group( + "f", + "col", + [box("wide", "a considerably longer label than the frame title")], + "F", + ) + const r = rects(layoutForest([tree]).roots) + expect(contains(r.get("f") as Rect, r.get("wide") as Rect)).toBe(true) + }) +}) + +describe("siblings never overlap", () => { + it("keeps a row of icons apart", () => { + const tree = group("f", "row", [icon("a"), icon("b"), icon("c")]) + const r = rects(layoutForest([tree]).roots) + expect(overlaps(r.get("a") as Rect, r.get("b") as Rect)).toBe(false) + expect(overlaps(r.get("b") as Rect, r.get("c") as Rect)).toBe(false) + }) + + it("keeps a column of frames apart", () => { + const tree = group("f", "col", [ + group("s1", "row", [icon("a")], "Public"), + group("s2", "row", [icon("b")], "Private"), + group("s3", "row", [icon("c")], "Data"), + ]) + const r = rects(layoutForest([tree]).roots) + expect(overlaps(r.get("s1") as Rect, r.get("s2") as Rect)).toBe(false) + expect(overlaps(r.get("s2") as Rect, r.get("s3") as Rect)).toBe(false) + }) + + it("keeps grid cells apart", () => { + const tree = grid("g", 2, [icon("a"), icon("b"), icon("c"), icon("d")]) + const r = rects(layoutForest([tree]).roots) + const ids = ["a", "b", "c", "d"] + for (let i = 0; i < ids.length; i++) + for (let j = i + 1; j < ids.length; j++) + expect( + overlaps(r.get(ids[i]) as Rect, r.get(ids[j]) as Rect), + ).toBe(false) + }) + + it("keeps separate roots apart", () => { + const r = rects( + layoutForest([ + box("users", "Users"), + group("region", "row", [icon("a")]), + ]).roots, + ) + expect(overlaps(r.get("users") as Rect, r.get("region") as Rect)).toBe( + false, + ) + }) + + it("keeps 30 siblings apart — no accumulation error", () => { + const kids = Array.from({ length: 30 }, (_, i) => + icon(`i${i}`, `n${i}`), + ) + const r = rects(layoutForest([group("f", "row", kids)]).roots) + for (let i = 1; i < 30; i++) { + const prev = r.get(`i${i - 1}`) as Rect + const cur = r.get(`i${i}`) as Rect + expect(cur.x).toBeGreaterThanOrEqual(prev.x + prev.w) + } + }) +}) + +describe("direction", () => { + it("advances along x in a row and keeps y aligned", () => { + const r = rects( + layoutForest([group("f", "row", [icon("a"), icon("b")])]).roots, + ) + const a = r.get("a") as Rect + const b = r.get("b") as Rect + expect(b.x).toBeGreaterThan(a.x) + expect(b.y).toBe(a.y) + }) + + it("advances along y in a column and keeps x aligned", () => { + const r = rects( + layoutForest([group("f", "col", [icon("a"), icon("b")])]).roots, + ) + const a = r.get("a") as Rect + const b = r.get("b") as Rect + expect(b.y).toBeGreaterThan(a.y) + expect(b.x).toBe(a.x) + }) + + it("wraps a grid at the column count", () => { + const r = rects( + layoutForest([ + grid("g", 2, [icon("a"), icon("b"), icon("c"), icon("d")]), + ]).roots, + ) + const a = r.get("a") as Rect + const b = r.get("b") as Rect + const c = r.get("c") as Rect + expect(b.y).toBe(a.y) // same row + expect(c.y).toBeGreaterThan(a.y) // wrapped + expect(c.x).toBe(a.x) // back to the first column + }) +}) + +describe("sibling equalisation", () => { + it("gives frames in a row a shared height", () => { + const tree = group("f", "row", [ + group("tall", "col", [icon("a"), icon("b"), icon("c")], "Tall"), + group("short", "col", [icon("d")], "Short"), + ]) + const r = rects(layoutForest([tree]).roots) + expect((r.get("short") as Rect).h).toBe((r.get("tall") as Rect).h) + }) + + it("gives frames in a column a shared width", () => { + const tree = group("f", "col", [ + group("wide", "row", [icon("a"), icon("b"), icon("c")], "Wide"), + group("narrow", "row", [icon("d")], "Narrow"), + ]) + const r = rects(layoutForest([tree]).roots) + expect((r.get("narrow") as Rect).w).toBe((r.get("wide") as Rect).w) + }) + + it("does not stretch a leaf icon — that would distort the glyph", () => { + const tree = group("f", "row", [ + group("tall", "col", [icon("a"), icon("b"), icon("c")], "Tall"), + icon("lone", "Lone"), + ]) + const r = rects(layoutForest([tree]).roots) + expect((r.get("lone") as Rect).h).toBeLessThan( + (r.get("tall") as Rect).h, + ) + }) +}) + +describe("title strip", () => { + it("reserves space above the children when a container is labelled", () => { + const withLabel = rects( + layoutForest([group("f", "row", [icon("a")], "Titled")]).roots, + ) + const withoutLabel = rects( + layoutForest([group("f", "row", [icon("a")], "")]).roots, + ) + expect((withLabel.get("f") as Rect).h).toBeGreaterThan( + (withoutLabel.get("f") as Rect).h, + ) + }) + + it("pushes children below the strip so the label is not covered", () => { + const r = rects( + layoutForest([group("f", "col", [icon("a")], "Titled")]).roots, + ) + const f = r.get("f") as Rect + const a = r.get("a") as Rect + expect(a.y).toBeGreaterThanOrEqual(f.y + 36) + }) + + it("widens a frame whose title is longer than its contents", () => { + const longTitle = + "A Very Long Container Title That Exceeds Its Single Child" + const r = rects( + layoutForest([group("f", "row", [icon("a")], longTitle)]).roots, + ) + expect((r.get("f") as Rect).w).toBeGreaterThan(longTitle.length * 5) + }) +}) + +describe("page size", () => { + it("covers every node plus a margin", () => { + const { roots, page } = layoutForest([ + group("f", "row", [icon("a"), icon("b")], "F"), + ]) + const all = flatten(roots) + const maxX = Math.max(...all.map((n) => n.rect.x + n.rect.w)) + const maxY = Math.max(...all.map((n) => n.rect.y + n.rect.h)) + expect(page.w).toBeGreaterThan(maxX) + expect(page.h).toBeGreaterThan(maxY) + }) + + it("grows with the content", () => { + const small = layoutForest([group("f", "row", [icon("a")])]).page + const big = layoutForest([ + group( + "f", + "row", + Array.from({ length: 10 }, (_, i) => icon(`i${i}`)), + ), + ]).page + expect(big.w).toBeGreaterThan(small.w) + }) +}) + +describe("pinned nodes", () => { + it("keeps a pinned root where the user left it", () => { + const pinned: GroupNode = { + ...group("f", "row", [icon("a")], "F"), + pinned: true, + rect: { x: 777, y: 555, w: 100, h: 100 }, + } + const r = rects(layoutForest([pinned]).roots) + expect((r.get("f") as Rect).x).toBe(777) + expect((r.get("f") as Rect).y).toBe(555) + }) + + it("still lays the pinned node's children out inside it", () => { + const pinned: GroupNode = { + ...group("f", "row", [icon("a")], "F"), + pinned: true, + rect: { x: 300, y: 300, w: 100, h: 100 }, + } + const r = rects(layoutForest([pinned]).roots) + expect(contains(r.get("f") as Rect, r.get("a") as Rect)).toBe(true) + }) + + it("does not let a pinned root consume flow space from the others", () => { + const pinned: BoxNode = { + ...box("pin", "Pinned"), + pinned: true, + rect: { x: 900, y: 900, w: 120, h: 60 }, + } + const r = rects(layoutForest([pinned, box("flow", "Flow")]).roots) + // the un-pinned root starts at the normal origin, not offset past the pinned one + expect((r.get("flow") as Rect).x).toBe(40) + }) +}) + +describe("icon sizing", () => { + it("applies the diagram-wide glyph size", () => { + const small = rects(layoutForest([icon("a")], { iconSize: 48 }).roots) + const large = rects(layoutForest([icon("a")], { iconSize: 96 }).roots) + expect((large.get("a") as Rect).h).toBeGreaterThan( + (small.get("a") as Rect).h, + ) + }) + + it("lets a per-icon size override the diagram default", () => { + const r = rects( + layoutForest([group("f", "row", [icon("a"), icon("b", "", 96)])], { + iconSize: 48, + }).roots, + ) + expect((r.get("b") as Rect).h).toBeGreaterThan((r.get("a") as Rect).h) + }) + + it("widens the cell for a long label so it does not overflow", () => { + const r = rects( + layoutForest([ + group("f", "row", [ + icon("a", "x"), + icon("b", "a much longer label"), + ]), + ]).roots, + ) + expect((r.get("b") as Rect).w).toBeGreaterThan((r.get("a") as Rect).w) + }) +}) + +describe("degenerate input", () => { + it("handles an empty forest", () => { + const { roots, page } = layoutForest([]) + expect(roots).toEqual([]) + expect(page.w).toBeGreaterThan(0) + }) + + it("handles an empty container without producing a negative size", () => { + const r = rects(layoutForest([group("f", "row", [], "Empty")]).roots) + const f = r.get("f") as Rect + expect(f.w).toBeGreaterThan(0) + expect(f.h).toBeGreaterThan(0) + }) + + it("handles a grid with fewer children than columns", () => { + const r = rects(layoutForest([grid("g", 5, [icon("a")], "G")]).roots) + expect(contains(r.get("g") as Rect, r.get("a") as Rect)).toBe(true) + }) + + it("rounds every coordinate to an integer — draw.io renders half-pixels blurry", () => { + const tree = group("f", "row", [ + group("a", "col", [icon("x"), icon("y"), icon("z")], "A"), + icon("b"), + ]) + for (const f of flatten(layoutForest([tree]).roots)) { + expect(Number.isInteger(f.rect.x)).toBe(true) + expect(Number.isInteger(f.rect.y)).toBe(true) + } + }) +}) + +describe("determinism", () => { + it("produces identical geometry for the same tree twice", () => { + const build = () => + group("f", "row", [ + group("a", "col", [icon("x"), icon("y")], "A"), + grid("g", 2, [icon("p"), icon("q"), icon("r")], "G"), + ]) + const first = flatten(layoutForest([build()]).roots).map((n) => [ + n.node.id, + n.rect, + ]) + const second = flatten(layoutForest([build()]).roots).map((n) => [ + n.node.id, + n.rect, + ]) + expect(second).toEqual(first) + }) +}) + +describe("flatten", () => { + it("reports the real parent id for a nested node and the layer for a root", () => { + const tree = group("f", "row", [group("inner", "row", [icon("leaf")])]) + const flat = flatten(layoutForest([tree]).roots) + const by = new Map(flat.map((n) => [n.node.id, n.parent])) + expect(by.get("f")).toBe("1") + expect(by.get("inner")).toBe("f") + expect(by.get("leaf")).toBe("inner") + }) + + it("emits a parent before its children, so XML order is valid", () => { + const tree = group("f", "row", [group("inner", "row", [icon("leaf")])]) + const ids = flatten(layoutForest([tree]).roots).map((n) => n.node.id) + expect(ids.indexOf("f")).toBeLessThan(ids.indexOf("inner")) + expect(ids.indexOf("inner")).toBeLessThan(ids.indexOf("leaf")) + }) +}) diff --git a/tests/unit/diagram-engine-parse.test.ts b/tests/unit/diagram-engine-parse.test.ts index 65a9ec1..5501e55 100644 --- a/tests/unit/diagram-engine-parse.test.ts +++ b/tests/unit/diagram-engine-parse.test.ts @@ -135,9 +135,17 @@ describe("parseDiagram on real engine output", () => { // // This is why our engine must not have phantoms: a wrapper that emits no cell // makes the round-trip lossy by construction. See task #5. - expect(findNode(tree, "vpc")?.kind).toBe("grid") + // + // The parser does not guess a direction here. It keeps the container and warns + // that the arrangement is two-dimensional, so the caller knows a re-layout will + // move these children rather than discovering it afterwards. expect(findParent(tree, "az_a")?.id).toBe("vpc") expect(findNode(tree, "azs")).toBeNull() + expect( + warnings.some( + (w) => w.includes("vpc") && w.includes("two dimensions"), + ), + ).toBe(true) }) it("classifies resourceIcon cells as icons and recovers their catalog name", () => { @@ -186,8 +194,11 @@ describe("parseDiagram on real engine output", () => { expect(needsAdoption).toBe(true) }) - it("parses without warnings on a well-formed single page", () => { - expect(warnings).toEqual([]) + it("warns only about the phantom-flattened container, nothing else", () => { + // The single warning is the 2-D arrangement the phantom left behind; a + // well-formed page produces no other complaint. + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain("two dimensions") }) it("assigns every cell exactly once — no duplicates, nothing lost", () => { diff --git a/tests/unit/diagram-engine-roundtrip.test.ts b/tests/unit/diagram-engine-roundtrip.test.ts new file mode 100644 index 0000000..1d9f085 --- /dev/null +++ b/tests/unit/diagram-engine-roundtrip.test.ts @@ -0,0 +1,541 @@ +/** + * Round-trip: tree → XML → tree. + * + * This is the test the whole design rests on. If structure does not survive a trip + * through draw.io XML, then the canvas cannot be the single source of truth and we are + * back to keeping a second copy of the state in sync. + */ +import { describe, expect, it } from "vitest" +import { parseDiagram } from "@/lib/diagram-engine/parse" +import { renderDiagram } from "@/lib/diagram-engine/render" +import { + type BoxNode, + type ContainerNode, + type DiagramNode, + type DiagramTree, + findNode, + findParent, + type GridNode, + type GroupNode, + type IconNode, + isContainer, + walkTree, +} from "@/lib/diagram-engine/types" + +const icon = (id: string, name = "ec2", label = ""): IconNode => ({ + kind: "icon", + id, + name, + label, +}) +const box = (id: string, label = ""): BoxNode => ({ kind: "box", id, label }) +const group = ( + id: string, + dir: "row" | "col", + children: DiagramNode[], + label = "", + gname: string | null = null, + gap = 20, +): GroupNode => ({ kind: "group", id, gname, label, dir, gap, children }) +const grid = ( + id: string, + cols: number, + children: DiagramNode[], + label = "", + gap = 14, +): GridNode => ({ kind: "grid", id, gname: null, label, cols, gap, children }) + +const tree = ( + roots: DiagramNode[], + extra: Partial = {}, +): DiagramTree => ({ + roots, + links: [], + foreign: [], + ...extra, +}) + +/** Structural signature: nesting, kinds, directions and order — everything but coordinates. */ +function signature(t: DiagramTree): string { + const line = (n: DiagramNode, depth: number): string[] => { + const pad = " ".repeat(depth) + if (!isContainer(n)) return [`${pad}${n.kind} ${n.id}`] + const meta = n.kind === "grid" ? `cols=${n.cols}` : `dir=${n.dir}` + return [ + `${pad}${n.kind} ${n.id} ${meta} gap=${n.gap}`, + ...n.children.flatMap((c) => line(c, depth + 1)), + ] + } + return t.roots.flatMap((r) => line(r, 0)).join("\n") +} + +/** Render then parse, returning the recovered tree. */ +function roundTrip(t: DiagramTree) { + const { xml } = renderDiagram(t) + return { xml, ...parseDiagram(xml) } +} + +describe("structure survives a round-trip", () => { + it("recovers a flat row", () => { + const t = tree([group("f", "row", [icon("a"), icon("b")], "Frame")]) + expect(signature(roundTrip(t).tree)).toBe(signature(t)) + }) + + it("recovers a column", () => { + const t = tree([group("f", "col", [icon("a"), icon("b")], "Frame")]) + expect(signature(roundTrip(t).tree)).toBe(signature(t)) + }) + + it("recovers a grid with its column count", () => { + const t = tree([ + grid("g", 3, [icon("a"), icon("b"), icon("c"), icon("d")], "G"), + ]) + expect(signature(roundTrip(t).tree)).toBe(signature(t)) + }) + + it("recovers a deep cloud-architecture nesting", () => { + const t = tree([ + group( + "region", + "row", + [ + group( + "vpc", + "col", + [ + icon("igw", "internet_gateway", "IGW"), + group( + "az_a", + "col", + [ + group( + "pub_a", + "col", + [icon("nat_a", "nat_gateway", "NAT")], + "Public Subnet", + "group_subnet", + ), + group( + "app_a", + "col", + [icon("ec2_a", "ec2", "EC2")], + "Private Subnet", + "group_subnet", + ), + ], + "AZ-a", + "group_availability_zone", + ), + ], + "VPC", + "group_vpc", + ), + ], + "Region", + "group_region", + ), + ]) + expect(signature(roundTrip(t).tree)).toBe(signature(t)) + }) + + it("recovers several roots in order", () => { + const t = tree([ + box("users", "Users"), + group("cloud", "row", [icon("a")], "Cloud"), + box("consumers", "Consumers"), + ]) + const back = roundTrip(t).tree + expect(back.roots.map((r) => r.id)).toEqual([ + "users", + "cloud", + "consumers", + ]) + }) + + it("recovers the direction of an UNLABELLED wrapper — the phantom problem, fixed", () => { + // The reference project would use a phantom here, which emits no cell: its two + // children would be reparented onto vpc, and the wrapper's "row" direction would + // be gone from the XML for good. We emit a real but invisible cell instead. + const t = tree([ + group( + "vpc", + "col", + [ + icon("igw", "internet_gateway", "IGW"), + group( + "azs", + "row", + [ + group("az_a", "col", [icon("ec2_a")], "AZ-a"), + group("az_b", "col", [icon("ec2_b")], "AZ-b"), + ], + "", // no label — a layout-only wrapper + ), + ], + "VPC", + "group_vpc", + ), + ]) + const back = roundTrip(t).tree + // the wrapper is still there, still a row, still holding both AZs + const azs = findNode(back, "azs") + expect(azs).not.toBeNull() + expect((azs as GroupNode).dir).toBe("row") + expect(findParent(back, "az_a")?.id).toBe("azs") + expect(findParent(back, "azs")?.id).toBe("vpc") + // and vpc kept its own direction instead of collapsing into a grid + expect((findNode(back, "vpc") as GroupNode).dir).toBe("col") + expect(signature(back)).toBe(signature(t)) + }) + + it("keeps the wrapper invisible", () => { + const t = tree([ + group("w", "row", [icon("a"), icon("b")], ""), // unlabelled → invisible + ]) + const { xml } = renderDiagram(t) + const cell = xml.match(/]*style="([^"]*)"/)?.[1] ?? "" + expect(cell).toContain("fillColor=none") + expect(cell).toContain("strokeColor=none") + // ...but it is still a real container, so draw.io reparents into it + expect(cell).toContain("container=1") + }) + + it("keeps a labelled frame visible", () => { + const t = tree([group("f", "row", [icon("a")], "Visible")]) + const { xml } = renderDiagram(t) + const style = xml.match(/]*style="([^"]*)"/)?.[1] ?? "" + expect(style).not.toContain("strokeColor=none") + }) + + it("survives repeated round-trips without drift", () => { + const t = tree([ + group( + "outer", + "row", + [ + group("a", "col", [icon("x"), icon("y")], "A"), + grid("g", 2, [icon("p"), icon("q"), icon("r")], "G"), + ], + "Outer", + ), + ]) + const once = roundTrip(t).tree + const twice = roundTrip(once).tree + const thrice = roundTrip(twice).tree + expect(signature(twice)).toBe(signature(once)) + expect(signature(thrice)).toBe(signature(once)) + }) + + it("keeps geometry stable across repeated round-trips", () => { + const t = tree([group("f", "row", [icon("a"), icon("b")], "F")]) + const first = renderDiagram(t) + const second = renderDiagram(parseDiagram(first.xml).tree) + expect(second.page).toEqual(first.page) + }) +}) + +describe("labels and content survive", () => { + it("recovers labels on containers and leaves", () => { + const t = tree([ + group( + "f", + "row", + [icon("a", "s3", "My Bucket"), box("b", "A Box")], + "My Frame", + ), + ]) + const back = roundTrip(t).tree + expect((findNode(back, "f") as ContainerNode).label).toBe("My Frame") + expect((findNode(back, "a") as IconNode).label).toBe("My Bucket") + expect((findNode(back, "b") as BoxNode).label).toBe("A Box") + }) + + it("recovers a label containing XML metacharacters", () => { + const nasty = "A & B \"quoted\" 'apostrophe'" + const t = tree([group("f", "row", [box("b", nasty)], nasty)]) + const back = roundTrip(t).tree + expect((findNode(back, "b") as BoxNode).label).toBe(nasty) + expect((findNode(back, "f") as ContainerNode).label).toBe(nasty) + }) + + it("recovers the page title", () => { + const t = tree([group("f", "row", [icon("a")], "F")], { + title: "My Architecture", + }) + expect(roundTrip(t).tree.title).toBe("My Architecture") + }) + + it("recovers a gap that is not the default", () => { + const t = tree([ + group("f", "row", [icon("a"), icon("b")], "F", null, 47), + ]) + expect((findNode(roundTrip(t).tree, "f") as GroupNode).gap).toBe(47) + }) + + it("recovers an icon's catalog name", () => { + const t = tree([ + group( + "f", + "row", + [icon("a", "nat_gateway"), icon("b", "rds")], + "F", + ), + ]) + const back = roundTrip(t).tree + expect((findNode(back, "a") as IconNode).name).toBe("nat_gateway") + expect((findNode(back, "b") as IconNode).name).toBe("rds") + }) + + it("recovers a group stencil name", () => { + const t = tree([group("v", "col", [icon("a")], "VPC", "group_vpc")]) + const { xml } = renderDiagram(t, { + resolveStyle: (name, kind) => + kind === "group" + ? `shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.${name};fillColor=none;strokeColor=#8C4FFF;verticalAlign=top;align=left;` + : null, + }) + expect( + (findNode(parseDiagram(xml).tree, "v") as ContainerNode).gname, + ).toBe("group_vpc") + }) +}) + +describe("edges survive", () => { + it("recovers source, target and label", () => { + const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], { + links: [{ source: "a", target: "b", label: "flows to" }], + }) + const back = roundTrip(t).tree + expect(back.links).toHaveLength(1) + expect(back.links[0].source).toBe("a") + expect(back.links[0].target).toBe("b") + expect(back.links[0].label).toBe("flows to") + }) + + it("recovers a step number and keeps it out of the label", () => { + const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], { + links: [{ source: "a", target: "b", label: "HTTPS", step: 1 }], + }) + const back = roundTrip(t).tree + expect(back.links[0].step).toBe(1) + expect(back.links[0].label).toBe("HTTPS") + }) + + it("recovers a dashed edge", () => { + const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], { + links: [{ source: "a", target: "b", dashed: true }], + }) + expect(roundTrip(t).tree.links[0].dashed).toBe(true) + }) + + it("drops an edge with a missing endpoint and reports it", () => { + const t = tree([group("f", "row", [icon("a")], "F")], { + links: [{ source: "a", target: "ghost" }], + }) + const r = renderDiagram(t) + expect(r.danglingLinks).toEqual(["ghost"]) + expect(r.xml).not.toContain('target="ghost"') + }) + + it("emits no waypoints, so draw.io re-routes when the user moves a node", () => { + const t = tree([group("f", "row", [icon("a"), icon("b")], "F")], { + links: [{ source: "a", target: "b", label: "x" }], + }) + expect(renderDiagram(t).xml).not.toContain('as="points"') + }) +}) + +describe("foreign cells survive", () => { + it("re-emits an unrecognised cell verbatim", () => { + const custom = + '' + const t = tree([group("f", "row", [icon("a")], "F")], { + foreign: [{ id: "note", xml: custom, parent: "1" }], + }) + expect(renderDiagram(t).xml).toContain(custom) + }) + + it("re-creates the boundaries layer when a foreign cell needs it", () => { + const t = tree([group("f", "row", [icon("a")], "F")], { + foreign: [ + { + id: "cluster", + xml: '', + parent: "boundaries", + }, + ], + }) + const { xml } = renderDiagram(t) + expect(xml).toContain(' { + const t = tree([group("f", "row", [icon("a")], "F")], { + foreign: [ + { + id: "legend", + xml: '', + parent: "1", + }, + ], + links: [{ source: "a", target: "legend" }], + }) + const r = renderDiagram(t) + expect(r.danglingLinks).toEqual([]) + expect(r.xml).toContain('target="legend"') + }) +}) + +describe("the emitted XML is well formed for draw.io", () => { + const t = tree( + [ + group( + "region", + "row", + [ + group( + "vpc", + "col", + [icon("a"), icon("b")], + "VPC", + "group_vpc", + ), + ], + "Region", + "group_region", + ), + ], + { title: "T", links: [{ source: "a", target: "b" }] }, + ) + const { xml } = renderDiagram(t) + + it("wraps the model in mxfile/diagram", () => { + expect(xml.startsWith(" { + expect(xml).toContain('') + expect(xml).toContain('') + }) + + it("declares a parent before any cell that references it", () => { + expect(xml.indexOf('id="region"')).toBeLessThan( + xml.indexOf('parent="region"'), + ) + expect(xml.indexOf('id="vpc"')).toBeLessThan( + xml.indexOf('parent="vpc"'), + ) + }) + + it("gives every cell a unique id", () => { + const ids = [...xml.matchAll(/ m[1]) + expect(new Set(ids).size).toBe(ids.length) + }) + + it("sets the page size from the content", () => { + const w = Number(xml.match(/pageWidth="(\d+)"/)?.[1]) + const h = Number(xml.match(/pageHeight="(\d+)"/)?.[1]) + expect(w).toBeGreaterThan(0) + expect(h).toBeGreaterThan(0) + }) + + it("writes nested geometry relative to the parent, as draw.io expects", () => { + // vpc sits inside region, so its x must be small — an absolute x would push it + // outside the frame when draw.io adds the parent offset. + const vpcGeo = xml.match( + /]*>\s* { + // Verified in-browser: without this, a shape dragged into the frame keeps + // parent="1" and the nesting is lost. + const regionStyle = + xml.match(/]*style="([^"]*)"/)?.[1] ?? "" + expect(regionStyle).toContain("container=1") + }) + + it("does not stamp container=1 on a leaf", () => { + const iconStyle = + xml.match(/]*style="([^"]*)"/)?.[1] ?? "" + expect(iconStyle).not.toContain("container=1") + }) +}) + +describe("user edits are inputs, not conflicts", () => { + it("keeps a hand-changed fill through a re-layout", () => { + // The user recoloured a box in draw.io. Re-deriving the tree picks up the style + // verbatim, so re-rendering preserves the colour rather than resetting it. + const t = tree([group("f", "row", [icon("a"), box("b", "Box")], "F")]) + const first = renderDiagram(t).xml + const edited = first.replace( + /(]*style=")([^"]*)"/, + '$1$2fillColor=#FF0000;"', + ) + const back = parseDiagram(edited).tree + expect(renderDiagram(back).xml).toContain("fillColor=#FF0000") + }) + + it("re-lays-out around a node the user dragged into a different frame", () => { + // Two frames; the user moves icon "b" from f1 into f2. draw.io rewrites the + // parent attribute (container=1 is in place), so the next layout puts it inside + // f2 — no reconciliation step, the canvas simply says where things are. + const t = tree([ + group( + "root", + "row", + [ + group("f1", "col", [icon("a"), icon("b")], "F1"), + group("f2", "col", [icon("c")], "F2"), + ], + "Root", + ), + ]) + const first = renderDiagram(t).xml + const moved = first.replace( + /(]*)parent="f1"/, + '$1parent="f2"', + ) + const back = parseDiagram(moved).tree + expect(findParent(back, "b")?.id).toBe("f2") + // and the re-render keeps it there, sized to fit + const again = parseDiagram(renderDiagram(back).xml).tree + expect(findParent(again, "b")?.id).toBe("f2") + }) + + it("honours a pin the user added by hand in Edit Style", () => { + const t = tree([box("pin", "Pinned"), box("flow", "Flow")]) + const first = renderDiagram(t).xml + const pinned = first.replace( + /(]*style="[^"]*)"/, + '$1dai_pin=1;"', + ) + const back = parseDiagram(pinned).tree + const node = findNode(back, "pin") as BoxNode + expect(node.pinned).toBe(true) + const pos = node.rect + // re-rendering leaves the pinned node exactly where it was + const again = parseDiagram(renderDiagram(back).xml).tree + expect((findNode(again, "pin") as BoxNode).rect).toEqual(pos) + }) + + it("does not lose a cell the user added by hand", () => { + const t = tree([group("f", "row", [icon("a")], "F")]) + const first = renderDiagram(t).xml + // user drops a new shape on the canvas + const withNew = first.replace( + "", + '', + ) + const back = parseDiagram(withNew).tree + const ids = [...walkTree(back)].map((n) => n.id) + expect(ids).toContain("userbox") + expect(renderDiagram(back).xml).toContain('id="userbox"') + }) +})