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