reladraw 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/resolve.js CHANGED
@@ -1,9 +1,10 @@
1
- import { ALL_ATTR_KEYS, ATTR_KEYS, COLOR_KEYS, COLOR_PARTS, describePlacement, } from './ast.js';
2
- import { ARROW_LENGTH, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, LABEL_CLEARANCE, PAD, SEPARATION_GAP, fontSizeFor, labelExtent, labelStyleFor, } from './constants.js';
1
+ import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, describePlacement, nameTarget, } from './ast.js';
2
+ import { ARROW_LENGTH, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
3
3
  import { fix, reachability, tightest } from './constrain.js';
4
4
  import { SourceError } from './errors.js';
5
- import { iconFor, shapeFor } from './icons.js';
6
- import { monospaceMeasurer, splitLines } from './measure.js';
5
+ import { bodyFor } from './icons.js';
6
+ import { monospaceMeasurer } from './measure.js';
7
+ import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
7
8
  /**
8
9
  * Turn a parsed document into solved geometry.
9
10
  *
@@ -23,21 +24,21 @@ export function resolve(doc, options = {}) {
23
24
  const styles = collectStyles(doc.statements);
24
25
  checkStyleKeys(doc.statements);
25
26
  const { nodes, byName, roots } = buildTree(doc.statements, styles);
26
- applyDecks(doc.statements, byName);
27
- // Links are resolved to nodes before anything is sized, because a labeled
28
- // link claims room in the gap it crosses and so has to be in hand while the
27
+ // Edges are resolved to nodes before anything is sized, because a labeled
28
+ // edge claims room in the gap it crosses and so has to be in hand while the
29
29
  // gaps are being worked out. Nothing here reads geometry.
30
- const links = buildLinks(doc.statements, byName, styles);
30
+ const edges = buildEdges(doc.statements, byName, styles);
31
31
  const local = new Map();
32
32
  for (const root of roots)
33
- sizeNode(root, links, measurer, fontSize, local);
34
- placeRoots(roots, byName, links, measurer, fontSize, local);
33
+ sizeNode(root, edges, measurer, fontSize, local);
34
+ placeRoots(roots, byName, edges, measurer, fontSize, local);
35
35
  normalize(nodes, margin);
36
36
  const extent = bounds(nodes);
37
37
  return {
38
38
  nodes,
39
39
  roots,
40
- links,
40
+ edges,
41
+ markup: markupColors(nodes, edges, styles),
41
42
  diagram: collectDiagram(doc.statements),
42
43
  width: Math.ceil(extent.maxX + margin),
43
44
  height: Math.ceil(extent.maxY + margin),
@@ -73,34 +74,65 @@ function buildTree(statements, styles) {
73
74
  const nodes = [];
74
75
  const byName = new Map();
75
76
  const roots = [];
77
+ /** Each badge child's name, and the node whose `badge:` it was written out from. */
78
+ const badges = new Map();
76
79
  for (const stmt of statements) {
77
- if (stmt.kind !== 'box' && stmt.kind !== 'note')
80
+ if (stmt.kind !== 'node')
78
81
  continue;
79
82
  if (byName.has(stmt.name)) {
83
+ const owner = badges.get(stmt.name);
84
+ if (owner !== undefined) {
85
+ throw new SourceError(`"${stmt.name}" is the child that "${owner}"'s badge: is written out as. Drop badge: from ` +
86
+ `"${owner}" and write the icon here yourself, or give this node another name`, stmt.line);
87
+ }
80
88
  throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
81
89
  }
90
+ const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
91
+ const body = bodyFor(stmt.attrs, appearance, stmt.line);
92
+ const kind = KIND_OF_BODY[body.kind];
93
+ // A node with no text of its own is labelled with its name, because the
94
+ // first lines anybody types are `node a` and `node b right of a` and they
95
+ // mean the boxes to read "a" and "b". A node drawn as a *picture* is the
96
+ // exception: a picture usually is the statement, and the default would
97
+ // caption a row of cubes a, b, db1, c, db2. `""` then says what writing
98
+ // nothing says, which is only true here — on a box it carries information.
99
+ const text = body.kind === 'icon' && !stmt.statedText ? '' : stmt.text;
100
+ // A style has no string of its own, so it says what it has to say about a
101
+ // text through `text: (…)`, which arrives here under dotted keys. What the
102
+ // node wrote in its own brackets wins, key by key, exactly as its own
103
+ // attributes win over the style's.
104
+ const textAttrs = { ...bracketOf('text', appearance), ...stmt.textAttrs };
82
105
  const node = {
83
106
  name: stmt.name,
84
- kind: stmt.kind,
85
- text: stmt.text,
86
- lines: linesFor(stmt.text, stmt.attrs, stmt.line),
107
+ kind,
108
+ body,
109
+ text,
110
+ lines: linesFor(text, textAttrs, `"${stmt.name}"`, stmt.line),
87
111
  children: [],
88
112
  x: 0,
89
113
  y: 0,
90
114
  width: 0,
91
115
  height: 0,
92
116
  inset: 0,
93
- deckLabels: [],
117
+ deckTexts: [],
94
118
  headerHeight: 0,
95
- label: stmt.kind === 'box' ? stmt.label : {},
119
+ reach: { left: 0, top: 0, right: 0, bottom: 0 },
120
+ banded: false,
121
+ textBox: { x: 0, y: 0, width: 0, height: 0 },
122
+ textSide: 'left',
123
+ textAttrs,
96
124
  attrs: stmt.attrs,
97
- appearance: appearanceOf(stmt.attrs, styles, stmt.line),
125
+ appearance,
98
126
  placements: stmt.placements,
99
127
  line: stmt.line,
100
128
  };
101
- const kind = kindOf(node);
102
129
  checkAttrs(kind, node.name, stmt.attrs, stmt.line);
103
130
  checkStyleUse(kind, node.name, stmt.attrs, styles, stmt.line);
131
+ // `deck:` holds one text per copy, joined on a line break the source cannot
132
+ // contain. Like a badge, only a box has an outline to repeat.
133
+ const deck = appearance['deck'];
134
+ if (deck !== undefined && body.kind === 'shape')
135
+ node.deckTexts = deck.split('\n');
104
136
  const cut = stmt.name.lastIndexOf('.');
105
137
  if (cut === -1) {
106
138
  roots.push(node);
@@ -116,16 +148,90 @@ function buildTree(statements, styles) {
116
148
  }
117
149
  nodes.push(node);
118
150
  byName.set(stmt.name, node);
151
+ // `badge: X` is a shorthand, and this is its expansion:
152
+ // node <self>.badge icon: X right of <self> text
153
+ // set at the parent's text size, so the picture is two of the parent's
154
+ // lines tall. Read from the merged appearance, so a style carrying a badge
155
+ // gives one to every box wearing it. Only a box has a text to be beside;
156
+ // the other kinds refuse the word in `checkAttrs`, and a style's is unused.
157
+ const named = appearance['badge'];
158
+ if (named !== undefined && body.kind === 'shape') {
159
+ const badge = badgeChild(node, named);
160
+ badges.set(badge.name, node.name);
161
+ node.children.push(badge);
162
+ nodes.push(badge);
163
+ byName.set(badge.name, badge);
164
+ }
119
165
  }
120
166
  return { nodes, byName, roots };
121
167
  }
168
+ /** The child `badge:` writes out, as `buildTree` describes. */
169
+ function badgeChild(parent, named) {
170
+ const icon = { icon: named };
171
+ const size = parent.textAttrs['size'];
172
+ return {
173
+ name: `${parent.name}.badge`,
174
+ kind: 'icon',
175
+ body: bodyFor(icon, icon, parent.line),
176
+ text: '',
177
+ lines: linesFor('', {}, `"${parent.name}.badge"`, parent.line),
178
+ children: [],
179
+ parent,
180
+ x: 0,
181
+ y: 0,
182
+ width: 0,
183
+ height: 0,
184
+ inset: 0,
185
+ deckTexts: [],
186
+ headerHeight: 0,
187
+ reach: { left: 0, top: 0, right: 0, bottom: 0 },
188
+ banded: false,
189
+ textBox: { x: 0, y: 0, width: 0, height: 0 },
190
+ textSide: 'center',
191
+ textAttrs: size === undefined ? {} : { size },
192
+ attrs: icon,
193
+ appearance: icon,
194
+ placements: [
195
+ {
196
+ kind: 'offset',
197
+ direction: 'right',
198
+ targets: [{ name: parent.name, part: 'text' }],
199
+ line: parent.line,
200
+ },
201
+ ],
202
+ line: parent.line,
203
+ };
204
+ }
205
+ /**
206
+ * "a node", "an edge". Only the kind words are ever passed here and only `edge`
207
+ * begins with a vowel, but writing `a ${word}` produced "a edge" the day the
208
+ * keyword changed, so the article follows the word rather than being assumed.
209
+ */
210
+ function article(word) {
211
+ return `${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word}`;
212
+ }
213
+ /** The same phrase opening a sentence. */
214
+ function capital(phrase) {
215
+ return phrase.charAt(0).toUpperCase() + phrase.slice(1);
216
+ }
217
+ /** Which kind a body makes the node. */
218
+ const KIND_OF_BODY = {
219
+ shape: 'shape',
220
+ icon: 'icon',
221
+ none: 'none',
222
+ };
122
223
  /** How each kind reads in an error, and what it is actually made of. */
123
- const KIND_WORD = { box: 'box', note: 'note', glyph: 'glyph body', link: 'link' };
224
+ const KIND_WORD = {
225
+ shape: 'node',
226
+ icon: 'node drawn as a picture',
227
+ none: 'node with no body',
228
+ edge: 'edge',
229
+ };
124
230
  const KIND_PARTS = {
125
- box: 'a fill, a border and text',
126
- note: 'bare text and nothing else',
127
- glyph: 'a picture and the text under it',
128
- link: 'a line and its label',
231
+ shape: 'a fill, a border and text',
232
+ icon: 'a picture and the text under it',
233
+ none: 'text and nothing else',
234
+ edge: 'a line and its text',
129
235
  };
130
236
  /**
131
237
  * An attribute is refused on a kind that has no use for it — the same rule as
@@ -136,7 +242,7 @@ const KIND_PARTS = {
136
242
  * What is checked is what the author wrote *on this statement*, not what a
137
243
  * style contributed. A style is a bundle meant to be shared across kinds — the
138
244
  * benchmark's `synced` carries a fill and a border for the green boxes and a
139
- * line for the four links joining them — so a key it carries that this kind has
245
+ * line for the four edges joining them — so a key it carries that this kind has
140
246
  * no part for is simply unused, and is not a mistake anybody made here. That is
141
247
  * forced rather than chosen: checking the merged appearance would refuse the
142
248
  * benchmark's own central idiom four times over. `checkStyleUse` is what keeps
@@ -146,13 +252,19 @@ function checkAttrs(kind, name, attrs, line) {
146
252
  const allowed = ATTR_KEYS[kind];
147
253
  // In the author's own order, so the error names the first offending word as
148
254
  // it is read rather than the first in some list of ours.
149
- for (const [key, value] of Object.entries(attrs)) {
255
+ for (const [written, value] of Object.entries(attrs)) {
256
+ const key = topKey(written);
150
257
  if (allowed.includes(key))
151
258
  continue;
259
+ // Quoted back the way it was written. A bracketed value arrives one dotted
260
+ // key at a time, and `contents: match` is not a line anybody could look for.
261
+ // A deck's texts are stored joined on a line break; quote them back as written.
262
+ const shown = key === 'deck' ? value.split('\n').map((text) => `"${text}"`).join(' ') : value;
263
+ const wrote = written === key ? `${key}: ${shown}` : `${key}: (${written.slice(key.length + 1)}: ${shown})`;
152
264
  if (!ALL_ATTR_KEYS.includes(key)) {
153
265
  // Nothing anywhere in the language answers to this word, so the only
154
266
  // remedy is the vocabulary itself.
155
- throw new SourceError(`"${name}" has ${key}: ${value}, which is not an attribute. A ${KIND_WORD[kind]} takes ${allowed.join(', ')}`, line);
267
+ throw new SourceError(`"${name}" has ${wrote}, which is not an attribute. ${capital(article(KIND_WORD[kind]))} takes ${allowed.join(', ')}`, line);
156
268
  }
157
269
  // A real word in the wrong place, and the two sorts of word want different
158
270
  // explanations. A color names a *part*, so saying what the kind is made of
@@ -160,15 +272,15 @@ function checkAttrs(kind, name, attrs, line) {
160
272
  // list of parts it does have: `line:` on a box is a different mistake from
161
273
  // `border:` on a note, and one hint cannot serve both.
162
274
  if (COLOR_KEYS.includes(key)) {
163
- const parts = COLOR_PARTS[kind].map((part) => `\`${part}:\``).join(', ');
164
- throw new SourceError(`"${name}" is a ${KIND_WORD[kind]} and has ${key}: ${value}. A ${KIND_WORD[kind]} is ${KIND_PARTS[kind]}, so it has no ${key} — it takes ${parts}`, line);
275
+ const parts = COLOR_PARTS[kind].map((part) => `\`${part}\``).join(', ');
276
+ throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. ${capital(article(KIND_WORD[kind]))} is ${KIND_PARTS[kind]}, so it has no ${key} — it takes ${parts}`, line);
165
277
  }
166
278
  // Anything else names no part, so what the kind is made of explains
167
279
  // nothing. What does explain it is where the word *does* belong, which is
168
280
  // also the more useful thing to be told: the author has usually written a
169
281
  // real statement about the wrong half of the diagram.
170
- throw new SourceError(`"${name}" is a ${KIND_WORD[kind]} and has ${key}: ${value}. \`${key}:\` belongs to ` +
171
- `${listKinds(belongTo(key))} — a ${KIND_WORD[kind]} takes ${allowed.join(', ')}`, line);
282
+ throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. \`${key}:\` belongs to ` +
283
+ `${listKinds(belongTo(key))} — ${article(KIND_WORD[kind])} takes ${allowed.join(', ')}`, line);
172
284
  }
173
285
  }
174
286
  /**
@@ -182,23 +294,25 @@ function checkAttrs(kind, name, attrs, line) {
182
294
  * construction — that hole is left open, because nobody writes one by accident.
183
295
  */
184
296
  function checkStyleUse(kind, name, attrs, styles, line) {
185
- const named = attrs['style'];
186
- if (named === undefined)
187
- return;
188
- const base = styles.get(named);
189
- if (base === undefined)
190
- return; // `appearanceOf` reports the missing style.
191
- // A style naming another style is the one way to carry nothing at all: the
192
- // parser already refuses one with no attributes, and `appearanceOf` does not
193
- // recurse, so the name would sit there doing nothing.
194
- const carried = Object.keys(base).filter((key) => key !== 'style');
195
- if (carried.length === 0) {
196
- throw new SourceError(`style "${named}" carries nothing but a style name`, line);
297
+ // Each style in a list is checked on its own: one that gives this thing
298
+ // nothing is a name written on the wrong sort of thing, whatever its
299
+ // neighbours in the list carry.
300
+ for (const named of styleNames(attrs)) {
301
+ const base = styles.get(named);
302
+ if (base === undefined)
303
+ continue; // `appearanceOf` reports the missing style.
304
+ // A style naming another style is the one way to carry nothing at all: the
305
+ // parser already refuses one with no attributes, and `appearanceOf` does not
306
+ // recurse, so the name would sit there doing nothing.
307
+ const carried = [...new Set(Object.keys(base).map(topKey))].filter((key) => key !== 'style');
308
+ if (carried.length === 0) {
309
+ throw new SourceError(`style "${named}" carries nothing but a style name`, line);
310
+ }
311
+ if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
312
+ continue;
313
+ throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
314
+ `${article(KIND_WORD[kind])} is ${KIND_PARTS[kind]}`, line);
197
315
  }
198
- if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
199
- return;
200
- throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
201
- `a ${KIND_WORD[kind]} is ${KIND_PARTS[kind]}`, line);
202
316
  }
203
317
  /**
204
318
  * A style is a bundle spanning kinds, so its keys cannot be checked against any
@@ -211,65 +325,76 @@ function checkStyleKeys(statements) {
211
325
  if (stmt.kind !== 'style')
212
326
  continue;
213
327
  for (const [key, value] of Object.entries(stmt.attrs)) {
214
- if (ALL_ATTR_KEYS.includes(key))
328
+ if (ALL_ATTR_KEYS.includes(topKey(key)))
215
329
  continue;
216
330
  throw new SourceError(`style "${stmt.name}" has ${key}: ${value}, which is not an attribute. ` +
217
331
  `The attributes are ${ALL_ATTR_KEYS.join(', ')}`, stmt.line);
218
332
  }
219
333
  }
220
334
  }
335
+ /**
336
+ * The attribute a key belongs to. A bracketed value arrives under dotted keys —
337
+ * `text: (color: muted)` is stored as `text.color` — so that a style merges into
338
+ * a node exactly the way every other attribute does; every check above asks
339
+ * about the part, which is the half before the dot.
340
+ */
341
+ function topKey(key) {
342
+ const dot = key.indexOf('.');
343
+ return dot === -1 ? key : key.slice(0, dot);
344
+ }
221
345
  /** Which kinds understand an attribute, in the order the table declares them. */
222
346
  function belongTo(key) {
223
347
  return Object.keys(ATTR_KEYS).filter((kind) => ATTR_KEYS[kind].includes(key));
224
348
  }
225
- /** "a link", "a box or a glyph body", "a box, a note or a glyph body". */
349
+ /** "an edge", "a node or a glyph body", "a node, a note or a glyph body". */
226
350
  function listKinds(kinds) {
227
- const words = kinds.map((kind) => `a ${KIND_WORD[kind]}`);
351
+ const words = kinds.map((kind) => article(KIND_WORD[kind]));
228
352
  if (words.length <= 1)
229
353
  return words[0] ?? 'nothing';
230
354
  return `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`;
231
355
  }
232
- /** Which of the four kinds a node is, which its `shape:` may decide. */
233
- function kindOf(node) {
234
- if (node.kind === 'note')
235
- return 'note';
236
- return shapeFor(node.appearance, node.line).body !== undefined ? 'glyph' : 'box';
237
- }
238
- function appearanceOf(attrs, styles, line) {
356
+ /** The styles a `style:` names, in the order written. The parser joins them on a space. */
357
+ function styleNames(attrs) {
239
358
  const named = attrs['style'];
240
- if (named === undefined)
241
- return { ...attrs };
242
- const base = styles.get(named);
243
- if (!base)
244
- throw new SourceError(`no style named "${named}"`, line);
245
- return { ...base, ...attrs };
359
+ return named === undefined ? [] : named.split(' ');
246
360
  }
247
- function applyDecks(statements, byName) {
248
- for (const stmt of statements) {
249
- if (stmt.kind !== 'deck')
250
- continue;
251
- const node = byName.get(stmt.name);
252
- if (!node)
253
- throw new SourceError(`deck names "${stmt.name}", which does not exist`, stmt.line);
254
- node.deckLabels = stmt.labels;
361
+ /**
362
+ * The styles in the order written, a later one winning where two set the same
363
+ * key, and then the statement's own words over all of them — a word written on
364
+ * the line was meant for this thing, wherever `style:` sits.
365
+ */
366
+ function appearanceOf(attrs, styles, line) {
367
+ const merged = {};
368
+ for (const named of styleNames(attrs)) {
369
+ const base = styles.get(named);
370
+ if (!base)
371
+ throw new SourceError(`no style named "${named}"`, line);
372
+ Object.assign(merged, base);
255
373
  }
374
+ return { ...merged, ...attrs };
256
375
  }
257
- function buildLinks(statements, byName, styles) {
258
- const links = [];
376
+ function buildEdges(statements, byName, styles) {
377
+ const edges = [];
259
378
  for (const stmt of statements) {
260
- if (stmt.kind !== 'link')
379
+ if (stmt.kind !== 'edge')
261
380
  continue;
262
381
  const from = byName.get(stmt.from);
263
382
  const to = byName.get(stmt.to);
264
383
  if (!from)
265
- throw new SourceError(`link from "${stmt.from}", which does not exist`, stmt.line);
384
+ throw new SourceError(`edge from "${stmt.from}", which does not exist`, stmt.line);
266
385
  if (!to)
267
- throw new SourceError(`link to "${stmt.to}", which does not exist`, stmt.line);
386
+ throw new SourceError(`edge to "${stmt.to}", which does not exist`, stmt.line);
268
387
  const between = stmt.between && {
269
- nodes: stmt.between.targets.map((name) => {
388
+ nodes: stmt.between.targets.map(({ name, part }) => {
389
+ if (part !== undefined) {
390
+ // A passage is the gap between two boxes, and a side or a point has
391
+ // no gap on either hand. Refused by name rather than dropped.
392
+ throw new SourceError(`edge passes between "${name} ${part}", and a passage runs between two whole boxes ` +
393
+ `— drop "${part}"`, stmt.line);
394
+ }
270
395
  const node = byName.get(name);
271
396
  if (!node) {
272
- throw new SourceError(`link passes between "${name}", which does not exist`, stmt.line);
397
+ throw new SourceError(`edge passes between "${name}", which does not exist`, stmt.line);
273
398
  }
274
399
  return node;
275
400
  }),
@@ -277,101 +402,169 @@ function buildLinks(statements, byName, styles) {
277
402
  };
278
403
  const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
279
404
  const what = `${stmt.from} -> ${stmt.to}`;
280
- checkAttrs('link', what, stmt.attrs, stmt.line);
281
- checkStyleUse('link', what, stmt.attrs, styles, stmt.line);
282
- links.push({
405
+ checkAttrs('edge', what, stmt.attrs, stmt.line);
406
+ checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);
407
+ if (stmt.attrs['url'] !== undefined && stmt.text === undefined) {
408
+ // Wrapping the line works, and `LINE_WIDTH` is 1.5 — a destination that
409
+ // technically has a target and practically has none. That is the silent
410
+ // defect shape the language refuses everywhere else, so it is refused
411
+ // here rather than shipped and explained.
412
+ throw new SourceError(`edge ${what} has a url and no text. A destination needs something to click, and a line ` +
413
+ 'is too thin to be it — give the edge a text, or put the url on one of the nodes', stmt.line);
414
+ }
415
+ const textAttrs = { ...bracketOf('text', appearance), ...stmt.textAttrs };
416
+ edges.push({
283
417
  from,
284
418
  to,
285
419
  both: stmt.both,
286
- ...(stmt.label !== undefined ? { label: stmt.label } : {}),
420
+ textAttrs,
421
+ ...(stmt.text !== undefined
422
+ ? { text: stmt.text, lines: linesFor(stmt.text, textAttrs, `edge ${what}`, stmt.line) }
423
+ : {}),
287
424
  ...(between ? { between } : {}),
288
425
  attrs: stmt.attrs,
289
426
  appearance,
290
427
  line: stmt.line,
291
428
  });
292
429
  }
293
- return links;
430
+ return edges;
294
431
  }
295
432
  /**
296
433
  * Give a node a width and height, sizing its children first. Also records each
297
434
  * child's offset within this node, which pass three turns into absolute
298
435
  * coordinates once this node itself is placed.
299
436
  */
300
- function sizeNode(node, links, measurer, fontSize, local) {
437
+ function sizeNode(node, edges, measurer, fontSize, local) {
301
438
  for (const child of node.children)
302
- sizeNode(child, links, measurer, fontSize, local);
439
+ sizeNode(child, edges, measurer, fontSize, local);
303
440
  // Text is measured at the size it will be drawn at — the size lives in
304
441
  // `constants.ts` precisely so the resolver reserving the room and the
305
442
  // renderer filling it cannot disagree about how much room there is.
306
- const textSize = fontSizeFor(node.kind, node.appearance, fontSize, node.line);
443
+ const textSize = fontSizeFor(node.kind, node.textAttrs, fontSize, node.line);
307
444
  const lineHeight = measurer.lineHeight(textSize);
308
445
  // A node with empty text takes no room for it. This is what makes an
309
446
  // invisible grouping container size to exactly its contents.
310
- const hasLabel = node.lines.some((line) => line.length > 0);
311
- const labelWidth = hasLabel ? widestLine(node.lines, measurer, textSize) : 0;
312
- const labelHeight = hasLabel ? node.lines.length * lineHeight : 0;
313
- if (node.kind === 'note') {
314
- // A note is bare text, so it gets no padding and takes no children.
315
- node.width = labelWidth;
316
- node.height = labelHeight;
447
+ const hasText = node.lines.some((line) => plain(line).length > 0);
448
+ const textWidth = hasText ? widestLine(node.lines, measurer, textSize) : 0;
449
+ const textHeight = hasText ? node.lines.length * lineHeight : 0;
450
+ const glyphSide = ICON_LINES * lineHeight;
451
+ if (node.body.kind === 'none') {
452
+ // No body, so the node is its text: no padding, no outline, no children.
453
+ if (node.children.length > 0) {
454
+ throw new SourceError(`"${node.name}" has shape: none and has children. With no body there is no box for ` +
455
+ 'anything to go inside', node.line);
456
+ }
457
+ node.width = textWidth;
458
+ node.height = textHeight;
459
+ node.textBox = textBoxIn({ x: 0, y: 0, width: node.width, height: node.height }, (node.textSide = textStyleFor(node.textAttrs, node.line, 'start', 'center').side), textWidth, textHeight);
317
460
  return;
318
461
  }
319
- const shape = shapeFor(node.appearance, node.line);
320
- const glyphSide = ICON_LINES * lineHeight;
321
- if (shape.body !== undefined) {
322
- // Drawn as a glyph, so there is no box to pad and the node's size is the
323
- // picture's. A label goes under it rather than inside it, which is the
462
+ if (node.body.kind === 'icon') {
463
+ // Drawn as a picture, so there is no box to pad and the node's size is the
464
+ // picture's. A text goes under it rather than inside it, which is the
324
465
  // arrangement that makes a row of these read as captioned things.
325
466
  if (node.children.length > 0) {
326
- throw new SourceError(`"${node.name}" is drawn as a glyph and has children. A glyph is not a box, so nothing can go inside it`, node.line);
467
+ throw new SourceError(`"${node.name}" is drawn as a picture and has children. A picture is not a box, so nothing can go inside it`, node.line);
327
468
  }
328
- node.width = Math.max(glyphSide, labelWidth);
329
- node.height = glyphSide + (hasLabel ? ICON_GAP + labelHeight : 0);
469
+ node.width = Math.max(glyphSide, textWidth);
470
+ node.height = glyphSide + (hasText ? ICON_GAP + textHeight : 0);
471
+ // The caption is under the picture and nowhere else, so only the
472
+ // horizontal half of `at` reaches it.
473
+ node.textBox = textBoxIn({ x: 0, y: glyphSide + ICON_GAP, width: node.width, height: textHeight }, (node.textSide = textStyleFor(node.textAttrs, node.line, 'middle', 'center').side), textWidth, textHeight);
330
474
  return;
331
475
  }
332
- // An icon takes a column of its own on the right of whatever the box holds,
333
- // so the label never runs underneath it and the box grows to fit both. That
334
- // is why an icon is not a renderer-only concern: it is content taking room,
335
- // like a label, and not appearance like `fill:`.
336
- const icon = iconFor(node.appearance, node.line);
337
- const iconSide = icon === undefined ? 0 : glyphSide;
338
- const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
476
+ // Read before the split, so a misspelt word is refused on a childless node
477
+ // too. The *absence* of children makes the setting inert, which stays silent;
478
+ // a value the language does not have is wrong wherever it is written.
479
+ const contents = contentsStyleFor(node);
339
480
  if (node.children.length === 0) {
340
- // A band only exists because contents have to sit clear of it, and a leaf
341
- // has none, so `at` has no end to name. `align` is a different question and
342
- // is allowed: a label of several lines has lines of unequal length in any
343
- // box, and ranging them left rather than centering them is a real thing to
344
- // want. It was refused here too until 2026-09-09, purely because the two
345
- // words arrive in the same brackets.
346
- if (node.label['at'] !== undefined) {
347
- throw new SourceError(`"${node.name}" holds nothing and its label carries at: ${node.label['at']}. ` +
348
- `A label sits at one end of a box so its contents can have the other; with no contents there is no band for it to sit at either end of`, node.line);
349
- }
350
- node.width = labelWidth + iconRoom + PAD * 2;
351
- node.height = Math.max(labelHeight, iconSide) + PAD * 2;
481
+ // `at` is read on a leaf too, and is inert wherever the box is exactly the
482
+ // size of what it holds — which is every leaf, since a leaf is sized from
483
+ // its own text. It bites once the box is sized by something else: a
484
+ // `widths:` that widens it, or a child placed beside the text. Inert-but-
485
+ // legal stays silent here, the same treatment `align` gets on a leaf with
486
+ // one line.
487
+ const style = textStyleFor(node.textAttrs, node.line, 'middle', 'center');
488
+ node.width = textWidth + PAD * 2;
489
+ node.height = textHeight + PAD * 2;
490
+ node.textBox = textBoxIn({
491
+ x: PAD,
492
+ y: leafTop(style.end, 0, node.height, textHeight),
493
+ width: node.width - PAD * 2,
494
+ height: textHeight,
495
+ }, (node.textSide = style.side), textWidth, textHeight);
496
+ }
497
+ else if (framedChildren(node).size > 0) {
498
+ // Something is placed against this node's own frame or text, so where its
499
+ // text sits and how big it is come out of one solve with its children.
500
+ const own = { textWidth, textHeight, contents };
501
+ const lay = (width) => {
502
+ layoutFramed(node, own, edges, measurer, fontSize, local, width - node.deckTexts.length * DECK_STEP);
503
+ applyDeck(node, local);
504
+ };
505
+ lay(0);
506
+ relayout.set(node, lay);
507
+ return;
352
508
  }
353
509
  else {
354
- applyAlign(node);
355
- const content = layoutChildren(node, links, measurer, fontSize, local);
356
- const band = Math.max(labelHeight, iconSide);
357
- // `headerHeight` is the band the label and icon take, whichever end of the
358
- // box that band is at. Only the contents' offset depends on the side.
359
- node.headerHeight = hasLabel || icon !== undefined ? band + HEADER_GAP : 0;
360
- node.width = Math.max(labelWidth + iconRoom, content.width) + PAD * 2;
510
+ if (contents.widths === 'match')
511
+ matchWidths(node.children);
512
+ let content = layoutChildren(node.children, edges, measurer, fontSize, local);
513
+ if (contents.widths === 'fill') {
514
+ // The band is the wider of the title and the contents, so filling it
515
+ // needs the contents measured first. Where they already set the width
516
+ // this is `match` exactly; where the title wins it is the answer `match`
517
+ // could not give.
518
+ const band = Math.max(textWidth, content.width);
519
+ for (const child of node.children)
520
+ widenTo(child, band);
521
+ content = layoutChildren(node.children, edges, measurer, fontSize, local);
522
+ checkOneColumn(node, node.children, local);
523
+ }
524
+ // `headerHeight` is the band the text takes, whichever end of the box that
525
+ // band is at. Only the contents' offset depends on the side.
526
+ node.headerHeight = hasText ? textHeight + HEADER_GAP : 0;
527
+ node.width = Math.max(textWidth, content.width) + PAD * 2;
361
528
  node.height = node.headerHeight + content.height + PAD * 2;
362
- const above = labelStyleFor(node.label, node.line).at === 'top' ? node.headerHeight : 0;
529
+ const style = textStyleFor(node.textAttrs, node.line);
530
+ if (style.end === 'center')
531
+ throw bandInMiddle(node, style.at);
532
+ const above = style.end === 'top' ? node.headerHeight : 0;
533
+ // The text has a band at one end of the box, and the contents have the
534
+ // other.
535
+ node.textBox = textBoxIn({
536
+ x: PAD,
537
+ y: style.end === 'top' ? PAD : node.height - PAD - textHeight,
538
+ width: node.width - PAD * 2,
539
+ height: textHeight,
540
+ }, (node.textSide = style.side), textWidth, textHeight);
541
+ // Where the title is wider than the contents, the slack is all on the right
542
+ // — every member sits at the smallest position its constraints allow and
543
+ // nothing pushes it along. `align` is what says where the block goes in it.
544
+ const slack = node.width - PAD * 2 - content.width;
545
+ const shift = contents.align === 'left' ? 0 : contents.align === 'center' ? slack / 2 : slack;
363
546
  for (const child of node.children) {
364
547
  const offset = local.get(child);
365
- offset.x += PAD;
548
+ offset.x += PAD + shift;
366
549
  offset.y += PAD + above;
367
550
  }
551
+ node.banded = true;
368
552
  }
369
- if (node.deckLabels.length > 0) {
370
- // The copies sit behind and above-left, so the whole node grows by the
371
- // depth of the stack and its own face moves down and right by the same.
372
- node.inset = node.deckLabels.length * DECK_STEP;
553
+ applyDeck(node, local);
554
+ }
555
+ /**
556
+ * Make room for a deck's copies, once the node's own face has been sized.
557
+ *
558
+ * The copies sit behind and above-left, so the whole node grows by the depth
559
+ * of the stack and its own face moves down and right by the same.
560
+ */
561
+ function applyDeck(node, local) {
562
+ if (node.deckTexts.length > 0) {
563
+ node.inset = node.deckTexts.length * DECK_STEP;
373
564
  node.width += node.inset;
374
565
  node.height += node.inset;
566
+ node.textBox.x += node.inset;
567
+ node.textBox.y += node.inset;
375
568
  for (const child of node.children) {
376
569
  const offset = local.get(child);
377
570
  offset.x += node.inset;
@@ -380,20 +573,85 @@ function sizeNode(node, links, measurer, fontSize, local) {
380
573
  }
381
574
  }
382
575
  /**
383
- * `align: widths` widens every direct child to the widest one's natural
384
- * width, before layoutChildren sizes and positions anything from those
385
- * widths. A container's own children are already sized by this point.
576
+ * Where a block of text ends up in the room it was given: the *ink* box, which
577
+ * is what `hub text` names as a placement target and what the renderer draws.
578
+ *
579
+ * Only the horizontal is decided here. The vertical is settled by whoever
580
+ * knows which end of the box the text sits at, which differs between a leaf, a
581
+ * container's band and a caption under a picture, and arrives as `room.y`.
386
582
  */
387
- function applyAlign(node) {
388
- const value = node.attrs['align'];
389
- if (value === undefined)
583
+ function textBoxIn(room, side, inkWidth, inkHeight) {
584
+ const x = side === 'left'
585
+ ? room.x
586
+ : side === 'right'
587
+ ? room.x + room.width - inkWidth
588
+ : room.x + (room.width - inkWidth) / 2;
589
+ return { x, y: room.y, width: inkWidth, height: inkHeight };
590
+ }
591
+ function contentsStyleFor(node) {
592
+ const written = bracketOf('contents', node.appearance);
593
+ const widths = written['widths'] ?? 'natural';
594
+ const align = written['align'] ?? 'left';
595
+ if (!CONTENT_WIDTHS.includes(widths)) {
596
+ throw new SourceError(`"${node.name}" has contents: (widths: ${widths}), which is not one of ${CONTENT_WIDTHS.join(', ')}`, node.line);
597
+ }
598
+ if (!CONTENT_ALIGNMENTS.includes(align)) {
599
+ throw new SourceError(`"${node.name}" has contents: (align: ${align}), which is not one of ${CONTENT_ALIGNMENTS.join(', ')}`, node.line);
600
+ }
601
+ return { widths, align };
602
+ }
603
+ /**
604
+ * `widths: match` widens every direct child to the widest one's natural width,
605
+ * before layoutChildren sizes and positions anything from those widths. A
606
+ * container's own children are already sized by this point.
607
+ */
608
+ function matchWidths(children) {
609
+ const maxWidth = Math.max(...children.map((child) => child.width));
610
+ for (const child of children)
611
+ widenTo(child, maxWidth);
612
+ }
613
+ /**
614
+ * `widths: fill` makes every child as wide as the band, which only holds for
615
+ * a single column: two children set to the band's width and not one above the
616
+ * other make the contents wider than the band, the band grows with them, and
617
+ * no child is the width it was asked to be. Every child of a column starts at
618
+ * the same x once they are all one width, so a child that does not is the
619
+ * proof, and the pair is named rather than drawn wrong.
620
+ */
621
+ function checkOneColumn(node, children, local) {
622
+ const [first, ...rest] = children;
623
+ if (!first)
624
+ return;
625
+ const off = rest.find((child) => Math.abs(local.get(child).x - local.get(first).x) > 0.5);
626
+ if (!off)
627
+ return;
628
+ throw new SourceError(`"${node.name}" has contents: (widths: fill), which makes every child the full width of the box, ` +
629
+ `and that only works in a single column — "${first.name}" and "${off.name}" are not one above the other. ` +
630
+ 'Put them in one column, or use contents: (widths: match) to make them one width without filling the box', node.line);
631
+ }
632
+ /**
633
+ * Make a node wider than it was sized, carrying its text with it.
634
+ *
635
+ * `widths: match` and `widths: fill` both set a child's width after that child
636
+ * was sized from its own contents, and the text's box was worked out against
637
+ * the old one. A left-ranged text stays where it is; a centered or right-ranged
638
+ * one moves by its share of the difference.
639
+ */
640
+ function widenTo(node, width) {
641
+ // A node with something placed against its own frame is laid out by a
642
+ // solve, and a wider frame is one more thing that solve has to hold: the
643
+ // things against its right edge move, and a centered text re-centers.
644
+ const again = relayout.get(node);
645
+ if (again) {
646
+ again(width);
390
647
  return;
391
- if (value !== 'widths') {
392
- throw new SourceError(`"${node.name}" has align: ${value}, which is not one of widths`, node.line);
393
648
  }
394
- const maxWidth = Math.max(...node.children.map((child) => child.width));
395
- for (const child of node.children)
396
- child.width = maxWidth;
649
+ const grew = width - node.width;
650
+ node.width = width;
651
+ if (node.textSide === 'center')
652
+ node.textBox.x += grew / 2;
653
+ else if (node.textSide === 'right')
654
+ node.textBox.x += grew;
397
655
  }
398
656
  /**
399
657
  * Position a container's children relative to each other. Children that make
@@ -401,15 +659,15 @@ function applyAlign(node) {
401
659
  * siblings they name, by the same constraint pass that positions top-level
402
660
  * nodes. A placement may only name a sibling — containment scopes the group.
403
661
  */
404
- function layoutChildren(parent, links, measurer, fontSize, local) {
405
- const siblings = new Map(parent.children.map((child) => [child.name, child]));
662
+ function layoutChildren(children, edges, measurer, fontSize, local) {
663
+ const siblings = new Map(children.map((child) => [child.name, child]));
406
664
  // Children that say nothing keep the written order, down the page and flush
407
665
  // left. Written as constraints rather than a cursor so a placed sibling can
408
666
  // push them along like anything else.
409
667
  const stack = [];
410
668
  const alignment = [];
411
- const quiet = parent.children.filter((child) => child.placements.length === 0);
412
- const indexOf = new Map(parent.children.map((child, index) => [child, index]));
669
+ const quiet = children.filter((child) => child.placements.length === 0);
670
+ const indexOf = new Map(children.map((child, index) => [child, index]));
413
671
  quiet.forEach((child, position) => {
414
672
  const previous = quiet[position - 1];
415
673
  if (!previous)
@@ -419,35 +677,38 @@ function layoutChildren(parent, links, measurer, fontSize, local) {
419
677
  stack.push({ from: before, to: after, weight: previous.height + CHILD_GAP });
420
678
  alignment.push(...fix(before, after, 0));
421
679
  });
422
- const positions = positionGroup(parent.children, (placement, owner) => placement.targets.map((name) => {
680
+ const positions = positionGroup(children, (placement, owner) => placement.targets.map(({ name, part }) => {
423
681
  const target = siblings.get(name);
424
682
  if (!target) {
425
- throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings`, placement.line);
683
+ throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings or its parent`, placement.line);
426
684
  }
427
- return {
685
+ return partOf({
428
686
  name,
429
687
  node: target,
430
688
  index: indexOf.get(target),
431
689
  offset: { x: 0, y: 0 },
432
690
  width: target.width,
433
691
  height: target.height,
434
- };
435
- }), { x: alignment, y: stack }, corridorsIn(links, (node) => liftTo(node, indexOf, local), measurer, fontSize));
436
- for (const child of parent.children)
692
+ }, part, owner, placement.line);
693
+ }), { x: alignment, y: stack }, corridorsIn(edges, (node) => liftTo(node, indexOf, local), measurer, fontSize));
694
+ for (const child of children)
437
695
  local.set(child, positions.get(child));
438
- return extentOf(parent.children, local);
696
+ return extentOf(children, local);
439
697
  }
440
698
  function extentOf(children, local) {
441
699
  let minX = Infinity;
442
700
  let minY = Infinity;
443
701
  let maxX = -Infinity;
444
702
  let maxY = -Infinity;
703
+ // What a child has placed outside itself is still part of it, so the block
704
+ // holding the child holds that too.
445
705
  for (const child of children) {
446
706
  const offset = local.get(child);
447
- minX = Math.min(minX, offset.x);
448
- minY = Math.min(minY, offset.y);
449
- maxX = Math.max(maxX, offset.x + child.width);
450
- maxY = Math.max(maxY, offset.y + child.height);
707
+ const { reach } = child;
708
+ minX = Math.min(minX, offset.x - reach.left);
709
+ minY = Math.min(minY, offset.y - reach.top);
710
+ maxX = Math.max(maxX, offset.x + child.width + reach.right);
711
+ maxY = Math.max(maxY, offset.y + child.height + reach.bottom);
451
712
  }
452
713
  for (const child of children) {
453
714
  const offset = local.get(child);
@@ -456,8 +717,443 @@ function extentOf(children, local) {
456
717
  }
457
718
  return { width: maxX - minX, height: maxY - minY };
458
719
  }
720
+ /** How to lay a framed node out again at a given width, which is what `widenTo` needs. */
721
+ const relayout = new WeakMap();
722
+ /**
723
+ * The children placed against their parent — its frame, a part of it, or its
724
+ * text — together with any child placed against one of those, and so on.
725
+ *
726
+ * These hang off the frame. The rest hang off the default position under the
727
+ * text, which is the contents stack as it always was. Nothing sorts a child
728
+ * into one or the other; it is read off what each placement names.
729
+ */
730
+ function framedChildren(parent) {
731
+ const framed = new Set();
732
+ const byName = new Map(parent.children.map((child) => [child.name, child]));
733
+ let grew = true;
734
+ while (grew) {
735
+ grew = false;
736
+ for (const child of parent.children) {
737
+ if (framed.has(child))
738
+ continue;
739
+ const names = child.placements.flatMap((placement) => placement.targets.map((target) => target.name));
740
+ const hangs = names.some((name) => {
741
+ if (name === parent.name)
742
+ return true;
743
+ const sibling = byName.get(name);
744
+ return sibling !== undefined && framed.has(sibling);
745
+ });
746
+ if (hangs) {
747
+ framed.add(child);
748
+ grew = true;
749
+ }
750
+ }
751
+ }
752
+ return framed;
753
+ }
754
+ /**
755
+ * The sides of its parent's frame a child is not held inside by the padding.
756
+ *
757
+ * Every child is held a padding in from the frame on all four sides, which is
758
+ * how a box grows to hold what is in it. A child placed against a side is
759
+ * held by that placement instead — tucked a gap in with `inside`, straddling
760
+ * it with `on`, beyond it with `outside` — and holding it by the padding as
761
+ * well would contradict the placement whenever the gap is the smaller.
762
+ *
763
+ * `overlap: allow` on a child is "do not grow for me": it lies over whatever
764
+ * is there, and the frame is not held open around it at all.
765
+ */
766
+ function freeSides(child, parent) {
767
+ if (allowsOverlap(child))
768
+ return new Set(PART_SIDES);
769
+ const free = new Set();
770
+ const across = (direction) => [
771
+ ...(/left|right/.test(direction) ? ['left', 'right'] : []),
772
+ ...(/above|below/.test(direction) ? ['top', 'bottom'] : []),
773
+ ];
774
+ for (const placement of child.placements) {
775
+ for (const target of placement.targets) {
776
+ if (target.name !== parent.name || target.part === 'text')
777
+ continue;
778
+ if (placement.kind === 'offset' && placement.written !== 'inside') {
779
+ // Beyond the frame, so it neither holds the frame open nor is held in
780
+ // by it — on either axis. Held on the other one, a note taller than
781
+ // its parent's text would stretch the parent to fit a thing outside it.
782
+ for (const side of PART_SIDES)
783
+ free.add(side);
784
+ continue;
785
+ }
786
+ const words = (target.part ?? '').split('-');
787
+ for (const side of PART_SIDES)
788
+ if (words.includes(side))
789
+ free.add(side);
790
+ if (placement.kind === 'align' && placement.side !== 'center')
791
+ free.add(placement.side);
792
+ }
793
+ }
794
+ return free;
795
+ }
796
+ /**
797
+ * The parent's frame as a target, narrowed to the part that was named.
798
+ *
799
+ * The frame is two members of the child system, its top-left corner and its
800
+ * bottom-right, so a part of it is read off those two per axis: `right` is
801
+ * across at the bottom-right corner and spans both down, `top-center` spans
802
+ * both across and is down at the top-left. A part that spans comes as its two
803
+ * ends, and a placement against two targets already means the region that
804
+ * bounds them.
805
+ */
806
+ function frameTargets(parent, part, first, last) {
807
+ const words = (part ?? '').split('-');
808
+ const pick = (near, far) => words.includes(near) ? [first] : words.includes(far) ? [last] : [first, last];
809
+ const xs = pick('left', 'right');
810
+ const ys = pick('top', 'bottom');
811
+ const name = part === undefined ? parent.name : `${parent.name} ${part}`;
812
+ const point = (x, y) => ({
813
+ name,
814
+ node: parent,
815
+ index: x,
816
+ byAxis: { x, y },
817
+ frame: true,
818
+ offset: { x: 0, y: 0 },
819
+ width: 0,
820
+ height: 0,
821
+ });
822
+ const start = point(xs[0], ys[0]);
823
+ const end = point(xs[xs.length - 1], ys[ys.length - 1]);
824
+ return xs.length === 1 && ys.length === 1 ? [start] : [start, end];
825
+ }
826
+ /**
827
+ * A stand-in member for something of the parent's own that takes room among
828
+ * its children: its text, its badge, the block of its contents, a corner of
829
+ * its frame. Named after the parent so an error about it reads in the
830
+ * author's words — `"hub text" and "star" overlap`.
831
+ */
832
+ function stand(of, what, width, height) {
833
+ return {
834
+ ...of,
835
+ name: `${of.name} ${what}`,
836
+ parent: undefined,
837
+ children: [],
838
+ placements: [],
839
+ attrs: {},
840
+ appearance: {},
841
+ deckTexts: [],
842
+ reach: NO_REACH,
843
+ x: 0,
844
+ y: 0,
845
+ width,
846
+ height,
847
+ };
848
+ }
849
+ function bandInMiddle(node, at) {
850
+ // A container's band is at one end precisely so its contents can have the
851
+ // other, so the three positions that name neither end have nothing to mean
852
+ // here. Refused rather than rounded to an end, for the reason every other
853
+ // word in the language is: a value that quietly becomes a different value
854
+ // looks like the tool being broken.
855
+ return new SourceError(`"${node.name}" holds things and its text is at: ${at}. A container's text sits at ` +
856
+ 'the top or the bottom so its contents can have the other end, so name a position on ' +
857
+ 'one of those edges', node.line);
858
+ }
859
+ /**
860
+ * Lay out a node that has something placed against its own frame or text.
861
+ *
862
+ * One solve holds everything: the children placed against the frame, the
863
+ * block of ordinary contents solved as it always was, the node's text and
864
+ * badge, and the frame's two corners. Every one of them is held a padding in
865
+ * from the frame, so the frame is the smallest box that holds them all, which
866
+ * is what a node's size has always been. A part of the frame is then just a
867
+ * target like any other.
868
+ *
869
+ * The band falls out rather than being decided. It exists because the
870
+ * contents stack below the text; where every child hangs off the frame
871
+ * instead, nothing is below the text, there is no band, and the node reads as
872
+ * a leaf — its text centered, and whatever is beside the text centered with
873
+ * it as one group.
874
+ */
875
+ function layoutFramed(node, own, edges, measurer, fontSize, local, minWidth) {
876
+ const { textWidth, textHeight, contents } = own;
877
+ const hasText = textWidth > 0;
878
+ const framed = framedChildren(node);
879
+ const stacked = node.children.filter((child) => !framed.has(child));
880
+ const placed = node.children.filter((child) => framed.has(child));
881
+ const banded = stacked.length > 0;
882
+ const style = banded
883
+ ? textStyleFor(node.textAttrs, node.line)
884
+ : textStyleFor(node.textAttrs, node.line, 'middle', 'center');
885
+ if (banded && style.end === 'center')
886
+ throw bandInMiddle(node, style.at);
887
+ // The children placed against this node's text. With it they make the
888
+ // text's row: the contents stack past all of them, and they range and
889
+ // center with the text as one group.
890
+ const againstText = (placement) => placement.targets.some((target) => target.name === node.name && target.part === 'text');
891
+ const withText = placed.filter((child) => child.placements.some(againstText));
892
+ let block = { width: 0, height: 0 };
893
+ if (banded) {
894
+ if (contents.widths === 'match')
895
+ matchWidths(stacked);
896
+ block = layoutChildren(stacked, edges, measurer, fontSize, local);
897
+ if (contents.widths === 'fill') {
898
+ // The row is the text and whatever stands beside it, each at its gap.
899
+ let row = textWidth;
900
+ for (const child of withText) {
901
+ const beside = child.placements.find((placement) => placement.kind === 'offset' && /left|right/.test(placement.direction) && againstText(placement));
902
+ if (beside)
903
+ row += child.width + (hasText ? gapFor(child, beside, node) : 0);
904
+ }
905
+ const across = Math.max(row, block.width);
906
+ for (const child of stacked)
907
+ widenTo(child, across);
908
+ block = layoutChildren(stacked, edges, measurer, fontSize, local);
909
+ checkOneColumn(node, stacked, local);
910
+ }
911
+ }
912
+ const members = [...placed];
913
+ const add = (member) => members.push(member) - 1;
914
+ const K = banded ? add(stand(node, 'contents', block.width, block.height)) : -1;
915
+ // An empty text is still somewhere, so a thing placed against it has a
916
+ // place to be: it is a point where the text would have been, and it takes
917
+ // no room — including the gap beside it, which `Target.empty` drops.
918
+ const T = hasText || withText.length > 0 ? add(stand(node, 'text', textWidth, textHeight)) : -1;
919
+ const TL = add(stand(node, 'frame', 0, 0));
920
+ const BR = add(stand(node, 'frame', 0, 0));
921
+ const x = [];
922
+ const y = [];
923
+ const hold = (index, free) => {
924
+ const { width, height, reach } = members[index];
925
+ if (!free.has('left'))
926
+ x.push({ from: TL, to: index, weight: PAD + reach.left });
927
+ if (!free.has('right'))
928
+ x.push({ from: index, to: BR, weight: width + reach.right + PAD });
929
+ if (!free.has('top'))
930
+ y.push({ from: TL, to: index, weight: PAD + reach.top });
931
+ if (!free.has('bottom'))
932
+ y.push({ from: index, to: BR, weight: height + reach.bottom + PAD });
933
+ };
934
+ placed.forEach((child, index) => hold(index, freeSides(child, node)));
935
+ for (const index of [K, T])
936
+ if (index >= 0)
937
+ hold(index, new Set());
938
+ if (minWidth > 0)
939
+ x.push({ from: TL, to: BR, weight: minWidth });
940
+ // The text's row, as members: the text and everything placed against it.
941
+ const row = [...(T >= 0 ? [T] : []), ...withText.map((child) => placed.indexOf(child))];
942
+ if (banded) {
943
+ // The text's row is at one end, the contents the other.
944
+ for (const index of row) {
945
+ const { height, reach } = members[index];
946
+ if (style.end === 'top')
947
+ y.push({ from: index, to: K, weight: height + reach.bottom + HEADER_GAP });
948
+ else
949
+ y.push({ from: K, to: index, weight: block.height + HEADER_GAP + reach.top });
950
+ }
951
+ }
952
+ const siblings = new Map(node.children.map((child) => [child.name, child]));
953
+ const indexOf = new Map(members.map((member, index) => [member, index]));
954
+ const locate = (placement, owner) => placement.targets.flatMap(({ name, part }) => {
955
+ if (name === node.name) {
956
+ if (part !== 'text')
957
+ return frameTargets(node, part, TL, BR);
958
+ const text = { x: 0, y: 0 };
959
+ return [
960
+ {
961
+ name: `${name} text`,
962
+ node,
963
+ index: T,
964
+ offset: text,
965
+ width: textWidth,
966
+ height: textHeight,
967
+ ...(hasText ? {} : { empty: true }),
968
+ },
969
+ ];
970
+ }
971
+ const sibling = siblings.get(name);
972
+ if (!sibling) {
973
+ throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings or its parent`, placement.line);
974
+ }
975
+ // A child of the contents is reached through the block that holds it,
976
+ // at the offset the contents solve already gave it.
977
+ const inBlock = !framed.has(sibling);
978
+ const target = {
979
+ name,
980
+ node: sibling,
981
+ index: inBlock ? K : indexOf.get(sibling),
982
+ offset: inBlock ? { ...local.get(sibling) } : { x: 0, y: 0 },
983
+ width: sibling.width,
984
+ height: sibling.height,
985
+ };
986
+ return [partOf(target, part, owner, placement.line)];
987
+ });
988
+ // Which way a child against a side stands from the rest of the box: across
989
+ // for a left or right side or corner, down for the top or bottom.
990
+ const facing = new Map();
991
+ placed.forEach((child, index) => {
992
+ for (const placement of child.placements) {
993
+ for (const target of placement.targets) {
994
+ if (target.name !== node.name || target.part === undefined || target.part === 'text')
995
+ continue;
996
+ const words = target.part.split('-');
997
+ if (words.includes('left') || words.includes('right'))
998
+ facing.set(index, 'x');
999
+ else if (words.includes('top') || words.includes('bottom'))
1000
+ facing.set(index, 'y');
1001
+ }
1002
+ }
1003
+ });
1004
+ const across = (i, j) => facing.get(i) ?? facing.get(j);
1005
+ // An edge end inside this node, as a member of this solve: a framed child
1006
+ // or something inside one, or something in the contents, reached through
1007
+ // the block at the offset the contents solve gave it. An end outside the
1008
+ // node is none of this solve's business.
1009
+ const endOf = (end) => {
1010
+ let member = end;
1011
+ const offset = { x: 0, y: 0 };
1012
+ while (member.parent !== node) {
1013
+ const step = local.get(member);
1014
+ if (!step || !member.parent)
1015
+ return undefined;
1016
+ offset.x += step.x;
1017
+ offset.y += step.y;
1018
+ member = member.parent;
1019
+ }
1020
+ const inBlock = !framed.has(member);
1021
+ if (inBlock) {
1022
+ const step = local.get(member);
1023
+ offset.x += step.x;
1024
+ offset.y += step.y;
1025
+ }
1026
+ return {
1027
+ name: end.name,
1028
+ node: end,
1029
+ index: inBlock ? K : indexOf.get(member),
1030
+ offset,
1031
+ width: end.width,
1032
+ height: end.height,
1033
+ };
1034
+ };
1035
+ // Two ends both in the contents come out as one member and are skipped:
1036
+ // the contents solve already made room for that edge's text.
1037
+ const corridors = corridorsIn(edges, endOf, measurer, fontSize);
1038
+ const solve = (pins) =>
1039
+ // Everything here is inside one box, so a collision separates by the step
1040
+ // the contents stack by, not by the gap kept between strangers.
1041
+ positionGroup(members, locate, { x: [...x, ...pins.x], y: [...y, ...pins.y] }, corridors, across, CHILD_GAP);
1042
+ let solved = solve({ x: [], y: [] });
1043
+ // Where a text is centered or ranged right, where it goes depends on how
1044
+ // wide the frame came out, which depends on everything else. So it is
1045
+ // measured off the first solve and pinned, the move `settle` makes.
1046
+ const at = (index) => solved.get(members[index]);
1047
+ const inner = (axis) => ({
1048
+ start: at(TL)[axis] + PAD,
1049
+ size: at(BR)[axis] - at(TL)[axis] - PAD * 2,
1050
+ });
1051
+ const pins = { x: [], y: [] };
1052
+ const pin = (axis, index, to) => {
1053
+ if (to - at(index)[axis] <= 1e-9)
1054
+ return;
1055
+ pins[axis].push(...fix(TL, index, to - at(TL)[axis]));
1056
+ };
1057
+ // The text and the things placed against it range and center as a group,
1058
+ // which is the fan rule's sentence again: several things against one target
1059
+ // are balanced on it together.
1060
+ const extent = (axis) => {
1061
+ let start = Infinity;
1062
+ let end = -Infinity;
1063
+ for (const index of row) {
1064
+ const size = axis === 'x' ? members[index].width : members[index].height;
1065
+ start = Math.min(start, at(index)[axis]);
1066
+ end = Math.max(end, at(index)[axis] + size);
1067
+ }
1068
+ return { start, end };
1069
+ };
1070
+ if (banded) {
1071
+ if (T >= 0 && style.side !== 'left') {
1072
+ const { start, end } = extent('x');
1073
+ pin('x', T, at(T).x + (alignedAt(style.side, inner('x'), end - start) - start));
1074
+ }
1075
+ if (contents.align !== 'left')
1076
+ pin('x', K, alignedAt(contents.align, inner('x'), block.width));
1077
+ }
1078
+ else if (T >= 0) {
1079
+ const sides = { x: style.side, y: style.end };
1080
+ // The text and its group range in the room their own row (across) or
1081
+ // column (down) leaves them — between whatever sits beside, above or below
1082
+ // them — not in the whole box. Ranged against the whole width, a
1083
+ // right-ranged title runs into a child in the top-right corner, pushes it
1084
+ // out and grows the box; centered on the whole height, a title with things
1085
+ // placed below it and more in the bottom corners drops into the middle.
1086
+ const room = (axis) => {
1087
+ const cross = axis === 'x' ? 'y' : 'x';
1088
+ const whole = inner(axis);
1089
+ const along = extent(axis);
1090
+ const beside = extent(cross);
1091
+ const size = (member, on) => (on === 'x' ? member.width : member.height);
1092
+ let start = whole.start;
1093
+ let end = whole.start + whole.size;
1094
+ members.forEach((member, index) => {
1095
+ if (row.includes(index) || index === TL || index === BR)
1096
+ return;
1097
+ const c0 = at(index)[cross];
1098
+ if (c0 + size(member, cross) <= beside.start || c0 >= beside.end)
1099
+ return;
1100
+ const a0 = at(index)[axis];
1101
+ if (a0 + size(member, axis) <= along.start)
1102
+ start = Math.max(start, a0 + size(member, axis) + CHILD_GAP);
1103
+ else if (a0 >= along.end)
1104
+ end = Math.min(end, a0 - CHILD_GAP);
1105
+ });
1106
+ return { start, size: end - start };
1107
+ };
1108
+ for (const axis of AXES) {
1109
+ if (sides[axis] === 'left' || sides[axis] === 'top')
1110
+ continue;
1111
+ const { start, end } = extent(axis);
1112
+ const want = alignedAt(sides[axis], room(axis), end - start);
1113
+ pin(axis, T, at(T)[axis] + (want - start));
1114
+ }
1115
+ }
1116
+ if (pins.x.length > 0 || pins.y.length > 0)
1117
+ solved = solve(pins);
1118
+ const origin = at(TL);
1119
+ const corner = at(BR);
1120
+ node.inset = 0;
1121
+ node.width = corner.x - origin.x;
1122
+ node.height = corner.y - origin.y;
1123
+ node.banded = banded;
1124
+ node.headerHeight = banded && hasText ? textHeight + HEADER_GAP : 0;
1125
+ node.textSide = style.side;
1126
+ const relative = (index, width, height) => ({
1127
+ x: at(index).x - origin.x,
1128
+ y: at(index).y - origin.y,
1129
+ width,
1130
+ height,
1131
+ });
1132
+ node.textBox = T >= 0 ? relative(T, textWidth, textHeight) : { x: 0, y: 0, width: 0, height: 0 };
1133
+ const reach = { left: 0, top: 0, right: 0, bottom: 0 };
1134
+ for (const child of placed) {
1135
+ const position = solved.get(child);
1136
+ const offset = { x: position.x - origin.x, y: position.y - origin.y };
1137
+ local.set(child, offset);
1138
+ // Whatever sticks out past the frame — a child placed outside it, or
1139
+ // something one of the children placed outside itself.
1140
+ reach.left = Math.max(reach.left, child.reach.left - offset.x);
1141
+ reach.top = Math.max(reach.top, child.reach.top - offset.y);
1142
+ reach.right = Math.max(reach.right, offset.x + child.width + child.reach.right - node.width);
1143
+ reach.bottom = Math.max(reach.bottom, offset.y + child.height + child.reach.bottom - node.height);
1144
+ }
1145
+ node.reach = reach;
1146
+ if (banded) {
1147
+ const shift = relative(K, 0, 0);
1148
+ for (const child of stacked) {
1149
+ const offset = local.get(child);
1150
+ offset.x += shift.x;
1151
+ offset.y += shift.y;
1152
+ }
1153
+ }
1154
+ }
459
1155
  // --- pass three: solve for positions -----------------------------------------
460
- function placeRoots(roots, byName, links, measurer, fontSize, local) {
1156
+ function placeRoots(roots, byName, edges, measurer, fontSize, local) {
461
1157
  const anchors = roots.filter((root) => root.placements.length === 0);
462
1158
  if (anchors.length === 0) {
463
1159
  throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
@@ -470,7 +1166,7 @@ function placeRoots(roots, byName, links, measurer, fontSize, local) {
470
1166
  // A placement may name something nested — `right of server.docker` places a top-level
471
1167
  // node against a box inside another. Sizes and offsets within a container are
472
1168
  // already settled, so a nested target is its root's position plus a constant.
473
- const positions = positionGroup(roots, (placement, owner) => placement.targets.map((name) => {
1169
+ const positions = positionGroup(roots, (placement, owner) => placement.targets.map(({ name, part }) => {
474
1170
  const target = byName.get(name);
475
1171
  if (!target) {
476
1172
  throw new SourceError(`"${owner.name}" is placed against "${name}", which does not exist`, placement.line);
@@ -483,15 +1179,15 @@ function placeRoots(roots, byName, links, measurer, fontSize, local) {
483
1179
  offset.y += step.y;
484
1180
  root = root.parent;
485
1181
  }
486
- return {
1182
+ return partOf({
487
1183
  name,
488
1184
  node: target,
489
1185
  index: indexOf.get(root),
490
1186
  offset,
491
1187
  width: target.width,
492
1188
  height: target.height,
493
- };
494
- }), { x: [], y: [] }, corridorsIn(links, (node) => liftTo(node, indexOf, local), measurer, fontSize));
1189
+ }, part, owner, placement.line);
1190
+ }), { x: [], y: [] }, corridorsIn(edges, (node) => liftTo(node, indexOf, local), measurer, fontSize));
495
1191
  for (const root of roots) {
496
1192
  const position = positions.get(root);
497
1193
  root.x = position.x;
@@ -509,12 +1205,17 @@ function spreadToChildren(node, local) {
509
1205
  }
510
1206
  }
511
1207
  const AXES = ['x', 'y'];
1208
+ const NO_REACH = { left: 0, top: 0, right: 0, bottom: 0 };
512
1209
  const AXIS_WORD = { x: 'horizontally', y: 'vertically' };
1210
+ /** Which member a target's position on this axis is measured from. */
1211
+ function memberOn(target, axis) {
1212
+ return target.byAxis ? target.byAxis[axis] : target.index;
1213
+ }
513
1214
  /**
514
1215
  * Where a node sits within the group being solved: which member holds it, and
515
- * where inside that member. A link may name anything at any depth, so its ends
1216
+ * where inside that member. An edge may name anything at any depth, so its ends
516
1217
  * are lifted to the members of whichever group is being solved — and a node
517
- * outside that group has no answer, which is how a link is sorted into the one
1218
+ * outside that group has no answer, which is how an edge is sorted into the one
518
1219
  * group where its two ends are different members.
519
1220
  */
520
1221
  function liftTo(node, indexOf, local) {
@@ -537,28 +1238,28 @@ function liftTo(node, indexOf, local) {
537
1238
  height: node.height,
538
1239
  };
539
1240
  }
540
- /** The labeled links whose two ends are different members of this group. */
541
- function corridorsIn(links, locate, measurer, fontSize) {
1241
+ /** The edges with text whose two ends are different members of this group. */
1242
+ function corridorsIn(edges, locate, measurer, fontSize) {
542
1243
  const corridors = [];
543
- for (const link of links) {
544
- // An unlabeled link asks for nothing: every gap holds an arrowhead. And a
545
- // link told to pass between two named things carries its label in *that*
1244
+ for (const edge of edges) {
1245
+ // An edge with no text asks for nothing: every gap holds an arrowhead. And a
1246
+ // edge told to pass between two named things carries its text in *that*
546
1247
  // corridor rather than in the gap between its own ends, so widening this one
547
- // would make room where the label never goes.
548
- if (link.label === undefined || link.between)
1248
+ // would make room where the text never goes.
1249
+ if (edge.text === undefined || edge.between)
549
1250
  continue;
550
- const from = locate(link.from);
551
- const to = locate(link.to);
1251
+ const from = locate(edge.from);
1252
+ const to = locate(edge.to);
552
1253
  if (!from || !to || from.index === to.index)
553
1254
  continue;
554
- // The clearance is doubled because the label is drawn at the *midpoint* of
1255
+ // The clearance is doubled because the text is drawn at the *midpoint* of
555
1256
  // the line, so the room it needs is symmetric about that point whatever sits
556
1257
  // at either end. The arrowhead is charged on both sides for the same reason:
557
1258
  // it covers `ARROW_LENGTH` of the line it arrives on, and reserving that at
558
1259
  // one end only would move the midpoint rather than lengthen the run.
559
- const extent = (axis) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line) +
560
- (LABEL_CLEARANCE + ARROW_LENGTH) * 2;
561
- corridors.push({ link, from, to, need: { x: extent('x'), y: extent('y') } });
1260
+ const extent = (axis) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line) +
1261
+ (TEXT_CLEARANCE + ARROW_LENGTH) * 2;
1262
+ corridors.push({ edge, from, to, need: { x: extent('x'), y: extent('y') } });
562
1263
  }
563
1264
  return corridors;
564
1265
  }
@@ -574,10 +1275,23 @@ function corridorsIn(links, locate, measurer, fontSize) {
574
1275
  * hold whatever is put in it and closes again when that is removed. No number
575
1276
  * anywhere has to be guessed, and nothing is ever tried and rejected.
576
1277
  */
577
- function positionGroup(members, locate, extra, corridors = []) {
1278
+ function positionGroup(members, locate, extra, corridors = [], across, clearance = SEPARATION_GAP) {
578
1279
  const constraints = { x: [...extra.x], y: [...extra.y] };
579
1280
  const indexOf = new Map(members.map((member, index) => [member, index]));
580
1281
  const pending = [];
1282
+ /** Pairs an overlay put on top of each other, which is the point of it. */
1283
+ const overlaid = [];
1284
+ // Several nodes saying the identical thing are one list, running down the
1285
+ // page in the order they were written. The first carries the placement for
1286
+ // the whole list; each of the rest hangs a fixed step below the one before,
1287
+ // and places itself across as it said.
1288
+ const fans = fansIn(members);
1289
+ for (const fan of fans.values()) {
1290
+ for (let at = 1; at < fan.members.length; at += 1) {
1291
+ const previous = fan.members[at - 1];
1292
+ constraints.y.push(...fix(indexOf.get(previous), indexOf.get(fan.members[at]), previous.height + CHILD_GAP));
1293
+ }
1294
+ }
581
1295
  for (const node of members) {
582
1296
  // Checked here as well as in `gapFor`, so a misspelt node-wide gap is caught
583
1297
  // on a node whose placements all name their own or are alignments — and on
@@ -586,13 +1300,23 @@ function positionGroup(members, locate, extra, corridors = []) {
586
1300
  if (node.placements.length === 0)
587
1301
  continue;
588
1302
  const me = indexOf.get(node);
589
- const size = { x: node.width, y: node.height };
1303
+ const fan = fans.get(node);
1304
+ // A later member of a fan has its vertical from the list, so its own
1305
+ // placements speak only across; the first speaks for the whole list.
1306
+ const follows = fan !== undefined && fan.members[0] !== node;
1307
+ const size = { x: node.width, y: fan ? fan.height : node.height };
590
1308
  const located = node.placements.map((placement) => ({ placement, targets: locate(placement, node) }));
1309
+ const speaks = (axis) => !(follows && axis === 'y');
591
1310
  const spokenFor = { x: false, y: false };
592
1311
  for (const { placement } of located) {
593
1312
  if (placement.kind === 'align') {
594
1313
  spokenFor[placement.axis] = true;
595
1314
  }
1315
+ else if (placement.kind === 'on') {
1316
+ // An overlay names a point of a box, so it settles both axes at once.
1317
+ spokenFor.x = true;
1318
+ spokenFor.y = true;
1319
+ }
596
1320
  else {
597
1321
  if (/left|right/.test(placement.direction))
598
1322
  spokenFor.x = true;
@@ -603,20 +1327,40 @@ function positionGroup(members, locate, extra, corridors = []) {
603
1327
  // Aligning to several targets means aligning to the box that just bounds
604
1328
  // them. That box is a constant only while its members hold still relative
605
1329
  // to one another; otherwise the alignment waits for the first solution.
606
- const alignOn = (axis, edge, targets, placement) => {
607
- const anchor = sharedMember(targets);
1330
+ const alignOn = (axis, side, targets, placement) => {
1331
+ if (!speaks(axis))
1332
+ return;
1333
+ const anchor = sharedMember(targets, axis);
608
1334
  if (anchor === undefined) {
609
- pending.push({ node, me, axis, edge, targets, placement });
1335
+ pending.push({ node, me, axis, side, targets, placement, own: size[axis] });
610
1336
  return;
611
1337
  }
612
1338
  const span = spanOf(targets, axis, () => 0);
613
- constraints[axis].push(...fix(anchor, me, alignedAt(edge, span, size[axis]), placement));
1339
+ constraints[axis].push(...fix(anchor, me, alignedAt(side, span, size[axis]), placement));
614
1340
  };
615
1341
  for (const { placement, targets } of located) {
616
1342
  if (placement.kind === 'align') {
617
- alignOn(placement.axis, placement.edge, targets, placement);
1343
+ alignOn(placement.axis, placement.side, targets, placement);
618
1344
  continue;
619
1345
  }
1346
+ if (placement.kind === 'on') {
1347
+ // The node's center at the part's center, on both axes. That is the
1348
+ // ordinary center alignment the language already has on one axis, said
1349
+ // twice — which is why `on` needs nothing of its own in the solver.
1350
+ // A side of a parent's frame comes as its two ends, so centering on
1351
+ // the part is centering on everything it came as.
1352
+ overlaid.push([me, targets[0].index]);
1353
+ for (const axis of AXES)
1354
+ alignOn(axis, 'center', targets, placement);
1355
+ continue;
1356
+ }
1357
+ // Saying `inside` is the author stating the overlap, so there is
1358
+ // nothing for the separation pass to report. `outside` is clear of the
1359
+ // box by construction and needs no exemption.
1360
+ if (placement.written === 'inside') {
1361
+ for (const target of targets)
1362
+ overlaid.push([me, target.index]);
1363
+ }
620
1364
  // One constraint per target, so the node clears the furthest of them.
621
1365
  // Taking that maximum is what longest paths already does, which is why a
622
1366
  // direction against a whole region needs nothing added to the solver.
@@ -626,39 +1370,35 @@ function positionGroup(members, locate, extra, corridors = []) {
626
1370
  // default. That is what lets a node wedged between two things sit tight
627
1371
  // against one of them and wide of the other.
628
1372
  for (const target of targets) {
629
- const gap = gapFor(node, placement, target.node);
1373
+ const gap = target.empty ? 0 : gapFor(node, placement, target.node);
1374
+ // Tucked inside the frame of the box that holds it, a child is *at*
1375
+ // that edge rather than at least so far from it: the frame is also
1376
+ // held open around every child, which is a pull the other way, and
1377
+ // without this the child would sit wherever that left it.
1378
+ const exact = target.frame === true && placement.written === 'inside';
1379
+ const push = (axis, from, to, weight) => {
1380
+ if (!speaks(axis))
1381
+ return;
1382
+ constraints[axis].push({ from, to, weight, placement });
1383
+ if (exact)
1384
+ constraints[axis].push({ from: to, to: from, weight: -weight, placement });
1385
+ };
1386
+ const x = memberOn(target, 'x');
1387
+ const y = memberOn(target, 'y');
1388
+ // The gap is between what each has placed outside itself, not only
1389
+ // their boxes: a note beside a box is part of it.
1390
+ const theirs = target.reach ?? NO_REACH;
1391
+ const mine = node.reach;
630
1392
  if (direction.includes('right')) {
631
- constraints.x.push({
632
- from: target.index,
633
- to: me,
634
- weight: target.offset.x + target.width + gap,
635
- placement,
636
- });
637
- }
638
- if (direction.includes('left')) {
639
- constraints.x.push({
640
- from: me,
641
- to: target.index,
642
- weight: node.width + gap - target.offset.x,
643
- placement,
644
- });
1393
+ push('x', x, me, target.offset.x + target.width + theirs.right + gap + mine.left);
645
1394
  }
1395
+ if (direction.includes('left'))
1396
+ push('x', me, x, size.x + mine.right + gap + theirs.left - target.offset.x);
646
1397
  if (direction.includes('below')) {
647
- constraints.y.push({
648
- from: target.index,
649
- to: me,
650
- weight: target.offset.y + target.height + gap,
651
- placement,
652
- });
653
- }
654
- if (direction.includes('above')) {
655
- constraints.y.push({
656
- from: me,
657
- to: target.index,
658
- weight: node.height + gap - target.offset.y,
659
- placement,
660
- });
1398
+ push('y', y, me, target.offset.y + target.height + theirs.bottom + gap + mine.top);
661
1399
  }
1400
+ if (direction.includes('above'))
1401
+ push('y', me, y, size.y + mine.bottom + gap + theirs.top - target.offset.y);
662
1402
  }
663
1403
  }
664
1404
  // An axis nobody spoke to falls back to the center line of whatever the
@@ -667,14 +1407,14 @@ function positionGroup(members, locate, extra, corridors = []) {
667
1407
  // so that is refused rather than guessed — but two targets named by one
668
1408
  // placement are a single region, and centering on it is unambiguous.
669
1409
  for (const axis of AXES) {
670
- if (spokenFor[axis])
1410
+ if (spokenFor[axis] || !speaks(axis))
671
1411
  continue;
672
1412
  const offers = located.filter((entry) => entry.placement.kind === 'offset');
673
1413
  const first = offers[0];
674
1414
  if (!first) {
675
1415
  throw new SourceError(`"${node.name}" says nothing about where it sits ${AXIS_WORD[axis]}`, node.placements[0].line);
676
1416
  }
677
- const named = (entry) => entry.placement.targets.join('\u0000');
1417
+ const named = (entry) => entry.placement.targets.map(nameTarget).join('\u0000');
678
1418
  const other = offers.find((entry) => named(entry) !== named(first));
679
1419
  if (other) {
680
1420
  throw new SourceError(`"${node.name}" does not say where it sits ${AXIS_WORD[axis]}: ` +
@@ -693,21 +1433,78 @@ function positionGroup(members, locate, extra, corridors = []) {
693
1433
  solved[axis] = outcome.positions;
694
1434
  }
695
1435
  };
696
- solveAll();
697
- room(corridors, constraints, solved, solveAll);
698
- settle(pending, members, constraints, solved, solveAll);
699
- snug(members, constraints, solved, solveAll);
700
- separate(members, constraints, solved, solveAll);
1436
+ // An edge's text is given room only once everything is where it goes: a
1437
+ // box still waiting to be centered, or still on top of another, is not yet
1438
+ // where it will be, and room judged against it goes in the wrong gap. Room
1439
+ // can move things in turn, so the centering and pulling-in are measured
1440
+ // again from what the file said, and the texts looked at again, until a
1441
+ // look adds nothing. What was placed, separated or widened stays.
1442
+ //
1443
+ // It stops: each text can be given room once across and once down, room
1444
+ // is never taken back, and a look that gives none ends it.
1445
+ const lasting = { x: [...constraints.x], y: [...constraints.y] };
1446
+ const made = new Set();
1447
+ const keep = (step) => {
1448
+ const before = { x: constraints.x.length, y: constraints.y.length };
1449
+ const changed = step();
1450
+ for (const axis of AXES)
1451
+ lasting[axis].push(...constraints[axis].slice(before[axis]));
1452
+ return changed === true;
1453
+ };
1454
+ for (;;) {
1455
+ constraints.x = [...lasting.x];
1456
+ constraints.y = [...lasting.y];
1457
+ solveAll();
1458
+ settle(pending, members, constraints, solved, solveAll);
1459
+ snug(members, constraints, solved, solveAll);
1460
+ keep(() => separate(members, constraints, solved, solveAll, overlaid, across, clearance));
1461
+ if (!keep(() => room(corridors, constraints, solved, solveAll, made)))
1462
+ break;
1463
+ }
701
1464
  confirm(pending, solved);
702
1465
  return new Map(members.map((member, index) => [
703
1466
  member,
704
1467
  { x: solved.x[index], y: solved.y[index] },
705
1468
  ]));
706
1469
  }
707
- /** The member every target belongs to, or nothing if they are spread across several. */
708
- function sharedMember(targets) {
709
- const first = targets[0].index;
710
- return targets.every((target) => target.index === first) ? first : undefined;
1470
+ /** The member every target belongs to on this axis, or nothing if they are spread across several. */
1471
+ function sharedMember(targets, axis) {
1472
+ const first = memberOn(targets[0], axis);
1473
+ return targets.every((target) => memberOn(target, axis) === first) ? first : undefined;
1474
+ }
1475
+ /**
1476
+ * The fans in a group: two or more members whose placements say the identical
1477
+ * thing, each member mapped to the one fan it is in.
1478
+ *
1479
+ * `b right of a`, `c right of a` and `d right of a` otherwise put three boxes
1480
+ * on one spot, and what they mean is "a points at three things" — the three
1481
+ * balanced against `a`, which no chain of placements can say. The same holds
1482
+ * of a part: three children `inside parent right` are a column against that
1483
+ * edge. The list runs down the page whatever the direction, including at a
1484
+ * corner, where it stacks into the corner and grows down.
1485
+ *
1486
+ * A node saying `overlap: allow` has asked for the literal pile, and gets it.
1487
+ */
1488
+ function fansIn(members) {
1489
+ const bySaying = new Map();
1490
+ for (const node of members) {
1491
+ if (node.placements.length === 0 || allowsOverlap(node))
1492
+ continue;
1493
+ const saying = node.placements.map(describePlacement).join('\n');
1494
+ const list = bySaying.get(saying) ?? [];
1495
+ list.push(node);
1496
+ bySaying.set(saying, list);
1497
+ }
1498
+ const fans = new Map();
1499
+ for (const list of bySaying.values()) {
1500
+ if (list.length < 2)
1501
+ continue;
1502
+ const height = list.reduce((sum, node) => sum + node.height, 0) + CHILD_GAP * (list.length - 1);
1503
+ const fan = { members: list, height };
1504
+ for (const node of list)
1505
+ fans.set(node, fan);
1506
+ }
1507
+ return fans;
711
1508
  }
712
1509
  /** The stretch of one axis that just covers every target. */
713
1510
  function spanOf(targets, axis, base) {
@@ -720,40 +1517,91 @@ function spanOf(targets, axis, base) {
720
1517
  }
721
1518
  return { start, size: end - start };
722
1519
  }
723
- /** Where a node of this size sits so that the named edge of it meets the span's. */
724
- function alignedAt(edge, span, own) {
725
- if (edge === 'center')
1520
+ /**
1521
+ * Narrow a target to the part of it the placement named — its text, one of its
1522
+ * four sides, or one of its nine points.
1523
+ *
1524
+ * A side comes back as a segment of zero thickness and a point as a rectangle
1525
+ * of no size at all, which is the whole of what a part is to the solver: an
1526
+ * extent, exactly as a whole box is, just a thinner one. Everything else — the
1527
+ * direction, the gap, the alignment on the axis nobody spoke to — then works
1528
+ * on it unchanged, which is why a part target needed nothing added to the
1529
+ * constraint system.
1530
+ *
1531
+ * Each position is read as two independent halves, one per axis, which is why
1532
+ * nine words need no table of nine entries: `top-right` is "right" across and
1533
+ * "top" down, `top-center` is "top" down and centered across.
1534
+ */
1535
+ function partOf(target, part, owner, line) {
1536
+ // The whole node is what it has placed outside itself too; a part of it is
1537
+ // just that part.
1538
+ if (part === undefined)
1539
+ return { ...target, reach: target.node.reach };
1540
+ const box = { ...target, offset: { ...target.offset } };
1541
+ if (part === 'text') {
1542
+ const text = target.node.textBox;
1543
+ if (text.width === 0 && text.height === 0) {
1544
+ throw new SourceError(`"${owner.name}" is placed against "${target.name} text", and "${target.name}" has no text`, line);
1545
+ }
1546
+ box.offset.x += text.x;
1547
+ box.offset.y += text.y;
1548
+ box.width = text.width;
1549
+ box.height = text.height;
1550
+ return box;
1551
+ }
1552
+ const words = part.split('-');
1553
+ const isSide = PART_SIDES.includes(part);
1554
+ const narrow = (axis, near, far) => {
1555
+ const size = axis === 'x' ? box.width : box.height;
1556
+ const named = words.includes(near) ? 0 : words.includes(far) ? size : undefined;
1557
+ // A *side* leaves the axis it does not name alone: `right` is the whole
1558
+ // right edge, top to bottom, where `right-center` is the one point on it.
1559
+ if (named === undefined && isSide)
1560
+ return;
1561
+ box.offset[axis] += named ?? size / 2;
1562
+ if (axis === 'x')
1563
+ box.width = 0;
1564
+ else
1565
+ box.height = 0;
1566
+ };
1567
+ narrow('x', 'left', 'right');
1568
+ narrow('y', 'top', 'bottom');
1569
+ return box;
1570
+ }
1571
+ /** Where a node of this size sits so that the named side of it meets the span's. */
1572
+ function alignedAt(side, span, own) {
1573
+ if (side === 'center')
726
1574
  return span.start + (span.size - own) / 2;
727
- if (edge === 'top' || edge === 'left')
1575
+ if (side === 'top' || side === 'left')
728
1576
  return span.start;
729
1577
  return span.start + span.size - own;
730
1578
  }
731
1579
  /**
732
- * Widen a corridor to hold the label of the link crossing it.
1580
+ * Widen a corridor to hold the text of the edge crossing it.
733
1581
  *
734
- * This is the one place a link reaches the constraint system, and it is the same
735
- * measure-then-constrain move `settle` makes rather than links joining the graph
736
- * outright: the first solution says which gap each label actually falls in, and
1582
+ * This is the one place an edge reaches the constraint system, and it is the same
1583
+ * measure-then-constrain move `settle` makes rather than edges joining the graph
1584
+ * outright: the first solution says which gap each text actually falls in, and
737
1585
  * from there the room it needs is an ordinary minimum distance like any other.
738
1586
  * Nothing is nudged and no layout is repaired — a constraint the file already
739
1587
  * implied is derived and the whole system is solved again.
740
1588
  *
741
1589
  * Which gap that is, is derived and never chosen. A pair clear of each other on
742
- * exactly one axis has exactly one corridor between them, and the label is in
1590
+ * exactly one axis has exactly one corridor between them, and the text is in
743
1591
  * it. A pair clear on *both* axes sits corner to corner, so the line runs
744
1592
  * diagonally through open space and there is no corridor to widen; a pair clear
745
1593
  * on neither overlaps, which is the separation pass's business and not this
746
1594
  * one's. Both are left alone, which is why this only ever moves boxes that a
747
- * label is genuinely wedged between.
1595
+ * text is genuinely wedged between.
748
1596
  *
749
1597
  * A gap being a minimum does the rest. Where the corridor is already wide enough
750
1598
  * — because the author said `gap: wide`, or because something else is in there
751
- * — the constraint is slack and nothing moves; delete the label and the corridor
1599
+ * — the constraint is slack and nothing moves; delete the text and the corridor
752
1600
  * closes back to whatever the file asked for.
753
1601
  */
754
- function room(corridors, constraints, solved, solveAll) {
1602
+ function room(corridors, constraints, solved, solveAll, made) {
755
1603
  let added = false;
756
- for (const { from, to, need } of corridors) {
1604
+ for (const [index, { from, to, need }] of corridors.entries()) {
757
1605
  const clear = (axis) => {
758
1606
  const at = (end) => solved[axis][end.index] + end.offset[axis];
759
1607
  const size = (end) => (axis === 'x' ? end.width : end.height);
@@ -767,6 +1615,11 @@ function room(corridors, constraints, solved, solveAll) {
767
1615
  if (open.length !== 1)
768
1616
  continue;
769
1617
  const axis = open[0];
1618
+ // Given already, and a minimum stays met: asking again would widen
1619
+ // nothing and only keep the caller looking.
1620
+ if (made.has(`${index}:${axis}`))
1621
+ continue;
1622
+ made.add(`${index}:${axis}`);
770
1623
  const { before, after } = clear(axis);
771
1624
  constraints[axis].push({
772
1625
  from: before.index,
@@ -780,6 +1633,7 @@ function room(corridors, constraints, solved, solveAll) {
780
1633
  }
781
1634
  if (added)
782
1635
  solveAll();
1636
+ return added;
783
1637
  }
784
1638
  /**
785
1639
  * Fix the alignments that had to wait, by measuring what they align to.
@@ -805,16 +1659,19 @@ function settle(pending, members, constraints, solved, solveAll) {
805
1659
  const reach = reachability(members.length, constraints[axis]);
806
1660
  for (const entry of here) {
807
1661
  for (const target of entry.targets) {
808
- if (target.index !== entry.me && !reach[entry.me][target.index])
1662
+ if (target.frame)
1663
+ continue;
1664
+ const at = memberOn(target, axis);
1665
+ if (at !== entry.me && !reach[entry.me][at])
809
1666
  continue;
810
1667
  throw new SourceError(`"${entry.node.name}" is ${describePlacement(entry.placement)}, but "${target.name}" ` +
811
1668
  `is placed ${AXIS_WORD[axis]} against "${entry.node.name}" in turn, so there is no ` +
812
1669
  `arrangement where each waits for the other`, entry.placement.line);
813
1670
  }
814
- const span = spanOf(entry.targets, axis, (target) => solved[axis][target.index]);
815
- const own = axis === 'x' ? entry.node.width : entry.node.height;
816
- const anchor = entry.targets[0].index;
817
- constraints[axis].push(...fix(anchor, entry.me, alignedAt(entry.edge, span, own) - solved[axis][anchor], entry.placement));
1671
+ const span = spanOf(entry.targets, axis, (target) => solved[axis][memberOn(target, axis)]);
1672
+ const own = entry.own ?? (axis === 'x' ? entry.node.width : entry.node.height);
1673
+ const anchor = memberOn(entry.targets[0], axis);
1674
+ constraints[axis].push(...fix(anchor, entry.me, alignedAt(entry.side, span, own) - solved[axis][anchor], entry.placement));
818
1675
  }
819
1676
  }
820
1677
  solveAll();
@@ -831,9 +1688,9 @@ function settle(pending, members, constraints, solved, solveAll) {
831
1688
  function confirm(pending, solved) {
832
1689
  for (const entry of pending) {
833
1690
  const { axis } = entry;
834
- const span = spanOf(entry.targets, axis, (target) => solved[axis][target.index]);
835
- const own = axis === 'x' ? entry.node.width : entry.node.height;
836
- if (Math.abs(alignedAt(entry.edge, span, own) - solved[axis][entry.me]) <= 0.5)
1691
+ const span = spanOf(entry.targets, axis, (target) => solved[axis][memberOn(target, axis)]);
1692
+ const own = entry.own ?? (axis === 'x' ? entry.node.width : entry.node.height);
1693
+ if (Math.abs(alignedAt(entry.side, span, own) - solved[axis][entry.me]) <= 0.5)
837
1694
  continue;
838
1695
  throw new SourceError(`"${entry.node.name}" cannot be ${describePlacement(entry.placement)}: keeping boxes off ` +
839
1696
  `each other moved them apart after that region was measured`, entry.placement.line);
@@ -928,21 +1785,31 @@ function snug(members, constraints, solved, solveAll) {
928
1785
  * Members here are always siblings, or the roots of the diagram, so no member
929
1786
  * ever contains another and containment needs no exemption of its own.
930
1787
  */
931
- function separate(members, constraints, solved, solveAll) {
1788
+ function separate(members, constraints, solved, solveAll, overlaid = [], across, clearance = SEPARATION_GAP) {
932
1789
  const eligible = members.map(allowsOverlap).map((allowed) => !allowed);
933
1790
  if (eligible.filter(Boolean).length < 2)
934
1791
  return;
1792
+ // An overlay and the box it is on overlap by construction, so that one pair
1793
+ // is exempt while both remain ordinary boxes to everything else. This is a
1794
+ // pair exemption rather than `overlap: allow` on the node for exactly that
1795
+ // reason: a badge sitting on its box says nothing about the box next door.
1796
+ const stamped = new Set(overlaid.map(([a, b]) => pairKey(a, b)));
935
1797
  // Adding only, so the number of separations is bounded; the cap is a
936
1798
  // backstop against a bug rather than an expected outcome.
937
1799
  for (let round = 0; round < members.length * members.length + 1; round += 1) {
938
1800
  let reach;
939
1801
  let added = false;
940
- for (let i = 0; i < members.length; i += 1) {
1802
+ // One separation per round, then solve again: a pair that collided only
1803
+ // because another pair had not yet been moved apart is not a collision,
1804
+ // and separating it anyway leaves an ordering that later moves drag along.
1805
+ scan: for (let i = 0; i < members.length; i += 1) {
941
1806
  if (!eligible[i])
942
1807
  continue;
943
1808
  for (let j = i + 1; j < members.length; j += 1) {
944
1809
  if (!eligible[j])
945
1810
  continue;
1811
+ if (stamped.has(pairKey(i, j)))
1812
+ continue;
946
1813
  const over = overlapOf(members, solved, i, j);
947
1814
  if (!over)
948
1815
  continue;
@@ -953,14 +1820,22 @@ function separate(members, constraints, solved, solveAll) {
953
1820
  if (order)
954
1821
  orders[axis] = order;
955
1822
  }
956
- const axis = pickAxis(orders, over);
1823
+ // A child against a side of its parent's frame has said which way it
1824
+ // stands from the rest of the box — `inside p right` is beside it —
1825
+ // so where that way is open, it is the way, and the smaller overlap
1826
+ // is not asked.
1827
+ const named = across?.(i, j);
1828
+ const axis = named !== undefined && orders[named] ? named : pickAxis(orders, over);
957
1829
  if (!axis)
958
1830
  throw unordered(members[i], members[j]);
959
1831
  const { before, after } = orders[axis];
960
1832
  const span = axis === 'x' ? members[before].width : members[before].height;
961
- constraints[axis].push({ from: before, to: after, weight: span + SEPARATION_GAP });
1833
+ const [near, far] = axis === 'x' ? ['left', 'right'] : ['top', 'bottom'];
1834
+ const past = members[before].reach[far] + members[after].reach[near];
1835
+ constraints[axis].push({ from: before, to: after, weight: span + past + clearance });
962
1836
  reach = undefined;
963
1837
  added = true;
1838
+ break scan;
964
1839
  }
965
1840
  }
966
1841
  if (!added)
@@ -968,13 +1843,16 @@ function separate(members, constraints, solved, solveAll) {
968
1843
  solveAll();
969
1844
  }
970
1845
  }
1846
+ function pairKey(a, b) {
1847
+ return a < b ? `${a}:${b}` : `${b}:${a}`;
1848
+ }
971
1849
  /** How far two members share space on each axis, or nothing if they are clear of each other. */
972
1850
  function overlapOf(members, solved, i, j) {
973
1851
  const shared = (axis) => {
974
- const size = (index) => (axis === 'x' ? members[index].width : members[index].height);
975
- const startI = solved[axis][i];
976
- const startJ = solved[axis][j];
977
- return Math.min(startI + size(i), startJ + size(j)) - Math.max(startI, startJ);
1852
+ const [near, far] = axis === 'x' ? ['left', 'right'] : ['top', 'bottom'];
1853
+ const start = (index) => solved[axis][index] - members[index].reach[near];
1854
+ const end = (index) => solved[axis][index] + (axis === 'x' ? members[index].width : members[index].height) + members[index].reach[far];
1855
+ return Math.min(end(i), end(j)) - Math.max(start(i), start(j));
978
1856
  };
979
1857
  const x = shared('x');
980
1858
  const y = shared('y');
@@ -1037,8 +1915,26 @@ function noRoom(contradiction, axis, members) {
1037
1915
  function gapFor(node, placement, target) {
1038
1916
  if (placement.gap !== undefined)
1039
1917
  return namedGap(node, placement.gap, placement.line);
1918
+ // Against the box that holds it, a child is spaced as that box spaces what it
1919
+ // holds: tucked in by the padding, and deaf to the box's own `gap:`, which
1920
+ // says how that box stands off its neighbours and not how it holds things.
1921
+ const own = target.children.includes(node);
1922
+ if (own && placement.written === 'inside')
1923
+ return PAD;
1924
+ // Beside or below the text of the box holding it, as the contents sit below
1925
+ // a title: a band said by placing things below the text is the band the
1926
+ // contents would have made.
1927
+ if (own && placement.targets.some((each) => each.name === target.name && each.part === 'text'))
1928
+ return CHILD_GAP;
1929
+ // An `inside` placement is an inset rather than a standoff, and the two want
1930
+ // different defaults: "beside that box" reads as room to breathe, "tucked in
1931
+ // that corner" reads as close to it. A node-wide `gap:`, which says how this
1932
+ // node stands off its *neighbours*, has no business setting an inset either
1933
+ // — so `inside` takes `tight` and stops there unless the placement says.
1934
+ if (placement.written === 'inside')
1935
+ return namedGap(node, 'tight', placement.line);
1040
1936
  const mine = node.attrs['gap'];
1041
- const theirs = target.attrs['gap'];
1937
+ const theirs = own ? undefined : target.attrs['gap'];
1042
1938
  // Only a gap somebody actually wrote down counts. Reading an absent one as the
1043
1939
  // default would make it a floor rather than a fallback, and every `gap: tight`
1044
1940
  // placed against a silent node would quietly widen back to normal.
@@ -1051,59 +1947,92 @@ function gapFor(node, placement, target) {
1051
1947
  return namedGap(node, undefined, node.line);
1052
1948
  return Math.max(...stated);
1053
1949
  }
1950
+ /**
1951
+ * A gap is one of the named steps or a plain number of pixels. The names are
1952
+ * the default because retuning `tight` moves every tight gap together, but a
1953
+ * number is no less relative: it is still a minimum distance from the target,
1954
+ * and nothing unrelated moving can make it wrong.
1955
+ */
1054
1956
  function namedGap(node, named, line) {
1055
1957
  const gap = GAPS[named ?? 'normal'];
1056
- if (gap === undefined) {
1057
- const known = Object.keys(GAPS).join(', ');
1058
- throw new SourceError(`"${node.name}" asks for gap: ${named}, which is not one of ${known}`, line);
1059
- }
1060
- return gap;
1958
+ if (gap !== undefined)
1959
+ return gap;
1960
+ if (named !== undefined && /^\d+(\.\d+)?$/.test(named))
1961
+ return Number(named);
1962
+ const known = Object.keys(GAPS).join(', ');
1963
+ const unit = named?.match(/^(\d+(?:\.\d+)?)px$/);
1964
+ const hint = unit
1965
+ ? `; write "gap: ${unit[1]}", a gap's number is already in pixels`
1966
+ : named?.startsWith('-')
1967
+ ? '; a gap is a distance and cannot be negative, and "overlap: allow" is what lets two boxes meet'
1968
+ : '';
1969
+ throw new SourceError(`"${node.name}" asks for gap: ${named}, which is not one of ${known} or a number of pixels${hint}`, line);
1061
1970
  }
1062
1971
  // --- shared helpers ----------------------------------------------------------
1063
- function widestLine(lines, measurer, fontSize) {
1064
- return lines.reduce((widest, line) => {
1065
- const { width } = measurer.measure(line, fontSize);
1066
- return Math.max(widest, width);
1067
- }, 0);
1972
+ /** The keys a bracketed attribute contributed, with its prefix taken off. */
1973
+ function bracketOf(key, attrs) {
1974
+ const found = {};
1975
+ for (const [written, value] of Object.entries(attrs)) {
1976
+ if (written.startsWith(`${key}.`))
1977
+ found[written.slice(key.length + 1)] = value;
1978
+ }
1979
+ return found;
1068
1980
  }
1069
1981
  /**
1070
- * Split a label into the lines that get drawn. `/` always breaks a line. A
1071
- * `width` attribute additionally folds each of those at word boundaries, which
1072
- * is what stops a long note running across the whole diagram.
1982
+ * Every style the markup in this file names, resolved to the color it lends.
1073
1983
  *
1074
- * The width is a character count rather than a distance. It says how much text
1984
+ * A style is the only thing markup may name — never a color — so that a marked
1985
+ * word borrows a meaning the file already has instead of restating a value that
1986
+ * goes stale the day the thing it means is recolored. Both ways that can fail
1987
+ * are refused by name: a style nobody declared, and one that says nothing about
1988
+ * text and so would lend nothing.
1989
+ */
1990
+ function markupColors(nodes, edges, styles) {
1991
+ const colors = {};
1992
+ const used = [
1993
+ ...nodes.map((node) => ({ lines: node.lines, what: `"${node.name}"`, line: node.line })),
1994
+ ...edges.flatMap((edge) => edge.lines ? [{ lines: edge.lines, what: `edge ${edge.from.name} -> ${edge.to.name}`, line: edge.line }] : []),
1995
+ ];
1996
+ for (const { lines, what, line } of used) {
1997
+ for (const name of markupStyles(lines)) {
1998
+ const style = styles.get(name);
1999
+ if (style === undefined) {
2000
+ throw new SourceError(`${what}: its text marks [${name}], and there is no style called "${name}"`, line);
2001
+ }
2002
+ const color = style['text.color'];
2003
+ if (color === undefined) {
2004
+ throw new SourceError(`${what}: its text marks [${name}], and style "${name}" says nothing about text — ` +
2005
+ `write \`style ${name} text: (color: …)\``, line);
2006
+ }
2007
+ colors[name] = color;
2008
+ }
2009
+ }
2010
+ return colors;
2011
+ }
2012
+ /**
2013
+ * Split a text into the lines that get drawn. ` / ` always breaks a line, and
2014
+ * `(wrap: n)` additionally folds each of those at word boundaries, which is
2015
+ * what stops a long note running across the whole diagram.
2016
+ *
2017
+ * The wrap is a character count rather than a distance. It says how much text
1075
2018
  * fits on a line, not where anything sits, so it stays a property of the text
1076
2019
  * and never becomes a coordinate in disguise.
2020
+ *
2021
+ * Markup is read off first, so everything downstream works in runs: a break or
2022
+ * a fold inside a marked-up stretch carries the mark onto both lines, which is
2023
+ * what makes markup general where the `subtext:` slice it replaced could only
2024
+ * ever reach the tail of a text.
1077
2025
  */
1078
- function linesFor(text, attrs, line) {
1079
- const stated = attrs['wrap'];
2026
+ function linesFor(text, textAttrs, subject, line) {
2027
+ const lines = splitRuns(parseMarkup(text, subject, line));
2028
+ const stated = textAttrs['wrap'];
1080
2029
  if (stated === undefined)
1081
- return splitLines(text);
2030
+ return lines;
1082
2031
  const columns = Number(stated);
1083
2032
  if (!Number.isInteger(columns) || columns < 1) {
1084
2033
  throw new SourceError(`wrap must be a whole number of characters, not "${stated}"`, line);
1085
2034
  }
1086
- return splitLines(text).flatMap((part) => wrap(part, columns));
1087
- }
1088
- /** Fold one line onto several at word boundaries, never exceeding `columns`. */
1089
- function wrap(text, columns) {
1090
- const lines = [];
1091
- let current = '';
1092
- for (const word of text.split(/\s+/).filter(Boolean)) {
1093
- if (current.length === 0) {
1094
- current = word;
1095
- }
1096
- else if (current.length + 1 + word.length <= columns) {
1097
- current += ` ${word}`;
1098
- }
1099
- else {
1100
- lines.push(current);
1101
- current = word;
1102
- }
1103
- }
1104
- if (current.length > 0)
1105
- lines.push(current);
1106
- return lines.length > 0 ? lines : [''];
2035
+ return lines.flatMap((part) => wrapLine(part, columns));
1107
2036
  }
1108
2037
  function bounds(nodes) {
1109
2038
  let minX = Infinity;