Files
next-ai-draw-io/lib/diagram-engine/shapes.ts
dayuan.jiang 50826c0ac8 feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:

- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
  matching perimeter — required or edges connect to the bounding box; a
  text-scale factor verified in the real editor — the same sentence overflows
  a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
  figures). Any other token passes through verbatim: draw.io degrades unknown
  shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
  rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
  distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
  rectangular node forever. set_shape/set_role/set_group operations make the
  fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
  displace each other) instead of string concatenation. This is what makes
  shape and theme composable by rule — shape owns geometry keys, theme owns
  colour/type keys, and an overlap resolves by order instead of emitting
  contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
  appearance-based reverse mapping cannot distinguish aliases (diamond vs
  decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
  boxes no longer freeze the first layout's numbers, so changing a label
  re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
  schema-drift problem the review predicted).

14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
2026-08-09 21:56:09 +09:00

236 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The shape vocabulary: what a box can BE, beyond a labelled rectangle.
*
* draw.io ships hundreds of shapes; the engine's declarative layer used to allow six.
* That gap — not colours, not spacing — was why engine output looked flat next to
* hand-written XML: a database drawn as a grey rectangle labelled "database" instead of
* a cylinder. This module opens the vocabulary in two tiers:
*
* CATALOG — ~20 curated shapes the engine fully understands. Each entry carries the
* complete style fragment (including the matching `perimeter=`, which draw.io's own
* style reference warns is required or edges connect to the bounding box), how much
* larger the box must be for its text to fit inside the non-rectangular outline
* (verified empirically in the real editor: a rhombus needs ~1.5× the rectangle's
* size for the same text), and whether the label renders below the glyph instead of
* inside it.
*
* PASS-THROUGH — any other token that looks like a draw.io shape name is emitted
* verbatim as `shape=<token>;`. Verified in the real editor: an unknown token
* degrades to a rectangle, it does not break the page. A conservative text scale
* covers the common case that the real shape is roughly convex. The tool response
* carries a near-match hint ("cyclinder → cylinder?") so a typo is a one-turn fix,
* not a silent permanent degradation.
*
* Style strings are merged structurally, not concatenated: each fragment is parsed to
* key=value tokens and later fragments override earlier ones per key. This is what
* makes shape and theme composable by rule — the shape fragment owns geometry keys
* (shape, perimeter, rounded…), the theme owns colour and type keys, and an overlap
* (a theme that says rounded=1 on a rhombus) resolves by order instead of emitting
* two conflicting tokens.
*/
/** How a known shape renders and measures. */
export interface ShapeSpec {
/** Geometry style tokens ONLY — no colours, no fonts; those belong to the theme. */
style: string
/**
* How much larger than a rectangle the box must be for the same text to fit
* inside the outline. 1.0 for the rectangle family; ~1.5 for a rhombus, whose
* inscribed rectangle is half its bounding box.
*/
textScale: number
/** The label renders below the glyph, not inside it (umlActor and friends). */
labelOutside?: boolean
/** Fixed glyph size for labelOutside shapes, which do not scale with text. */
glyph?: { w: number; h: number }
}
/**
* The curated catalog. Keys are the vocabulary the model is taught; several are
* semantic aliases for the same geometry (decision/diamond), because the model will
* reach for both names.
*/
export const SHAPE_CATALOG: Record<string, ShapeSpec> = {
// ---- the rectangle family (the original six) ----
box: { style: "rounded=0;", textScale: 1 },
round: { style: "rounded=1;arcSize=12;", textScale: 1 },
terminator: { style: "rounded=1;arcSize=50;", textScale: 1.15 },
decision: {
style: "rhombus;perimeter=rhombusPerimeter;",
textScale: 1.5,
},
diamond: {
style: "rhombus;perimeter=rhombusPerimeter;",
textScale: 1.5,
},
data: {
style: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
textScale: 1.2,
},
document: { style: "shape=document;boundedLbl=1;", textScale: 1.15 },
// ---- the semantic vocabulary (D2's tier: a node that IS a thing) ----
/** A database or datastore. */
cylinder: {
style: "shape=cylinder3;boundedLbl=1;backgroundOutline=1;size=12;",
textScale: 1.3,
},
/** A message queue: a cylinder on its side. */
queue: {
style: "shape=cylinder3;direction=south;boundedLbl=1;backgroundOutline=1;size=12;",
textScale: 1.3,
},
/** An actor or user. Label below the figure. */
person: {
style: "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;outlineConnect=0;",
textScale: 1,
labelOutside: true,
glyph: { w: 40, h: 60 },
},
/** An external system, the internet. */
cloud: { style: "ellipse;shape=cloud;", textScale: 1.6 },
/** A service or process step. */
hexagon: {
style: "shape=hexagon;perimeter=hexagonPerimeter2;fixedSize=1;size=16;",
textScale: 1.25,
},
/** A concept, state or category. */
ellipse: { style: "ellipse;", textScale: 1.3 },
/** A speech-bubble annotation. */
callout: {
style: "shape=callout;perimeter=calloutPerimeter;rounded=1;size=16;position=0.5;base=24;",
textScale: 1.35,
},
/** A chevron stage in a pipeline. */
step: {
style: "shape=step;perimeter=stepPerimeter;fixedSize=1;size=16;",
textScale: 1.2,
},
/** A sticky note. */
note: { style: "shape=note;size=14;", textScale: 1.1 },
/** A card with a cut corner. */
card: { style: "shape=card;size=14;", textScale: 1.1 },
/** A process box with side bars (predefined subroutine). */
process: { style: "shape=process;size=0.1;", textScale: 1.2 },
/** Punched tape — legacy data, files. */
tape: { style: "shape=tape;size=0.2;", textScale: 1.3 },
/** A double-walled cube. */
cube: { style: "shape=cube;size=12;", textScale: 1.25 },
}
/** A shape token that may pass through unrecognised: draw.io style-key charset only. */
const SAFE_TOKEN = /^[a-zA-Z0-9._]+$/
export interface ResolvedShape {
spec: ShapeSpec
/** Set when the token was not in the catalog and passed through verbatim. */
passthrough?: boolean
}
/**
* Resolve a shape token: catalog entry, safe pass-through, or null for a token that
* could inject style keys (`;`/`=`/quotes) and must be rejected outright.
*/
export function resolveShape(token: string): ResolvedShape | null {
const known = SHAPE_CATALOG[token]
if (known) return { spec: known }
if (!SAFE_TOKEN.test(token)) return null
// Unknown but safe: emit verbatim. draw.io degrades an unregistered shape to a
// rectangle, so the worst case is a plain box — same as before the vocabulary
// existed. The conservative scale covers roughly-convex real shapes.
return {
spec: { style: `shape=${token};`, textScale: 1.25 },
passthrough: true,
}
}
/** The catalog key most similar to a token, for "did you mean" hints. */
export function nearestShape(token: string): string | null {
const t = token.toLowerCase()
let best: string | null = null
let bestD = 3 // more than 2 edits away is not a typo
for (const key of Object.keys(SHAPE_CATALOG)) {
const d = editDistance(t, key.toLowerCase(), bestD)
if (d < bestD) {
bestD = d
best = key
}
}
return best
}
/** Bounded Levenshtein distance; returns limit when the strings are further apart. */
function editDistance(a: string, b: string, limit: number): number {
if (Math.abs(a.length - b.length) >= limit) return limit
const prev = new Array(b.length + 1)
for (let j = 0; j <= b.length; j++) prev[j] = j
for (let i = 1; i <= a.length; i++) {
let diag = prev[0]
prev[0] = i
let rowMin = prev[0]
for (let j = 1; j <= b.length; j++) {
const cur = Math.min(
prev[j] + 1,
prev[j - 1] + 1,
diag + (a[i - 1] === b[j - 1] ? 0 : 1),
)
diag = prev[j]
prev[j] = cur
if (cur < rowMin) rowMin = cur
}
if (rowMin >= limit) return limit
}
return Math.min(prev[b.length], limit)
}
// ---- structured style merge ----
/**
* Merge style fragments by key, later fragments winning.
*
* A draw.io style is `tok;key=value;key=value;` — bare class tokens (rhombus, ellipse,
* text) come first and key=value pairs follow. String concatenation made every
* conflict a duplicate key resolved by draw.io's last-wins rule, which worked until
* shape fragments and theme fragments both owned geometry keys (a theme's rounded=1
* against a shape's rhombus). Merging structurally keeps exactly one token per key and
* one bare-token set, so the output is canonical and the ownership rule — theme owns
* colour and type, shape owns geometry — is enforced by fragment ORDER, not by hoping
* the keys never meet.
*
* Bare tokens are kept in first-appearance order, except that a later fragment's bare
* SHAPE CLASS (rhombus/ellipse/triangle) replaces an earlier one — two shape classes
* on one cell is a contradiction, not a union.
*/
export function mergeStyle(...fragments: (string | undefined)[]): string {
const bare: string[] = []
const kv = new Map<string, string>()
const SHAPE_CLASSES = new Set(["rhombus", "ellipse", "triangle"])
for (const f of fragments) {
if (!f) continue
for (const tok of f.split(";")) {
if (tok === "") continue
const eq = tok.indexOf("=")
if (eq < 0) {
if (SHAPE_CLASSES.has(tok)) {
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
if (i >= 0) bare.splice(i, 1)
}
if (!bare.includes(tok)) bare.push(tok)
continue
}
const key = tok.slice(0, eq)
kv.set(key, tok.slice(eq + 1))
// An explicit shape= also displaces a bare shape class from an earlier
// fragment — same contradiction as two bare classes.
if (key === "shape") {
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
if (i >= 0) bare.splice(i, 1)
}
}
}
let out = bare.join(";")
if (out) out += ";"
for (const [k, v] of kv) out += `${k}=${v};`
return out
}