reladraw 0.24.0 → 0.25.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/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BUNDLE_KEYS, COLOR_KEYS, POINT_KEYS, REGION_KEYS, WAYS, describePoint, fractionAxis, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, TEXT_KEYS, MARK_TEXT_KEYS, POINT_TEXT_SIDES, LINE_KEYS, LINE_VALUES, BORDER_KEYS, CONTENTS_KEYS, EDGE_ORDER_KEYS, LAYOUT_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
1
+ import { BUNDLE_KEYS, DESCRIPTION_NAME_KEY, LINK_NAMES_KEY, COLOR_KEYS, POINT_KEYS, REGION_KEYS, WAYS, describePoint, fractionAxis, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, TEXT_KEYS, MARK_TEXT_KEYS, POINT_TEXT_SIDES, LINE_KEYS, LINE_VALUES, BORDER_KEYS, CONTENTS_KEYS, EDGE_ORDER_KEYS, LAYOUT_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { CHECK_DEFAULTS, CHECK_KINDS, setCheck } from './checks.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { STATEMENT_KEYWORDS } from './grammar.js';
@@ -175,14 +175,20 @@ function parseStatement(tokens, line) {
175
175
  }
176
176
  // What a line break inside a node's or an edge's text should mean has not
177
177
  // been decided, so until it is a `"""` string is refused anywhere but the one
178
- // place it was made for, rather than drawn in some way that might change.
179
- if (keyword.text !== 'icon' && tokens.some((token) => token.triple)) {
180
- throw new SourceError('a """ string holds the SVG of an `icon` declaration and is not accepted anywhere else yet — ' +
178
+ // places it was made for, rather than drawn in some way that might change:
179
+ // an icon's SVG and a description's text.
180
+ const holdsTriple = keyword.text === 'icon' || keyword.text === 'description';
181
+ if (!holdsTriple && tokens.some((token, index) => token.triple && tokens[index - 1]?.text !== 'description:')) {
182
+ throw new SourceError('a """ string holds an `icon` declaration\'s SVG or a description\'s text, and is not accepted anywhere else yet — ' +
181
183
  'use ordinary quotes here', line);
182
184
  }
183
185
  switch (keyword.text) {
184
186
  case 'icon':
185
187
  return parseIcon(tokens, line);
188
+ case 'description':
189
+ return parseDescription(tokens, line);
190
+ case 'url':
191
+ return parseUrl(tokens, line);
186
192
  case 'node':
187
193
  return parseNode(tokens, line);
188
194
  case 'edge':
@@ -716,13 +722,36 @@ function readAttr(tokens, at, attrs, line, subject) {
716
722
  // its cards, each named in a bracket after it. The first stays under `url`,
717
723
  // so whatever follows one link follows the first of several; the whole
718
724
  // list is kept beside it only when there is more than a bare address.
725
+ // A declared link's name is kept with the list until `resolve` knows what
726
+ // it declares; `url` holds the first entry meanwhile, so a style's or a
727
+ // default's refusal of a destination sees one either way.
719
728
  const read = readLinks(tokens, at + 1, subject, line);
720
- setOnce(attrs, key, read.links[0].href, subject, line, { key, value: quoteOf });
721
- if (read.links.length > 1 || read.links.some((link) => link.name !== undefined || link.icon !== undefined)) {
729
+ const first = read.links[0];
730
+ setOnce(attrs, key, 'href' in first ? first.href : first.ref, subject, line, { key, value: quoteOf });
731
+ if (read.links.some((link) => 'ref' in link)) {
732
+ attrs[LINK_NAMES_KEY] = JSON.stringify(read.links);
733
+ }
734
+ else if (read.links.length > 1 || read.links.some((link) => 'href' in link && (link.name !== undefined || link.icon !== undefined))) {
722
735
  attrs[LINKS_KEY] = JSON.stringify(read.links);
723
736
  }
724
737
  return read.next;
725
738
  }
739
+ if (key === 'description') {
740
+ // Quoted, the text itself; unquoted, the name of a `description` statement,
741
+ // as `icon:` takes letters quoted and a picture's name bare.
742
+ const value = tokens[at + 1];
743
+ if (!value || isAttrKey(value) || value.text === '(' || value.text === ')') {
744
+ throw new SourceError(`${subject}: description: takes its text in quotes, \`description: "What this does"\`, or the name of a \`description\` statement`, line);
745
+ }
746
+ const had = attrs[DESCRIPTION_NAME_KEY] ?? attrs[key];
747
+ if (had !== undefined)
748
+ throw new SourceError(`${subject}: "description" is written twice — keep one`, line);
749
+ if (value.quoted)
750
+ attrs[key] = descriptionText(value.text);
751
+ else
752
+ attrs[DESCRIPTION_NAME_KEY] = value.text;
753
+ return at + 2;
754
+ }
726
755
  if (key === 'title') {
727
756
  // A title is a text, and every text is quoted. Unquoted, its first word
728
757
  // would be the title and the rest read as stray words.
@@ -1018,15 +1047,34 @@ function readEdgeTextBracket(tokens, start, subject, text, line) {
1018
1047
  continue;
1019
1048
  }
1020
1049
  // `2 above s1`: the distance from the box, written in front as a box writes it.
1021
- const distance = /^\d*\.?\d+$/.test(word) && /^(above|below|left|right)$/.test(plain(tokens[i + 1]) ?? '') ? word : undefined;
1022
- if (distance !== undefined) {
1023
- if (place.gap !== undefined)
1024
- throw new SourceError(`${about} says its distance from the box twice (gap: ${place.gap}, ${distance} in front) — keep one`, line);
1025
- place.gap = distance;
1026
- seen.add('gap');
1050
+ // `2 above edge`: how far the text rises from its spot on the line.
1051
+ // `clear above edge`, `1/2 clear above edge`: the rise that clears the line, or a fraction of it.
1052
+ const isDirection = (token) => /^(above|below|left|right)$/.test(plain(token) ?? '');
1053
+ const distance = /^\d*\.?\d+$/.test(word) && isDirection(tokens[i + 1]) ? word : undefined;
1054
+ let clear;
1055
+ if (word === 'clear' && isDirection(tokens[i + 1])) {
1056
+ clear = { fraction: 1, written: 'clear' };
1027
1057
  i += 1;
1028
1058
  }
1029
- const said = distance === undefined ? word : plain(tokens[i]);
1059
+ else if (plain(tokens[i + 1]) === 'clear' && isDirection(tokens[i + 2]) && fractionValue(word) !== undefined) {
1060
+ const fraction = fractionValue(word);
1061
+ if (fraction <= 0)
1062
+ throw new SourceError(`${about}: "${word} clear" — say how much of the way clear, more than none, such as 1/2 clear`, line);
1063
+ clear = { fraction, written: `${word} clear` };
1064
+ i += 2;
1065
+ }
1066
+ else if (/^\d+\/\d+$/.test(word) && isDirection(tokens[i + 1])) {
1067
+ const value = fractionValue(word);
1068
+ const decimal = value === undefined ? word : String(Math.round(value * 1000) / 1000);
1069
+ const way = plain(tokens[i + 1]);
1070
+ const two = way === 'left' || way === 'right';
1071
+ const onEdge = plain(tokens[i + (two ? 3 : 2)]) === 'edge';
1072
+ throw new SourceError(`${about}: "${word} ${way}" — a distance is a number, \`${decimal} ${way}${two ? ' of' : ''}\`` +
1073
+ (onEdge ? `; for part of the way clear of the line, write \`${word} clear ${way}${two ? ' of' : ''} edge\`` : ''), line);
1074
+ }
1075
+ if (distance !== undefined)
1076
+ i += 1;
1077
+ const said = distance === undefined && clear === undefined ? word : plain(tokens[i]);
1030
1078
  let relation;
1031
1079
  if (said === 'straight') {
1032
1080
  const way = plain(tokens[i + 1]);
@@ -1049,12 +1097,23 @@ function readEdgeTextBracket(tokens, start, subject, text, line) {
1049
1097
  `${EDGE_TEXT_KEYS.join(':, ')}:, relations such as \`2 above s1\`, \`straight right from c\` or \`right of edge\`, \`x even with s1\`, and \`on the line\``, line);
1050
1098
  }
1051
1099
  const besideIt = relation !== 'straight' && plain(tokens[i]) === 'edge';
1052
- if (besideIt && distance !== undefined) {
1053
- throw new SourceError(`${about}: "${distance} ${relation} edge" — the distance off the line is written \`edge-gap: ${distance}\``, line);
1100
+ if (clear !== undefined && !besideIt) {
1101
+ throw new SourceError(`${about}: "${clear.written}" says how far off its own line a text moves, so it goes with \`${relation} edge\`` +
1102
+ (relation === 'straight' ? '' : `, not a box — for a distance from the box, write it in front, \`2 ${relation} …\``), line);
1103
+ }
1104
+ if (distance !== undefined && !besideIt) {
1105
+ if (place.gap !== undefined)
1106
+ throw new SourceError(`${about} says its distance from the box twice (gap: ${place.gap}, ${distance} in front) — keep one`, line);
1107
+ place.gap = distance;
1108
+ seen.add('gap');
1054
1109
  }
1055
1110
  if (besideIt) {
1056
1111
  once('a side of the line', `${relation} edge`);
1057
1112
  place.beside = relation;
1113
+ if (distance !== undefined)
1114
+ place.rise = { distance, written: `${distance} ${relation} edge` };
1115
+ if (clear !== undefined)
1116
+ place.rise = { clear: clear.fraction, written: `${clear.written} ${relation} edge` };
1058
1117
  i += 1;
1059
1118
  }
1060
1119
  else {
@@ -1110,6 +1169,9 @@ function readEdgeTextBracket(tokens, start, subject, text, line) {
1110
1169
  throw new SourceError(`${about}: "${written}" and "${other.said}" both say where it sits ${placement.axis === 'x' ? 'across' : 'up and down'} — keep one`, line);
1111
1170
  }
1112
1171
  }
1172
+ if (place.edgeGap !== undefined && place.rise?.distance !== undefined) {
1173
+ throw new SourceError(`${about}: "${place.rise.written}" and "edge-gap: ${place.edgeGap}" both say how far off the line it sits — keep one`, line);
1174
+ }
1113
1175
  if (place.edgeGap !== undefined && !place.beside) {
1114
1176
  throw new SourceError(`${about}: edge-gap: is the distance off the line for a text beside it, and it says no side — add \`right of edge\` or another`, line);
1115
1177
  }
@@ -1976,6 +2038,72 @@ function parseIcon(head, line) {
1976
2038
  }
1977
2039
  return { kind: 'icon', name, source: picture.text, pasted: picture.triple === true || picture.text.trim().startsWith('<'), line };
1978
2040
  }
2041
+ /**
2042
+ * A description's text as written between `"""` marks, without the line break
2043
+ * after the opening marks, the blank lines at either end, or the indentation
2044
+ * every line shares, so a description indented under its statement reads as
2045
+ * Markdown from the left margin.
2046
+ */
2047
+ export function descriptionText(raw) {
2048
+ const lines = raw.replace(/\r\n?/g, '\n').split('\n');
2049
+ while (lines.length > 0 && lines[0].trim() === '')
2050
+ lines.shift();
2051
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
2052
+ lines.pop();
2053
+ const indents = lines.filter((one) => one.trim() !== '').map((one) => one.match(/^[ \t]*/)[0].length);
2054
+ const shared = indents.length === 0 ? 0 : Math.min(...indents);
2055
+ return lines.map((one) => one.slice(shared).trimEnd()).join('\n');
2056
+ }
2057
+ /**
2058
+ * `description for proxy, cache and api """…"""` or `description notes """…"""`.
2059
+ * `for` keeps the two apart: without it a misspelled box name would silently
2060
+ * declare a description nothing uses.
2061
+ */
2062
+ function parseDescription(head, line) {
2063
+ const textAt = head.findIndex((token, index) => index > 0 && token.quoted);
2064
+ const textToken = textAt === -1 ? undefined : head[textAt];
2065
+ const shape = 'write `description for proxy """…"""` to describe things already named, or `description notes """…"""` to declare one for `description: notes`';
2066
+ if (head[1] === undefined)
2067
+ throw new SourceError(`description needs a name or \`for\` — ${shape}`, line);
2068
+ if (textToken === undefined) {
2069
+ throw new SourceError('a description needs its text, between """ marks or in quotes, after what it names', line);
2070
+ }
2071
+ if (textAt + 1 < head.length) {
2072
+ throw new SourceError(`a description ends with its text — "${head[textAt + 1].text}" is left over`, line);
2073
+ }
2074
+ const text = descriptionText(textToken.text);
2075
+ if (text.trim() === '')
2076
+ throw new SourceError('a description\'s text is empty', line);
2077
+ if (follows(head, 1, 'for')) {
2078
+ const targets = [];
2079
+ for (let next = 2; next < textAt; next++) {
2080
+ const token = head[next];
2081
+ if (token.text === 'and' && targets.length > 0)
2082
+ continue;
2083
+ if (isAttrKey(token) || token.text === '(' || token.text === ')') {
2084
+ throw new SourceError(`description for: expected the names of boxes or lines, found "${token.text}"`, line);
2085
+ }
2086
+ const name = token.text.replace(/,$/, '');
2087
+ if (name === '')
2088
+ continue;
2089
+ if (targets.includes(name))
2090
+ throw new SourceError(`description for: "${name}" is named twice — keep one`, line);
2091
+ targets.push(name);
2092
+ }
2093
+ if (targets.length === 0)
2094
+ throw new SourceError(`description for names nothing — ${shape}`, line);
2095
+ return { kind: 'description', targets, text, line };
2096
+ }
2097
+ const name = requireName(head[1], 'description', line, head[2]);
2098
+ if (textAt !== 2) {
2099
+ throw new SourceError(`description "${name}" takes one name — to describe several things at once, write \`description for ${head
2100
+ .slice(1, textAt)
2101
+ .map((token) => token.text.replace(/,$/, ''))
2102
+ .filter((one) => one !== 'and')
2103
+ .join(', ')} """…"""\``, line);
2104
+ }
2105
+ return { kind: 'description', name, text, line };
2106
+ }
1979
2107
  /**
1980
2108
  * `diagram <attributes>` — no name, because a file holds one diagram. Unknown
1981
2109
  * keys are refused rather than ignored: a misspelt diagram-wide setting that
@@ -2069,6 +2197,9 @@ function defaultRefusal(kind, key) {
2069
2197
  const takes = `it takes ${DEFAULT_KEYS[kind].map((k) => `${k}:`).join(', ')}`;
2070
2198
  if (key === 'url')
2071
2199
  return `and a destination belongs to one thing rather than to every one of a kind`;
2200
+ if (key === 'description') {
2201
+ return 'and a description belongs to the things it describes — to give several one text, write `description for a, b and c """…"""`';
2202
+ }
2072
2203
  if (key === 'heading') {
2073
2204
  return "and a heading names one node's own child, so it is written on that node";
2074
2205
  }
@@ -2163,18 +2294,20 @@ function readPlacement(tokens, at, line, subject, nested = false) {
2163
2294
  throw new SourceError(`${subject}: a distance is never negative — write \`${word.text.slice(1)} ${flipped}${/left|right/.test(flipped) ? ' of' : ''} …\`, ` +
2164
2295
  'or inside or on for a box meant to overlap', line);
2165
2296
  }
2166
- // `3 below and 2 right of a`: a different distance on each axis.
2167
- const other = tokens[at + 3];
2168
- if (follows(tokens, at + 2, 'and') && other && !other.quoted && /^\d*\.?\d+(px)?$/.test(other.text) && tokens[at + 4] && isDirection(tokens[at + 4].text)) {
2297
+ // `3 below and 2 right of a`: a different distance on each axis. The first
2298
+ // half may keep its `of`, `2 right of and 3 below a`.
2299
+ const of = follows(tokens, at + 2, 'of') && /left|right/.test(tokens[at + 1].text) ? 1 : 0;
2300
+ const other = tokens[at + 3 + of];
2301
+ if (follows(tokens, at + 2 + of, 'and') && other && !other.quoted && /^\d*\.?\d+(px)?$/.test(other.text) && tokens[at + 4 + of] && isDirection(tokens[at + 4 + of].text)) {
2169
2302
  const first = tokens[at + 1].text;
2170
- const second = tokens[at + 4].text;
2303
+ const second = tokens[at + 4 + of].text;
2171
2304
  const vertical = (d) => d === 'above' || d === 'below';
2172
2305
  const horizontal = (d) => d === 'left' || d === 'right';
2173
2306
  if (!((vertical(first) && horizontal(second)) || (horizontal(first) && vertical(second)))) {
2174
2307
  throw new SourceError(`${subject}: "${word.text} ${first} and ${other.text} ${second}" gives two distances, one for each axis, ` +
2175
2308
  'so one direction is above or below and the other left or right', line);
2176
2309
  }
2177
- const read = readPlacement(tokens, at + 3, line, subject, true);
2310
+ const read = readPlacement(tokens, at + 3 + of, line, subject, true);
2178
2311
  if (read.placement.kind !== 'offset')
2179
2312
  return read;
2180
2313
  const y = vertical(first) ? first : second;
@@ -2509,10 +2642,12 @@ function checkColorWord(value, subject, line) {
2509
2642
  throw new SourceError(`${subject}: \`${value}\` is not a color — ${fix} \`#${value}\``, line);
2510
2643
  }
2511
2644
  }
2512
- const URL_QUOTES = 'a url is written in quotes — `url: "https://example.com"`. Without them everything from the `//` onwards is read as a comment';
2645
+ const URL_QUOTES = 'a url is written in quotes — `url: "https://example.com"`. Without them everything from the `//` onwards is read as a comment. ' +
2646
+ 'An unquoted word is the name of a `url` statement';
2513
2647
  /**
2514
- * `url: "https://a" (name: "A", icon: "A" (fill: red)) "https://b"`: one or
2515
- * more quoted addresses, each with an optional bracket after it. The values in
2648
+ * `url: "https://a" (name: "A", icon: "A" (fill: red)) "https://b" docs`: one
2649
+ * or more quoted addresses, each with an optional bracket after it, and the
2650
+ * bare names of `url` statements, which take no bracket. The values in
2516
2651
  * a link's bracket are a name (a text, so quoted) and a badge, which is quoted
2517
2652
  * letters or the unquoted name of a picture. `readBracket` takes only unquoted
2518
2653
  * values and no bracket inside a bracket, so links are read here.
@@ -2522,25 +2657,87 @@ function readLinks(tokens, start, subject, line) {
2522
2657
  // `//` opens a comment — so the value is either missing entirely or is the
2523
2658
  // bare scheme, which reads as another attribute key. Neither report says
2524
2659
  // what is wrong, and the remedy is punctuation rather than a missing word.
2525
- if (!tokens[start]?.quoted)
2660
+ if (!tokens[start] || !(tokens[start].quoted || isLinkName(tokens, start))) {
2526
2661
  throw new SourceError(`${subject}: ${URL_QUOTES}`, line);
2662
+ }
2527
2663
  const links = [];
2528
2664
  let i = start;
2529
- while (tokens[i]?.quoted) {
2530
- const link = { href: tokens[i].text };
2665
+ while (tokens[i]?.quoted || isLinkName(tokens, i)) {
2666
+ const token = tokens[i];
2531
2667
  i += 1;
2668
+ if (!token.quoted) {
2669
+ // A declared link's name and badge belong to the destination, so the
2670
+ // same link can't be shown one way on one box and another way on the next.
2671
+ if (follows(tokens, i, '(')) {
2672
+ throw new SourceError(`${subject}: the link "${token.text}" is declared with its own name and badge, so a bracket after it here is refused — ` +
2673
+ 'to show it differently, declare another `url` with the bracket you want', line);
2674
+ }
2675
+ if (token.text.endsWith(',')) {
2676
+ throw new SourceError(`${subject}: links are written one after another, without commas — \`url: ${token.text.slice(0, -1)} …\``, line);
2677
+ }
2678
+ if (links.some((had) => 'ref' in had && had.ref === token.text)) {
2679
+ throw new SourceError(`${subject}: "${token.text}" is linked twice in one \`url:\` — keep one`, line);
2680
+ }
2681
+ links.push({ ref: token.text });
2682
+ continue;
2683
+ }
2684
+ const link = { href: token.text };
2532
2685
  if (follows(tokens, i, '(')) {
2533
2686
  const read = readLinkBracket(tokens, i, link.href, subject, line);
2534
2687
  Object.assign(link, read.link);
2535
2688
  i = read.next;
2536
2689
  }
2537
- if (links.some((had) => had.href === link.href)) {
2690
+ if (links.some((had) => 'href' in had && had.href === link.href)) {
2538
2691
  throw new SourceError(`${subject}: "${link.href}" is linked twice in one \`url:\` — keep one`, line);
2539
2692
  }
2540
2693
  links.push(link);
2541
2694
  }
2542
2695
  return { links, next: i };
2543
2696
  }
2697
+ /**
2698
+ * A bare word in a list of links is a declared link's name, unless it opens a
2699
+ * placement, which may follow a `url:` on the same statement. A `url`
2700
+ * declaration refuses those words as names, so the two never meet.
2701
+ */
2702
+ function isLinkName(tokens, at) {
2703
+ const token = tokens[at];
2704
+ if (!token || token.quoted || isAttrKey(token))
2705
+ return false;
2706
+ if (token.text === '(' || token.text === ')' || token.text === ',')
2707
+ return false;
2708
+ return !startsPlacement(token, tokens[at + 1]) && !PLACEMENT_LEADS.includes(token.text);
2709
+ }
2710
+ /** Words that open a clause after a statement's attributes without `startsPlacement` knowing them. */
2711
+ const PLACEMENT_LEADS = ['stays', 'and', 'x', 'y'];
2712
+ /**
2713
+ * `url orders-docs "https://…" (name: "Docs", icon: "D")`, or a bundle:
2714
+ * `url orders-links orders-docs orders-code "https://…" (name: "Wiki")`.
2715
+ */
2716
+ function parseUrl(head, line) {
2717
+ const shape = 'write `url orders-docs "https://…" (name: "Docs")`, then `url: orders-docs` on whatever links to it';
2718
+ const nameToken = head[1];
2719
+ if (nameToken === undefined || nameToken.quoted)
2720
+ throw new SourceError(`url needs a name — ${shape}`, line);
2721
+ if (isAttrKey(nameToken)) {
2722
+ throw new SourceError(`\`url\` starts a statement that declares a link; to link this, \`${nameToken.text}\` goes on a box or a line as \`url:\` — ${shape}`, line);
2723
+ }
2724
+ const name = requireName(nameToken, 'url', line, head[2]);
2725
+ if (startsPlacement(nameToken, head[2]) || PLACEMENT_LEADS.includes(name)) {
2726
+ throw new SourceError(`url "${name}": "${name}" can't name a link, because after \`url:\` it would start a placement — choose another name`, line);
2727
+ }
2728
+ const subject = `url "${name}"`;
2729
+ if (head[2] === undefined)
2730
+ throw new SourceError(`${subject} links to nothing — ${shape}`, line);
2731
+ // The links are one clause to the formatter, so a bundle's names stay
2732
+ // together after its own name instead of lining up as part of it.
2733
+ clauseStarts?.add(head[2]);
2734
+ const read = readLinks(head, 2, subject, line);
2735
+ const stray = head[read.next];
2736
+ if (stray !== undefined) {
2737
+ throw new SourceError(`${subject}: "${stray.text}" is not a link — a \`url\` statement holds quoted addresses, each with an optional bracket, and the names of other \`url\` statements`, line);
2738
+ }
2739
+ return { kind: 'url', name, entries: read.links, line };
2740
+ }
2544
2741
  function readLinkBracket(tokens, start, href, subject, line) {
2545
2742
  const link = {};
2546
2743
  const what = `the link "${href}"`;
@@ -2685,16 +2882,36 @@ function readTargets(tokens, start, subject, placement, line, partFirst = false)
2685
2882
  const read = readOneTarget(tokens, i, subject, line, partFirst);
2686
2883
  targets.push(read.target);
2687
2884
  i = read.next;
2885
+ // `right of a and 2 above c top`: a distance and a direction after the
2886
+ // joiner start the next placement, not a point as another target. A point
2887
+ // meant as a target is named first (design.org 4.5).
2688
2888
  if (follows(tokens, i, 'and')) {
2889
+ if (opensMeasuredPlacement(tokens, i + 1))
2890
+ return { targets, next: i + 1 };
2689
2891
  i += 1;
2690
2892
  continue;
2691
2893
  }
2894
+ if (read.listed && opensMeasuredPlacement(tokens, i))
2895
+ return { targets, next: i };
2692
2896
  comma = read.listed ? nameTarget(read.target) : undefined;
2693
2897
  if (read.listed)
2694
2898
  continue;
2695
2899
  return { targets, next: i };
2696
2900
  }
2697
2901
  }
2902
+ /**
2903
+ * Whether a placement written with its distance starts at `at`: `2 above c
2904
+ * top`, or measured from this box's part, `center 2 above c center`.
2905
+ */
2906
+ function opensMeasuredPlacement(tokens, at) {
2907
+ const word = tokens[at];
2908
+ if (!word || word.quoted)
2909
+ return false;
2910
+ if (isPart(word.text))
2911
+ return opensPartPlacement(tokens, at);
2912
+ const direction = tokens[at + 1];
2913
+ return /^-?\d*\.?\d+(px)?$/.test(word.text) && direction !== undefined && !direction.quoted && isDirection(direction.text);
2914
+ }
2698
2915
  /**
2699
2916
  * One target: a name with its part and how far along, or a point written out
2700
2917
  * (design.org 4.2–4.4). `listed` says it ended on a comma, which promises
package/dist/place.js CHANGED
@@ -770,12 +770,10 @@ export class Placer {
770
770
  // Bare, the direction would give way to the pins (`checkYielded`); it is
771
771
  // the distance written on it that they would overrule.
772
772
  const [direction] = directions;
773
- const pins = statements
774
- .filter((statement) => statement.kind === 'align')
775
- .map((statement) => `"${describePlacement(statement.placement)}"`)
776
- .join(' and ');
773
+ const aligns = statements.filter((statement) => statement.kind === 'align');
774
+ const pins = aligns.map((statement) => `"${describePlacement(statement.placement)}"`).join(' and ');
777
775
  return new SourceError(`"${node.name}" says how far away "${describePlacement(direction.placement)}" is, but ${pins} already ` +
778
- `say where it goes ${AXIS_SAID[axis]} — drop the distance to keep only the direction, or drop the direction`, direction.placement.line);
776
+ `${aligns.length === 1 ? 'says' : 'say'} where it goes ${AXIS_SAID[axis]} — drop the distance to keep only the direction, or drop the direction`, direction.placement.line);
779
777
  }
780
778
  return new SourceError(`"${node.name}" says where it goes ${AXIS_SAID[axis]} more than once: ${said}. Each axis is stated ` +
781
779
  'once — keep one, or pin two opposite edges with even with to give it a size', statements[statements.length - 1].placement.line);
package/dist/render.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ARROW_LENGTH, ARROW_MARKER_WIDTH, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, arrowLength, fontSizeFor, textStyleFor, widestLine, } from './constants.js';
1
+ import { ARROW_LENGTH, ARROW_MARKER_WIDTH, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, arrowLength, fontSizeFor, lineSize, lineTall, textStyleFor, widestLine, } from './constants.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { ICON_STROKE } from './icons.js';
4
4
  import { insideOutline, isBoxy, isRound, placePath, shapeOf, SUBPROCESS_INSET } from './outline.js';
@@ -9,6 +9,7 @@ import { crossingPoints, planLines, traceRoute, visibleRuns } from './lines.js';
9
9
  import { patchPadding, placeLineTexts } from './linetexts.js';
10
10
  import { plain } from './text.js';
11
11
  import { linkLabel, linksOf } from './links.js';
12
+ import { markdownPlain } from './markdown.js';
12
13
  const CORNER = 8;
13
14
  /** Turn solved geometry into a standalone SVG document. */
14
15
  export function render(layout, options = {}) {
@@ -51,7 +52,7 @@ function drawing(layout, options) {
51
52
  const boxes = layout.roots.flatMap((root) => drawNode(root, theme, measurer, fontSize, layout.markup, guides));
52
53
  const marks = layout.marks.map((mark) => ({
53
54
  mark,
54
- item: { key: mark.name, svg: markSvg(mark, theme, guides, layout.markup, fontSize), fills: markFills(mark, theme) },
55
+ item: { key: mark.name, svg: markSvg(mark, theme, guides, layout.markup, fontSize, measurer), fills: markFills(mark, theme) },
55
56
  }));
56
57
  const lines = [];
57
58
  // Everything the boxes cover. Edges are added to it as they are drawn.
@@ -113,7 +114,7 @@ function drawing(layout, options) {
113
114
  }
114
115
  const lineSvg = new Map(lines.map((line) => [line.key, line.svg]));
115
116
  const body = sequence
116
- .map((item) => ({ item, svg: lineSvg.get(item.key) ?? item.svg }))
117
+ .map((item) => ({ item, svg: described(layout, item, lineSvg.get(item.key) ?? item.svg) }))
117
118
  .filter(({ svg }) => svg.length > 0);
118
119
  // An edge's geometry is measured rather than solved for, so the resolver sized
119
120
  // the canvas from the boxes alone. A curve out of a `top` side, or a text
@@ -183,6 +184,18 @@ export function themeOf(layout, options = {}) {
183
184
  * `<g data-name="api" data-line="12">` round each thing drawn: what a page
184
185
  * shows on hover, and what a finding about that line lights up.
185
186
  */
187
+ /**
188
+ * A box or a line with a description carries it, marks stripped, as hover
189
+ * text. A `<title>` is the tooltip of the element it opens, so the drawing is
190
+ * wrapped in a group of its own; one without a description draws as before.
191
+ */
192
+ function described(layout, item, svg) {
193
+ const attrs = item.node?.attrs ?? (item.key.startsWith('edge ') ? layout.edges[Number(item.key.slice('edge '.length))]?.attrs : undefined);
194
+ const text = attrs?.['description'];
195
+ if (text === undefined || svg.length === 0)
196
+ return svg;
197
+ return ` <g>\n <title>${escapeXml(markdownPlain(text))}</title>\n${svg}\n </g>`;
198
+ }
186
199
  function tag(layout, item, svg) {
187
200
  let name;
188
201
  let line;
@@ -475,7 +488,7 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
475
488
  while (behind && behind.body.kind === 'none')
476
489
  behind = behind.parent;
477
490
  const onFill = behind ? textOnFill(behind.appearance['fill'] ?? '', theme) : undefined;
478
- return [sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
491
+ return [sized(textBlock(node.lines, node.x, node.y, measurer, size, node.textBox, {
479
492
  color: textColorOf(node.textAttrs, theme, onFill ?? theme.text),
480
493
  align: style.align,
481
494
  ink,
@@ -491,7 +504,7 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
491
504
  const style = textStyleFor(node.textAttrs, node.line, 'middle', 'center');
492
505
  const drawn = [drawIcon(node.body.icon, node.x + (node.width - glyphSide) / 2, node.y, glyphSide, theme)];
493
506
  if (node.lines.some((line) => plain(line).length > 0)) {
494
- drawn.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
507
+ drawn.push(sized(textBlock(node.lines, node.x, node.y, measurer, size, node.textBox, {
495
508
  color: textColorOf(node.textAttrs, theme, theme.text),
496
509
  align: style.align,
497
510
  ink,
@@ -555,7 +568,7 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
555
568
  }
556
569
  const titled = node.banded && container;
557
570
  const textStyle = textStyleFor(node.textAttrs, node.line, titled ? 'start' : 'middle', titled ? 'top-left' : 'center');
558
- parts.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
571
+ parts.push(sized(textBlock(node.lines, node.x, node.y, measurer, size, node.textBox, {
559
572
  color: text,
560
573
  align: textStyle.align,
561
574
  ink,
@@ -569,12 +582,12 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
569
582
  * primary color; a point is a dot. A guide is drawn only when guides are
570
583
  * shown, dashed and unfilled in the guide color.
571
584
  */
572
- function markSvg(mark, theme, guides, markup, fontSize) {
585
+ function markSvg(mark, theme, guides, markup, fontSize, measurer) {
573
586
  const said = (key) => mark.appearance[key] ?? (mark.guide ? guides.looks[mark.kind][key] : undefined);
574
587
  if (mark.guide && !guides.shown)
575
588
  return '';
576
589
  const color = (value) => (value === undefined ? undefined : themeColor(value, theme));
577
- const label = markTextSvg(mark, theme, mark.guide ? guides.color : theme.text, markup, fontSize);
590
+ const label = markTextSvg(mark, theme, mark.guide ? guides.color : theme.text, markup, fontSize, measurer);
578
591
  if (mark.kind === 'point') {
579
592
  const fill = color(said('fill')) ?? (mark.guide ? guides.color : theme.edge);
580
593
  return ` <circle cx="${round(mark.x)}" cy="${round(mark.y)}" r="3" fill="${fill}"/>${label}`;
@@ -637,12 +650,12 @@ function markFills(mark, theme) {
637
650
  return [{ x: mark.x, y: mark.y, width: mark.width, height: mark.height, ...fill }];
638
651
  }
639
652
  /** A point's or a region's text (design.org 7.4), on a line of its own after the mark. */
640
- function markTextSvg(mark, theme, fallback, markup, fontSize) {
653
+ function markTextSvg(mark, theme, fallback, markup, fontSize, measurer) {
641
654
  const text = mark.text;
642
655
  if (!text)
643
656
  return '';
644
657
  const align = text.textAttrs['align'];
645
- const block = textBlock(text.lines, 0, 0, text.lineHeight, text.size, text.box, {
658
+ const block = textBlock(text.lines, 0, 0, measurer, text.size, text.box, {
646
659
  color: textColorOf(text.textAttrs, theme, fallback),
647
660
  align: align === 'left' || align === 'top' ? 'start' : align === 'right' || align === 'bottom' ? 'end' : 'middle',
648
661
  ink: (run, own) => runInk(run, own, markup, theme),
@@ -908,7 +921,6 @@ function drawEdge(edge, route, theme, measurer, fontSize, markup) {
908
921
  const patches = [];
909
922
  for (const placed of placeLineTexts(edge, route)) {
910
923
  const { text, center, angle } = placed;
911
- const lineHeight = measurer.lineHeight(text.size);
912
924
  const { width, height } = text;
913
925
  const top = center.y - height / 2;
914
926
  const turn = angle === 0 ? '' : ` transform="rotate(${round(angle)} ${round(center.x)} ${round(center.y)})"`;
@@ -923,7 +935,7 @@ function drawEdge(edge, route, theme, measurer, fontSize, markup) {
923
935
  center,
924
936
  ...(text.textAttrs['fill'] !== undefined ? { fill: text.textAttrs['fill'] } : {}),
925
937
  });
926
- rest.push(...(turn ? [' <g' + turn + '>'] : []), marker, sized(textBlock(text.lines, center.x - width / 2, top, lineHeight, text.size, { x: 0, y: 0, width, height }, {
938
+ rest.push(...(turn ? [' <g' + turn + '>'] : []), marker, sized(textBlock(text.lines, center.x - width / 2, top, measurer, text.size, { x: 0, y: 0, width, height }, {
927
939
  // A colored edge carries its meaning into its text; an uncolored
928
940
  // one leaves the words to read as ordinary text.
929
941
  color: textColorOf(text.textAttrs, theme, lineOf(edge.appearance, theme, theme.text)),
@@ -1272,13 +1284,13 @@ function sized(block, size, fontSize) {
1272
1284
  * sized from it, so nearly all of them — the two coincide and `side` changes
1273
1285
  * nothing.
1274
1286
  */
1275
- function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
1287
+ function textBlock(lines, x, y, heights, fontSize, box, style) {
1276
1288
  if (style.reads === 'up' || style.reads === 'down') {
1277
1289
  // `box` is the turned ink, so the level block is the same box on its side.
1278
1290
  const cx = x + box.x + box.width / 2;
1279
1291
  const cy = y + box.y + box.height / 2;
1280
1292
  const level = { x: cx - box.height / 2, y: cy - box.width / 2, width: box.height, height: box.width };
1281
- const inner = textBlock(lines, 0, 0, lineHeight, fontSize, level, { ...style, reads: undefined });
1293
+ const inner = textBlock(lines, 0, 0, heights, fontSize, level, { ...style, reads: undefined });
1282
1294
  return ` <g transform="rotate(${style.reads === 'up' ? -90 : 90} ${round(cx)} ${round(cy)})">\n${inner}\n </g>`;
1283
1295
  }
1284
1296
  // `box` is the ink the text occupies, worked out by the resolver — the one
@@ -1291,26 +1303,38 @@ function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
1291
1303
  : style.align === 'end'
1292
1304
  ? blockLeft + box.width
1293
1305
  : blockLeft;
1306
+ // Each line is as tall as its largest text (design.org 7.1), so the lines are
1307
+ // stacked by their own heights rather than at one spacing.
1308
+ let lineTop = top;
1294
1309
  return lines
1295
- .map((line, index) => {
1310
+ .map((line) => {
1311
+ const tall = lineTall(line, heights, fontSize);
1312
+ const largest = lineSize(line, fontSize);
1313
+ const at = lineTop;
1314
+ lineTop += tall;
1296
1315
  if (plain(line).length === 0)
1297
1316
  return '';
1298
- const baseline = top + index * lineHeight + lineHeight / 2 + fontSize * 0.35;
1317
+ // The runs on a line share one baseline, set by the largest of them.
1318
+ const baseline = at + tall / 2 + largest * 0.35;
1299
1319
  // One `<text>` per line, with a `<tspan>` per run inside it, so the runs
1300
1320
  // flow from the line's own anchor and a mark never moves a character.
1301
- // A line drawn in one color says so on the `<text>` and emits no spans at
1302
- // all, which is what keeps a whole quiet line identical to what the
1303
- // `subtext:` it replaced produced.
1321
+ // A line drawn in one color and one size says so on the `<text>` and
1322
+ // emits no spans at all, which is what keeps a whole quiet line identical
1323
+ // to what the `subtext:` it replaced produced.
1304
1324
  const colors = line.map((run) => style.ink(run, style.color));
1305
- const uniform = colors.every((color) => color === colors[0]);
1325
+ const sizes = line.map((run) => run.size);
1326
+ const uniform = colors.every((color) => color === colors[0]) && sizes.every((size) => size === sizes[0]);
1327
+ const sizeAttr = (size) => (size === undefined ? '' : ` font-size="${size}px"`);
1306
1328
  const body = uniform
1307
1329
  ? escapeXml(plain(line))
1308
1330
  : line
1309
- .map((run, run_index) => colors[run_index] === style.color
1310
- ? escapeXml(run.text)
1311
- : `<tspan fill="${colors[run_index]}">${escapeXml(run.text)}</tspan>`)
1331
+ .map((run, run_index) => {
1332
+ const fill = colors[run_index] === style.color ? '' : ` fill="${colors[run_index]}"`;
1333
+ const sized = sizeAttr(run.size);
1334
+ return fill || sized ? `<tspan${fill}${sized}>${escapeXml(run.text)}</tspan>` : escapeXml(run.text);
1335
+ })
1312
1336
  .join('');
1313
- return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${uniform ? colors[0] ?? style.color : style.color}" text-anchor="${style.align}">${body}</text>`;
1337
+ return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${uniform ? colors[0] ?? style.color : style.color}"${uniform ? sizeAttr(sizes[0]) : ''} text-anchor="${style.align}">${body}</text>`;
1314
1338
  })
1315
1339
  .filter((element) => element.length > 0)
1316
1340
  .join('\n');
@@ -1320,12 +1344,12 @@ function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
1320
1344
  * color, so the word borrows a meaning the file already has rather than
1321
1345
  * restating a value that goes stale the day the thing it means is recolored.
1322
1346
  * The resolver has already refused a mark naming a style that does not exist or
1323
- * that says nothing about text.
1347
+ * that gives text neither a color nor a size; a style that lends only a size
1348
+ * leaves the run in the text's own color.
1324
1349
  */
1325
1350
  function runInk(run, own, markup, theme) {
1326
- if (run.style === undefined)
1327
- return own;
1328
- return themeColor(markup[run.style], theme);
1351
+ const color = run.style === undefined ? undefined : markup[run.style];
1352
+ return color === undefined ? own : themeColor(color, theme);
1329
1353
  }
1330
1354
  /**
1331
1355
  * A text's own color, the same words `fill:` and `border:` take — so