mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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.
236 lines
9.7 KiB
TypeScript
236 lines
9.7 KiB
TypeScript
/**
|
||
* 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
|
||
}
|