feat(diagram-engine): corners, borderless fills, shadows and strikethrough

Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:

  rounded-*      mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
                 absolute pixels and halves it, so the same class is the same
                 corner on every box. Previously excluded as 'percentage only'
  shadow-sm..xl  mxShape.js:505-535 — getShadowStyle reads five independent
                 params, not one flag, so Tailwind's offset+blur rungs map one
                 to one. Previously excluded as 'six sizes collapse to one'
  line-through   mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
                 mxText.js:723 and :1040. The bitmask has four bits, not three
  border-none    the only one of the four that adds something previously
                 inexpressible: a fill with no outline

Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.

Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.

Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).

615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
This commit is contained in:
dayuan.jiang
2026-08-11 09:09:45 +09:00
parent 8687e8f04b
commit 0b03e15336
25 changed files with 3669 additions and 1234 deletions

View File

@@ -11,12 +11,6 @@
*/
import { checkNames, resolveStyle } from "./catalog"
import {
type GraphEdge,
type GraphNode,
type GraphOptions,
graphToOperations,
} from "./graph"
import {
applyOperations,
collectNames,
@@ -74,6 +68,7 @@ export function restructureDiagram(
const applied = applyOperations(tree, ops)
const errors = [...applied.errors]
warnings.push(...applied.warnings)
// Catch invented names before rendering, so the model gets a correctable error
// instead of a diagram with blank squares in it.
@@ -124,69 +119,6 @@ export function restructureDiagram(
}
}
/**
* Draw a flowchart, dependency graph, ER diagram or site map from nodes and arrows alone.
*
* The model gives no positions and no nesting — just what the boxes are and what points at
* what. The engine works out how many rows there are, who shares a row, and who goes left
* of whom, then hands the result to the same layout and edge router the architecture
* diagrams use.
*
* This exists because declaring a flowchart as nesting does not work: six steps declared in
* their natural order become one column, and every branch then has to jump over the step
* beside it. The layering has to come from the arrows, and only the engine can see all of
* them at once.
*
* Replaces the whole diagram rather than adding to it: the layer assignment depends on every
* arrow, so one new edge can move half the nodes. Editing afterwards goes through
* `restructureDiagram` as usual.
*/
export function drawGraph(
nodes: GraphNode[],
edges: GraphEdge[],
opts: RestructureOptions & GraphOptions & { title?: string } = {},
): RestructureResult {
if (nodes.length === 0)
return {
xml: null,
outline: "",
errors: ["draw_graph: no nodes — nothing to draw."],
warnings: [],
}
const dupes = nodes
.map((n) => n.id)
.filter((id, i, all) => all.indexOf(id) !== i)
if (dupes.length > 0)
return {
xml: null,
outline: "",
errors: [
`draw_graph: duplicate node id(s): ${[...new Set(dupes)].join(", ")}.`,
],
warnings: [],
}
const graph = graphToOperations(nodes, edges, opts)
const warnings: string[] = []
if (graph.unknownEndpoints.length)
warnings.push(
`Dropped edge(s) naming nodes that were not in the node list: ${graph.unknownEndpoints.join(", ")}.`,
)
if (graph.backEdges.length)
warnings.push(
`Loop(s) drawn but not used for ordering: ${graph.backEdges
.map((e) => `${e.source}${e.target}`)
.join(", ")}.`,
)
const ops: Operation[] = opts.title
? [{ op: "set_title", title: opts.title }, ...graph.operations]
: graph.operations
const result = restructureDiagram("", ops, opts)
return { ...result, warnings: [...warnings, ...result.warnings] }
}
/** Read the current canvas structure without changing it. */
export function describeDiagram(
currentXml: string,

View File

@@ -31,8 +31,10 @@
import { resolveShape } from "./shapes"
import { type Role, roleMetrics } from "./theme"
import type {
Align,
ContainerNode,
DiagramNode,
Justify,
PoolNode,
RadialNode,
Rect,
@@ -56,10 +58,181 @@ function growOf(n: DiagramNode): number {
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"
/**
* The cross-axis alignment a node ends up with: its own `align`, else the parent's
* `alignItems`, else centred. Same cascade as CSS, where align-self overrides the
* container's align-items.
*/
function alignOf(n: DiagramNode, parent?: ContainerNode): Align {
const own = (n.kind === "box" || n.kind === "group") && n.align
if (
own === "start" ||
own === "end" ||
own === "stretch" ||
own === "center"
)
return own
const inherited = parent?.kind === "group" ? parent.alignItems : undefined
if (
inherited === "start" ||
inherited === "end" ||
inherited === "stretch" ||
inherited === "center"
)
return inherited
return "center"
}
/**
* The main-axis distribution a container declared, or null when it declared none.
*
* Null matters: it selects the engine's original per-axis defaults rather than any value
* in this vocabulary. A row centred its children and padded their gaps, a column packed to
* the top — neither is expressible as one `Justify`, and both are what every diagram built
* before this existed relies on. Declaring `justify` opts out of them.
*/
function justifyOf(n: ContainerNode): Justify | null {
const j = n.kind === "group" ? n.justify : undefined
return j === "start" ||
j === "center" ||
j === "end" ||
j === "between" ||
j === "around" ||
j === "evenly"
? j
: null
}
/** The width cap a node declared, or Infinity. */
function maxWOf(n: DiagramNode): number {
const m = (n.kind === "box" || n.kind === "group") && n.maxW
return typeof m === "number" && m > 0 ? m : Number.POSITIVE_INFINITY
}
/**
* Divide `room` among weighted children, honouring each one's floor and ceiling.
*
* The naive version — give each child `room * weight / total` and never go below its
* content width — overflows: a child whose content is wider than its share keeps the
* wider figure, and the total then exceeds what there was to divide, so the last child
* hangs out of the frame.
*
* CSS resolves this by FREEZING any item that cannot take its share and re-dividing the
* rest among those that still can, repeating until nothing changes. That is what this
* does. It terminates because every round either freezes at least one child or stops.
*
* Returns the width for each child, in order; a child with no weight keeps its size.
*/
function shareOut(
sizes: number[],
weights: number[],
caps: number[],
floors: number[],
room: number,
): number[] {
const out = [...sizes]
const frozen = sizes.map((_, i) => weights[i] <= 0)
for (;;) {
const liveTotal = weights.reduce(
(s, w, i) => s + (frozen[i] ? 0 : w),
0,
)
if (liveTotal <= 0) return out
// What is left once everything already settled has taken its width.
const rest = room - out.reduce((s, v, i) => s + (frozen[i] ? v : 0), 0)
let changed = false
for (let i = 0; i < out.length; i++) {
if (frozen[i]) continue
const share = (rest * weights[i]) / liveTotal
// A child cannot go below its own content, and cannot pass a declared cap.
// Either way it settles here and the others divide what is left.
if (share < floors[i]) {
out[i] = floors[i]
frozen[i] = true
changed = true
} else if (share > caps[i]) {
out[i] = caps[i]
frozen[i] = true
changed = true
}
}
if (changed) continue
for (let i = 0; i < out.length; i++)
if (!frozen[i]) out[i] = (rest * weights[i]) / liveTotal
return out
}
}
/**
* The narrowest a node may be squeezed to when weights divide a row.
*
* Its own content width, unless it opted out with `minW0` (CSS's `min-width: 0`), in which
* case a weight may take it below that. Matching CSS here is deliberate: `min-width`
* defaults to `auto`, so in a browser too a `flex: 2` column stops shrinking at its text
* and a declared 2:1 comes out closer to 1.4:1 — surprising, but it is what everyone
* writing flexbox already works with.
*/
function floorOf(n: DiagramNode, contentW: number): number {
const opted = (n.kind === "box" || n.kind === "group") && n.minW0
return opted ? 0 : contentW
}
/**
* Does this node need the full width of its parent to mean what it says?
*
* A row whose children carry `grow` weights does: the weights are shares of the row's
* width, so if the row is only as wide as its own content there is nothing to share and
* every declared proportion silently comes out 1:1.
*
* This is where CSS and this engine disagree, and the disagreement is why it has to be
* inferred. CSS and Yoga default `align-items` to `stretch`, so a row inside a column
* fills that column's width for free. This engine defaults to `center`, which is the
* better default for diagrams — a lone icon in a wide frame should sit in the middle, not
* be smeared across it — but it means a row of weighted columns gets no width unless
* something asks. Declaring weights IS the ask.
*/
function needsFullWidth(n: DiagramNode): boolean {
return (
n.kind === "group" &&
n.dir === "row" &&
n.children.some((c) => growOf(c) > 0)
)
}
/**
* Where the children start, and how much goes between them — CSS's justify-content.
*
* `slack` is what is left after the children and their base gaps. Returning both the
* leading offset and the per-gap addition covers all six values in one place, so the
* row and column branches no longer need their own contradictory policies.
*/
function distribute(
justify: Justify,
slack: number,
count: number,
): { lead: number; extraGap: number } {
if (slack <= 0 || count === 0) return { lead: 0, extraGap: 0 }
switch (justify) {
case "center":
return { lead: slack / 2, extraGap: 0 }
case "end":
return { lead: slack, extraGap: 0 }
case "between":
return count > 1
? { lead: 0, extraGap: slack / (count - 1) }
: { lead: 0, extraGap: 0 }
case "around": {
// Half a share before the first child and after the last, a full share between.
const share = slack / count
return { lead: share / 2, extraGap: share }
}
case "evenly": {
const share = slack / (count + 1)
return { lead: share, extraGap: share }
}
default:
return { lead: 0, extraGap: 0 }
}
}
/** 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. */
@@ -180,6 +353,43 @@ export interface Placed {
children: Placed[]
}
/** One line of a wrapped row: which children sit on it, and how big it is. */
interface WrapLine {
items: Placed[]
width: number
height: number
}
/**
* Break a row of children into lines that each fit `room`.
*
* Greedy, the same rule as a text line-breaker and as CSS flex-wrap: keep adding to the
* current line while it fits, otherwise start a new one. A single child wider than the
* whole row still gets its own line rather than being dropped.
*
* Shared by measure and place so both agree on where the breaks fall — computing them
* twice from the same input is cheap, keeping two copies in sync is not.
*/
function wrapLines(kids: Placed[], room: number, gap: number): WrapLine[] {
const lines: WrapLine[] = []
let cur: WrapLine | null = null
for (const k of kids) {
const next = cur ? cur.width + gap + k.rect.w : k.rect.w
if (cur && next > room && cur.items.length > 0) {
lines.push(cur)
cur = null
}
if (!cur) cur = { items: [k], width: k.rect.w, height: k.rect.h }
else {
cur.items.push(k)
cur.width = next
cur.height = Math.max(cur.height, k.rect.h)
}
}
if (cur) lines.push(cur)
return lines
}
/**
* The arrows layout needs, which the node tree alone does not carry.
*
@@ -209,11 +419,20 @@ function visibleText(label: string): string {
*
* The role scales the estimate: a banner sets 20px type and a footnote 9px, and layout has
* to reserve what render will draw or the text overflows its cell.
*
* `atWidth` is the width the box will ACTUALLY be drawn at, when that is already known
* (see the reflow pass in `layoutForest`). Height is then counted for that width while
* the reported width stays intrinsic — which is what a browser does, and what this was
* missing: a paragraph measured at its 260px natural width needs eight lines, the same
* paragraph stretched to 750px needs three, and reserving the eight-line height left
* every panel with a slab of dead space under its text.
*/
export function autoBoxSize(
label: string,
role?: Role,
shape?: string,
atWidth?: number,
maxW?: number,
): { w: number; h: number } {
const spec = shape ? resolveShape(shape)?.spec : undefined
// A glyph shape (umlActor…) has a fixed figure with the label below it: the slot is
@@ -226,19 +445,35 @@ export function autoBoxSize(
}
}
const r = roleMetrics(role)
const maxW = Math.round(260 * Math.max(1, r.charScale))
// Two caps: the role's own default, and whatever the model declared. The declared one
// is allowed to go BELOW the floor of 120 — capping a box at 90px has to mean 90px,
// or the cap silently does nothing on short labels.
const roleCap = Math.round(260 * Math.max(1, r.charScale))
const declared = maxW != null && maxW > 0 ? maxW : Number.POSITIVE_INFINITY
const explicit = visibleText(label).split("\n")
const longest = Math.max(1, ...explicit.map((l) => l.length))
const w = Math.min(
maxW,
Math.max(120, Math.round(longest * CHAR_W * r.charScale + 28)),
const natural = Math.max(
120,
Math.round(longest * CHAR_W * r.charScale + 28),
)
const w = Math.min(declared, roleCap, natural)
const s = spec?.textScale ?? 1
// Count the lines the text ACTUALLY occupies: draw.io wraps at the box width, so a
// long line becomes several. Estimating by explicit newlines alone left the box one
// line tall while the text wrapped to six — and overflowed straight out of it.
//
// Wrapping happens at the DRAWN width, which for a stretched box is wider than the
// intrinsic one. `atWidth` carries it; the shape factor is divided back out because
// it is applied to the final height below.
// A declared cap also bounds the reflow hint: a box capped at 200 never gets to count
// its lines as if it had been drawn at 600, however wide its parent turned out.
const textW = Math.min(
declared,
Math.max(w, atWidth != null ? atWidth / s : 0),
)
const charsPerLine = Math.max(
8,
Math.floor((w - 28) / (CHAR_W * r.charScale)),
Math.floor((textW - 28) / (CHAR_W * r.charScale)),
)
const lines = explicit.reduce(
(sum, l) => sum + Math.max(1, Math.ceil(l.length / charsPerLine)),
@@ -250,7 +485,6 @@ export function autoBoxSize(
// a rhombus exactly half — so the box grows by the shape's measured factor.
// Verified in the real editor: the same sentence overflows a 1.0× rhombus and fits
// a 1.5× one.
const s = spec?.textScale ?? 1
return { w: Math.round(w * s), h: Math.round(h * s) }
}
@@ -443,6 +677,7 @@ function measure(
n: DiagramNode,
defaultGlyph: number,
links: LayoutLinks,
widthHints?: Map<string, number>,
): Placed {
if (n.kind === "icon") {
const glyph = n.size ?? defaultGlyph
@@ -450,7 +685,15 @@ function measure(
return { node: n, rect: { x: 0, y: 0, ...s }, children: [] }
}
if (n.kind === "box") {
const auto = autoBoxSize(n.label, n.role, n.shape)
// The hint is the width this box was drawn at last pass; its text rewraps to
// that width, so its height has to be counted there.
const auto = autoBoxSize(
n.label,
n.role,
n.shape,
widthHints?.get(n.id),
n.maxW,
)
return {
node: n,
rect: { x: 0, y: 0, w: n.w ?? auto.w, h: n.h ?? auto.h },
@@ -461,7 +704,9 @@ function measure(
return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] }
}
const kids = n.children.map((c) => measure(c, defaultGlyph, links))
const kids = n.children.map((c) =>
measure(c, defaultGlyph, links, widthHints),
)
const head = headerFor(n)
const gap = n.gap
@@ -581,6 +826,9 @@ function measure(
// group: row or col
const pad = padOf(n)
// A declared cap wins over the measured content, so a row of six cards capped at 900
// reports 900 and the place pass below has real negative slack to shrink into.
const cap = maxWOf(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,
@@ -590,6 +838,31 @@ function measure(
// lane bands from the nodes sitting on them.
for (const k of kids)
if (k.node.kind === "group") k.rect.h = Math.max(k.rect.h, tallest)
// A capped row wraps into as many lines as it takes, so the cap is a real limit
// rather than something the content silently overflows. Sized here and positioned
// by the same line-breaking in `place`, so measure and place cannot disagree.
if (cap < Number.POSITIVE_INFINITY) {
const lines = wrapLines(kids, cap - pad * 2, gap)
const h =
head +
pad * 2 +
lines.reduce((s, l) => s + l.height, 0) +
gap * Math.max(0, lines.length - 1)
const widestLine = Math.max(0, ...lines.map((l) => l.width))
return {
node: n,
rect: {
x: 0,
y: 0,
w: Math.max(
Math.min(cap, pad * 2 + widestLine),
titleFloor(n.label, pad),
),
h,
},
children: kids,
}
}
const w =
pad * 2 +
kids.reduce((s, k) => s + k.rect.w, 0) +
@@ -597,7 +870,12 @@ function measure(
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,
}
}
@@ -606,6 +884,43 @@ 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)
// A row of weighted columns divides a width it does not have yet (see needsFullWidth).
// Its share of the extra has to be handed out during MEASURE: `place` runs top-down, so
// a child widened there leaves this container already sized for the narrow version, and
// the child then sticks out of the frame that is supposed to contain it.
//
// Distributed by weight rather than to the full interior, because that is the answer
// `place` will independently arrive at — the two passes have to agree or the frame is
// sized for one arrangement and drawn as another.
for (const k of kids) {
if (!needsFullWidth(k.node) || k.node.kind !== "group") continue
const kp = padOf(k.node)
const room =
widest - kp * 2 - k.node.gap * Math.max(0, k.children.length - 1)
const weights = k.children.map((c) => growOf(c.node))
// Unweighted children keep their size and take their width off the top; the rest is
// what the weights divide.
const fixed = k.children.reduce(
(s, c, i) => s + (weights[i] ? 0 : c.rect.w),
0,
)
const widths = shareOut(
k.children.map((c) => c.rect.w),
weights,
k.children.map((c) => maxWOf(c.node)),
k.children.map((c) => floorOf(c.node, c.rect.w)),
room - fixed,
)
k.children.forEach((c, i) => {
if (weights[i]) c.rect.w = widths[i]
})
k.rect.w = Math.max(
k.rect.w,
kp * 2 +
k.children.reduce((s, c) => s + c.rect.w, 0) +
k.node.gap * Math.max(0, k.children.length - 1),
)
}
const w = pad * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
const h =
head +
@@ -614,7 +929,12 @@ function measure(
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.min(cap, Math.max(w, titleFloor(n.label, pad))),
h,
},
children: kids,
}
}
@@ -628,7 +948,20 @@ function measure(
* stretched frame reads as deliberately spaced instead of sparse, and the resulting
* cluster is centred.
*/
function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
function place(
p: Placed,
x: number,
y: number,
links: LayoutLinks,
/**
* True when this container's width was decided from outside its own content — the page
* aspect gave the top level a width, or an ancestor stretched it. Only then do `grow`
* weights read as absolute proportions ("3:1"), because only then is there a total to
* take a share OF. It passes down through stretched children: a full-width column that
* inherited its width hands that same certainty to the row inside it.
*/
definiteWidth = false,
): void {
p.rect.x = Math.round(x)
p.rect.y = Math.round(y)
const n = p.node
@@ -701,6 +1034,64 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
}
const alongRow = n.dir === "row"
// A capped row that had to WRAP lays each line out like its own row, so the two passes
// cannot disagree about where the breaks fall. A capped row that still fits on one line
// falls through to the ordinary path below — it is an ordinary row, and skipping that
// path would skip `grow`, leaving declared proportions unapplied.
const wrapped =
alongRow && maxWOf(n) < Number.POSITIVE_INFINITY && kids.length > 0
? wrapLines(kids, innerW, n.gap)
: null
if (wrapped && wrapped.length > 1) {
const lines = wrapped
let lineTop = innerTop
for (const line of lines) {
const used = line.items.reduce((s, k) => s + k.rect.w, 0)
const room = Math.max(
0,
innerW - used - n.gap * (line.items.length - 1),
)
// A wrapped line follows the same row default as an unwrapped one: centred,
// with its gaps padded by up to one extra gap.
const declared = justifyOf(n)
const { lead, extraGap } = declared
? distribute(declared, room, line.items.length)
: line.items.length > 1
? (() => {
const e = Math.min(
n.gap,
room / (line.items.length - 1),
)
return {
lead: Math.max(
0,
(room - e * (line.items.length - 1)) / 2,
),
extraGap: e,
}
})()
: { lead: room / 2, extraGap: 0 }
let x = innerX + lead
for (const kid of line.items) {
const a = alignOf(kid.node, n)
const off =
a === "start"
? 0
: a === "end"
? line.height - kid.rect.h
: a === "stretch"
? 0
: (line.height - kid.rect.h) / 2
if (a === "stretch") kid.rect.h = line.height
place(kid, x, lineTop + Math.max(0, off), links)
x += kid.rect.w + n.gap + extraGap
}
lineTop += line.height + n.gap
}
return
}
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
@@ -708,42 +1099,107 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
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))
// glue. This runs before the justify distribution below — declared weights are a
// statement about where the slack should go, and spreading it into the gaps instead
// would silently override that statement.
//
// A LEAF box never grows along a column: growing its height just inflates a text
// box around its own text — the giant hollow panels of an early poster. Along a row
// it stays legal (two bars splitting a card's width is real layout), and containers
// grow on either axis, since they distribute the space onwards.
const weights = kids.map((kid) =>
!alongRow && kid.node.kind === "box" ? 0 : 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
// Along a ROW, when the container's width was set from OUTSIDE (a declared page
// aspect, or its own cap), the weights divide the whole track: `grow: 3` beside
// `grow: 1` then really is three times as wide, which is what writing those numbers
// means and what CSS's `flex: 3` shorthand does by zeroing flex-basis.
//
// Otherwise only the slack is divided, which is the older contract: the width came
// from the content itself, so treating the weights as absolute proportions would
// shrink a column below the text already in it.
const proportional =
alongRow &&
(definiteWidth ||
needsFullWidth(n) ||
maxWOf(n) < Number.POSITIVE_INFINITY)
if (proportional) {
// The weights divide the whole track. shareOut settles anyone who cannot take
// their share — too wide already, or capped — and re-divides among the rest, so
// the total never exceeds the room available and no child spills out.
const fixed = kids.reduce(
(s, kid, i) => s + (weights[i] ? 0 : kid.rect.w),
0,
)
const widths = shareOut(
kids.map((kid) => kid.rect.w),
weights,
kids.map((kid) => maxWOf(kid.node)),
kids.map((kid) => floorOf(kid.node, kid.rect.w)),
extent - n.gap * (k - 1) - fixed,
)
kids.forEach((kid, i) => {
if (weights[i]) kid.rect.w = widths[i]
})
} else {
kids.forEach((kid, i) => {
if (!weights[i]) return
const share = (slack * weights[i]) / totalWeight
// Never past a declared cap: min/max outranks grow, Yoga's rule too.
if (alongRow)
kid.rect.w = Math.min(maxWOf(kid.node), kid.rect.w + share)
else kid.rect.h += share
})
}
// Recompute: a child clamped by its cap or its content refused part of its share,
// and that remainder is still free space the distribution below has to place.
const used = kids.reduce(
(s, kid) => s + (alongRow ? kid.rect.w : kid.rect.h),
0,
)
slack = Math.max(0, extent - used - n.gap * (k - 1))
}
// Slack policy differs by axis. A ROW spreads and centres — a flowchart layer
// reads as a pyramid, and dead space at the right edge of a row looks like a
// mistake. A COLUMN packs to the top and leaves the slack at the bottom: a column
// is usually tall because a SIBLING made it tall, and stretching its gaps (or its
// boxes, via grow) turns every panel into a huge frame with three lines floating
// in the middle — the single ugliest thing in the poster this replaced.
const gap =
alongRow && k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap
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 + Math.max(0, (extent - span) / 2) : innerTop
// How the remaining slack is spread. A declared `justify` decides it; otherwise the
// engine's original per-axis defaults stand, because they are what every diagram built
// before `justify` existed was laid out with:
//
// ROW — spread the gaps by up to one extra gap, then centre the result. A flowchart
// layer reads as a pyramid, and dead space at the right edge of a row looks like a
// mistake.
// COLUMN — pack to the top and leave the slack at the bottom. A column is usually
// tall because a SIBLING made it tall, and stretching its gaps turns every panel into
// a big frame with three lines floating in the middle.
const declared = justifyOf(n)
let lead: number
let extraGap: number
if (declared) {
;({ lead, extraGap } = distribute(declared, slack, k))
} else if (alongRow && k > 1) {
extraGap = Math.min(n.gap, slack / (k - 1))
lead = Math.max(0, (slack - extraGap * (k - 1)) / 2)
} else {
lead = 0
extraGap = 0
}
const gap = n.gap + extraGap
let cur = (alongRow ? innerX : innerTop) + lead
for (const kid of kids) {
// A stretching role fills the cross axis: a masthead spans its page, a section
// 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 a = alignOf(kn, n)
const stretches =
a === "stretch" ||
(kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch)
(kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch) ||
// A row of weighted columns fills this column, whatever the alignment default
// says — see needsFullWidth. Only along a column: across a row the cross axis
// is height, and a row does not hand its height out proportionally.
(!alongRow && needsFullWidth(kn))
// Cross-axis position: centred unless the child asked for an edge.
const cross = (room: number, size: number): number => {
if (a === "start") return 0
@@ -752,11 +1208,28 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
}
if (alongRow) {
if (stretches) kid.rect.h = innerH
place(kid, cur, innerTop + cross(innerH, kid.rect.h), links)
// A row's child got its width from the weights above, so if this row's own
// width was definite the child's is too.
place(
kid,
cur,
innerTop + cross(innerH, kid.rect.h),
links,
definiteWidth && growOf(kn) > 0,
)
cur += kid.rect.w + gap
} else {
if (stretches) kid.rect.w = innerW
place(kid, innerX + cross(innerW, kid.rect.w), cur, links)
// Stretching along a column still respects a declared cap.
if (stretches) kid.rect.w = Math.min(maxWOf(kn), innerW)
// A stretched child fills a width this column already knew, so it inherits
// that certainty; an unstretched one is still sized by its own content.
place(
kid,
innerX + cross(innerW, kid.rect.w),
cur,
links,
definiteWidth && stretches,
)
cur += kid.rect.h + gap
}
}
@@ -867,6 +1340,12 @@ export interface LayoutResult {
/** 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 }
/**
* Area of draw.io's default page (A4 at 850x1100), the yardstick a declared aspect ratio
* is measured against. Using the editor's own page size means aspect 1 lands on a square
* about one page in area, rather than on some number invented here.
*/
const PAGE_AREA = 850 * 1100
/**
* Lay out a forest of roots side by side and report the page size that fits them.
@@ -882,34 +1361,130 @@ export function layoutForest(
/** The diagram's links. Needed by sequence containers, which size themselves from
* the number of messages between their participants. */
links?: LayoutLinks
/**
* Target width : height for the page. When set, the top level is given a width
* that lands near it, which is the only way a proportional rule has anything to
* divide: without a definite width there is no leftover space, so every `grow`
* weight resolves to zero. Yoga's own docs say the same — a container distributes
* "any remaining space" among its children, so some space has to remain.
*/
aspect?: number
} = {},
): LayoutResult {
const glyph = opts.iconSize ?? ICON_SIZE
const gap = opts.gap ?? 70
const links: LayoutLinks = opts.links ?? []
const placed = roots.map((r) => measure(r, glyph, links))
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, links)
} else {
place(p, cur, ORIGIN.y, links)
cur += p.rect.w + gap
const run = (hints?: Map<string, number>, target?: number): Placed[] => {
// A target page width narrower than the content is a request to WRAP, and wrapping
// has to happen during measure — the line breaks change every height. So the cap is
// pushed onto the root group before measuring, unless it declared its own.
const rootCap = new Map<string, number>()
if (target) {
for (const r of roots)
if (
r.kind === "group" &&
r.dir === "row" &&
!r.pinned &&
r.maxW == null
) {
rootCap.set(r.id, target)
r.maxW = target
}
}
const placed = roots.map((r) => measure(r, glyph, links, hints))
// Widen the roots to the target width when they came out narrower. Only a group can
// absorb it — a grid, pool, sequence or radial computes its interior from its own
// rule, so forcing one wider just adds dead space inside it.
if (target) {
const own = placed.filter(
(p) => p.node.kind !== "title" && !p.node.pinned,
)
const spread = gap * Math.max(0, own.length - 1)
const share = (target - spread) / Math.max(1, own.length)
for (const p of own)
if (p.node.kind === "group")
p.rect.w = Math.min(
maxWOf(p.node),
Math.max(p.rect.w, share),
)
}
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, links)
} else {
// A target width makes the top level's width definite, which is what lets
// grow weights inside it read as proportions of a whole.
place(p, cur, ORIGIN.y, links, Boolean(target))
cur += p.rect.w + gap
}
}
// Undo only now: `place` needs the cap to break lines in the same places `measure`
// did, but `roots` is the caller's tree and must come back exactly as it went in.
for (const r of roots)
if (rootCap.has(r.id) && r.kind === "group") r.maxW = undefined
return placed
}
const extent = (placed: Placed[]) => {
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 { maxX, maxY }
}
let placed = run()
if (opts.aspect && opts.aspect > 0) {
// The target width has to come from OUTSIDE the content, or it cannot create the
// spare space that proportional rules divide: deriving it from the area the content
// already occupies just returns that content's own width back, leaving nothing over.
// draw.io's page is the natural external reference — one A4 at 850x1100 — so
// width = sqrt(pageArea x aspect) is the first guess.
let want = Math.round(Math.sqrt(PAGE_AREA * opts.aspect))
// Then iterate, because width and height are not independent: widening the page
// makes every paragraph rewrap to fewer lines, which SHORTENS it, which changes the
// ratio that was being aimed at. One pass therefore lands wide of the mark — asking
// for 0.8 gave 1.13. Each round measures what the last width actually produced and
// corrects toward the target; three is enough to get inside a few percent, and the
// loop stops early once the correction is negligible.
//
// The hint map is what makes the correction real: it carries the width each box was
// drawn at, so its text is re-counted at that width instead of at its intrinsic one.
for (let pass = 0; pass < 4; pass++) {
placed = run(undefined, want)
const hints = new Map<string, number>()
const collect = (p: Placed) => {
if (p.node.kind === "box") hints.set(p.node.id, p.rect.w)
p.children.forEach(collect)
}
placed.forEach(collect)
placed = run(hints, want)
const { maxX, maxY } = extent(placed)
const w = maxX + MARGIN.right
const h = maxY + MARGIN.bottom
const err = w / h / opts.aspect
if (Math.abs(err - 1) < 0.04) break
// Geometric correction: to move the ratio by a factor, move the width by its
// square root, since shrinking the width lengthens the page and vice versa.
const next = Math.round(want / Math.sqrt(err))
if (next === want) break
want = next
}
}
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)
const { maxX, maxY } = extent(placed)
return {
roots: placed,

View File

@@ -80,8 +80,32 @@ export const MARKER = {
grow: "dai_grow",
/** Cross-axis position within the parent: "start" | "center" | "end". */
align: "dai_align",
/** How a container spreads children along its own axis — justify-content. */
justify: "dai_justify",
/** A container's cross-axis default for children that declare no align of their own. */
alignItems: "dai_aitems",
/** Opted out of the content-width floor when weights divide a row — CSS's min-width:0. */
minw0: "dai_minw0",
/**
* Declared width cap, px.
*
* Has to be a marker rather than inferred from the drawn width: the two are only equal
* when the cap actually bit. A box capped at 400 that happens to be 260 wide would come
* back with a 260 cap, and the next re-layout could never let it grow again.
*/
maxw: "dai_maxw",
/** A container's interior padding, px. */
pad: "dai_pad",
/**
* The page's declared width:height, on the default layer's cell.
*
* Page-level rather than per-node, so it goes on layer "1" — the one cell every
* diagram has and draw.io never discards. It cannot be inferred from pageWidth and
* pageHeight: those are what the last layout produced, so reading them back would
* turn whatever shape a diagram happened to come out as into a standing request to
* keep it.
*/
aspect: "dai_aspect",
/**
* 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
@@ -237,6 +261,36 @@ export function isPinned(style: string): boolean {
* load-bearing there: the container tokens rely on appending `container=1` after a catalog
* style that may say `container=0`.
*/
/**
* Set each `key=value;` token of `tokens` on a style, replacing any value already there.
*
* Matching is per token, not on the whole run: a catalog style may already declare
* `container=1` while saying nothing about `pointerEvents`, and re-adding the whole run
* because one token was missing is what let these accumulate.
*
* Exported because the same defect appeared a second time, on EDGES: an edge's style starts
* from whatever the canvas held, which already carried the previous pass's `exitX`/`entryX`
* port keys, and the router appended a fresh set on top of them every render — 76 characters
* per round-trip, without bound. Any code that re-stamps a computed mxGraph key onto a style
* recovered from the canvas needs this rather than `+=`.
*/
export function appendOnce(style: string, tokens: string): string {
let s = style
for (const tok of tokens.split(";")) {
if (!tok) continue
const key = tok.slice(0, tok.indexOf("="))
// The key must not be present with ANY value: `container=0` from a catalog stencil
// has to be overwritten, which is what appending the correct value does.
const has = new RegExp(`(?:^|;)${key}=[^;]*;`).test(s)
if (has) {
s = s.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
}
s = s.endsWith(";") || s === "" ? s : `${s};`
s += `${tok};`
}
return s
}
function append(style: string, key: string, value: string | number): string {
const cleaned = key.startsWith("dai_")
? style.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
@@ -266,8 +320,14 @@ export function stampContainer(
},
): string {
let s = style.endsWith(";") || style === "" ? style : `${style};`
s += CONTAINER_TOKENS
if (opts.invisible) s += INVISIBLE_TOKENS
// Appended only when not already there. These are plain mxGraph keys, so `append`'s
// de-duplication (which is limited to `dai_*`) does not cover them — and a container
// goes through here on EVERY re-layout, so a blind `+=` grew the style string by
// another `container=1;pointerEvents=0;collapsible=0;recursiveResize=0;` per round
// trip, without bound. Harmless to draw.io, which takes the last value, but the XML
// never reached a fixed point and every edit shipped a longer style.
s = appendOnce(s, CONTAINER_TOKENS)
if (opts.invisible) s = appendOnce(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))
@@ -416,17 +476,32 @@ export function isAutoSized(style: string): boolean {
}
type FlexAlign = "start" | "center" | "end" | "stretch"
type FlexJustify = "start" | "center" | "end" | "between" | "around" | "evenly"
/** 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 },
opts: {
grow?: number
align?: FlexAlign
justify?: FlexJustify
alignItems?: FlexAlign
maxW?: number
minW0?: boolean
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.justify && opts.justify !== "start")
s = append(s, MARKER.justify, opts.justify)
if (opts.alignItems) s = append(s, MARKER.alignItems, opts.alignItems)
if (opts.maxW != null && opts.maxW > 0)
s = append(s, MARKER.maxw, Math.round(opts.maxW))
if (opts.minW0) s = append(s, MARKER.minw0, 1)
if (opts.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
return s
}
@@ -437,6 +512,63 @@ export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
return v === "start" || v === "end" || v === "stretch" ? v : null
}
/** Read a container's cross-axis default. Null means it declared none. */
export function readAlignItems(style: string): FlexAlign | null {
const v = readMarker(style, MARKER.alignItems)
return v === "start" || v === "end" || v === "stretch" || v === "center"
? v
: null
}
/** Read the justify marker back. Anything unrecognised means the default (start). */
export function readJustify(
style: string,
): Exclude<FlexJustify, "start"> | null {
const v = readMarker(style, MARKER.justify)
return v === "center" ||
v === "end" ||
v === "between" ||
v === "around" ||
v === "evenly"
? v
: null
}
/** Read the declared width cap back, or null when there was none. */
export function readMaxW(style: string): number | null {
const v = Number(readMarker(style, MARKER.maxw))
return Number.isFinite(v) && v > 0 ? v : null
}
/** Did this node opt out of the content-width floor? */
export function readMinW0(style: string): boolean {
return readMarker(style, MARKER.minw0) === "1"
}
/** The page's declared aspect ratio, stamped on the default layer. */
export function stampAspect(layerXml: string, aspect: number): string {
return layerXml.replace(
/<mxCell id="1" parent="0"\/>/,
`<mxCell id="1" parent="0" style="${MARKER.aspect}=${aspect};"/>`,
)
}
/**
* Read the page's declared aspect back out of a model body.
*
* Scans for the marker anywhere in the page rather than parsing the layer cell: the
* marker name is namespaced, so a match cannot be anything else, and this keeps working
* if draw.io ever reorders or reformats that cell.
*/
export function readAspect(page: string): number | undefined {
const m = new RegExp(`${MARKER.aspect}=([\\d.]+)`).exec(page)
if (!m) return undefined
const v = Number(m[1])
return Number.isFinite(v) && v > 0
? Math.min(4, Math.max(0.25, v))
: undefined
}
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
export function stripMarkers(style: string): string {
return style

View File

@@ -13,6 +13,7 @@
import { z } from "zod"
// A runtime import while graph.ts imports only TYPES from here — no cycle at runtime.
import { graphToOperations } from "./graph"
import { parseTw, type TwLayout } from "./tw"
import {
type ContainerNode,
type DiagramNode,
@@ -21,6 +22,7 @@ import {
findParent,
isContainer,
type LinkSpec,
type TextStyle,
walkTree,
} from "./types"
@@ -81,7 +83,7 @@ export const OperationSchema = z.discriminatedUnion("op", [
.string()
.optional()
.describe(
"Fill colour, e.g. #DAE8FC. Prefer draw_graph's group field over picking colours",
"Fill colour, e.g. #DAE8FC. Prefer the group field over picking colours",
),
stroke: z
.string()
@@ -105,6 +107,18 @@ export const OperationSchema = z.discriminatedUnion("op", [
.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",
),
maxW: z
.number()
.optional()
.describe(
"Hard width cap in px. Long text rewraps to fit instead of stretching the box, so this is what keeps a paragraph from making the whole page a letterbox. Beats grow",
),
class: z
.string()
.optional()
.describe(
'Tailwind layout classes, e.g. "grow-2 self-stretch max-w-md". Supported: grow / grow-N / flex-N, w-1/3 (a share of the row), w-full, min-w-0, self-start|center|end|stretch, max-w-N or max-w-xs..4xl. NO colour classes — colour comes from role and group. Unknown classes are ignored and reported back',
),
lane: z
.number()
.optional()
@@ -173,6 +187,30 @@ export const OperationSchema = z.discriminatedUnion("op", [
.describe(
"Cross-axis position in the parent: start/end pin to an edge, stretch fills. Default center",
),
justify: z
.enum(["start", "center", "end", "between", "around", "evenly"])
.optional()
.describe(
"How children spread along dir when there is spare room. Default start packs them and leaves the gap at the far end — set between or evenly to spread a short column down its full height instead of leaving a hole at the bottom",
),
alignItems: z
.enum(["start", "center", "end", "stretch"])
.optional()
.describe(
"Cross-axis default for every child, so cards in a column all span the same width without setting align on each. stretch is what makes a column of cards line up",
),
maxW: z
.number()
.optional()
.describe(
"Hard width cap in px. Children wrap or shrink to fit rather than run past it. Beats grow",
),
class: z
.string()
.optional()
.describe(
'Tailwind classes, e.g. "flex-col gap-4 p-4 grow-3 items-stretch justify-between max-w-2xl". LAYOUT: flex-row|flex-col, grow / grow-N / flex-N, w-1/3, w-full, min-w-0 (let a weight shrink this below its own text width — needed on every column when you want an exact ratio), items-* and self-* (start|center|end|stretch), justify-start|center|end|between|around|evenly, gap-N, p-N, max-w-N or max-w-xs..4xl. Spacing is Tailwind\'s 4px scale, so gap-4 is 16px. TEXT (applies to the frame title): font-bold / font-normal, italic, underline, text-xs..text-4xl, text-left|center|right, align-top|middle|bottom, whitespace-nowrap. BORDER: border / border-N, border-dashed / border-dotted / border-solid — a dashed frame reads as planned or logical rather than deployed. NOT accepted: any colour class — colour comes from role and group; the other seven font weights; opacity-*, truncate, rounded-*, shadow-*, outline-*, transforms. Unknown classes are dropped and reported back',
),
after: z.string().optional(),
}),
z.object({
@@ -412,6 +450,21 @@ export const OperationSchema = z.discriminatedUnion("op", [
op: z.literal("set_title"),
title: z.string(),
}),
z.object({
op: z.literal("clear"),
keepTitle: z
.boolean()
.optional()
.describe("Keep the page title; default drops it too"),
}),
z.object({
op: z.literal("set_page"),
aspect: z
.number()
.describe(
"Target width:height for the whole page. 1 = square, 1.4 = landscape slide, 0.75 = portrait poster, 1.6 = wide architecture diagram. Declare this FIRST on any multi-column diagram: it is what gives the columns a total width to divide, so grow weights and column proportions only take effect once it is set",
),
}),
])
export type Operation = z.infer<typeof OperationSchema>
@@ -420,6 +473,13 @@ export interface ApplyResult {
tree: DiagramTree
/** One entry per operation that could not be applied, in order. */
errors: string[]
/**
* Things that were drawn, but not the way they were asked for — an arrow naming a node
* that is not in the list, a loop that could not order the layers. Not errors: the
* diagram is fine and re-sending it would produce the same result, so failing would
* cost a turn and fix nothing.
*/
warnings: string[]
}
/**
@@ -514,6 +574,50 @@ export function applyOperations(
): ApplyResult {
const tree: DiagramTree = structuredClone(input)
const errors: string[] = []
const warnings: string[] = []
/**
* Resolve an operation's Tailwind class string into layout fields.
*
* Explicit fields win over classes. Both are accepted because they are the same
* vocabulary said two ways, and a caller mixing them — `class: "flex-col gap-4"` plus
* `grow: 3` — means the explicit number, not a conflict to reject.
*
* Unknown classes are collected once per call rather than per operation: a poster
* repeating `shadow-lg` on twelve cards should say so once.
*/
const ignoredClasses = new Set<string>()
const twOf = (cls: string | undefined): TwLayout | null => {
if (!cls?.trim()) return null
const parsed = parseTw(cls)
for (const c of parsed.ignored) ignoredClasses.add(c)
return parsed
}
/**
* The presentation overrides a class string asked for, or undefined when it asked for
* none. Sparse on purpose: an absent field means "let the role decide", so a class
* string that only sets alignment cannot silently reset the type size.
*/
const textOf = (tw: TwLayout | null): TextStyle | undefined => {
if (!tw) return undefined
const t: TextStyle = {
...(tw.bold != null ? { bold: tw.bold } : {}),
...(tw.italic != null ? { italic: tw.italic } : {}),
...(tw.underline != null ? { underline: tw.underline } : {}),
...(tw.strike != null ? { strike: tw.strike } : {}),
...(tw.fontSize != null ? { size: tw.fontSize } : {}),
...(tw.textAlign ? { align: tw.textAlign } : {}),
...(tw.verticalAlign ? { valign: tw.verticalAlign } : {}),
...(tw.nowrap != null ? { nowrap: tw.nowrap } : {}),
...(tw.borderWidth != null ? { borderWidth: tw.borderWidth } : {}),
...(tw.borderStyle ? { borderStyle: tw.borderStyle } : {}),
...(tw.borderless != null ? { borderless: tw.borderless } : {}),
...(tw.radius != null ? { radius: tw.radius } : {}),
...(tw.shadow != null ? { shadow: tw.shadow } : {}),
}
return Object.keys(t).length > 0 ? t : undefined
}
const exists = (id: string) => findNode(tree, id) !== null
@@ -528,6 +632,23 @@ export function applyOperations(
expanded.push(op)
continue
}
// Reject a graph that cannot be drawn, rather than emitting a broken one. Both
// checks have to happen here: an empty node list would otherwise produce an empty
// frame, and a duplicate id would surface as "add_box: id already taken", naming a
// synthetic operation the model never wrote.
if (op.nodes.length === 0) {
errors.push(`add_graph "${op.id}": no nodes — nothing to draw.`)
continue
}
const dupes = op.nodes
.map((nd) => nd.id)
.filter((id, i, all) => all.indexOf(id) !== i)
if (dupes.length > 0) {
errors.push(
`add_graph "${op.id}": duplicate node id(s): ${[...new Set(dupes)].join(", ")}.`,
)
continue
}
const g = graphToOperations(op.nodes, op.edges, {
flow: op.dir ?? "col",
parent: op.parent,
@@ -536,9 +657,17 @@ export function applyOperations(
prefix: op.id,
rootId: op.id,
})
// A stray endpoint is a warning, not an error: the rest of the graph is drawn
// correctly, so rejecting it would cost a turn and produce the same diagram.
if (g.unknownEndpoints.length)
errors.push(
`add_graph "${op.id}": edge endpoint(s) not in nodes: ${g.unknownEndpoints.join(", ")}`,
warnings.push(
`Dropped edge(s) naming nodes that were not in the node list: ${g.unknownEndpoints.join(", ")}.`,
)
if (g.backEdges.length)
warnings.push(
`Loop(s) drawn but not used for ordering: ${g.backEdges
.map((b) => `${b.source}${b.target}`)
.join(", ")}.`,
)
if (op.label || op.after) {
const root = g.operations[0]
@@ -577,7 +706,13 @@ export function applyOperations(
label: op.label ?? "",
...cellOf(op),
}
else if (op.op === "add_box")
else if (op.op === "add_box") {
// Classes first, then the explicit fields on top: an explicit number is
// the more specific statement of the two.
const tw = twOf(op.class)
const grow = op.grow ?? tw?.grow
const align = op.align ?? tw?.align
const maxW = op.maxW ?? tw?.maxW
node = {
kind: "box",
id: op.id,
@@ -589,30 +724,44 @@ 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 }
: {}),
...(grow && grow > 0 ? { grow } : {}),
...(align && align !== "center" ? { align } : {}),
...(maxW && maxW > 0 ? { maxW } : {}),
...(tw?.minW0 ? { minW0: true } : {}),
...(textOf(tw) ? { text: textOf(tw) } : {}),
...cellOf(op),
}
else if (op.op === "add_container")
} else if (op.op === "add_container") {
const tw = twOf(op.class)
const grow = op.grow ?? tw?.grow
const align = op.align ?? tw?.align
const justify = op.justify ?? tw?.justify
const alignItems = op.alignItems ?? tw?.alignItems
const maxW = op.maxW ?? tw?.maxW
const pad = op.pad ?? tw?.pad
node = {
kind: "group",
id: op.id,
gname: op.gname ?? null,
label: op.label,
// `dir` is required on the operation, so a class can only confirm
// it. Reading the class first would let `flex-col` silently override
// a declared `dir: "row"`.
dir: op.dir,
gap: op.gap ?? 20,
gap: op.gap ?? tw?.gap ?? 20,
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) } : {}),
...(grow && grow > 0 ? { grow } : {}),
...(align && align !== "center" ? { align } : {}),
...(justify && justify !== "start" ? { justify } : {}),
...(alignItems ? { alignItems } : {}),
...(maxW && maxW > 0 ? { maxW } : {}),
...(tw?.minW0 ? { minW0: true } : {}),
...(textOf(tw) ? { text: textOf(tw) } : {}),
...(pad != null ? { pad: Math.max(0, pad) } : {}),
}
else if (op.op === "add_grid")
} else if (op.op === "add_grid")
node = {
kind: "grid",
id: op.id,
@@ -880,13 +1029,43 @@ export function applyOperations(
break
}
case "clear": {
// Start over. Needed because some diagrams are rebuilt rather than
// patched: in a flowchart one new arrow can change which row several
// nodes belong in, so there is no meaningful way to merge a new graph
// into the old layout.
//
// `foreign` goes too. Those are cells the parser could not place in the
// tree, and keeping them would leave a user's stray annotations floating
// over a diagram they no longer refer to.
tree.roots = []
tree.links = []
tree.foreign = []
if (!op.keepTitle) tree.title = undefined
break
}
case "set_title":
tree.title = op.title
break
case "set_page":
// Clamped rather than rejected: an out-of-range ratio is a slip, and a
// diagram 40 times wider than it is tall is never what was meant.
tree.aspect = Math.min(4, Math.max(0.25, op.aspect))
break
}
}
return { tree, errors }
// Names the whole supported vocabulary, not just the group the dropped class looked like
// it belonged to: a model that reached for `pt-8` needs to see that padding is `p-N` only,
// and one that reached for `shadow-2xl` needs the four rungs that do exist.
if (ignoredClasses.size > 0)
warnings.push(
`Ignored class(es) with no equivalent here: ${[...ignoredClasses].join(", ")}. Supported — layout: flex-row/flex-col, grow/grow-N/flex-N, w-1/N, w-full, min-w-0, items-*, self-*, justify-*, gap-N, p-N, max-w-N/max-w-xs..4xl. Text: font-bold/font-normal, italic, underline, line-through, text-xs..4xl, text-left/center/right, align-top/middle/bottom, whitespace-nowrap. Border: border/border-N, border-solid/dashed/dotted, border-none, rounded/rounded-sm..4xl/rounded-full, shadow-sm/md/lg/xl/shadow-none. Colour comes from role and group; per-side borders and padding (border-l, pt-4) are not available.`,
)
return { tree, errors, warnings }
}
/** Every icon/group name in a tree, for validating against the catalog. */

View File

@@ -31,14 +31,19 @@ import {
MARKER,
type NodeKind,
readAlign,
readAlignItems,
readAspect,
readCell,
readDir,
readIntMarker,
readJustify,
readKind,
readList,
readMarker,
readMaxW,
readMinW0,
} from "./markers"
import { isRole, type Role } from "./theme"
import { isRole, type Role, roleIsBorderless } from "./theme"
import type {
BoxNode,
BoxShape,
@@ -54,6 +59,7 @@ import type {
RadialNode,
Rect,
SequenceNode,
TextStyle,
} from "./types"
/** Hard cap on nesting depth, matching the reference project's 50-hop guard. */
@@ -293,6 +299,112 @@ function styleValue(style: string, key: string): string | undefined {
return all.length ? all[all.length - 1][1] : undefined
}
/**
* Read a node's presentation overrides back out of its style.
*
* No `dai_*` marker needed: these are draw.io's OWN style keys, so the values on the canvas
* are the values that were asked for — and if the user changed one in the editor, reading it
* back is exactly right.
*
* Only reports a field when the style says something a plain box would not, because every
* cell carries `fontSize` and `verticalAlign` from the fallback style. Treating those as
* declared overrides would freeze the theme's defaults onto every node, so a later role
* change could no longer alter the type.
*/
function textStyleOf(
style: string,
defaults: {
size?: number
valign?: string
align?: string
/**
* Whether this node's ROLE already draws it borderless, so `strokeColor=none` is the
* theme talking rather than a declared override.
*
* Needed because two roles emit it themselves: a `banner` leaf is a dark filled slab
* with no outline, and `heading`/`muted` are ghost text with neither fill nor stroke
* (theme.ts:220, 230). Without this the parser would record every banner as having
* explicitly asked for no border, and since `set_role` clears `style` but keeps
* `text`, a later change to a bordered role would come back still borderless.
*/
borderless?: boolean
},
): TextStyle | undefined {
const t: TextStyle = {}
// fontStyle is a bitmask: 1 bold, 2 italic, 4 underline, 8 strikethrough.
const fs = styleValue(style, "fontStyle")
if (fs !== undefined) {
const bits = Number(fs)
if (Number.isFinite(bits)) {
if (bits & 1) t.bold = true
if (bits & 2) t.italic = true
if (bits & 4) t.underline = true
if (bits & 8) t.strike = true
}
}
const size = Number(styleValue(style, "fontSize"))
if (Number.isFinite(size) && size > 0 && size !== defaults.size)
t.size = size
const align = styleValue(style, "align")
if (
(align === "left" || align === "center" || align === "right") &&
align !== defaults.align
)
t.align = align
const valign = styleValue(style, "verticalAlign")
if (
(valign === "top" || valign === "middle" || valign === "bottom") &&
valign !== defaults.valign
)
t.valign = valign
if (styleValue(style, "whiteSpace") === "nowrap") t.nowrap = true
const sw = Number(styleValue(style, "strokeWidth"))
if (Number.isFinite(sw) && sw > 1) t.borderWidth = sw
if (styleValue(style, "dashed") === "1")
t.borderStyle = styleValue(style, "dashPattern") ? "dotted" : "dashed"
if (
styleValue(style, "strokeColor") === "none" &&
defaults.borderless !== true
)
t.borderless = true
// A radius is only recoverable when `absoluteArcSize=1` says the number is pixels. A bare
// `arcSize` is a PERCENTAGE of the box, which is what the shape catalog's own `round` and
// `terminator` emit — reading those back as a pixel radius would silently convert a
// shape's proportional corner into a fixed one on the first round-trip.
//
// `rounded=0` is NOT read back as a declared `radius: 0`. Nearly every box carries it from
// the fallback style, so recording it would freeze square corners onto the node and stop a
// later role or shape change from rounding them — the same trap `size` and `align` avoid
// by comparing against defaults.
if (styleValue(style, "absoluteArcSize") === "1") {
const arc = Number(styleValue(style, "arcSize"))
if (Number.isFinite(arc) && arc > 0) t.radius = arc / 2
}
// Which rung a shadow came from, recovered from its blur — the one parameter that differs
// across all four steps (3/6/15/25). Reading the rung rather than the raw numbers is what
// keeps the round-trip a fixed point: re-emitting rung 2 gives back the same five keys.
if (styleValue(style, "shadow") === "1") {
const blur = Number(styleValue(style, "shadowBlur"))
const rung = SHADOW_RUNGS[blur]
if (rung !== undefined) t.shadow = rung
} else if (styleValue(style, "shadow") === "0") t.shadow = 0
return Object.keys(t).length > 0 ? t : undefined
}
/** Blur radius back to the Tailwind rung that produced it. Mirrors render.ts's SHADOW_KEYS. */
const SHADOW_RUNGS: Record<number, number> = { 3: 1, 6: 2, 15: 3, 25: 4 }
/** Does this style declare a draw.io container? */
function declaresContainer(style: string): boolean {
return styleValue(style, "container") === "1"
@@ -372,12 +484,30 @@ function shapeOf(style: string): string | undefined {
function flexOf(style: string): {
grow?: number
align?: "start" | "end" | "stretch"
maxW?: number
minW0?: boolean
} {
const grow = readIntMarker(style, MARKER.grow)
const align = readAlign(style)
const maxW = readMaxW(style)
return {
...(grow !== null && grow > 0 ? { grow } : {}),
...(align ? { align } : {}),
...(maxW !== null ? { maxW } : {}),
...(readMinW0(style) ? { minW0: true } : {}),
}
}
/** The container-only flex fields: how children spread, and their cross-axis default. */
function containerFlexOf(style: string): {
justify?: "center" | "end" | "between" | "around" | "evenly"
alignItems?: "start" | "center" | "end" | "stretch"
} {
const justify = readJustify(style)
const alignItems = readAlignItems(style)
return {
...(justify ? { justify } : {}),
...(alignItems ? { alignItems } : {}),
}
}
@@ -1103,6 +1233,19 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
: undefined,
fill: styleValue(c.style, "fillColor"),
stroke: styleValue(c.style, "strokeColor"),
// Read back against the fallback box's own values, so only a real override
// is reported — every cell carries fontSize and verticalAlign from that
// fallback, and treating those as declared would freeze the theme onto the
// node and stop a later role change from altering the type.
...(() => {
const t = textStyleOf(c.style, {
size: 11,
valign: "middle",
align: "center",
borderless: roleIsBorderless(roleOf(c.style), "leaf"),
})
return t ? { text: t } : {}
})(),
// 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>`.
@@ -1263,6 +1406,19 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
role: roleOf(c.style),
group: zoneOf(c.style),
...flexOf(c.style),
...containerFlexOf(c.style),
// A frame's own defaults differ from a box's: 12px bold title, flush left and
// top. Same reasoning as above — compare against those, not against a box's.
// No `borderless` default: a themed panel always draws its border, whatever its
// role, so `strokeColor=none` on a container is always someone's own request.
...(() => {
const t = textStyleOf(c.style, {
size: 12,
valign: "top",
align: "left",
})
return t ? { text: t } : {}
})(),
...(markedPad !== null ? { pad: markedPad } : {}),
}
return node
@@ -1344,8 +1500,21 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
`Children of ${ambiguousContainers.join(", ")} are arranged in two dimensions, which no single direction describes; a re-layout will move them.`,
)
// The page proportions come back from a marker, NOT from pageWidth/pageHeight. Those
// record the size the last layout happened to produce, which is only the requested
// ratio when one was requested at all: reading them back turns an accidental 340x306
// page into a standing instruction to keep that shape, and the next re-layout inflates
// the diagram to obey it.
const aspect = readAspect(page)
return {
tree: { roots, links, title, foreign },
tree: {
roots,
links,
title,
foreign,
...(aspect != null ? { aspect } : {}),
},
needsAdoption: !marked,
warnings,
}

View File

@@ -22,7 +22,9 @@ import {
sequenceMetrics,
} from "./layout"
import {
appendOnce,
isInvisible,
MARKER,
stampAuto,
stampCell,
stampContainer,
@@ -48,6 +50,7 @@ import {
type PoolNode,
type Rect,
type SequenceNode,
type TextStyle,
} from "./types"
import type { Point } from "./visgraph"
@@ -81,6 +84,77 @@ function isParagraph(label: string): boolean {
return /\n/.test(label) || /<br/i.test(label) || plain.length > 60
}
/**
* The five draw.io shadow parameters per Tailwind rung, keyed by TextStyle.shadow.
*
* Values are the primary layer of Tailwind's own CSS: `shadow-md` is
* `0 4px 6px rgb(0 0 0/0.1)`, so 4px down, 6px of blur, 10% opaque. Tailwind stacks a second
* tighter layer on each step; draw.io renders one `drop-shadow()`, so the main layer is what
* survives. `shadowColor` is left off deliberately — draw.io's default grey is correct, and
* colour belongs to `role` and `group`.
*/
const SHADOW_KEYS: Record<number, string> = {
1: "shadow=1;shadowOffsetX=0;shadowOffsetY=1;shadowBlur=3;shadowOpacity=10;",
2: "shadow=1;shadowOffsetX=0;shadowOffsetY=4;shadowBlur=6;shadowOpacity=10;",
3: "shadow=1;shadowOffsetX=0;shadowOffsetY=10;shadowBlur=15;shadowOpacity=10;",
4: "shadow=1;shadowOffsetX=0;shadowOffsetY=20;shadowBlur=25;shadowOpacity=10;",
}
/**
* A node's presentation overrides, as draw.io style keys.
*
* Bold, italic, underline and strikethrough are ONE key: draw.io packs them into `fontStyle`
* as a bitmask (1 bold, 2 italic, 4 underline, 8 strikethrough) which you add together.
* Writing four separate keys, or writing `fontStyle=1` twice, would leave only the last one
* in effect — so the bits are combined here and emitted once.
*
* `dashPattern` accompanies dotted: `dashed=1` alone gives draw.io's default dash, and the
* short-on-long-off pattern is what makes it read as a dotted line rather than a dashed one.
*
* A radius needs three keys together. `rounded=1` turns corners on at all, `absoluteArcSize=1`
* makes the number pixels instead of a percentage of the box, and `arcSize` is DOUBLE the
* radius because draw.io halves it on the way in (mxShape.js:1172-1189). All three or none:
* `arcSize` alone would be read as a percentage and give a different radius on every box.
*/
function textStyleKeys(t: TextStyle | undefined): string | undefined {
if (!t) return undefined
const parts: string[] = []
const bits =
(t.bold ? 1 : 0) +
(t.italic ? 2 : 0) +
(t.underline ? 4 : 0) +
(t.strike ? 8 : 0)
// Only when something asked. `fontStyle=0` would override a role's own bold.
if (
t.bold != null ||
t.italic != null ||
t.underline != null ||
t.strike != null
)
parts.push(`fontStyle=${bits};`)
if (t.size != null && t.size > 0) parts.push(`fontSize=${t.size};`)
if (t.align) parts.push(`align=${t.align};`)
if (t.valign) parts.push(`verticalAlign=${t.valign};`)
if (t.nowrap != null)
parts.push(`whiteSpace=${t.nowrap ? "nowrap" : "wrap"};`)
if (t.borderWidth != null && t.borderWidth > 0)
parts.push(`strokeWidth=${t.borderWidth};`)
if (t.borderStyle === "dashed") parts.push("dashed=1;")
else if (t.borderStyle === "dotted") parts.push("dashed=1;dashPattern=1 3;")
else if (t.borderStyle === "solid") parts.push("dashed=0;")
// `strokeColor=none` is how draw.io says "no border" (mxShape.js:1398 accepts it), and
// it is the only way to draw a plain colour field with no outline at all.
if (t.borderless) parts.push("strokeColor=none;")
if (t.radius != null) {
if (t.radius > 0)
parts.push(`rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2};`)
else parts.push("rounded=0;")
}
if (t.shadow != null)
parts.push(t.shadow > 0 ? (SHADOW_KEYS[t.shadow] ?? "") : "shadow=0;")
return parts.length ? parts.join("") : undefined
}
/** Resolve a catalog name to a style. Injected so the engine does not own the catalog. */
export type StyleResolver = (
name: string,
@@ -191,13 +265,22 @@ function styleFor(
n.fill ? `fillColor=${n.fill};` : undefined,
n.stroke ? `strokeColor=${n.stroke};` : undefined,
n.bold ? "fontStyle=1;" : undefined,
// Last, so an explicit override beats both the role's type and the
// paragraph heuristic above — asking for centred text has to win over
// "this looks like a paragraph, so set it flush left".
textStyleKeys(n.text),
)
}
let stamped = stampLeaf(base, "box")
if (n.shape && n.shape !== "box") stamped = stampShape(stamped, n.shape)
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 })
stamped = stampFlex(stamped, {
grow: n.grow,
align: n.align,
maxW: n.maxW,
minW0: n.minW0,
})
// Engine-measured (no explicit w/h): mark it, so the parser re-measures next
// time instead of freezing this layout's numbers as a fixed size.
if (n.w == null && n.h == null) stamped = stampAuto(stamped)
@@ -240,10 +323,22 @@ function styleFor(
if (n.fill) base += `fillColor=${n.fill};`
if (n.stroke) base += `strokeColor=${n.stroke};`
}
if (n.kind === "group") {
const overrides = textStyleKeys(n.text)
if (overrides) base = mergeStyle(base, overrides)
}
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 })
base = stampFlex(base, {
grow: n.grow,
align: n.align,
justify: n.justify,
alignItems: n.alignItems,
maxW: n.maxW,
minW0: n.minW0,
pad: n.pad,
})
return stampContainer(base, {
kind: n.kind,
dir: n.kind === "grid" ? "grid" : n.dir,
@@ -253,8 +348,12 @@ function styleFor(
})
}
// `connectable=0` because an invisible wrapper is scaffolding, not a thing to draw arrows
// from. Without it draw.io treats the empty frame as a normal shape: hovering anywhere over
// the group pops up its connection crosses and direction arrows, which land on top of the
// content inside it and read as stray marks in the middle of the diagram.
const INVISIBLE_FRAME_STYLE =
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;"
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;connectable=0;"
/**
* One `<mxCell>` for a vertex, with geometry relative to its parent.
@@ -624,10 +723,18 @@ function edgeXml(
style += `startArrow=${l.tail};startFill=${l.tailFill ? 1 : 0};`
if (label) style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
}
// SET rather than append. Unlike the branch above, this runs for a recovered style too —
// the router recomputes the ports on every layout, so they cannot be left at whatever the
// canvas held. But that recovered style ALREADY carries the previous pass's eight port
// keys, and appending grew the style by 76 characters per round-trip without bound.
// draw.io resolves duplicates last-wins so the arrow always looked right, which is why
// this survived until a byte-identity check caught it.
if (route)
style +=
style = appendOnce(
style,
`exitX=${route.exit.x};exitY=${route.exit.y};exitDx=0;exitDy=0;` +
`entryX=${route.entry.x};entryY=${route.entry.y};entryDx=0;entryDy=0;`
`entryX=${route.entry.x};entryY=${route.entry.y};entryDx=0;entryDy=0;`,
)
const id = l.id ?? `ed${index + 1}`
const points =
route?.freeze && route.waypoints.length
@@ -730,6 +837,7 @@ export function renderDiagram(
iconSize: opts.iconSize,
gap: opts.rootGap,
links: tree.links,
aspect: tree.aspect,
})
const flat = flatten(roots)
@@ -904,7 +1012,7 @@ export function renderDiagram(
//
// An INVISIBLE container is excluded, because both of those judgements are about what a
// reader sees, and there is no border on screen to run alongside or to trespass across.
// A layer band in a flowchart is exactly that: `draw_graph` wraps each row of the graph
// A layer band in a flowchart is exactly that: `add_graph` wraps each row of the graph
// in an unlabelled, unstroked container purely to stack them. Counting those as frames
// measurably ruined the arrows — a back edge such as "return for correction" → "submit"
// leaves its own band, so every clean route was rejected for trespassing on a frame that
@@ -971,11 +1079,16 @@ export function renderDiagram(
for (const m of messages)
cells.push(messageXml(m.link, m.index, m.y, cellById))
// The declared page ratio rides on the default layer's cell — the one cell every
// diagram has, and page-level state has no node to live on.
const layer = tree.aspect
? `<mxCell id="1" parent="0" style="${MARKER.aspect}=${tree.aspect};"/>`
: `<mxCell id="1" parent="0"/>`
const model =
`<mxGraphModel dx="1400" dy="900" grid="0" gridSize="10" guides="1" tooltips="1"` +
` connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${page.w}"` +
` pageHeight="${page.h}" math="0" shadow="0"><root><mxCell id="0"/>` +
`<mxCell id="1" parent="0"/>${cells.join("")}</root></mxGraphModel>`
`${layer}${cells.join("")}</root></mxGraphModel>`
return {
xml: `<mxfile host="app.diagrams.net"><diagram name="Page-1" id="page-1">${model}</diagram></mxfile>`,

View File

@@ -182,6 +182,26 @@ const ROLE_SPECS: Record<Role, RoleSpec> = {
},
}
/**
* Does this role already draw itself with no border?
*
* The parser needs this to tell a THEME's `strokeColor=none` from a DECLARED one. A banner is
* a dark filled slab and a heading is ghost text; both are borderless because of what they
* are, not because anyone asked. Recording that as an explicit override would make it
* outlive a later role change, since `set_role` clears a node's style but keeps its text
* overrides.
*
* Leaf only: the container branch of `themedStyle` always draws a border, whatever the role.
*/
export function roleIsBorderless(
role: Role | undefined,
kind: "leaf" | "container",
): boolean {
if (kind === "container") return false
const e = ROLE_SPECS[role ?? "body"].emphasis
return e === "filled" || e === "ghost"
}
/** Metrics the measure pass needs, so layout reserves what render will draw. */
export function roleMetrics(role: Role | undefined): {
fontSize: number

538
lib/diagram-engine/tw.ts Normal file
View File

@@ -0,0 +1,538 @@
/**
* Tailwind utility classes → the engine's layout fields.
*
* WHY a second way to say the same thing. The engine's own vocabulary (`dir`, `grow`,
* `align`, `justify`, `pad`, `gap`, `maxW`) is words we invented, so a model has seen them
* only in our tool description. It has seen `flex-col grow-3 items-stretch p-4` millions of
* times. Microsoft's DSL study (arXiv 2407.02742) found models hallucinate custom function
* names at a much higher rate than familiar ones, and arXiv 2311.09519 measured a large
* improvement from swapping a rare DSL for a popular language, precisely because it puts the
* output back in the distribution the model was trained on.
*
* The other half of why Tailwind and not free-form CSS: its values are a FIXED SCALE, not
* arbitrary numbers. `p-4` is 16px because one spacing unit is 4px, and there is no `p-7.5`.
* Tailwind's own docs make that the point of the thing — with inline styles "every value is a
* magic number", with utilities you pick from a system. That is the property we want, because
* an unconstrained number field is exactly where a model invents 13px here and 27px there.
*
* The supported set was picked by reading Tailwind's property index against the draw.io
* renderer's ACTUAL SOURCE — public/drawio/mxgraph/src and public/drawio/js/grapheditor,
* vendored in this repo — rather than against a prose style reference. That matters: three
* properties were excluded on wrong grounds when the reference was a document, and reading
* the code put them back (radius, strikethrough, shadow, all noted below).
*
* WHAT IS DELIBERATELY NOT HERE, and why:
*
* - COLOUR of any kind (`bg-*`, `text-red-500`, `border-blue-400`). draw.io has
* `fillColor`/`fontColor`/`strokeColor`, so this is possible — but colour is derived from
* `role` and `group` precisely so one palette stays coherent, and a colour class would be
* a back door into the hex-picking that was removed. Gradients (`bg-linear-to-b from-X
* to-Y`) are excluded for the same reason, even though `gradientColor` with a four-way
* `gradientDirection` maps onto them exactly (mxShape.js:1392-1393, 1054-1060).
*
* - Per-SIDE borders (`border-t`, `border-l-4`, `border-x`). draw.io draws these properly:
* `shape=partialRectangle` reads independent `top`/`right`/`bottom`/`left` booleans
* (Shapes.js:3914-3917) and still fills the background first (3919-3920), so a single
* heavy left edge would render correctly. The cost is the SHAPE SLOT: `partialRectangle`
* is itself a shape name, so a node could not be both a diamond and left-edge-only. What
* a node IS — a database, a decision, a person — outranks how its border looks, so the
* shape vocabulary keeps the slot.
*
* - Per-SIDE padding (`pt-8`, `px-4`). draw.io's `spacingTop`/`spacingRight`/
* `spacingBottom`/`spacingLeft` (mxText.js:422-425) look like an exact match and are not:
* they pad the LABEL inside its own cell, while this engine's `pad` is the room a
* container leaves for its CHILDREN. Accepting `pt-8` would suggest it pushes child nodes
* down, which it cannot.
*
* - `outline-*` (width, colour, style, offset). draw.io has no concept: a shape carries one
* border, and nothing draws a second ring outside it. In CSS an outline is a focus ring,
* which a static diagram does not have.
*
* - `opacity-*`. draw.io's `opacity` is 0100 and would map cleanly, but Tailwind's
* `opacity-<number>` takes ANY number — `opacity-37` is valid — so it is not a scale.
* Admitting it would give up the one property that makes this vocabulary worth having.
*
* - `truncate` / `text-ellipsis`. Sets `text-overflow: ellipsis`. draw.io's `overflow`
* branches on exactly five values — visible, hidden, fill, width, block (mxText.js:
* 1080-1095) — and a repo-wide grep for "ellipsis" finds no implementation, so the text
* would be cut with no "…": a class named `truncate` that silently loses characters.
*
* - Seven of the nine `font-*` weights. See UNSUPPORTED_WEIGHTS below.
*
* - `text-shadow-*`. Unlike the box `shadow-*` family, draw.io's `textShadow`
* (mxText.js:668) is a bare on/off flag with no offset or blur, so Tailwind's six sizes
* would collapse into one picture.
*
* - Per-CORNER radius (`rounded-tl-lg`) and the decorative corner treatments beside it
* (snip, fold, inverse round). draw.io does have these, but only on a separate template
* shape, `mxgraph.basic.rect` (Shapes.js:4118), which would take the place of the node's
* own `shape` — the same trade the per-side borders lose. Whole-shape `rounded-*` IS
* supported and costs no slot; see RADIUS.
*
* - `tracking-*` (letter-spacing), `uppercase`/`lowercase`/`capitalize` (text-transform),
* and per-node `leading-*` (line-height). Not merely coarse — absent. Grepping the whole
* vendored renderer for letterSpacing/textTransform finds nothing, and line height is a
* global constant (`mxConstants.LINE_HEIGHT`) with no per-cell style key.
*
* - `rotate-*`, `scale-*`, `skew-*`, `translate-*`. draw.io has `rotation`/`flipH`/`flipV`,
* but a rotated box breaks the two things this engine guarantees: the layout no longer
* knows what area it covers, and the edge router cannot route around it.
*
* - Document-flow properties (`float`, `clear`, `position`, `top/right/bottom/left`,
* `z-index`, `visibility`, `columns`, `break-*`, `object-*`, `overscroll-*`) and the
* table and list families. There is no document flow here — every coordinate is computed
* — and draw.io has no z-index at all: later cells simply paint on top.
*
* - `filter`/`backdrop-filter`, `mask-*`, `mix-blend-mode`, `transition-*`, `animation`,
* `perspective*`, `cursor`, `resize`, `appearance`, `caret-color`, `accent-color`:
* no corresponding key anywhere in the vendored renderer.
*
* - Arbitrary values (`w-[137px]`, `p-[13px]`). The scale is the feature; a bracket escape
* hatch removes it.
*
* Unknown classes are returned in `ignored` rather than rejected — D2's "warnings over
* errors" rule: a diagram that renders with one class dropped beats an error that renders
* nothing. The caller reports them, which is how a typo becomes a one-turn fix instead of a
* silent no-op.
*/
import type { Align, Justify } from "./types"
/** What a class string resolves to. Every field optional: a class string sets only what it names. */
export interface TwLayout {
dir?: "row" | "col"
grow?: number
align?: Align
justify?: Justify
alignItems?: Align
gap?: number
pad?: number
maxW?: number
/** `min-w-0`: let a weight shrink this below its content width. */
minW0?: boolean
// ---- text, the part draw.io can actually render ----
/** `font-bold` / `font-normal`. draw.io has one bold bit, not nine weights. */
bold?: boolean
/** `italic` / `not-italic`. */
italic?: boolean
/** `underline` / `no-underline`. */
underline?: boolean
/** `line-through`. draw.io's fontStyle carries a strikethrough bit beside the other three. */
strike?: boolean
/** `text-xs`…`text-4xl` → px, from Tailwind's own scale. */
fontSize?: number
/** `text-left` / `text-center` / `text-right`. */
textAlign?: "left" | "center" | "right"
/** `align-top` / `align-middle` / `align-bottom`. */
verticalAlign?: "top" | "middle" | "bottom"
/** `whitespace-nowrap` / `whitespace-normal`. */
nowrap?: boolean
// ---- border ----
/** `border` / `border-N` → strokeWidth in px. */
borderWidth?: number
/** `border-dashed` / `border-dotted` / `border-solid`. */
borderStyle?: "solid" | "dashed" | "dotted"
/** `rounded`, `rounded-lg`, `rounded-full` → corner radius in px. */
radius?: number
/** `border-none` / `border-0`. */
borderless?: boolean
/** `shadow-sm`…`shadow-xl` → 14; `shadow-none` → 0. See SHADOW. */
shadow?: number
/** Classes that matched nothing, verbatim and in order. */
ignored: string[]
}
/**
* Tailwind's spacing scale: one unit is 0.25rem, which is 4px at the default root size.
*
* Only whole steps are accepted. Tailwind itself has half-steps (`p-0.5`), but a diagram has
* no use for 2px padding and allowing them widens the scale for nothing.
*/
const SPACING_UNIT = 4
/** `p-6` / `gap-3` → px, or null when the suffix is not a plain scale step. */
function scaleToPx(suffix: string): number | null {
if (!/^\d+$/.test(suffix)) return null
return Number(suffix) * SPACING_UNIT
}
/**
* Tailwind's width fractions, as a share of the parent.
*
* Expressed as `grow` rather than an absolute width, because that is what the fraction means
* inside a flex row: `w-1/3` beside `w-2/3` is the same layout as `grow-1` beside `grow-2`,
* and going through grow means the existing proportional path applies — including the rule
* that a declared cap outranks it.
*/
function fractionToGrow(suffix: string): number | null {
const m = /^(\d+)\/(\d+)$/.exec(suffix)
if (!m) return null
const num = Number(m[1])
const den = Number(m[2])
if (den === 0 || num === 0 || num > den) return null
return num
}
const ALIGN_ITEMS: Record<string, Align> = {
"items-start": "start",
"items-center": "center",
"items-end": "end",
"items-stretch": "stretch",
}
const ALIGN_SELF: Record<string, Align> = {
"self-start": "start",
"self-center": "center",
"self-end": "end",
"self-stretch": "stretch",
}
const JUSTIFY: Record<string, Justify> = {
"justify-start": "start",
"justify-center": "center",
"justify-end": "end",
"justify-between": "between",
"justify-around": "around",
"justify-evenly": "evenly",
}
/**
* Tailwind's type scale in px, its own documented values.
*
* Stops at 4xl. The ladder goes on to 9xl (128px), but a 128px word is not a diagram
* label, and offering the step invites a model to pick it.
*/
const FONT_SIZE: Record<string, number> = {
"text-xs": 12,
"text-sm": 14,
"text-base": 16,
"text-lg": 18,
"text-xl": 20,
"text-2xl": 24,
"text-3xl": 30,
"text-4xl": 36,
}
/**
* `text-left|center|right` — horizontal text alignment inside the shape.
*
* `text-justify`, `text-start` and `text-end` are absent because draw.io's `align` has
* only the three physical values; justified text is not available at all.
*/
const TEXT_ALIGN: Record<string, "left" | "center" | "right"> = {
"text-left": "left",
"text-center": "center",
"text-right": "right",
}
/** `align-*` → draw.io's verticalAlign. */
const VERTICAL_ALIGN: Record<string, "top" | "middle" | "bottom"> = {
"align-top": "top",
"align-middle": "middle",
"align-bottom": "bottom",
}
/**
* Tailwind's border-radius scale in px, its own documented values.
*
* These are REAL pixels, which is only true because of `absoluteArcSize`: draw.io's `arcSize`
* is a percentage of the shape by default, but that flag switches it to absolute units
* (mxShape.js:1172-1189). Without it a radius class would mean something different on every
* box, which is why this looked unimplementable at first glance.
*
* `rounded-full` is `calc(infinity * 1px)` in Tailwind v4 — "as round as it goes". The same
* function clamps the radius to half the shorter side, so any number past half the box's
* height gives a stadium. 200 is chosen rather than something enormous because the number
* reaches the user: draw.io's Arrange panel shows `arcSize` in an editable field, and a
* diagram box taller than 400px does not exist, so 200 is both always enough and readable.
*/
const RADIUS: Record<string, number> = {
"rounded-none": 0,
"rounded-xs": 2,
"rounded-sm": 4,
rounded: 4,
"rounded-md": 6,
"rounded-lg": 8,
"rounded-xl": 12,
"rounded-2xl": 16,
"rounded-3xl": 24,
"rounded-4xl": 32,
"rounded-full": 200,
}
/**
* Tailwind's box-shadow steps, as a rung number the renderer turns into draw.io's five
* shadow parameters. 0 means "explicitly no shadow".
*
* draw.io's shadow is not the on/off flag it looks like: `shadowOffsetX`, `shadowOffsetY`,
* `shadowBlur`, `shadowColor` and `shadowOpacity` are read independently
* (mxShape.js:505-535) and become a CSS `drop-shadow(dx dy blur colour)` (540-552). Since
* Tailwind's own steps are also just offset-and-blur, they map one for one.
*
* Four rungs, not Tailwind's eight. `shadow-2xs` and `shadow-xs` are indistinguishable from
* `shadow-sm` at a diagram's scale, and `shadow-2xl`'s 50px blur is noise on a page of
* boxes — offering a step invites a model to pick it.
*/
const SHADOW: Record<string, number> = {
"shadow-none": 0,
"shadow-sm": 1,
"shadow-md": 2,
"shadow-lg": 3,
"shadow-xl": 4,
}
/**
* Font-weight classes that are NOT accepted, and why.
*
* Tailwind has nine weights; draw.io's `fontStyle` is a bitmask whose bold flag is a single
* bit. Accepting all nine would collapse five of them onto "bold" and four onto "normal",
* which is the same defect that rules out `shadow-*` (six sizes, one on/off flag). So only
* `font-bold` and `font-normal` are honoured and the rest are reported, rather than
* pretending a distinction the renderer cannot draw.
*/
const UNSUPPORTED_WEIGHTS = new Set([
"font-thin",
"font-extralight",
"font-light",
"font-medium",
"font-semibold",
"font-extrabold",
"font-black",
])
/**
* Parse a Tailwind class string into layout fields.
*
* Later classes win over earlier ones, the same as Tailwind's own last-one-wins behaviour
* for conflicting utilities, so a caller can append an override without removing anything.
*/
export function parseTw(classes: string): TwLayout {
const out: TwLayout = { ignored: [] }
for (const raw of String(classes ?? "").split(/\s+/)) {
const cls = raw.trim()
if (!cls) continue
// Direction. `flex` on its own is the default and says nothing here — every engine
// container is already a flex container — so it is accepted and ignored rather than
// reported, since a model writing `flex flex-col` is not making a mistake.
if (cls === "flex" || cls === "flex-row") {
if (cls === "flex-row") out.dir = "row"
continue
}
if (cls === "flex-col") {
out.dir = "col"
continue
}
if (cls in ALIGN_ITEMS) {
out.alignItems = ALIGN_ITEMS[cls]
continue
}
if (cls in ALIGN_SELF) {
out.align = ALIGN_SELF[cls]
continue
}
if (cls in JUSTIFY) {
out.justify = JUSTIFY[cls]
continue
}
// `grow` alone is flex-grow: 1, `grow-N` is the weight. Tailwind writes the latter
// as `grow-[3]`; the plain form is accepted because it is what a model reaches for
// and the bracket form carries no extra meaning here.
if (cls === "grow") {
out.grow = 1
continue
}
const growN = /^grow-(\d+)$/.exec(cls)
if (growN) {
out.grow = Number(growN[1])
continue
}
// `flex-1` / `flex-3`: the shorthand whose whole point is proportional sizing.
const flexN = /^flex-(\d+)$/.exec(cls)
if (flexN) {
out.grow = Number(flexN[1])
continue
}
// Fractional widths become weights — see fractionToGrow.
const wFrac = /^w-(\d+\/\d+)$/.exec(cls)
if (wFrac) {
const g = fractionToGrow(wFrac[1])
if (g !== null) {
out.grow = g
continue
}
}
if (cls === "w-full") {
out.align = "stretch"
continue
}
// `min-w-0` is the standard CSS escape hatch for "let the weight win over my
// content width". Without it a weighted child is floored by its own text — that is
// real flexbox behaviour, since `min-width` defaults to `auto` — so a narrow column
// beside a wide one settles at its text width and a declared 2:1 comes out 1.4:1.
if (cls === "min-w-0") {
out.minW0 = true
continue
}
// Spacing. `p-*` is interior padding, `gap-*` the space between children. Tailwind's
// per-side variants (`pt-*`, `px-*`) are not here: the engine has one padding value,
// and quietly treating `pt-8` as padding on all four sides would be wrong in a way
// the model could not see.
const pad = /^p-(\d+)$/.exec(cls)
if (pad) {
const px = scaleToPx(pad[1])
if (px !== null) {
out.pad = px
continue
}
}
const gap = /^gap-(\d+)$/.exec(cls)
if (gap) {
const px = scaleToPx(gap[1])
if (px !== null) {
out.gap = px
continue
}
}
// `max-w-*` uses the spacing scale too, so `max-w-96` is 384px. Tailwind's named
// sizes are also accepted, because a model reaches for `max-w-md` more readily than
// for a step number.
const maxW = /^max-w-(\d+)$/.exec(cls)
if (maxW) {
const px = scaleToPx(maxW[1])
if (px !== null) {
out.maxW = px
continue
}
}
const named = NAMED_MAX_W[cls]
if (named) {
out.maxW = named
continue
}
// ---- text ----
// The three flags draw.io's fontStyle bitmask actually carries. They combine by
// adding bits, so bold + italic is legal and needs no special case here.
if (cls === "font-bold" || cls === "font-normal") {
out.bold = cls === "font-bold"
continue
}
// The other seven weights fall through to `ignored` on purpose, so the model is
// told the distinction was dropped instead of quietly getting plain bold.
if (UNSUPPORTED_WEIGHTS.has(cls)) {
out.ignored.push(cls)
continue
}
if (cls === "italic" || cls === "not-italic") {
out.italic = cls === "italic"
continue
}
if (cls === "underline" || cls === "no-underline") {
out.underline = cls === "underline"
continue
}
// Strikethrough is its own bit (8) beside bold/italic/underline, so it combines with
// them rather than replacing one. `no-underline` above deliberately does NOT clear
// it: in CSS both are values of `text-decoration-line`, and Tailwind's `no-underline`
// means "not underlined", not "undecorated".
if (cls === "line-through") {
out.strike = true
continue
}
// `text-*` is three different Tailwind properties sharing one prefix: size
// (text-lg), alignment (text-left) and COLOUR (text-red-500). The size and
// alignment tables are exact-match, so a colour class falls through to `ignored`
// rather than being mistaken for a size.
if (cls in FONT_SIZE) {
out.fontSize = FONT_SIZE[cls]
continue
}
if (cls in TEXT_ALIGN) {
out.textAlign = TEXT_ALIGN[cls]
continue
}
if (cls in VERTICAL_ALIGN) {
out.verticalAlign = VERTICAL_ALIGN[cls]
continue
}
if (cls === "whitespace-nowrap" || cls === "whitespace-normal") {
out.nowrap = cls === "whitespace-nowrap"
continue
}
// ---- border ----
// `border` alone is 1px, `border-N` is N px — Tailwind's border width is a plain
// pixel count, not the 4px spacing scale.
if (cls === "border") {
out.borderWidth = 1
continue
}
// `border-0` and `border-none` both mean no border, so they are handled before the
// numeric case (which would otherwise read border-0 as a zero-width border and
// leave draw.io drawing its default hairline).
if (cls === "border-none" || cls === "border-0") {
out.borderless = true
continue
}
const bw = /^border-(\d+)$/.exec(cls)
if (bw) {
out.borderWidth = Number(bw[1])
continue
}
if (
cls === "border-solid" ||
cls === "border-dashed" ||
cls === "border-dotted"
) {
out.borderStyle = cls.slice("border-".length) as
| "solid"
| "dashed"
| "dotted"
continue
}
// Whole-shape corner radius. Per-corner classes (`rounded-tl-lg`) fall through to
// `ignored`: draw.io only offers those on a separate template shape.
if (cls in RADIUS) {
out.radius = RADIUS[cls]
continue
}
// Drop shadow. Per-side border classes (`border-l-4`) fall through to `ignored`, and
// so does every colour form (`shadow-blue-500`) since these tables are exact-match.
if (cls in SHADOW) {
out.shadow = SHADOW[cls]
continue
}
out.ignored.push(cls)
}
return out
}
/**
* Tailwind's named max-width steps, in px.
*
* Tailwind's own values, rounded to whole pixels. Stopping at `4xl` is deliberate: anything
* wider than about a thousand pixels is not a cap a diagram needs, and offering the whole
* ladder invites a model to pick one at random.
*/
const NAMED_MAX_W: Record<string, number> = {
"max-w-xs": 320,
"max-w-sm": 384,
"max-w-md": 448,
"max-w-lg": 512,
"max-w-xl": 576,
"max-w-2xl": 672,
"max-w-3xl": 768,
"max-w-4xl": 896,
}

View File

@@ -31,6 +31,83 @@ export interface PoolCell {
*/
export type Align = "start" | "center" | "end" | "stretch"
/**
* Presentation a node may override, beyond what its `role` decides.
*
* The admission test is that draw.io can draw the distinction FAITHFULLY — see tw.ts for the
* properties that failed it and why. Most fields here are one style key with one value; a few
* (`shadow`, `borderStyle`, the radius trio) expand to a fixed group of keys, which is fine
* because the field still names one visual decision. What is not allowed is a field whose
* values collapse onto fewer pictures than it promises.
*
* Kept as one optional object rather than a dozen loose fields so the round-trip has one
* thing to carry and the node type does not grow a field per CSS property.
*
* `role` remains the primary way to say what a node IS; this is for the cases where the
* model needs to override one aspect of how it looks.
*/
export interface TextStyle {
/** Bold. draw.io's fontStyle carries one bold bit, not a weight ladder. */
bold?: boolean
italic?: boolean
underline?: boolean
/** Strikethrough — a fourth bit in the same mask, so it combines with the others. */
strike?: boolean
/** Type size in px. */
size?: number
/** Horizontal text alignment inside the shape. */
align?: "left" | "center" | "right"
/** Vertical text alignment inside the shape. */
valign?: "top" | "middle" | "bottom"
/** Keep the label on one line instead of wrapping it. */
nowrap?: boolean
/** Border thickness in px. */
borderWidth?: number
/** Border line style. Dashed and dotted read as "planned", "optional", "logical". */
borderStyle?: "solid" | "dashed" | "dotted"
/**
* Corner radius in px.
*
* Real pixels, not a percentage: draw.io's `arcSize` is a percentage of the shape by
* default, but `absoluteArcSize=1` switches it to absolute units, and it halves the
* value, so an 8px radius is emitted as `arcSize=16` (mxShape.getArcSize,
* mxShape.js:1172-1189).
*
* Overrides the radius of a shape that has one of its own: `round` and `terminator` are
* rounded rectangles already, and changing how round they are does not change what they
* are, so a radius class is allowed to win.
*/
radius?: number
/** No border at all — a plain colour field. */
borderless?: boolean
/**
* Drop shadow, as a rung: 14 for Tailwind's sm/md/lg/xl, 0 for explicitly none.
*
* A rung rather than raw offsets because draw.io takes five separate numbers
* (`shadowOffsetX/Y`, `shadowBlur`, `shadowColor`, `shadowOpacity` — mxShape.js:505-535)
* and letting a caller set them individually is exactly the magic-number freedom this
* vocabulary exists to remove.
*/
shadow?: number
}
/**
* How a container spreads its children along its own stacking axis — CSS's
* justify-content, and Yoga's six values.
*
* Until this existed the policy was hard-coded and differed per axis: a row padded its
* gaps and centred the result, a column packed to the top and left every spare pixel in
* one slab at the bottom. That slab is the empty bottom-left corner of a poster, and
* nothing the model could declare would move it.
*/
export type Justify =
| "start"
| "center"
| "end"
| "between"
| "around"
| "evenly"
/**
* What a box IS, drawn as its conventional outline.
*
@@ -78,6 +155,16 @@ export interface BoxNode {
grow?: number
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
align?: Align
/**
* Hard cap on width, px. Text rewraps to fit instead of running the box wider, so
* this is what stops one long sentence stretching a whole page into a letterbox.
* Higher priority than `grow`, matching Yoga's min/max rule.
*/
maxW?: number
/** Let a `grow` weight shrink this below its own text width — CSS's `min-width: 0`. */
minW0?: boolean
/** Presentation overrides: type, alignment, border. Absent means the role decides. */
text?: TextStyle
/** Flowchart outline. Absent means a plain rectangle. */
shape?: BoxShape
style?: string
@@ -118,6 +205,22 @@ export interface GroupNode {
grow?: number
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
align?: Align
/** How the children spread along `dir`. Absent = start (packed, no extra spacing). */
justify?: Justify
/** Cross-axis default for every child that does not declare its own `align`. */
alignItems?: Align
/** Hard cap on width, px. Children wrap or shrink to fit rather than overflow it. */
maxW?: number
/**
* Let a `grow` weight shrink this below its own content width — CSS's `min-width: 0`.
*
* Without it a weighted child is floored by its text, which is real flexbox behaviour
* (`min-width` defaults to `auto`) but means a declared 2:1 quietly resolves to
* whatever the two columns' text allows.
*/
minW0?: boolean
/** Presentation overrides: title type, alignment, frame border. */
text?: TextStyle
/** Interior padding, px. Absent = the default (24). */
pad?: number
style?: string
@@ -273,6 +376,16 @@ export interface DiagramTree {
links: LinkSpec[]
/** Page title, if the diagram has one. */
title?: string
/**
* Target width : height of the whole page. 1 is square, 1.6 landscape, 0.7 portrait.
*
* This is the one number that decides whether a diagram reads as a poster or as a
* letterbox, and it cannot be derived: the same content is a legitimate 1-column
* portrait or 3-column landscape. So the model declares it, the engine gives the top
* level a width to match, and every proportional rule below finally has a share of
* something real to divide up.
*/
aspect?: number
/**
* Cells the parser could not fit into the tree — a user's own annotation boxes, a
* legend, shapes from an imported file. Kept verbatim and re-emitted untouched so