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.
This commit is contained in:
dayuan.jiang
2026-08-09 21:56:09 +09:00
parent db9db1ff4e
commit 50826c0ac8
11 changed files with 644 additions and 75 deletions

View File

@@ -25,6 +25,7 @@ import { extractDiagramXML } from "@/lib/utils"
import {
type Direction,
hasMarkers,
isAutoSized,
isLaneChrome,
isPinned,
MARKER,
@@ -361,6 +362,12 @@ function zoneOf(style: string): string | undefined {
return v ? decodeURIComponent(v) : undefined
}
/** The declared shape token stamped on a cell, if any. */
function shapeOf(style: string): string | undefined {
const v = readMarker(style, MARKER.shape)
return v ? decodeURIComponent(v) : undefined
}
/** The flex fields stamped on a cell, as sparse properties ready to spread. */
function flexOf(style: string): {
grow?: number
@@ -1069,19 +1076,29 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
const headH = lifeline
? Number(styleValue(c.style, "size") ?? "44")
: undefined
// An engine-measured box must NOT read its size back as a fixed size:
// that would freeze the first layout's measurement, so a later label or
// shape change would keep the stale box instead of re-measuring. A pinned
// node is the exception — the user froze it, geometry and all.
const auto = isAutoSized(c.style) && !pinned
const node: BoxNode = {
kind: "box",
id: c.id,
label: c.value,
w: c.geo ? Math.round(c.geo.w) : undefined,
w: auto ? undefined : c.geo ? Math.round(c.geo.w) : undefined,
h: lifeline
? Math.round(headH && headH > 0 ? headH : 44)
: c.geo
? Math.round(c.geo.h)
: undefined,
: auto
? undefined
: c.geo
? Math.round(c.geo.h)
: undefined,
fill: styleValue(c.style, "fillColor"),
stroke: styleValue(c.style, "strokeColor"),
shape: boxShape(c.style),
// The declared token wins: appearance-based reverse mapping cannot
// distinguish aliases (decision vs diamond) or identify a passed-through
// token whose style is just `shape=<name>`.
shape: shapeOf(c.style) ?? boxShape(c.style),
role: roleOf(c.style),
group: zoneOf(c.style),
...flexOf(c.style),