reladraw 0.1.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 { 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
  *
@@ -21,22 +22,24 @@ export function resolve(doc, options = {}) {
21
22
  const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
22
23
  const margin = options.margin ?? DEFAULT_MARGIN;
23
24
  const styles = collectStyles(doc.statements);
25
+ checkStyleKeys(doc.statements);
24
26
  const { nodes, byName, roots } = buildTree(doc.statements, styles);
25
27
  applyDecks(doc.statements, byName);
26
- // Links are resolved to nodes before anything is sized, because a labelled
27
- // 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
28
30
  // gaps are being worked out. Nothing here reads geometry.
29
- const links = buildLinks(doc.statements, byName, styles);
31
+ const edges = buildEdges(doc.statements, byName, styles);
30
32
  const local = new Map();
31
33
  for (const root of roots)
32
- sizeNode(root, links, measurer, fontSize, local);
33
- placeRoots(roots, byName, links, measurer, fontSize, local);
34
+ sizeNode(root, edges, measurer, fontSize, local);
35
+ placeRoots(roots, byName, edges, measurer, fontSize, local);
34
36
  normalize(nodes, margin);
35
37
  const extent = bounds(nodes);
36
38
  return {
37
39
  nodes,
38
40
  roots,
39
- links,
41
+ edges,
42
+ markup: markupColors(nodes, edges, styles),
40
43
  diagram: collectDiagram(doc.statements),
41
44
  width: Math.ceil(extent.maxX + margin),
42
45
  height: Math.ceil(extent.maxY + margin),
@@ -72,31 +75,60 @@ function buildTree(statements, styles) {
72
75
  const nodes = [];
73
76
  const byName = new Map();
74
77
  const roots = [];
78
+ /** Each badge child's name, and the node whose `badge:` it was written out from. */
79
+ const badges = new Map();
75
80
  for (const stmt of statements) {
76
- if (stmt.kind !== 'box' && stmt.kind !== 'note')
81
+ if (stmt.kind !== 'node')
77
82
  continue;
78
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
+ }
79
89
  throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
80
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 };
81
106
  const node = {
82
107
  name: stmt.name,
83
- kind: stmt.kind,
84
- text: stmt.text,
85
- lines: linesFor(stmt.text, stmt.attrs, stmt.line),
108
+ kind,
109
+ body,
110
+ text,
111
+ lines: linesFor(text, textAttrs, `"${stmt.name}"`, stmt.line),
86
112
  children: [],
87
113
  x: 0,
88
114
  y: 0,
89
115
  width: 0,
90
116
  height: 0,
91
117
  inset: 0,
92
- deckLabels: [],
118
+ deckTexts: [],
93
119
  headerHeight: 0,
94
- 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,
95
125
  attrs: stmt.attrs,
96
- appearance: appearanceOf(stmt.attrs, styles, stmt.line),
126
+ appearance,
97
127
  placements: stmt.placements,
98
128
  line: stmt.line,
99
129
  };
130
+ checkAttrs(kind, node.name, stmt.attrs, stmt.line);
131
+ checkStyleUse(kind, node.name, stmt.attrs, styles, stmt.line);
100
132
  const cut = stmt.name.lastIndexOf('.');
101
133
  if (cut === -1) {
102
134
  roots.push(node);
@@ -112,9 +144,207 @@ function buildTree(statements, styles) {
112
144
  }
113
145
  nodes.push(node);
114
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
+ }
115
161
  }
116
162
  return { nodes, byName, roots };
117
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
+ };
219
+ /** How each kind reads in an error, and what it is actually made of. */
220
+ const KIND_WORD = {
221
+ shape: 'node',
222
+ icon: 'node drawn as a picture',
223
+ none: 'node with no body',
224
+ edge: 'edge',
225
+ };
226
+ const KIND_PARTS = {
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',
231
+ };
232
+ /**
233
+ * An attribute is refused on a kind that has no use for it — the same rule as
234
+ * an unknown `diagram` key, and for the same reason: a key that silently does
235
+ * nothing looks like the tool being broken. A color names a part and so is a
236
+ * special case of this, which is why the two checks are one.
237
+ *
238
+ * What is checked is what the author wrote *on this statement*, not what a
239
+ * style contributed. A style is a bundle meant to be shared across kinds — the
240
+ * benchmark's `synced` carries a fill and a border for the green boxes and a
241
+ * line for the four edges joining them — so a key it carries that this kind has
242
+ * no part for is simply unused, and is not a mistake anybody made here. That is
243
+ * forced rather than chosen: checking the merged appearance would refuse the
244
+ * benchmark's own central idiom four times over. `checkStyleUse` is what keeps
245
+ * the permissiveness honest.
246
+ */
247
+ function checkAttrs(kind, name, attrs, line) {
248
+ const allowed = ATTR_KEYS[kind];
249
+ // In the author's own order, so the error names the first offending word as
250
+ // it is read rather than the first in some list of ours.
251
+ for (const [written, value] of Object.entries(attrs)) {
252
+ const key = topKey(written);
253
+ if (allowed.includes(key))
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})`;
258
+ if (!ALL_ATTR_KEYS.includes(key)) {
259
+ // Nothing anywhere in the language answers to this word, so the only
260
+ // remedy is the vocabulary itself.
261
+ throw new SourceError(`"${name}" has ${wrote}, which is not an attribute. ${capital(article(KIND_WORD[kind]))} takes ${allowed.join(', ')}`, line);
262
+ }
263
+ // A real word in the wrong place, and the two sorts of word want different
264
+ // explanations. A color names a *part*, so saying what the kind is made of
265
+ // is the whole reason it has no such color, and the remedy is the narrower
266
+ // list of parts it does have: `line:` on a box is a different mistake from
267
+ // `border:` on a note, and one hint cannot serve both.
268
+ if (COLOR_KEYS.includes(key)) {
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);
271
+ }
272
+ // Anything else names no part, so what the kind is made of explains
273
+ // nothing. What does explain it is where the word *does* belong, which is
274
+ // also the more useful thing to be told: the author has usually written a
275
+ // real statement about the wrong half of the diagram.
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);
278
+ }
279
+ }
280
+ /**
281
+ * A style may carry keys this kind has no use for, but it may not carry *only*
282
+ * those. Partial overlap is the normal case and the reason styles exist; zero
283
+ * overlap is a style name written on the wrong sort of thing, and nothing else.
284
+ *
285
+ * This is the check that lets `checkAttrs` ignore style-contributed keys
286
+ * without the silence coming back. It cannot catch a style that names every key
287
+ * in the language, since such a style contributes to everything by
288
+ * construction — that hole is left open, because nobody writes one by accident.
289
+ */
290
+ function checkStyleUse(kind, name, attrs, styles, line) {
291
+ const named = attrs['style'];
292
+ if (named === undefined)
293
+ return;
294
+ const base = styles.get(named);
295
+ if (base === undefined)
296
+ return; // `appearanceOf` reports the missing style.
297
+ // A style naming another style is the one way to carry nothing at all: the
298
+ // parser already refuses one with no attributes, and `appearanceOf` does not
299
+ // recurse, so the name would sit there doing nothing.
300
+ const carried = [...new Set(Object.keys(base).map(topKey))].filter((key) => key !== 'style');
301
+ if (carried.length === 0) {
302
+ throw new SourceError(`style "${named}" carries nothing but a style name`, line);
303
+ }
304
+ if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
305
+ return;
306
+ throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
307
+ `${article(KIND_WORD[kind])} is ${KIND_PARTS[kind]}`, line);
308
+ }
309
+ /**
310
+ * A style is a bundle spanning kinds, so its keys cannot be checked against any
311
+ * one of them — but a word that is an attribute of *nothing* is a misspelling
312
+ * wherever it sits, and a style was the last place in the language where one
313
+ * could hide.
314
+ */
315
+ function checkStyleKeys(statements) {
316
+ for (const stmt of statements) {
317
+ if (stmt.kind !== 'style')
318
+ continue;
319
+ for (const [key, value] of Object.entries(stmt.attrs)) {
320
+ if (ALL_ATTR_KEYS.includes(topKey(key)))
321
+ continue;
322
+ throw new SourceError(`style "${stmt.name}" has ${key}: ${value}, which is not an attribute. ` +
323
+ `The attributes are ${ALL_ATTR_KEYS.join(', ')}`, stmt.line);
324
+ }
325
+ }
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
+ }
337
+ /** Which kinds understand an attribute, in the order the table declares them. */
338
+ function belongTo(key) {
339
+ return Object.keys(ATTR_KEYS).filter((kind) => ATTR_KEYS[kind].includes(key));
340
+ }
341
+ /** "an edge", "a node or a glyph body", "a node, a note or a glyph body". */
342
+ function listKinds(kinds) {
343
+ const words = kinds.map((kind) => article(KIND_WORD[kind]));
344
+ if (words.length <= 1)
345
+ return words[0] ?? 'nothing';
346
+ return `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`;
347
+ }
118
348
  function appearanceOf(attrs, styles, line) {
119
349
  const named = attrs['style'];
120
350
  if (named === undefined)
@@ -131,129 +361,201 @@ function applyDecks(statements, byName) {
131
361
  const node = byName.get(stmt.name);
132
362
  if (!node)
133
363
  throw new SourceError(`deck names "${stmt.name}", which does not exist`, stmt.line);
134
- node.deckLabels = stmt.labels;
364
+ node.deckTexts = stmt.texts;
135
365
  }
136
366
  }
137
- function buildLinks(statements, byName, styles) {
138
- const links = [];
367
+ function buildEdges(statements, byName, styles) {
368
+ const edges = [];
139
369
  for (const stmt of statements) {
140
- if (stmt.kind !== 'link')
370
+ if (stmt.kind !== 'edge')
141
371
  continue;
142
372
  const from = byName.get(stmt.from);
143
373
  const to = byName.get(stmt.to);
144
374
  if (!from)
145
- 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);
146
376
  if (!to)
147
- 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);
148
378
  const between = stmt.between && {
149
- 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
+ }
150
386
  const node = byName.get(name);
151
387
  if (!node) {
152
- 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);
153
389
  }
154
390
  return node;
155
391
  }),
156
392
  ...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
157
393
  };
158
- links.push({
394
+ const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
395
+ const what = `${stmt.from} -> ${stmt.to}`;
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({
159
408
  from,
160
409
  to,
161
410
  both: stmt.both,
162
- ...(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
+ : {}),
163
415
  ...(between ? { between } : {}),
164
416
  attrs: stmt.attrs,
165
- appearance: appearanceOf(stmt.attrs, styles, stmt.line),
417
+ appearance,
166
418
  line: stmt.line,
167
419
  });
168
420
  }
169
- return links;
421
+ return edges;
170
422
  }
171
423
  /**
172
424
  * Give a node a width and height, sizing its children first. Also records each
173
425
  * child's offset within this node, which pass three turns into absolute
174
426
  * coordinates once this node itself is placed.
175
427
  */
176
- function sizeNode(node, links, measurer, fontSize, local) {
428
+ function sizeNode(node, edges, measurer, fontSize, local) {
177
429
  for (const child of node.children)
178
- sizeNode(child, links, measurer, fontSize, local);
430
+ sizeNode(child, edges, measurer, fontSize, local);
179
431
  // Text is measured at the size it will be drawn at — the size lives in
180
432
  // `constants.ts` precisely so the resolver reserving the room and the
181
433
  // renderer filling it cannot disagree about how much room there is.
182
- const textSize = fontSizeFor(node.kind, node.appearance, fontSize, node.line);
434
+ const textSize = fontSizeFor(node.kind, node.textAttrs, fontSize, node.line);
183
435
  const lineHeight = measurer.lineHeight(textSize);
184
436
  // A node with empty text takes no room for it. This is what makes an
185
437
  // invisible grouping container size to exactly its contents.
186
- const hasLabel = node.lines.some((line) => line.length > 0);
187
- const labelWidth = hasLabel ? widestLine(node.lines, measurer, textSize) : 0;
188
- const labelHeight = hasLabel ? node.lines.length * lineHeight : 0;
189
- if (node.kind === 'note') {
190
- // A note is bare text, so it gets no padding and takes no children.
191
- // Both are refused rather than ignored, for the reason an unknown diagram
192
- // key is: a note is bare text with no box to decorate or replace, so either
193
- // word would silently do nothing and look like the tool being broken.
194
- for (const key of ['icon', 'shape']) {
195
- if (node.appearance[key] !== undefined) {
196
- throw new SourceError(`"${node.name}" is a note and has ${key}: ${node.appearance[key]}. A note is bare text, with no box to ${key === 'icon' ? 'decorate' : 'replace'}`, node.line);
197
- }
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);
198
447
  }
199
- node.width = labelWidth;
200
- node.height = labelHeight;
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);
201
451
  return;
202
452
  }
203
- const shape = shapeFor(node.appearance, node.line);
204
- const glyphSide = ICON_LINES * lineHeight;
205
- if (shape.body !== undefined) {
206
- // Drawn as a glyph, so there is no box to pad and the node's size is the
207
- // 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
208
456
  // arrangement that makes a row of these read as captioned things.
209
457
  if (node.children.length > 0) {
210
- 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);
211
459
  }
212
- node.width = Math.max(glyphSide, labelWidth);
213
- 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);
214
465
  return;
215
466
  }
216
- // An icon takes a column of its own on the right of whatever the box holds,
217
- // so the label never runs underneath it and the box grows to fit both. That
218
- // is why an icon is not a renderer-only concern: it is content taking room,
219
- // like a label, and not appearance like `fill:`.
220
- const icon = iconFor(node.appearance, node.line);
221
- const iconSide = icon === undefined ? 0 : glyphSide;
222
- 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);
223
471
  if (node.children.length === 0) {
224
- // A band only exists because contents have to sit clear of it. A leaf has
225
- // none, so its label is centred in the box and there is nothing for `at` or
226
- // `align` to move it relative to. Refused rather than silently dropped.
227
- const stated = Object.keys(node.label);
228
- if (stated.length > 0) {
229
- throw new SourceError(`"${node.name}" holds nothing and its label carries ${stated.join(' and ')}. ` +
230
- `A label sits at one end of a box so its contents can have the other; with no contents it is centred, and there is nothing to say`, node.line);
231
- }
232
- node.width = labelWidth + iconRoom + PAD * 2;
233
- 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;
234
499
  }
235
500
  else {
236
- applyAlign(node);
237
- const content = layoutChildren(node, links, measurer, fontSize, local);
238
- const band = Math.max(labelHeight, iconSide);
239
- // `headerHeight` is the band the label and icon take, whichever end of the
240
- // box that band is at. Only the contents' offset depends on the side.
241
- node.headerHeight = hasLabel || icon !== undefined ? band + HEADER_GAP : 0;
242
- 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;
243
519
  node.height = node.headerHeight + content.height + PAD * 2;
244
- 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;
245
537
  for (const child of node.children) {
246
538
  const offset = local.get(child);
247
- offset.x += PAD;
539
+ offset.x += PAD + shift;
248
540
  offset.y += PAD + above;
249
541
  }
542
+ node.banded = true;
250
543
  }
251
- if (node.deckLabels.length > 0) {
252
- // The copies sit behind and above-left, so the whole node grows by the
253
- // depth of the stack and its own face moves down and right by the same.
254
- 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;
255
555
  node.width += node.inset;
256
556
  node.height += node.inset;
557
+ node.textBox.x += node.inset;
558
+ node.textBox.y += node.inset;
257
559
  for (const child of node.children) {
258
560
  const offset = local.get(child);
259
561
  offset.x += node.inset;
@@ -262,20 +564,85 @@ function sizeNode(node, links, measurer, fontSize, local) {
262
564
  }
263
565
  }
264
566
  /**
265
- * `align: widths` widens every direct child to the widest one's natural
266
- * width, before layoutChildren sizes and positions anything from those
267
- * 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`.
268
573
  */
269
- function applyAlign(node) {
270
- const value = node.attrs['align'];
271
- 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);
272
638
  return;
273
- if (value !== 'widths') {
274
- throw new SourceError(`"${node.name}" has align: ${value}, which is not one of widths`, node.line);
275
639
  }
276
- const maxWidth = Math.max(...node.children.map((child) => child.width));
277
- for (const child of node.children)
278
- 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;
279
646
  }
280
647
  /**
281
648
  * Position a container's children relative to each other. Children that make
@@ -283,15 +650,15 @@ function applyAlign(node) {
283
650
  * siblings they name, by the same constraint pass that positions top-level
284
651
  * nodes. A placement may only name a sibling — containment scopes the group.
285
652
  */
286
- function layoutChildren(parent, links, measurer, fontSize, local) {
287
- 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]));
288
655
  // Children that say nothing keep the written order, down the page and flush
289
656
  // left. Written as constraints rather than a cursor so a placed sibling can
290
657
  // push them along like anything else.
291
658
  const stack = [];
292
659
  const alignment = [];
293
- const quiet = parent.children.filter((child) => child.placements.length === 0);
294
- 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]));
295
662
  quiet.forEach((child, position) => {
296
663
  const previous = quiet[position - 1];
297
664
  if (!previous)
@@ -301,35 +668,38 @@ function layoutChildren(parent, links, measurer, fontSize, local) {
301
668
  stack.push({ from: before, to: after, weight: previous.height + CHILD_GAP });
302
669
  alignment.push(...fix(before, after, 0));
303
670
  });
304
- const positions = positionGroup(parent.children, (placement, owner) => placement.targets.map((name) => {
671
+ const positions = positionGroup(children, (placement, owner) => placement.targets.map(({ name, part }) => {
305
672
  const target = siblings.get(name);
306
673
  if (!target) {
307
- 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);
308
675
  }
309
- return {
676
+ return partOf({
310
677
  name,
311
678
  node: target,
312
679
  index: indexOf.get(target),
313
680
  offset: { x: 0, y: 0 },
314
681
  width: target.width,
315
682
  height: target.height,
316
- };
317
- }), { x: alignment, y: stack }, corridorsIn(links, (node) => liftTo(node, indexOf, local), measurer, fontSize));
318
- 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)
319
686
  local.set(child, positions.get(child));
320
- return extentOf(parent.children, local);
687
+ return extentOf(children, local);
321
688
  }
322
689
  function extentOf(children, local) {
323
690
  let minX = Infinity;
324
691
  let minY = Infinity;
325
692
  let maxX = -Infinity;
326
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.
327
696
  for (const child of children) {
328
697
  const offset = local.get(child);
329
- minX = Math.min(minX, offset.x);
330
- minY = Math.min(minY, offset.y);
331
- maxX = Math.max(maxX, offset.x + child.width);
332
- 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);
333
703
  }
334
704
  for (const child of children) {
335
705
  const offset = local.get(child);
@@ -338,8 +708,443 @@ function extentOf(children, local) {
338
708
  }
339
709
  return { width: maxX - minX, height: maxY - minY };
340
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
+ }
341
1146
  // --- pass three: solve for positions -----------------------------------------
342
- function placeRoots(roots, byName, links, measurer, fontSize, local) {
1147
+ function placeRoots(roots, byName, edges, measurer, fontSize, local) {
343
1148
  const anchors = roots.filter((root) => root.placements.length === 0);
344
1149
  if (anchors.length === 0) {
345
1150
  throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
@@ -352,7 +1157,7 @@ function placeRoots(roots, byName, links, measurer, fontSize, local) {
352
1157
  // A placement may name something nested — `right of server.docker` places a top-level
353
1158
  // node against a box inside another. Sizes and offsets within a container are
354
1159
  // already settled, so a nested target is its root's position plus a constant.
355
- const positions = positionGroup(roots, (placement, owner) => placement.targets.map((name) => {
1160
+ const positions = positionGroup(roots, (placement, owner) => placement.targets.map(({ name, part }) => {
356
1161
  const target = byName.get(name);
357
1162
  if (!target) {
358
1163
  throw new SourceError(`"${owner.name}" is placed against "${name}", which does not exist`, placement.line);
@@ -365,15 +1170,15 @@ function placeRoots(roots, byName, links, measurer, fontSize, local) {
365
1170
  offset.y += step.y;
366
1171
  root = root.parent;
367
1172
  }
368
- return {
1173
+ return partOf({
369
1174
  name,
370
1175
  node: target,
371
1176
  index: indexOf.get(root),
372
1177
  offset,
373
1178
  width: target.width,
374
1179
  height: target.height,
375
- };
376
- }), { 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));
377
1182
  for (const root of roots) {
378
1183
  const position = positions.get(root);
379
1184
  root.x = position.x;
@@ -391,12 +1196,17 @@ function spreadToChildren(node, local) {
391
1196
  }
392
1197
  }
393
1198
  const AXES = ['x', 'y'];
1199
+ const NO_REACH = { left: 0, top: 0, right: 0, bottom: 0 };
394
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
+ }
395
1205
  /**
396
1206
  * Where a node sits within the group being solved: which member holds it, and
397
- * 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
398
1208
  * are lifted to the members of whichever group is being solved — and a node
399
- * 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
400
1210
  * group where its two ends are different members.
401
1211
  */
402
1212
  function liftTo(node, indexOf, local) {
@@ -419,28 +1229,28 @@ function liftTo(node, indexOf, local) {
419
1229
  height: node.height,
420
1230
  };
421
1231
  }
422
- /** The labelled links whose two ends are different members of this group. */
423
- 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) {
424
1234
  const corridors = [];
425
- for (const link of links) {
426
- // An unlabelled link asks for nothing: every gap holds an arrowhead. And a
427
- // 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*
428
1238
  // corridor rather than in the gap between its own ends, so widening this one
429
- // would make room where the label never goes.
430
- if (link.label === undefined || link.between)
1239
+ // would make room where the text never goes.
1240
+ if (edge.text === undefined || edge.between)
431
1241
  continue;
432
- const from = locate(link.from);
433
- const to = locate(link.to);
1242
+ const from = locate(edge.from);
1243
+ const to = locate(edge.to);
434
1244
  if (!from || !to || from.index === to.index)
435
1245
  continue;
436
- // 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
437
1247
  // the line, so the room it needs is symmetric about that point whatever sits
438
1248
  // at either end. The arrowhead is charged on both sides for the same reason:
439
1249
  // it covers `ARROW_LENGTH` of the line it arrives on, and reserving that at
440
1250
  // one end only would move the midpoint rather than lengthen the run.
441
- const extent = (axis) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line) +
442
- (LABEL_CLEARANCE + ARROW_LENGTH) * 2;
443
- 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') } });
444
1254
  }
445
1255
  return corridors;
446
1256
  }
@@ -456,10 +1266,23 @@ function corridorsIn(links, locate, measurer, fontSize) {
456
1266
  * hold whatever is put in it and closes again when that is removed. No number
457
1267
  * anywhere has to be guessed, and nothing is ever tried and rejected.
458
1268
  */
459
- function positionGroup(members, locate, extra, corridors = []) {
1269
+ function positionGroup(members, locate, extra, corridors = [], across, clearance = SEPARATION_GAP) {
460
1270
  const constraints = { x: [...extra.x], y: [...extra.y] };
461
1271
  const indexOf = new Map(members.map((member, index) => [member, index]));
462
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
+ }
463
1286
  for (const node of members) {
464
1287
  // Checked here as well as in `gapFor`, so a misspelt node-wide gap is caught
465
1288
  // on a node whose placements all name their own or are alignments — and on
@@ -468,13 +1291,23 @@ function positionGroup(members, locate, extra, corridors = []) {
468
1291
  if (node.placements.length === 0)
469
1292
  continue;
470
1293
  const me = indexOf.get(node);
471
- 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 };
472
1299
  const located = node.placements.map((placement) => ({ placement, targets: locate(placement, node) }));
1300
+ const speaks = (axis) => !(follows && axis === 'y');
473
1301
  const spokenFor = { x: false, y: false };
474
1302
  for (const { placement } of located) {
475
1303
  if (placement.kind === 'align') {
476
1304
  spokenFor[placement.axis] = true;
477
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
+ }
478
1311
  else {
479
1312
  if (/left|right/.test(placement.direction))
480
1313
  spokenFor.x = true;
@@ -485,20 +1318,40 @@ function positionGroup(members, locate, extra, corridors = []) {
485
1318
  // Aligning to several targets means aligning to the box that just bounds
486
1319
  // them. That box is a constant only while its members hold still relative
487
1320
  // to one another; otherwise the alignment waits for the first solution.
488
- const alignOn = (axis, edge, targets, placement) => {
489
- const anchor = sharedMember(targets);
1321
+ const alignOn = (axis, side, targets, placement) => {
1322
+ if (!speaks(axis))
1323
+ return;
1324
+ const anchor = sharedMember(targets, axis);
490
1325
  if (anchor === undefined) {
491
- pending.push({ node, me, axis, edge, targets, placement });
1326
+ pending.push({ node, me, axis, side, targets, placement, own: size[axis] });
492
1327
  return;
493
1328
  }
494
1329
  const span = spanOf(targets, axis, () => 0);
495
- 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));
496
1331
  };
497
1332
  for (const { placement, targets } of located) {
498
1333
  if (placement.kind === 'align') {
499
- 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);
500
1346
  continue;
501
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
+ }
502
1355
  // One constraint per target, so the node clears the furthest of them.
503
1356
  // Taking that maximum is what longest paths already does, which is why a
504
1357
  // direction against a whole region needs nothing added to the solver.
@@ -508,62 +1361,58 @@ function positionGroup(members, locate, extra, corridors = []) {
508
1361
  // default. That is what lets a node wedged between two things sit tight
509
1362
  // against one of them and wide of the other.
510
1363
  for (const target of targets) {
511
- 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;
512
1383
  if (direction.includes('right')) {
513
- constraints.x.push({
514
- from: target.index,
515
- to: me,
516
- weight: target.offset.x + target.width + gap,
517
- placement,
518
- });
519
- }
520
- if (direction.includes('left')) {
521
- constraints.x.push({
522
- from: me,
523
- to: target.index,
524
- weight: node.width + gap - target.offset.x,
525
- placement,
526
- });
1384
+ push('x', x, me, target.offset.x + target.width + theirs.right + gap + mine.left);
527
1385
  }
1386
+ if (direction.includes('left'))
1387
+ push('x', me, x, size.x + mine.right + gap + theirs.left - target.offset.x);
528
1388
  if (direction.includes('below')) {
529
- constraints.y.push({
530
- from: target.index,
531
- to: me,
532
- weight: target.offset.y + target.height + gap,
533
- placement,
534
- });
535
- }
536
- if (direction.includes('above')) {
537
- constraints.y.push({
538
- from: me,
539
- to: target.index,
540
- weight: node.height + gap - target.offset.y,
541
- placement,
542
- });
1389
+ push('y', y, me, target.offset.y + target.height + theirs.bottom + gap + mine.top);
543
1390
  }
1391
+ if (direction.includes('above'))
1392
+ push('y', me, y, size.y + mine.bottom + gap + theirs.top - target.offset.y);
544
1393
  }
545
1394
  }
546
- // An axis nobody spoke to falls back to the centre line of whatever the
1395
+ // An axis nobody spoke to falls back to the center line of whatever the
547
1396
  // node was placed against, which is why "right of docker" alone is a whole
548
1397
  // position. Two different targets would decide which row the node shares,
549
1398
  // so that is refused rather than guessed — but two targets named by one
550
- // placement are a single region, and centring on it is unambiguous.
1399
+ // placement are a single region, and centering on it is unambiguous.
551
1400
  for (const axis of AXES) {
552
- if (spokenFor[axis])
1401
+ if (spokenFor[axis] || !speaks(axis))
553
1402
  continue;
554
1403
  const offers = located.filter((entry) => entry.placement.kind === 'offset');
555
1404
  const first = offers[0];
556
1405
  if (!first) {
557
1406
  throw new SourceError(`"${node.name}" says nothing about where it sits ${AXIS_WORD[axis]}`, node.placements[0].line);
558
1407
  }
559
- const named = (entry) => entry.placement.targets.join('\u0000');
1408
+ const named = (entry) => entry.placement.targets.map(nameTarget).join('\u0000');
560
1409
  const other = offers.find((entry) => named(entry) !== named(first));
561
1410
  if (other) {
562
1411
  throw new SourceError(`"${node.name}" does not say where it sits ${AXIS_WORD[axis]}: ` +
563
1412
  `"${describePlacement(first.placement)}" and "${describePlacement(other.placement)}" ` +
564
1413
  `would put it in different places`, other.placement.line);
565
1414
  }
566
- alignOn(axis, 'centre', first.targets, first.placement);
1415
+ alignOn(axis, 'center', first.targets, first.placement);
567
1416
  }
568
1417
  }
569
1418
  const solved = { x: [], y: [] };
@@ -575,21 +1424,78 @@ function positionGroup(members, locate, extra, corridors = []) {
575
1424
  solved[axis] = outcome.positions;
576
1425
  }
577
1426
  };
578
- solveAll();
579
- room(corridors, constraints, solved, solveAll);
580
- settle(pending, members, constraints, solved, solveAll);
581
- snug(members, constraints, solved, solveAll);
582
- 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
+ }
583
1455
  confirm(pending, solved);
584
1456
  return new Map(members.map((member, index) => [
585
1457
  member,
586
1458
  { x: solved.x[index], y: solved.y[index] },
587
1459
  ]));
588
1460
  }
589
- /** The member every target belongs to, or nothing if they are spread across several. */
590
- function sharedMember(targets) {
591
- const first = targets[0].index;
592
- 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;
593
1499
  }
594
1500
  /** The stretch of one axis that just covers every target. */
595
1501
  function spanOf(targets, axis, base) {
@@ -602,40 +1508,91 @@ function spanOf(targets, axis, base) {
602
1508
  }
603
1509
  return { start, size: end - start };
604
1510
  }
605
- /** Where a node of this size sits so that the named edge of it meets the span's. */
606
- function alignedAt(edge, span, own) {
607
- if (edge === 'centre')
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')
608
1565
  return span.start + (span.size - own) / 2;
609
- if (edge === 'top' || edge === 'left')
1566
+ if (side === 'top' || side === 'left')
610
1567
  return span.start;
611
1568
  return span.start + span.size - own;
612
1569
  }
613
1570
  /**
614
- * 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.
615
1572
  *
616
- * This is the one place a link reaches the constraint system, and it is the same
617
- * measure-then-constrain move `settle` makes rather than links joining the graph
618
- * 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
619
1576
  * from there the room it needs is an ordinary minimum distance like any other.
620
1577
  * Nothing is nudged and no layout is repaired — a constraint the file already
621
1578
  * implied is derived and the whole system is solved again.
622
1579
  *
623
1580
  * Which gap that is, is derived and never chosen. A pair clear of each other on
624
- * 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
625
1582
  * it. A pair clear on *both* axes sits corner to corner, so the line runs
626
1583
  * diagonally through open space and there is no corridor to widen; a pair clear
627
1584
  * on neither overlaps, which is the separation pass's business and not this
628
1585
  * one's. Both are left alone, which is why this only ever moves boxes that a
629
- * label is genuinely wedged between.
1586
+ * text is genuinely wedged between.
630
1587
  *
631
1588
  * A gap being a minimum does the rest. Where the corridor is already wide enough
632
1589
  * — because the author said `gap: wide`, or because something else is in there
633
- * — 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
634
1591
  * closes back to whatever the file asked for.
635
1592
  */
636
- function room(corridors, constraints, solved, solveAll) {
1593
+ function room(corridors, constraints, solved, solveAll, made) {
637
1594
  let added = false;
638
- for (const { from, to, need } of corridors) {
1595
+ for (const [index, { from, to, need }] of corridors.entries()) {
639
1596
  const clear = (axis) => {
640
1597
  const at = (end) => solved[axis][end.index] + end.offset[axis];
641
1598
  const size = (end) => (axis === 'x' ? end.width : end.height);
@@ -649,6 +1606,11 @@ function room(corridors, constraints, solved, solveAll) {
649
1606
  if (open.length !== 1)
650
1607
  continue;
651
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}`);
652
1614
  const { before, after } = clear(axis);
653
1615
  constraints[axis].push({
654
1616
  from: before.index,
@@ -662,6 +1624,7 @@ function room(corridors, constraints, solved, solveAll) {
662
1624
  }
663
1625
  if (added)
664
1626
  solveAll();
1627
+ return added;
665
1628
  }
666
1629
  /**
667
1630
  * Fix the alignments that had to wait, by measuring what they align to.
@@ -687,16 +1650,19 @@ function settle(pending, members, constraints, solved, solveAll) {
687
1650
  const reach = reachability(members.length, constraints[axis]);
688
1651
  for (const entry of here) {
689
1652
  for (const target of entry.targets) {
690
- 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])
691
1657
  continue;
692
1658
  throw new SourceError(`"${entry.node.name}" is ${describePlacement(entry.placement)}, but "${target.name}" ` +
693
1659
  `is placed ${AXIS_WORD[axis]} against "${entry.node.name}" in turn, so there is no ` +
694
1660
  `arrangement where each waits for the other`, entry.placement.line);
695
1661
  }
696
- const span = spanOf(entry.targets, axis, (target) => solved[axis][target.index]);
697
- const own = axis === 'x' ? entry.node.width : entry.node.height;
698
- const anchor = entry.targets[0].index;
699
- 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));
700
1666
  }
701
1667
  }
702
1668
  solveAll();
@@ -713,9 +1679,9 @@ function settle(pending, members, constraints, solved, solveAll) {
713
1679
  function confirm(pending, solved) {
714
1680
  for (const entry of pending) {
715
1681
  const { axis } = entry;
716
- const span = spanOf(entry.targets, axis, (target) => solved[axis][target.index]);
717
- const own = axis === 'x' ? entry.node.width : entry.node.height;
718
- 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)
719
1685
  continue;
720
1686
  throw new SourceError(`"${entry.node.name}" cannot be ${describePlacement(entry.placement)}: keeping boxes off ` +
721
1687
  `each other moved them apart after that region was measured`, entry.placement.line);
@@ -810,21 +1776,31 @@ function snug(members, constraints, solved, solveAll) {
810
1776
  * Members here are always siblings, or the roots of the diagram, so no member
811
1777
  * ever contains another and containment needs no exemption of its own.
812
1778
  */
813
- function separate(members, constraints, solved, solveAll) {
1779
+ function separate(members, constraints, solved, solveAll, overlaid = [], across, clearance = SEPARATION_GAP) {
814
1780
  const eligible = members.map(allowsOverlap).map((allowed) => !allowed);
815
1781
  if (eligible.filter(Boolean).length < 2)
816
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)));
817
1788
  // Adding only, so the number of separations is bounded; the cap is a
818
1789
  // backstop against a bug rather than an expected outcome.
819
1790
  for (let round = 0; round < members.length * members.length + 1; round += 1) {
820
1791
  let reach;
821
1792
  let added = false;
822
- 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) {
823
1797
  if (!eligible[i])
824
1798
  continue;
825
1799
  for (let j = i + 1; j < members.length; j += 1) {
826
1800
  if (!eligible[j])
827
1801
  continue;
1802
+ if (stamped.has(pairKey(i, j)))
1803
+ continue;
828
1804
  const over = overlapOf(members, solved, i, j);
829
1805
  if (!over)
830
1806
  continue;
@@ -835,14 +1811,22 @@ function separate(members, constraints, solved, solveAll) {
835
1811
  if (order)
836
1812
  orders[axis] = order;
837
1813
  }
838
- 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);
839
1820
  if (!axis)
840
1821
  throw unordered(members[i], members[j]);
841
1822
  const { before, after } = orders[axis];
842
1823
  const span = axis === 'x' ? members[before].width : members[before].height;
843
- 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 });
844
1827
  reach = undefined;
845
1828
  added = true;
1829
+ break scan;
846
1830
  }
847
1831
  }
848
1832
  if (!added)
@@ -850,13 +1834,16 @@ function separate(members, constraints, solved, solveAll) {
850
1834
  solveAll();
851
1835
  }
852
1836
  }
1837
+ function pairKey(a, b) {
1838
+ return a < b ? `${a}:${b}` : `${b}:${a}`;
1839
+ }
853
1840
  /** How far two members share space on each axis, or nothing if they are clear of each other. */
854
1841
  function overlapOf(members, solved, i, j) {
855
1842
  const shared = (axis) => {
856
- const size = (index) => (axis === 'x' ? members[index].width : members[index].height);
857
- const startI = solved[axis][i];
858
- const startJ = solved[axis][j];
859
- 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));
860
1847
  };
861
1848
  const x = shared('x');
862
1849
  const y = shared('y');
@@ -919,8 +1906,26 @@ function noRoom(contradiction, axis, members) {
919
1906
  function gapFor(node, placement, target) {
920
1907
  if (placement.gap !== undefined)
921
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);
922
1927
  const mine = node.attrs['gap'];
923
- const theirs = target.attrs['gap'];
1928
+ const theirs = own ? undefined : target.attrs['gap'];
924
1929
  // Only a gap somebody actually wrote down counts. Reading an absent one as the
925
1930
  // default would make it a floor rather than a fallback, and every `gap: tight`
926
1931
  // placed against a silent node would quietly widen back to normal.
@@ -933,59 +1938,92 @@ function gapFor(node, placement, target) {
933
1938
  return namedGap(node, undefined, node.line);
934
1939
  return Math.max(...stated);
935
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
+ */
936
1947
  function namedGap(node, named, line) {
937
1948
  const gap = GAPS[named ?? 'normal'];
938
- if (gap === undefined) {
939
- const known = Object.keys(GAPS).join(', ');
940
- throw new SourceError(`"${node.name}" asks for gap: ${named}, which is not one of ${known}`, line);
941
- }
942
- 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);
943
1961
  }
944
1962
  // --- shared helpers ----------------------------------------------------------
945
- function widestLine(lines, measurer, fontSize) {
946
- return lines.reduce((widest, line) => {
947
- const { width } = measurer.measure(line, fontSize);
948
- return Math.max(widest, width);
949
- }, 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;
1971
+ }
1972
+ /**
1973
+ * Every style the markup in this file names, resolved to the color it lends.
1974
+ *
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;
950
2002
  }
951
2003
  /**
952
- * Split a label into the lines that get drawn. `/` always breaks a line. A
953
- * `width` attribute additionally folds each of those at word boundaries, which
954
- * is what stops a long note running across the whole diagram.
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.
955
2007
  *
956
- * The width is a character count rather than a distance. It says how much text
2008
+ * The wrap is a character count rather than a distance. It says how much text
957
2009
  * fits on a line, not where anything sits, so it stays a property of the text
958
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.
959
2016
  */
960
- function linesFor(text, attrs, line) {
961
- const stated = attrs['width'];
2017
+ function linesFor(text, textAttrs, subject, line) {
2018
+ const lines = splitRuns(parseMarkup(text, subject, line));
2019
+ const stated = textAttrs['wrap'];
962
2020
  if (stated === undefined)
963
- return splitLines(text);
2021
+ return lines;
964
2022
  const columns = Number(stated);
965
2023
  if (!Number.isInteger(columns) || columns < 1) {
966
- throw new SourceError(`width must be a whole number of characters, not "${stated}"`, line);
967
- }
968
- return splitLines(text).flatMap((part) => wrap(part, columns));
969
- }
970
- /** Fold one line onto several at word boundaries, never exceeding `columns`. */
971
- function wrap(text, columns) {
972
- const lines = [];
973
- let current = '';
974
- for (const word of text.split(/\s+/).filter(Boolean)) {
975
- if (current.length === 0) {
976
- current = word;
977
- }
978
- else if (current.length + 1 + word.length <= columns) {
979
- current += ` ${word}`;
980
- }
981
- else {
982
- lines.push(current);
983
- current = word;
984
- }
2024
+ throw new SourceError(`wrap must be a whole number of characters, not "${stated}"`, line);
985
2025
  }
986
- if (current.length > 0)
987
- lines.push(current);
988
- return lines.length > 0 ? lines : [''];
2026
+ return lines.flatMap((part) => wrapLine(part, columns));
989
2027
  }
990
2028
  function bounds(nodes) {
991
2029
  let minX = Infinity;