reladraw 0.17.1 → 0.19.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
@@ -3,9 +3,36 @@ import { CHECK_DEFAULTS, CHECK_KINDS, setCheck } from './checks.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { STATEMENT_KEYWORDS } from './grammar.js';
5
5
  import { isAttrKey, nameMark, OpenString, tokenizeLine } from './lexer.js';
6
+ import { SHAPE_SETTINGS } from './outline.js';
6
7
  import { checkOpacity } from './place.js';
8
+ /** The settings a shape's bracket takes as a word on its own: `cylinder (lying)`. */
9
+ const SHAPE_FLAGS = [...new Set(Object.values(SHAPE_SETTINGS).flatMap((one) => Object.entries(one).filter(([, kind]) => kind === 'flag').map(([name]) => name)))];
7
10
  import { readSize, SIZE_KEYS, writeSize } from './size.js';
8
11
  import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
12
+ /**
13
+ * Set while `clausesOf` runs: the first token of every clause the parser
14
+ * reads, so the formatter can tell where one clause ends and the next begins
15
+ * without a second grammar of its own. Clauses inside brackets are recorded
16
+ * too, and the formatter passes over them.
17
+ */
18
+ let clauseStarts = null;
19
+ /**
20
+ * Parse a source for the formatter: each statement as gathered, and the first
21
+ * token of each clause read. Throws as `parse` does, so a file that does not
22
+ * parse is never rewritten.
23
+ */
24
+ export function clausesOf(source) {
25
+ const statements = [];
26
+ const starts = new Set();
27
+ clauseStarts = starts;
28
+ try {
29
+ parse(source, (statement) => statements.push(statement));
30
+ }
31
+ finally {
32
+ clauseStarts = null;
33
+ }
34
+ return { statements, starts };
35
+ }
9
36
  /**
10
37
  * Parse a whole source file. A statement starts on an unindented line and runs
11
38
  * on through any line that starts with whitespace; a blank line or the next
@@ -21,16 +48,18 @@ import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
21
48
  * are its own, blank or not, and a line opening with one continues the
22
49
  * statement above without being indented.
23
50
  */
24
- export function parse(source) {
51
+ export function parse(source, seen) {
25
52
  const statements = [];
26
- /** The statement being gathered, and the line it starts on. */
53
+ /** The statement being gathered, the line it starts on, and its lines' tokens. */
27
54
  let open = null;
28
55
  // Brackets may close on a later line, and the lexer only reads `)` as a
29
56
  // bracket while one is open, so the depth is carried from line to line.
30
57
  let depth = 0;
31
58
  const close = () => {
32
- if (open)
59
+ if (open) {
60
+ seen?.({ line: open.line, lines: open.lines });
33
61
  statements.push(parseStatement(open.tokens, open.line));
62
+ }
34
63
  open = null;
35
64
  depth = 0;
36
65
  };
@@ -71,7 +100,7 @@ export function parse(source) {
71
100
  if (tokens.length === 0)
72
101
  continue;
73
102
  if (!continues) {
74
- open = { tokens, line: lineNumber };
103
+ open = { tokens: [...tokens], line: lineNumber, lines: [{ line: lineNumber, tokens }] };
75
104
  }
76
105
  else {
77
106
  const first = tokens[0];
@@ -87,6 +116,7 @@ export function parse(source) {
87
116
  `the one before it, so remove the indentation to start a new statement`, lineNumber);
88
117
  }
89
118
  open.tokens.push(...tokens);
119
+ open.lines.push({ line: lineNumber, tokens });
90
120
  }
91
121
  for (const token of tokens) {
92
122
  if (token.quoted)
@@ -253,6 +283,7 @@ function parseTail(tokens, start, line, subject, other) {
253
283
  let i = start;
254
284
  while (i < tokens.length) {
255
285
  const token = tokens[i];
286
+ clauseStarts?.add(token);
256
287
  if (isAttrKey(token)) {
257
288
  i = readAttr(tokens, i, attrs, line, subject);
258
289
  continue;
@@ -321,6 +352,7 @@ function setOnce(attrs, key, value, subject, line, shown = { key, value: (v) =>
321
352
  /** Read one `key: value` pair, and refuse the words that used to be keys. */
322
353
  function readAttr(tokens, at, attrs, line, subject) {
323
354
  const keyToken = tokens[at];
355
+ clauseStarts?.add(keyToken);
324
356
  const key = keyToken.text.slice(0, -1);
325
357
  if (key === 'gap') {
326
358
  throw new SourceError(`${subject}: a node has no gap:, only a placement does — write it in the placement's bracket, ` +
@@ -601,6 +633,53 @@ function readAttr(tokens, at, attrs, line, subject) {
601
633
  }
602
634
  return next;
603
635
  }
636
+ if (key === 'shape' && follows(tokens, at + 2, '(')) {
637
+ // `shape: cylinder (cap: 1.5, lying)` — a value, then its settings. Which
638
+ // settings the shape takes is checked once its shape is known, since a
639
+ // style may carry the shape and a node the bracket's other half.
640
+ const value = tokens[at + 1];
641
+ if (value.quoted || isAttrKey(value)) {
642
+ throw new SourceError(`${subject}: shape: names an outline, such as diamond, before its settings`, line);
643
+ }
644
+ setOnce(attrs, 'shape', value.text, subject, line);
645
+ // A flag stands alone, `lying`; read it as `lying: true`.
646
+ const inner = [];
647
+ let depth = 0;
648
+ let end = at + 2;
649
+ for (; end < tokens.length; end++) {
650
+ const token = tokens[end];
651
+ if (!token.quoted && token.text === '(')
652
+ depth += 1;
653
+ if (!token.quoted && token.text === ')')
654
+ depth -= 1;
655
+ const bare = token.text.replace(/,$/, '');
656
+ if (depth === 1 && !token.quoted && SHAPE_FLAGS.includes(bare) && !isAttrKey(tokens[end - 1])) {
657
+ inner.push({ ...token, text: `${bare}:` }, { ...token, text: token.text.endsWith(',') ? 'true,' : 'true' });
658
+ }
659
+ else {
660
+ inner.push(token);
661
+ }
662
+ if (depth === 0)
663
+ break;
664
+ }
665
+ const takes = Object.keys(SHAPE_SETTINGS[value.text] ?? {});
666
+ if (takes.length === 0) {
667
+ throw new SourceError(`${subject}: \`shape: ${value.text}\` takes no settings, so it has no bracket`, line);
668
+ }
669
+ const read = readBracket(inner, 0, takes, {
670
+ subject,
671
+ what: `\`shape: ${value.text}\``,
672
+ kind: `a ${value.text}`,
673
+ example: `${takes[0]}: …`,
674
+ line,
675
+ });
676
+ if (Object.keys(read.values).length === 0) {
677
+ throw new SourceError(`${subject}: \`shape: ${value.text}\` opens empty brackets`, line);
678
+ }
679
+ for (const [inside, set] of Object.entries(read.values))
680
+ setOnce(attrs, `shape.${inside}`, set, subject, line);
681
+ return end + 1;
682
+ }
604
683
  const bracketKeys = BRACKET_KEYS[key];
605
684
  if (bracketKeys !== undefined && follows(tokens, at + 1, '(')) {
606
685
  // `text: (color: theme-muted)` — the whole bracket belongs to one part, and it is
@@ -1177,7 +1256,7 @@ function parseNode(head, line) {
1177
1256
  * A node's text takes the look keys and `reads:` (design.org 7.2). `at` is
1178
1257
  * read only to be refused with the side form, so it isn't listed as one it takes.
1179
1258
  */
1180
- const NODE_TEXT_KEYS = [...TEXT_KEYS, 'reads'];
1259
+ const NODE_TEXT_KEYS = [...TEXT_KEYS, 'reads', 'fill'];
1181
1260
  const NODE_TEXT_LISTED = NODE_TEXT_KEYS.filter((key) => key !== 'at');
1182
1261
  /** A node's text turns a quarter turn; `along` follows a line, and a box has none. */
1183
1262
  function refuseNodeReads(reads, subject, line, whose = "a node's text") {
@@ -1461,7 +1540,7 @@ function eachChildAt(head) {
1461
1540
  return found;
1462
1541
  }
1463
1542
  /** Whether a token is an arrow, or begins one: a bracketed mark, or a run of dashes with marks at its ends. */
1464
- function looksLikeArrow(token) {
1543
+ export function looksLikeArrow(token) {
1465
1544
  if (token.quoted)
1466
1545
  return false;
1467
1546
  if (token.text === '(' || /^\[[^\]]*\]-*$/.test(token.text))
@@ -1708,6 +1787,7 @@ function parsePoint(head, line) {
1708
1787
  if (!head[start] || isAttrKey(head[start])) {
1709
1788
  throw new SourceError(`${subject} says where it is, such as \`point ${name} between a and b\``, line);
1710
1789
  }
1790
+ clauseStarts?.add(head[start]);
1711
1791
  const read = readOneTarget(head, start, subject, line, true);
1712
1792
  if (read.listed)
1713
1793
  throw new SourceError(`${subject} is one point, so it takes no list`, line);
@@ -1726,6 +1806,7 @@ function parseRegion(head, line) {
1726
1806
  if (!head[start] || isAttrKey(head[start])) {
1727
1807
  throw new SourceError(`${subject} says what it covers: two corners, \`region ${name} a top-left to b bottom-right\`, or \`region ${name} between a and b\``, line);
1728
1808
  }
1809
+ clauseStarts?.add(head[start]);
1729
1810
  const first = readOneTarget(head, start, subject, line, true);
1730
1811
  if (first.listed)
1731
1812
  throw new SourceError(`${subject}: a region runs from one point to another, written with "to", not a list`, line);
@@ -1938,8 +2019,12 @@ function refuseNameMark(token, where, line) {
1938
2019
  * helps and never demanded. Shorthands added later extend this without
1939
2020
  * disturbing what it already reads.
1940
2021
  */
1941
- function readPlacement(tokens, at, line, subject) {
2022
+ function readPlacement(tokens, at, line, subject, nested = false) {
1942
2023
  const word = tokens[at];
2024
+ // A placement read after its own distance or part (`6 above x`) is still
2025
+ // the clause that word began, so only the outermost read starts one.
2026
+ if (!nested)
2027
+ clauseStarts?.add(word);
1943
2028
  if (word.quoted) {
1944
2029
  throw new SourceError(`${subject}: unexpected text "${word.text}"`, line);
1945
2030
  }
@@ -1948,7 +2033,7 @@ function readPlacement(tokens, at, line, subject) {
1948
2033
  // the distance is required.
1949
2034
  if (!word.quoted && isPart(word.text) && !follows(tokens, at + 1, 'of') && !follows(tokens, at + 1, 'even')) {
1950
2035
  if (opensPartPlacement(tokens, at)) {
1951
- const read = readPlacement(tokens, at + 1, line, subject);
2036
+ const read = readPlacement(tokens, at + 1, line, subject, true);
1952
2037
  if (read.placement.kind !== 'offset')
1953
2038
  return read;
1954
2039
  return { placement: { ...read.placement, own: word.text }, next: read.next };
@@ -1981,7 +2066,7 @@ function readPlacement(tokens, at, line, subject) {
1981
2066
  throw new SourceError(`${subject}: "${word.text} ${first} and ${other.text} ${second}" gives two distances, one for each axis, ` +
1982
2067
  'so one direction is above or below and the other left or right', line);
1983
2068
  }
1984
- const read = readPlacement(tokens, at + 3, line, subject);
2069
+ const read = readPlacement(tokens, at + 3, line, subject, true);
1985
2070
  if (read.placement.kind !== 'offset')
1986
2071
  return read;
1987
2072
  const y = vertical(first) ? first : second;
@@ -1993,7 +2078,7 @@ function readPlacement(tokens, at, line, subject) {
1993
2078
  next: read.next,
1994
2079
  };
1995
2080
  }
1996
- const read = readPlacement(tokens, at + 1, line, subject);
2081
+ const read = readPlacement(tokens, at + 1, line, subject, true);
1997
2082
  if (read.placement.kind !== 'offset')
1998
2083
  return read;
1999
2084
  if (read.placement.gap !== undefined) {
package/dist/place.d.ts CHANGED
@@ -253,6 +253,11 @@ export declare class Placer {
253
253
  * the part it names, or the whole box.
254
254
  */
255
255
  private ownPart;
256
+ /**
257
+ * For a node that is not a rectangle, where the line from its middle toward
258
+ * a point of its box meets its outline, from the box's top-left.
259
+ */
260
+ private outlineOf;
256
261
  private overStated;
257
262
  /** Every statement a node's placements make about one axis. */
258
263
  private statements;
package/dist/place.js CHANGED
@@ -2,6 +2,7 @@ import { describePlacement, describePoint, fractionAxis, nameTarget, PART_SIDES,
2
2
  import { DECK_STEP, GAPS, HEADER_GAP, UNIT } from './constants.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { isWithin, joinedToText } from './model.js';
5
+ import { isBoxy, middleOf, shapeOf, towardOutline } from './outline.js';
5
6
  import { SIZE_KEYS, storedSize } from './size.js';
6
7
  /**
7
8
  * How a region grows to hold its text, on one axis (design.org 7.4). Inside,
@@ -546,7 +547,29 @@ export class Placer {
546
547
  }
547
548
  return this.textOf(node, axis, 0);
548
549
  }
549
- return narrow(whole, placement.own, axis, this.facts.get(node).circle, this.size(node, 'x'));
550
+ return narrow(whole, placement.own, axis, this.outlineOf(node));
551
+ }
552
+ /**
553
+ * For a node that is not a rectangle, where the line from its middle toward
554
+ * a point of its box meets its outline, from the box's top-left.
555
+ */
556
+ outlineOf(node) {
557
+ const body = node.body;
558
+ if (body.kind !== 'shape' || isBoxy(body.outline))
559
+ return undefined;
560
+ return (across, down) => {
561
+ const width = this.size(node, 'x');
562
+ const height = this.size(node, 'y');
563
+ if (body.outline === 'circle') {
564
+ // An ellipse, round when the two are equal; exact rather than sampled.
565
+ const dx = (across - 0.5) * width;
566
+ const dy = (down - 0.5) * height;
567
+ const scale = dx === 0 && dy === 0 ? 0 : 1 / Math.hypot((dx * 2) / width, (dy * 2) / height);
568
+ return { x: width / 2 + dx * scale, y: height / 2 + dy * scale };
569
+ }
570
+ const middle = middleOf(body.outline, body.look, width, height);
571
+ return towardOutline(shapeOf(body.outline, body.look, width, height).polygon, middle, { x: across * width, y: down * height });
572
+ };
550
573
  }
551
574
  overStated(node, axis, statements) {
552
575
  const said = [...new Set(statements.map((statement) => statement.placement))]
@@ -730,7 +753,7 @@ export class Placer {
730
753
  if (target.part === 'text') {
731
754
  throw new SourceError(`"${node.name}" is placed against "${nameTarget(target)}", and a ${defined.kind} has no text`, placement.line);
732
755
  }
733
- return this.alongFraction(whole, target, axis, () => narrow(whole, target.part, axis, false, 0));
756
+ return this.alongFraction(whole, target, axis, () => narrow(whole, target.part, axis));
734
757
  }
735
758
  if (target.fraction) {
736
759
  const whole = this.extentOf(node, { name: target.name }, axis, placement);
@@ -1004,7 +1027,7 @@ export class Placer {
1004
1027
  }
1005
1028
  return text;
1006
1029
  }
1007
- return narrow(whole, target.part, axis, this.facts.get(named).circle, this.size(named, 'x'));
1030
+ return narrow(whole, target.part, axis, this.outlineOf(named));
1008
1031
  }
1009
1032
  /** A node's text on one axis, given where the node starts. */
1010
1033
  textOf(node, axis, start) {
@@ -1546,7 +1569,7 @@ export class Placer {
1546
1569
  const whole = { start: this.start(child, axis), size: this.size(child, axis) };
1547
1570
  const at = target.part === 'text'
1548
1571
  ? this.textOf(child, axis, whole.start)
1549
- : narrow(whole, target.part, axis, this.facts.get(child).circle, this.size(child, 'x'));
1572
+ : narrow(whole, target.part, axis, this.outlineOf(child));
1550
1573
  start = Math.min(start, at.start);
1551
1574
  end = Math.max(end, at.start + at.size);
1552
1575
  }
@@ -2089,6 +2112,11 @@ export class Placer {
2089
2112
  width: box.width,
2090
2113
  height: box.height,
2091
2114
  };
2115
+ if (node.body.kind === 'shape' && !isBoxy(node.body.outline) && node.body.outline !== 'circle' && (node.textAttrs['place'] === undefined || title.how === 'center')) {
2116
+ // At the shape's own middle, which moves with its size: a triangle's stays a third of the way up.
2117
+ const middle = middleOf(node.body.outline, node.body.look, width, height);
2118
+ return { x: middle.x - box.width / 2, y: middle.y - box.height / 2, width: box.width, height: box.height };
2119
+ }
2092
2120
  if (node.textAttrs['place'] === undefined || title.how === 'center' || facts.circle || node.body.kind === 'icon') {
2093
2121
  return centered;
2094
2122
  }
@@ -2204,9 +2232,11 @@ function commonFrame(a, b) {
2204
2232
  /**
2205
2233
  * Narrow an extent to the part a placement named. A side leaves the axis it
2206
2234
  * doesn't name alone (`right` is the whole right edge); a point is a point on
2207
- * both. On a circle a corner is the point on it halfway between its sides.
2235
+ * both. On a shape other than a rectangle, a point is where the line from the
2236
+ * shape's middle toward that point of its box meets the outline: on a circle
2237
+ * a corner is halfway between its sides, on a diamond the middle of an edge.
2208
2238
  */
2209
- function narrow(whole, part, axis, circle, diameter) {
2239
+ function narrow(whole, part, axis, onOutline) {
2210
2240
  if (part === undefined || part === 'text')
2211
2241
  return whole;
2212
2242
  const words = part.split('-');
@@ -2215,12 +2245,12 @@ function narrow(whole, part, axis, circle, diameter) {
2215
2245
  const named = words.includes(near) ? 0 : words.includes(far) ? whole.size : undefined;
2216
2246
  if (named === undefined && isSide)
2217
2247
  return whole;
2218
- let at = whole.start + (named ?? whole.size / 2);
2219
- const across = words.includes('left') || words.includes('right');
2220
- const down = words.includes('top') || words.includes('bottom');
2221
- if (circle && across && down) {
2222
- const pull = (diameter / 2) * (1 - Math.SQRT1_2);
2223
- at += named === 0 ? pull : -pull;
2224
- }
2225
- return { start: at, size: 0 };
2248
+ if (onOutline) {
2249
+ // Where on the box the point is, as fractions across and down.
2250
+ const across = words.includes('left') ? 0 : words.includes('right') ? 1 : 0.5;
2251
+ const down = words.includes('top') ? 0 : words.includes('bottom') ? 1 : 0.5;
2252
+ const point = onOutline(across, down);
2253
+ return { start: whole.start + (axis === 'x' ? point.x : point.y), size: 0 };
2254
+ }
2255
+ return { start: whole.start + (named ?? whole.size / 2), size: 0 };
2226
2256
  }
package/dist/render.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ARROW_LENGTH, ARROW_MARKER_WIDTH, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, arrowLength, fontSizeFor, textStyleFor, widestLine, } from './constants.js';
2
2
  import { SourceError } from './errors.js';
3
3
  import { ICON_STROKE } from './icons.js';
4
+ import { insideOutline, isBoxy, isRound, placePath, shapeOf, SUBPROCESS_INSET } from './outline.js';
4
5
  import { monospaceMeasurer } from './measure.js';
5
6
  import { joinedToText } from './model.js';
6
7
  import { DARK_THEME, THEMES, overlapFill, regionTint, textOnFill, themeColor } from './themes.js';
@@ -246,6 +247,8 @@ function patchSvg(patch, beneath, theme) {
246
247
  return ` <rect ${rect} fill="${themeColor(patch.fill, theme)}"/>`;
247
248
  const { x, y } = patch.center;
248
249
  const under = beneath.flatMap((drawn) => drawn.fills ?? []).filter((fill) => {
250
+ if (fill.polygon)
251
+ return insideOutline(fill.polygon, { x, y });
249
252
  if (!fill.round)
250
253
  return x >= fill.x && x <= fill.x + fill.width && y >= fill.y && y <= fill.y + fill.height;
251
254
  const r = Math.min(fill.width, fill.height) / 2;
@@ -362,7 +365,7 @@ function borderSvg(node, theme) {
362
365
  return '';
363
366
  const face = faceOf(node);
364
367
  const border = borderOf(node.appearance, theme, theme.containerStroke);
365
- return ` <path d="${outlinePath(node.body.outline, face.x, face.y, face.width, face.height, node.corners)}" fill="none" stroke="${border}"/>`;
368
+ return ` <path d="${outlinePath(node.body.outline, face.x, face.y, face.width, face.height, node.corners, node.body.look)}" fill="none" stroke="${border}"/>`;
366
369
  }
367
370
  /** What a node draws of its own, without its children, which are drawn as items of their own. */
368
371
  function nodeSvg(node, theme, measurer, fontSize, markup) {
@@ -409,6 +412,7 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
409
412
  return drawn;
410
413
  }
411
414
  const outline = node.body.outline;
415
+ const look = node.body.look;
412
416
  const parts = [];
413
417
  const face = faceOf(node);
414
418
  // A node with children is colored as a backdrop however they are placed,
@@ -432,12 +436,12 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
432
436
  const room = PAD / 2;
433
437
  const hole = node.textBox;
434
438
  const grown = { x: face.x - PAD, y: face.y - PAD, w: face.width + PAD * 2, h: face.height + PAD * 2 };
435
- parts.push(` <mask id="${id}" maskUnits="userSpaceOnUse" x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}">`, ` <rect x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}" fill="white"/>`, ` <rect x="${round(node.x + hole.x - room)}" y="${round(node.y + hole.y)}" width="${round(hole.width + room * 2)}" height="${round(hole.height)}" fill="black"/>`, ' </mask>', ` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners)}" fill="${fill}" stroke="none"/>`, ` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners)}" fill="none" stroke="${border}" mask="url(#${id})"/>`);
439
+ parts.push(` <mask id="${id}" maskUnits="userSpaceOnUse" x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}">`, ` <rect x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}" fill="white"/>`, ` <rect x="${round(node.x + hole.x - room)}" y="${round(node.y + hole.y)}" width="${round(hole.width + room * 2)}" height="${round(hole.height)}" fill="black"/>`, ' </mask>', ` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look)}" fill="${fill}" stroke="none"/>`, ` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look)}" fill="none" stroke="${border}" mask="url(#${id})"/>`);
436
440
  }
437
441
  else {
438
- parts.push(` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners)}" fill="${fill}" stroke="${border}"/>`);
442
+ parts.push(` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look)}" fill="${fill}" stroke="${border}"/>`);
439
443
  }
440
- for (const extra of outlineDetail(outline, face.x, face.y, face.width, face.height)) {
444
+ for (const extra of outlineDetail(outline, face.x, face.y, face.width, face.height, look)) {
441
445
  parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
442
446
  }
443
447
  // A leaf's text defaults to the middle of its box, a container's to the top
@@ -447,6 +451,15 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
447
451
  // colors: a badged leaf is a backdrop but its text still centers. A box
448
452
  // whose only children are joined to its text is a leaf, and its lines center
449
453
  // against each other as a leaf's do.
454
+ // Collate's text sits on the point where its two triangles meet, so the
455
+ // lines break behind it on a patch of its own fill, as a line's text does.
456
+ const patch = node.textAttrs['fill'];
457
+ if (outline === 'flow-collate' && patch !== 'none' && node.lines.length > 0) {
458
+ const box = node.textBox;
459
+ const reach = 4;
460
+ const color = patch !== undefined ? themeColor(patch, theme) : fill === 'none' ? theme.background : fill;
461
+ parts.push(` <rect x="${round(node.x + box.x - reach)}" y="${round(node.y + box.y)}" width="${round(box.width + reach * 2)}" height="${round(box.height)}" fill="${color}"/>`);
462
+ }
450
463
  const titled = node.banded && container;
451
464
  const textStyle = textStyleFor(node.textAttrs, node.line, titled ? 'start' : 'middle', titled ? 'top-left' : 'center');
452
465
  parts.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
@@ -630,12 +643,15 @@ const FOLD = CORNER * 2;
630
643
  * corners and a document's three round ones; the fold and the circle are as
631
644
  * they are either way.
632
645
  */
633
- function outlinePath(shape, x, y, w, h, corners) {
646
+ function outlinePath(shape, x, y, w, h, corners, look = {}) {
634
647
  const r = CORNER;
648
+ const fold = shape === 'document' ? (look.fold ?? FOLD) : FOLD;
649
+ if (!isBoxy(shape) && !isRound(shape, w, h))
650
+ return placePath(shapeOf(shape, look, w, h).path, x, y);
635
651
  if (corners === 'sharp' && shape !== 'circle') {
636
652
  return [
637
653
  `M${round(x)} ${round(y)}`,
638
- ...(shape === 'document' ? [`H${round(x + w - FOLD)}`, `L${round(x + w)} ${round(y + FOLD)}`] : [`H${round(x + w)}`]),
654
+ ...(shape === 'document' ? [`H${round(x + w - fold)}`, `L${round(x + w)} ${round(y + fold)}`] : [`H${round(x + w)}`]),
639
655
  `V${round(y + h)}`,
640
656
  `H${round(x)}`,
641
657
  'Z',
@@ -655,8 +671,8 @@ function outlinePath(shape, x, y, w, h, corners) {
655
671
  // Every corner rounded but the top-right one, which is cut away and folded.
656
672
  return [
657
673
  `M${round(x + r)} ${round(y)}`,
658
- `H${round(x + w - FOLD)}`,
659
- `L${round(x + w)} ${round(y + FOLD)}`,
674
+ `H${round(x + w - fold)}`,
675
+ `L${round(x + w)} ${round(y + fold)}`,
660
676
  `V${round(y + h - r)}`,
661
677
  `a${r} ${r} 0 0 1 ${-r} ${r}`,
662
678
  `H${round(x + r)}`,
@@ -679,14 +695,19 @@ function outlinePath(shape, x, y, w, h, corners) {
679
695
  'Z',
680
696
  ].join(' ');
681
697
  }
682
- /** Lines drawn inside the outline: the flap of a fold, and nothing else so far. */
683
- function outlineDetail(shape, x, y, w, h) {
684
- void h;
685
- if (shape !== 'document')
698
+ /** Lines drawn inside the outline: the flap of a fold, a subprocess's bars, a cylinder's near rim. */
699
+ function outlineDetail(shape, x, y, w, h, look = {}) {
700
+ if (shape === 'document') {
701
+ const fold = look.fold ?? FOLD;
702
+ return [`M${round(x + w - fold)} ${round(y)} V${round(y + fold)} H${round(x + w)}`];
703
+ }
704
+ if (shape === 'flow-subprocess') {
705
+ const inset = SUBPROCESS_INSET;
706
+ return [`M${round(x + inset)} ${round(y)} V${round(y + h)} M${round(x + w - inset)} ${round(y)} V${round(y + h)}`];
707
+ }
708
+ if (isBoxy(shape) || isRound(shape, w, h))
686
709
  return [];
687
- return [
688
- `M${round(x + w - FOLD)} ${round(y)} V${round(y + FOLD)} H${round(x + w)}`,
689
- ];
710
+ return shapeOf(shape, look, w, h).detail.map((d) => placePath(d, x, y));
690
711
  }
691
712
  /** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
692
713
  function drawIcon(icon, x, y, side, theme) {
@@ -1093,15 +1114,19 @@ function markMarker(mark, color, background, free) {
1093
1114
  function markerId(mark, color, free) {
1094
1115
  return `${mark}-${color.replace(/[^a-zA-Z0-9]/g, '')}${free && (mark === 'dot' || mark === 'odot') ? '-free' : ''}`;
1095
1116
  }
1096
- /** The rectangle drawn, and whether it is round. */
1117
+ /** The rectangle drawn, and whether it is round or another shape. */
1097
1118
  function faceOf(node) {
1098
- return {
1119
+ const box = {
1099
1120
  x: node.x,
1100
1121
  y: node.y,
1101
1122
  width: node.width,
1102
1123
  height: node.height,
1103
- round: node.body.kind === 'shape' && node.body.outline === 'circle',
1124
+ round: node.body.kind === 'shape' && isRound(node.body.outline, node.width, node.height),
1104
1125
  };
1126
+ if (node.body.kind === 'shape' && !isBoxy(node.body.outline) && !box.round) {
1127
+ box.polygon = shapeOf(node.body.outline, node.body.look, node.width, node.height).polygon.map((p) => ({ x: node.x + p.x, y: node.y + p.y }));
1128
+ }
1129
+ return box;
1105
1130
  }
1106
1131
  /**
1107
1132
  * Wrap a block of text in its own size, but only when that differs from the
package/dist/resolve.js CHANGED
@@ -4,6 +4,7 @@ import { DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, ICON_GAP, ICON_LINES, LINE_WID
4
4
  import { SourceError } from './errors.js';
5
5
  import { bodyFor, pastedIcon } from './icons.js';
6
6
  import { monospaceMeasurer } from './measure.js';
7
+ import { isBoxy, middleOf, sizeFor, SUBPROCESS_INSET } from './outline.js';
7
8
  import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
8
9
  import { joinedToText } from './model.js';
9
10
  import { checkOpacity, gapPixels, Placer, regionTextAt } from './place.js';
@@ -47,6 +48,11 @@ function factsFor(node, measurer, fontSize) {
47
48
  }
48
49
  pad[side] = gapPixels(written, `"${node.name}" padding-${side}:`, node.line);
49
50
  }
51
+ // A subprocess's bars stand inside its sides, so its text keeps its padding clear of them.
52
+ if (node.body.kind === 'shape' && node.body.outline === 'flow-subprocess') {
53
+ pad.left += SUBPROCESS_INSET;
54
+ pad.right += SUBPROCESS_INSET;
55
+ }
50
56
  node.textSide = 'center';
51
57
  const measures = {
52
58
  lineHeight,
@@ -83,6 +89,26 @@ function factsFor(node, measurer, fontSize) {
83
89
  circle,
84
90
  };
85
91
  }
92
+ if (node.body.kind === 'shape' && !circle && !isBoxy(node.body.outline)) {
93
+ if (node.children.length > 0) {
94
+ throw new SourceError(`"${node.name}" is a ${node.body.outline} and has children. Only a rectangle, a document or a circle holds other nodes so far`, node.line);
95
+ }
96
+ // Grown until the padded text fits at the shape's middle: a diamond twice
97
+ // the block each way, a triangle's text a third of the way from its base.
98
+ const block = { width: textWidth + pad.left + pad.right, height: textHeight + pad.top + pad.bottom };
99
+ const natural = sizeFor(node.body.outline, node.body.look, block.width, block.height);
100
+ const middle = middleOf(node.body.outline, node.body.look, natural.width, natural.height);
101
+ return {
102
+ natural,
103
+ textBox: { x: middle.x - textWidth / 2, y: middle.y - textHeight / 2, width: textWidth, height: textHeight },
104
+ hasText,
105
+ padding,
106
+ pad,
107
+ ...measures,
108
+ title,
109
+ circle,
110
+ };
111
+ }
86
112
  if (layout && node.children.length === 0) {
87
113
  throw new SourceError(`"${node.name}" has layout: but no children — a node with no children is not a container`, node.line);
88
114
  }
@@ -611,6 +637,10 @@ function over(base, top) {
611
637
  if (top['shape'] === undefined && top['icon'] === undefined)
612
638
  return { ...base, ...top };
613
639
  const { shape: _shape, icon: _icon, ...rest } = base;
640
+ // A shape's settings belong to the shape they were written with.
641
+ for (const key of Object.keys(rest))
642
+ if (key.startsWith('shape.'))
643
+ delete rest[key];
614
644
  return { ...rest, ...top };
615
645
  }
616
646
  /** A file holds one diagram, so a second `diagram` statement is a mistake. */
@@ -702,6 +732,12 @@ function buildTree(statements, styles, defaults, icons) {
702
732
  ...(stmt.attrs['heading.align'] !== undefined ? { align: stmt.attrs['heading.align'] } : {}),
703
733
  }
704
734
  : { ...bracketOf('text', appearance), ...stmt.textAttrs };
735
+ // The patch behind a text is a line's text's, and flow-collate's, whose
736
+ // text sits on the point where its two halves meet.
737
+ if (stmt.textAttrs['fill'] !== undefined && !(body.kind === 'shape' && body.outline === 'flow-collate')) {
738
+ throw new SourceError(`"${stmt.name}" has fill: in its text's brackets, which is the patch behind a text on a line or on a flow-collate — ` +
739
+ 'a box\'s own color is fill: outside the brackets', stmt.line);
740
+ }
705
741
  // A card's text sits at its top left, where it shows above the card in
706
742
  // front of it, unless it says where.
707
743
  const deck = byName.get(stmt.name.slice(0, Math.max(stmt.name.lastIndexOf('.'), 0)));
@@ -1113,9 +1149,13 @@ function cornersOf(node, value, written) {
1113
1149
  if (!NODE_CORNERS.includes(value)) {
1114
1150
  throw new SourceError(`"${node.name}" has corners: ${value}, which is not a way to draw corners — use ${NODE_CORNERS.join(' or ')}`, node.line);
1115
1151
  }
1116
- if (written && node.body.outline === 'circle') {
1117
- throw new SourceError(`"${node.name}" is a circle and has corners: ${value}. A circle has no corners to make ${value} — ` +
1118
- '`corners:` takes a `rectangle` or a `document`', node.line);
1152
+ if (!isBoxy(node.body.outline)) {
1153
+ // A circle has no corners, and a diamond's or a hexagon's are drawn as
1154
+ // points; only a box's are rounded. From a style it lands where it can.
1155
+ if (!written)
1156
+ return NODE_CORNERS[0];
1157
+ throw new SourceError(`"${node.name}" is a ${node.body.outline} and has corners: ${value}. Only a box's corners are rounded or sharp — ` +
1158
+ '`corners:` takes a `rectangle`, a `document` or a `flow-subprocess`', node.line);
1119
1159
  }
1120
1160
  return value;
1121
1161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {