reladraw 0.17.0 → 0.18.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,7 +3,10 @@ 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';
9
12
  /**
@@ -601,6 +604,53 @@ function readAttr(tokens, at, attrs, line, subject) {
601
604
  }
602
605
  return next;
603
606
  }
607
+ if (key === 'shape' && follows(tokens, at + 2, '(')) {
608
+ // `shape: cylinder (cap: 1.5, lying)` — a value, then its settings. Which
609
+ // settings the shape takes is checked once its shape is known, since a
610
+ // style may carry the shape and a node the bracket's other half.
611
+ const value = tokens[at + 1];
612
+ if (value.quoted || isAttrKey(value)) {
613
+ throw new SourceError(`${subject}: shape: names an outline, such as diamond, before its settings`, line);
614
+ }
615
+ setOnce(attrs, 'shape', value.text, subject, line);
616
+ // A flag stands alone, `lying`; read it as `lying: true`.
617
+ const inner = [];
618
+ let depth = 0;
619
+ let end = at + 2;
620
+ for (; end < tokens.length; end++) {
621
+ const token = tokens[end];
622
+ if (!token.quoted && token.text === '(')
623
+ depth += 1;
624
+ if (!token.quoted && token.text === ')')
625
+ depth -= 1;
626
+ const bare = token.text.replace(/,$/, '');
627
+ if (depth === 1 && !token.quoted && SHAPE_FLAGS.includes(bare) && !isAttrKey(tokens[end - 1])) {
628
+ inner.push({ ...token, text: `${bare}:` }, { ...token, text: token.text.endsWith(',') ? 'true,' : 'true' });
629
+ }
630
+ else {
631
+ inner.push(token);
632
+ }
633
+ if (depth === 0)
634
+ break;
635
+ }
636
+ const takes = Object.keys(SHAPE_SETTINGS[value.text] ?? {});
637
+ if (takes.length === 0) {
638
+ throw new SourceError(`${subject}: \`shape: ${value.text}\` takes no settings, so it has no bracket`, line);
639
+ }
640
+ const read = readBracket(inner, 0, takes, {
641
+ subject,
642
+ what: `\`shape: ${value.text}\``,
643
+ kind: `a ${value.text}`,
644
+ example: `${takes[0]}: …`,
645
+ line,
646
+ });
647
+ if (Object.keys(read.values).length === 0) {
648
+ throw new SourceError(`${subject}: \`shape: ${value.text}\` opens empty brackets`, line);
649
+ }
650
+ for (const [inside, set] of Object.entries(read.values))
651
+ setOnce(attrs, `shape.${inside}`, set, subject, line);
652
+ return end + 1;
653
+ }
604
654
  const bracketKeys = BRACKET_KEYS[key];
605
655
  if (bracketKeys !== undefined && follows(tokens, at + 1, '(')) {
606
656
  // `text: (color: theme-muted)` — the whole bracket belongs to one part, and it is
@@ -1177,7 +1227,7 @@ function parseNode(head, line) {
1177
1227
  * A node's text takes the look keys and `reads:` (design.org 7.2). `at` is
1178
1228
  * read only to be refused with the side form, so it isn't listed as one it takes.
1179
1229
  */
1180
- const NODE_TEXT_KEYS = [...TEXT_KEYS, 'reads'];
1230
+ const NODE_TEXT_KEYS = [...TEXT_KEYS, 'reads', 'fill'];
1181
1231
  const NODE_TEXT_LISTED = NODE_TEXT_KEYS.filter((key) => key !== 'at');
1182
1232
  /** A node's text turns a quarter turn; `along` follows a line, and a box has none. */
1183
1233
  function refuseNodeReads(reads, subject, line, whose = "a node's text") {
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.0",
3
+ "version": "0.18.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {