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"') + }) +})