reladraw 0.24.0 → 0.25.1
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/.claude/skills/reladraw/SKILL.md +27 -4
- package/.claude/skills/reladraw/reference/syntax.md +105 -11
- package/README.md +1 -1
- package/SYNTAX.md +105 -11
- package/dist/ast.d.ts +47 -1
- package/dist/ast.js +12 -4
- package/dist/constants.d.ts +20 -0
- package/dist/constants.js +34 -1
- package/dist/format.js +5 -1
- package/dist/grammar.d.ts +1 -1
- package/dist/grammar.js +16 -3
- package/dist/linetexts.d.ts +4 -1
- package/dist/linetexts.js +13 -3
- package/dist/links.d.ts +4 -0
- package/dist/markdown.d.ts +14 -0
- package/dist/markdown.js +102 -0
- package/dist/model.d.ts +4 -3
- package/dist/page.js +21 -3
- package/dist/parser.d.ts +7 -0
- package/dist/parser.js +244 -27
- package/dist/place.js +3 -5
- package/dist/render.js +52 -28
- package/dist/resolve.js +207 -22
- package/dist/text.d.ts +15 -3
- package/dist/text.js +16 -7
- package/package.json +1 -1
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
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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 (
|
|
1053
|
-
throw new SourceError(`${about}: "${
|
|
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
|
-
|
|
2168
|
-
|
|
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
|
|
2515
|
-
* more quoted addresses, each with an optional bracket after it
|
|
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]
|
|
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
|
|
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
|
|
774
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
1302
|
-
// all, which is what keeps a whole quiet line identical
|
|
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
|
|
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) =>
|
|
1310
|
-
?
|
|
1311
|
-
|
|
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
|
|
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
|
-
|
|
1327
|
-
|
|
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
|