feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels

The expressiveness gap between engine output and hand-written XML came down
to two missing capabilities, both generic:

- Block layout inside a box: nested containers already existed, but there was
  no way to split space by weight, pin a child to an edge, or tighten padding.
  Added grow (flex-grow over the parent's leftover flow-axis space, TeX's
  glue), align (start/center/end/stretch on the cross axis) and pad
  (per-group interior padding). All three round-trip via dai_grow/dai_align/
  dai_pad markers.

- Inline rich text: labels already render HTML (html=1 on every style, esc()
  entities decode back), but the measure pass counted markup as text. The
  visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a
  line, other inline tags are invisible.

Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape)
and TeX (box+glue) converged on exactly this design: nested boxes for block
structure, proportional glue, a small inline set for text — never full HTML.

Prompts teach the composition with a comparison-card recipe; verified by
rebuilding the CoT poster end to end in the real editor.
This commit is contained in:
dayuan.jiang
2026-08-09 20:55:12 +09:00
parent e78322ca52
commit db9db1ff4e
9 changed files with 432 additions and 23 deletions

View File

@@ -43,6 +43,23 @@ import { isContainer } from "./types"
export const ICON_SIZE = 48
/** Interior padding of a container. */
const PAD = 24
/** A group's interior padding: its own `pad` when declared, the default otherwise. */
function padOf(n: ContainerNode): number {
return n.kind === "group" && n.pad != null ? Math.max(0, n.pad) : PAD
}
/** The flex-grow weight a node declared, 0 when none. */
function growOf(n: DiagramNode): number {
const g = (n.kind === "box" || n.kind === "group") && n.grow
return typeof g === "number" && g > 0 ? g : 0
}
/** The cross-axis alignment a node declared. */
function alignOf(n: DiagramNode): "start" | "center" | "end" | "stretch" {
const a = (n.kind === "box" || n.kind === "group") && n.align
return a === "start" || a === "end" || a === "stretch" ? a : "center"
}
/** 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
@@ -172,6 +189,20 @@ export interface Placed {
*/
export type LayoutLinks = { source: string; target: string; step?: number }[]
/**
* Reduce a label to the text draw.io will actually lay out.
*
* Labels may carry inline HTML (every style has `html=1`): a <br> is a line break, any
* other tag is invisible markup around visible text. Measuring the raw string counted
* `<font color="#B85450">` as thirty characters of text, making rich boxes twice as
* wide as their content.
*/
function visibleText(label: string): string {
return String(label ?? "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/?(?:b|i|u|s|sub|sup|font|span|div)(?:\s[^<>]*)?>/gi, "")
}
/**
* Intrinsic size of a text box: widest wrapped line by line count.
*
@@ -184,7 +215,7 @@ export function autoBoxSize(
): { w: number; h: number } {
const r = roleMetrics(role)
const maxW = Math.round(260 * Math.max(1, r.charScale))
const explicit = String(label ?? "").split("\n")
const explicit = visibleText(label).split("\n")
const longest = Math.max(1, ...explicit.map((l) => l.length))
const w = Math.min(
maxW,
@@ -531,6 +562,7 @@ function measure(
}
// group: row or col
const pad = padOf(n)
if (n.dir === "row") {
const tallest = Math.max(0, ...kids.map((k) => k.rect.h))
// Only a group stretches to match its siblings. A leaf keeps its natural size,
@@ -541,13 +573,13 @@ function measure(
for (const k of kids)
if (k.node.kind === "group") k.rect.h = Math.max(k.rect.h, tallest)
const w =
PAD * 2 +
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))
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 },
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, pad)), h },
children: kids,
}
}
@@ -556,15 +588,15 @@ function measure(
// Only a group stretches — same reasoning as the row branch above.
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 w = pad * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
const h =
head +
PAD * 2 +
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 },
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, pad)), h },
children: kids,
}
}
@@ -585,10 +617,11 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
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 pad = n.kind === "group" ? padOf(n) : PAD
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") {
@@ -654,9 +687,27 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
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))
let slack = Math.max(0, extent - content - n.gap * (k - 1))
// flex-grow: children with a weight split the leftover space between them, TeX's
// glue. This runs before the gap stretch below — declared weights are a statement
// about where the slack should go, and padding it into the gaps instead would
// silently override that statement.
const weights = kids.map((kid) => growOf(kid.node))
const totalWeight = weights.reduce((s, v) => s + v, 0)
if (totalWeight > 0 && slack > 0) {
kids.forEach((kid, i) => {
const extra = (slack * weights[i]) / totalWeight
if (alongRow) kid.rect.w += extra
else kid.rect.h += extra
})
slack = 0
}
const gap = k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap
const span = content + gap * Math.max(0, k - 1)
const span =
kids.reduce((s, kid) => s + (alongRow ? kid.rect.w : kid.rect.h), 0) +
gap * Math.max(0, k - 1)
let cur = (alongRow ? innerX : innerTop) + Math.max(0, (extent - span) / 2)
for (const kid of kids) {
@@ -664,15 +715,23 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
// heading spans its column. Measured at its text width, then widened here — the
// container's size still comes from the widest ordinary child.
const kn = kid.node
const a = alignOf(kn)
const stretches =
kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch
a === "stretch" ||
(kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch)
// Cross-axis position: centred unless the child asked for an edge.
const cross = (room: number, size: number): number => {
if (a === "start") return 0
if (a === "end") return Math.max(0, room - size)
return (room - size) / 2
}
if (alongRow) {
if (stretches) kid.rect.h = innerH
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, links)
place(kid, cur, innerTop + cross(innerH, kid.rect.h), links)
cur += kid.rect.w + gap
} else {
if (stretches) kid.rect.w = innerW
place(kid, innerX + (innerW - kid.rect.w) / 2, cur, links)
place(kid, innerX + cross(innerW, kid.rect.w), cur, links)
cur += kid.rect.h + gap
}
}

View File

@@ -64,6 +64,12 @@ export const MARKER = {
role: "dai_role",
/** The node's semantic zone, whose hue ramp colours it. */
group: "dai_group",
/** Share of the parent's leftover flow-axis space — flex-grow. */
grow: "dai_grow",
/** Cross-axis position within the parent: "start" | "center" | "end". */
align: "dai_align",
/** A container's interior padding, px. */
pad: "dai_pad",
/**
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
* columns, its milestone strip. The parser must not read these back as nodes — they are
@@ -382,6 +388,28 @@ export function stampGroup(style: string, group: string): string {
return append(style, MARKER.group, encodeURIComponent(group))
}
type FlexAlign = "start" | "center" | "end" | "stretch"
/** Stamp the flex fields a node carries, so a round-trip preserves them. */
export function stampFlex(
style: string,
opts: { grow?: number; align?: FlexAlign; pad?: number },
): string {
let s = style
if (opts.grow != null && opts.grow > 0)
s = append(s, MARKER.grow, opts.grow)
if (opts.align && opts.align !== "center")
s = append(s, MARKER.align, opts.align)
if (opts.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
return s
}
/** Read the align marker back. Anything unrecognised means the default (center). */
export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
const v = readMarker(style, MARKER.align)
return v === "start" || v === "end" || v === "stretch" ? v : null
}
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
export function stripMarkers(style: string): string {
return style

View File

@@ -98,6 +98,18 @@ export const OperationSchema = z.discriminatedUnion("op", [
.describe(
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
),
grow: z
.number()
.optional()
.describe(
"Flex-grow weight: this box takes that share of the parent's leftover space along its stacking axis. Omit for natural size",
),
align: z
.enum(["start", "center", "end", "stretch"])
.optional()
.describe(
"Cross-axis position in the parent: start/end pin to an edge, stretch fills the axis (a divider or highlight bar spanning its card). Default center",
),
lane: z
.number()
.optional()
@@ -148,6 +160,24 @@ export const OperationSchema = z.discriminatedUnion("op", [
"Group stencil name, e.g. 'group_vpc'; omit for a plain frame",
),
gap: z.number().optional(),
pad: z
.number()
.optional()
.describe(
"Interior padding px (default 24). Small values make tight cards; nest containers for internal structure",
),
grow: z
.number()
.optional()
.describe(
"Flex-grow weight: this container takes that share of the parent's leftover space. E.g. two columns with grow 2 and 1 split the width 2:1",
),
align: z
.enum(["start", "center", "end", "stretch"])
.optional()
.describe(
"Cross-axis position in the parent: start/end pin to an edge, stretch fills. Default center",
),
after: z.string().optional(),
}),
z.object({
@@ -414,6 +444,10 @@ export function applyOperations(
...(op.stroke ? { stroke: op.stroke } : {}),
...(op.role ? { role: op.role } : {}),
...(op.group ? { group: op.group } : {}),
...(op.grow && op.grow > 0 ? { grow: op.grow } : {}),
...(op.align && op.align !== "center"
? { align: op.align }
: {}),
...cellOf(op),
}
else if (op.op === "add_container")
@@ -427,6 +461,11 @@ export function applyOperations(
children: [],
...(op.role ? { role: op.role } : {}),
...(op.group ? { group: op.group } : {}),
...(op.grow && op.grow > 0 ? { grow: op.grow } : {}),
...(op.align && op.align !== "center"
? { align: op.align }
: {}),
...(op.pad != null ? { pad: Math.max(0, op.pad) } : {}),
}
else if (op.op === "add_grid")
node = {

View File

@@ -29,6 +29,7 @@ import {
isPinned,
MARKER,
type NodeKind,
readAlign,
readCell,
readDir,
readIntMarker,
@@ -360,6 +361,19 @@ function zoneOf(style: string): string | undefined {
return v ? decodeURIComponent(v) : undefined
}
/** The flex fields stamped on a cell, as sparse properties ready to spread. */
function flexOf(style: string): {
grow?: number
align?: "start" | "end" | "stretch"
} {
const grow = readIntMarker(style, MARKER.grow)
const align = readAlign(style)
return {
...(grow !== null && grow > 0 ? { grow } : {}),
...(align ? { align } : {}),
}
}
function boxShape(style: string): BoxShape | undefined {
const shape = styleValue(style, "shape")
if (shape === "parallelogram") return "data"
@@ -1070,6 +1084,7 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
shape: boxShape(c.style),
role: roleOf(c.style),
group: zoneOf(c.style),
...flexOf(c.style),
// A lifeline's style is chrome the renderer rebuilds, so keeping it verbatim
// would re-emit a lifeline that no longer matches the new message count.
style: lifeline ? undefined : c.style,
@@ -1215,12 +1230,15 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
}
return node
}
const markedPad = readIntMarker(c.style, MARKER.pad)
const node: GroupNode = {
kind: "group",
...common,
dir: dir === "col" ? "col" : "row",
role: roleOf(c.style),
group: zoneOf(c.style),
...flexOf(c.style),
...(markedPad !== null ? { pad: markedPad } : {}),
}
return node
}

View File

@@ -25,6 +25,7 @@ import {
isInvisible,
stampCell,
stampContainer,
stampFlex,
stampGroup,
stampLane,
stampLeaf,
@@ -35,7 +36,7 @@ import {
stampSequence,
} from "./markers"
import { type RoutedEdge, routeEdges } from "./route"
import { hueOf, NEUTRAL, type Role, themedStyle } from "./theme"
import { hueOf, NEUTRAL, themedStyle } from "./theme"
import {
type BoxShape,
type DiagramNode,
@@ -58,6 +59,14 @@ export function esc(s: string): string {
.replace(/'/g, "&#39;")
}
// NOTE ON RICH TEXT: a label may carry inline HTML — <b>, <i>, <font color>, <br>,
// <span> — and needs no special handling here. `esc()` writes it into the value
// attribute as entities, the XML parser decodes them back, and because every style
// carries `html=1` draw.io renders the tags. That is exactly how hand-written rich
// labels have always worked; the editor sanitises HTML labels itself. The one place
// tags DO need handling is the measure pass (layout.ts autoBoxSize), which must not
// count markup as text.
/** Resolve a catalog name to a style. Injected so the engine does not own the catalog. */
export type StyleResolver = (
name: string,
@@ -177,6 +186,7 @@ function styleFor(
let stamped = stampLeaf(base, "box")
if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role)
if (n.group) stamped = stampGroup(stamped, n.group)
stamped = stampFlex(stamped, { grow: n.grow, align: n.align })
return n.cell ? stampCell(stamped, n.cell) : stamped
}
@@ -218,6 +228,8 @@ function styleFor(
}
if (groupRole && groupRole !== "body") base = stampRole(base, groupRole)
if (zone) base = stampGroup(base, zone)
if (n.kind === "group")
base = stampFlex(base, { grow: n.grow, align: n.align, pad: n.pad })
return stampContainer(base, {
kind: n.kind,
dir: n.kind === "grid" ? "grid" : n.dir,

View File

@@ -25,6 +25,12 @@ export interface PoolCell {
col: number
}
/**
* Cross-axis behaviour of a child inside a row/col group, CSS's align-items per child:
* pin to either edge, centre (the default), or stretch to fill the axis.
*/
export type Align = "start" | "center" | "end" | "stretch"
/**
* The outline a flowchart box is drawn with.
*
@@ -73,6 +79,10 @@ export interface BoxNode {
role?: Role
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
group?: string
/** Share of the parent's leftover flow-axis space, like flex-grow. 0/absent = natural size. */
grow?: number
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
align?: Align
/** Flowchart outline. Absent means a plain rectangle. */
shape?: BoxShape
style?: string
@@ -109,6 +119,12 @@ export interface GroupNode {
role?: Role
/** Semantic zone name; the panel takes this hue's tint. */
group?: string
/** Share of the parent's leftover flow-axis space, like flex-grow. */
grow?: number
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
align?: Align
/** Interior padding, px. Absent = the default (24). */
pad?: number
style?: string
pinned?: boolean
rect?: Rect