reladraw 0.17.1 → 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.
@@ -0,0 +1,564 @@
1
+ /**
2
+ * The geometry of every outline `shape:` names: where its edge runs, where its
3
+ * middle is, and how big it grows to hold its text.
4
+ *
5
+ * One place knows each shape, and the resolver, the line planner, the checks
6
+ * and the renderer all ask it, so they cannot disagree about where a diamond's
7
+ * edge is.
8
+ *
9
+ * A shape stretches to fill its box. What the box doesn't decide is at most one
10
+ * amount and one direction (a parallelogram's slant and which way it leans),
11
+ * and those are the settings in the shape's bracket. Amounts are fixed
12
+ * distances, not fractions of the box, as the document's fold always was.
13
+ *
14
+ * The rectangle-like outlines (`rectangle`, `document`, `flow-subprocess`) and
15
+ * a round `circle` keep the exact geometry they always had; every other
16
+ * outline is worked with as a polygon, its curves sampled finely enough that a
17
+ * line meeting one lands on the drawn curve to within a fraction of a pixel.
18
+ */
19
+ import { UNIT } from './constants.js';
20
+ import { SourceError } from './errors.js';
21
+ /**
22
+ * The settings each shape takes in its bracket, by name. A shape not listed
23
+ * takes none. The first word of a direction is the default.
24
+ */
25
+ export const SHAPE_SETTINGS = {
26
+ document: { fold: 'distance' },
27
+ parallelogram: { slant: 'distance', lean: ['right', 'left'] },
28
+ hexagon: { point: 'distance' },
29
+ trapezoid: { taper: 'distance', narrow: ['bottom', 'top'] },
30
+ triangle: { aim: ['down', 'up', 'left', 'right'] },
31
+ cylinder: { cap: 'distance', lying: 'flag' },
32
+ };
33
+ /** Each amount's default in pixels, the settings' and those the flow- shapes fix. A document's fold is the renderer's `FOLD`. */
34
+ const SHAPE_DEFAULTS = {
35
+ slant: 18,
36
+ point: 18,
37
+ taper: 18,
38
+ cap: 9,
39
+ inset: 10,
40
+ wave: 7,
41
+ cut: 14,
42
+ slope: 16,
43
+ curve: 12,
44
+ offPage: 18,
45
+ tail: 16,
46
+ };
47
+ /**
48
+ * Read a shape's bracket, refusing a setting the shape doesn't take and a
49
+ * value that isn't one. `written` holds the dotted keys as stored, `shape.slant`.
50
+ */
51
+ export function readShapeLook(outline, written, subject, line) {
52
+ const takes = SHAPE_SETTINGS[outline] ?? {};
53
+ const look = {};
54
+ for (const [key, value] of Object.entries(written)) {
55
+ const kind = takes[key];
56
+ if (kind === undefined) {
57
+ const names = Object.keys(takes);
58
+ throw new SourceError(`${subject}: a ${outline} has no "${key}" setting — ` +
59
+ (names.length === 0 ? `\`shape: ${outline}\` takes no settings` : `it takes ${names.join(', ')}`), line);
60
+ }
61
+ if (kind === 'flag') {
62
+ if (value !== 'true')
63
+ throw new SourceError(`${subject}: "${key}" is a word on its own, written without a value: \`shape: ${outline} (${key})\``, line);
64
+ look[key] = true;
65
+ continue;
66
+ }
67
+ if (kind === 'distance') {
68
+ look[key] = readDistance(value, `${subject} ${key}:`, line);
69
+ continue;
70
+ }
71
+ if (!kind.includes(value)) {
72
+ throw new SourceError(`${subject}: "${key}: ${value}" is not a way a ${outline} can go — use ${kind.join(', ')}`, line);
73
+ }
74
+ look[key] = value;
75
+ }
76
+ return look;
77
+ }
78
+ function readDistance(written, subject, line) {
79
+ if (/^\d*\.?\d+$/.test(written))
80
+ return Number(written) * UNIT;
81
+ const pixels = /^(\d*\.?\d+)px$/.exec(written);
82
+ if (pixels)
83
+ return Number(pixels[1]);
84
+ throw new SourceError(`${subject} "${written}" is not a distance — write a number in the file's unit, or pixels such as 12px`, line);
85
+ }
86
+ /** How far inside each side a subprocess's bars stand. */
87
+ export const SUBPROCESS_INSET = SHAPE_DEFAULTS.inset;
88
+ /** The outlines handled as a plain rectangle by everything but the renderer. */
89
+ const BOXY = new Set(['rectangle', 'document', 'flow-subprocess']);
90
+ /** Whether an outline is a rectangle as far as placement and lines go. */
91
+ export function isBoxy(outline) {
92
+ return BOXY.has(outline);
93
+ }
94
+ /** Whether an outline is drawn round in a square box, so the exact circle code applies. */
95
+ export function isRound(outline, width, height) {
96
+ return outline === 'circle' && Math.abs(width - height) < 0.5;
97
+ }
98
+ // --- curves, sampled ------------------------------------------------------------
99
+ const CURVE_STEPS = 24;
100
+ /** Points along an elliptical arc about (cx, cy), from angle a0 to a1, both ends included. */
101
+ function arcPoints(cx, cy, rx, ry, a0, a1) {
102
+ const out = [];
103
+ for (let i = 0; i <= CURVE_STEPS; i++) {
104
+ const a = a0 + ((a1 - a0) * i) / CURVE_STEPS;
105
+ out.push({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) });
106
+ }
107
+ return out;
108
+ }
109
+ /** Points along a quadratic curve, the start left out. */
110
+ function quadPoints(p0, c, p1) {
111
+ const out = [];
112
+ for (let i = 1; i <= CURVE_STEPS; i++) {
113
+ const t = i / CURVE_STEPS;
114
+ const u = 1 - t;
115
+ out.push({ x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x, y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y });
116
+ }
117
+ return out;
118
+ }
119
+ /** A wave across a box from (x0, y) to (x1, y), up then down, as the standard's document draws its bottom. */
120
+ function wavePoints(x0, x1, y, depth) {
121
+ const mid = (x0 + x1) / 2;
122
+ const q = (x1 - x0) / 4;
123
+ return [...quadPoints({ x: x0, y }, { x: x0 + q, y: y - 2 * depth }, { x: mid, y }), ...quadPoints({ x: mid, y }, { x: mid + q, y: y + 2 * depth }, { x: x1, y })];
124
+ }
125
+ function polyShape(points) {
126
+ return { polygon: points, path: polygonPath(points), detail: [] };
127
+ }
128
+ function polygonPath(points) {
129
+ return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${r2(p.x)} ${r2(p.y)}`).join(' ') + ' Z';
130
+ }
131
+ function r2(value) {
132
+ return Math.round(value * 100) / 100;
133
+ }
134
+ /**
135
+ * An outline at a size. Rectangle-like outlines and the round circle are
136
+ * included so a caller can always ask, but the renderer draws those itself.
137
+ */
138
+ export function shapeOf(outline, look, w, h) {
139
+ switch (outline) {
140
+ case 'circle': {
141
+ const points = arcPoints(w / 2, h / 2, w / 2, h / 2, Math.PI, 3 * Math.PI).slice(0, -1);
142
+ return { polygon: points, path: `M0 ${r2(h / 2)} A${r2(w / 2)} ${r2(h / 2)} 0 1 0 ${r2(w)} ${r2(h / 2)} A${r2(w / 2)} ${r2(h / 2)} 0 1 0 0 ${r2(h / 2)} Z`, detail: [] };
143
+ }
144
+ case 'diamond':
145
+ return polyShape([{ x: w / 2, y: 0 }, { x: w, y: h / 2 }, { x: w / 2, y: h }, { x: 0, y: h / 2 }]);
146
+ case 'pill': {
147
+ const rad = Math.min(w, h) / 2;
148
+ if (w >= h) {
149
+ const points = [...arcPoints(w - rad, rad, rad, rad, -Math.PI / 2, Math.PI / 2), ...arcPoints(rad, rad, rad, rad, Math.PI / 2, (3 * Math.PI) / 2)];
150
+ return { polygon: points, path: `M${r2(rad)} 0 H${r2(w - rad)} A${r2(rad)} ${r2(rad)} 0 0 1 ${r2(w - rad)} ${r2(h)} H${r2(rad)} A${r2(rad)} ${r2(rad)} 0 0 1 ${r2(rad)} 0 Z`, detail: [] };
151
+ }
152
+ const points = [...arcPoints(rad, h - rad, rad, rad, 0, Math.PI), ...arcPoints(rad, rad, rad, rad, Math.PI, 2 * Math.PI)];
153
+ return { polygon: points, path: `M0 ${r2(rad)} A${r2(rad)} ${r2(rad)} 0 0 1 ${r2(w)} ${r2(rad)} V${r2(h - rad)} A${r2(rad)} ${r2(rad)} 0 0 1 0 ${r2(h - rad)} Z`, detail: [] };
154
+ }
155
+ case 'parallelogram': {
156
+ const s = Math.min(look.slant ?? SHAPE_DEFAULTS.slant, w / 2);
157
+ return look.lean === 'left'
158
+ ? polyShape([{ x: 0, y: 0 }, { x: w - s, y: 0 }, { x: w, y: h }, { x: s, y: h }])
159
+ : polyShape([{ x: s, y: 0 }, { x: w, y: 0 }, { x: w - s, y: h }, { x: 0, y: h }]);
160
+ }
161
+ case 'hexagon': {
162
+ const p = Math.min(look.point ?? SHAPE_DEFAULTS.point, w / 2);
163
+ return polyShape([{ x: p, y: 0 }, { x: w - p, y: 0 }, { x: w, y: h / 2 }, { x: w - p, y: h }, { x: p, y: h }, { x: 0, y: h / 2 }]);
164
+ }
165
+ case 'trapezoid': {
166
+ const t = Math.min(look.taper ?? SHAPE_DEFAULTS.taper, w / 2);
167
+ return look.narrow === 'top'
168
+ ? polyShape([{ x: t, y: 0 }, { x: w - t, y: 0 }, { x: w, y: h }, { x: 0, y: h }])
169
+ : polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w - t, y: h }, { x: t, y: h }]);
170
+ }
171
+ case 'triangle':
172
+ switch (look.aim ?? 'down') {
173
+ case 'up':
174
+ return polyShape([{ x: w / 2, y: 0 }, { x: w, y: h }, { x: 0, y: h }]);
175
+ case 'left':
176
+ return polyShape([{ x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h / 2 }]);
177
+ case 'right':
178
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: h / 2 }, { x: 0, y: h }]);
179
+ default:
180
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w / 2, y: h }]);
181
+ }
182
+ case 'cylinder': {
183
+ if (look.lying) {
184
+ const c = Math.min(look.cap ?? SHAPE_DEFAULTS.cap, w / 4);
185
+ const points = [...arcPoints(w - c, h / 2, c, h / 2, -Math.PI / 2, Math.PI / 2), ...arcPoints(c, h / 2, c, h / 2, Math.PI / 2, (3 * Math.PI) / 2)];
186
+ return {
187
+ polygon: points,
188
+ path: `M${r2(c)} 0 H${r2(w - c)} A${r2(c)} ${r2(h / 2)} 0 0 1 ${r2(w - c)} ${r2(h)} H${r2(c)} A${r2(c)} ${r2(h / 2)} 0 0 1 ${r2(c)} 0 Z`,
189
+ detail: [`M${r2(w - c)} 0 A${r2(c)} ${r2(h / 2)} 0 0 0 ${r2(w - c)} ${r2(h)}`],
190
+ };
191
+ }
192
+ const c = Math.min(look.cap ?? SHAPE_DEFAULTS.cap, h / 4);
193
+ const points = [...arcPoints(w / 2, c, w / 2, c, Math.PI, 2 * Math.PI), ...arcPoints(w / 2, h - c, w / 2, c, 0, Math.PI)];
194
+ return {
195
+ polygon: points,
196
+ path: `M0 ${r2(c)} A${r2(w / 2)} ${r2(c)} 0 0 1 ${r2(w)} ${r2(c)} V${r2(h - c)} A${r2(w / 2)} ${r2(c)} 0 0 1 0 ${r2(h - c)} Z`,
197
+ detail: [`M0 ${r2(c)} A${r2(w / 2)} ${r2(c)} 0 0 0 ${r2(w)} ${r2(c)}`],
198
+ };
199
+ }
200
+ case 'flow-document': {
201
+ const a = SHAPE_DEFAULTS.wave;
202
+ const points = [{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h - a }, ...wavePoints(w, 0, h - a, -a)];
203
+ return polyShape(points);
204
+ }
205
+ case 'flow-off-page': {
206
+ const p = Math.min(SHAPE_DEFAULTS.offPage, h / 2);
207
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h - p }, { x: w / 2, y: h }, { x: 0, y: h - p }]);
208
+ }
209
+ case 'flow-delay': {
210
+ const rad = Math.min(h / 2, w);
211
+ const points = [{ x: 0, y: 0 }, ...arcPoints(w - rad, h / 2, rad, h / 2, -Math.PI / 2, Math.PI / 2), { x: 0, y: h }];
212
+ return { polygon: points, path: `M0 0 H${r2(w - rad)} A${r2(rad)} ${r2(h / 2)} 0 0 1 ${r2(w - rad)} ${r2(h)} H0 Z`, detail: [] };
213
+ }
214
+ case 'flow-manual-input': {
215
+ const s = Math.min(SHAPE_DEFAULTS.slope, h / 2);
216
+ return polyShape([{ x: 0, y: s }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }]);
217
+ }
218
+ case 'flow-stored-data': {
219
+ const k = Math.min(SHAPE_DEFAULTS.curve, w / 4);
220
+ const points = [{ x: k, y: 0 }, { x: w, y: 0 }, ...quadPoints({ x: w, y: 0 }, { x: w - 2 * k, y: h / 2 }, { x: w, y: h }), { x: k, y: h }, ...quadPoints({ x: k, y: h }, { x: -k, y: h / 2 }, { x: k, y: 0 })];
221
+ return {
222
+ polygon: points.slice(0, -1),
223
+ path: `M${r2(k)} 0 H${r2(w)} Q${r2(w - 2 * k)} ${r2(h / 2)} ${r2(w)} ${r2(h)} H${r2(k)} Q${r2(-k)} ${r2(h / 2)} ${r2(k)} 0 Z`,
224
+ detail: [],
225
+ };
226
+ }
227
+ case 'flow-loop-start': {
228
+ const c = Math.min(SHAPE_DEFAULTS.cut, w / 3, h / 2);
229
+ return polyShape([{ x: c, y: 0 }, { x: w - c, y: 0 }, { x: w, y: c }, { x: w, y: h }, { x: 0, y: h }, { x: 0, y: c }]);
230
+ }
231
+ case 'flow-loop-end': {
232
+ const c = Math.min(SHAPE_DEFAULTS.cut, w / 3, h / 2);
233
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h - c }, { x: w - c, y: h }, { x: c, y: h }, { x: 0, y: h - c }]);
234
+ }
235
+ case 'flow-card': {
236
+ const c = Math.min(SHAPE_DEFAULTS.cut, w / 3, h / 2);
237
+ return polyShape([{ x: c, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }, { x: 0, y: c }]);
238
+ }
239
+ case 'flow-display': {
240
+ const p = Math.min(h * 0.35, w / 4);
241
+ const k = Math.min(h * 0.45, w / 4);
242
+ const points = [{ x: p, y: 0 }, { x: w - k, y: 0 }, ...quadPoints({ x: w - k, y: 0 }, { x: w, y: 0 }, { x: w, y: h / 2 }), ...quadPoints({ x: w, y: h / 2 }, { x: w, y: h }, { x: w - k, y: h }), { x: p, y: h }, { x: 0, y: h / 2 }];
243
+ return {
244
+ polygon: points,
245
+ path: `M${r2(p)} 0 H${r2(w - k)} Q${r2(w)} 0 ${r2(w)} ${r2(h / 2)} Q${r2(w)} ${r2(h)} ${r2(w - k)} ${r2(h)} H${r2(p)} L0 ${r2(h / 2)} Z`,
246
+ detail: [],
247
+ };
248
+ }
249
+ case 'flow-tape': {
250
+ const d = Math.min(h, w - SHAPE_DEFAULTS.tail);
251
+ const rad = d / 2;
252
+ const points = arcPoints(rad, h / 2, rad, rad, Math.PI, 3 * Math.PI).slice(0, -1);
253
+ return {
254
+ polygon: points,
255
+ path: `M0 ${r2(h / 2)} A${r2(rad)} ${r2(rad)} 0 1 0 ${r2(d)} ${r2(h / 2)} A${r2(rad)} ${r2(rad)} 0 1 0 0 ${r2(h / 2)} Z`,
256
+ detail: [`M${r2(rad)} ${r2(h / 2 + rad)} H${r2(w)}`],
257
+ };
258
+ }
259
+ case 'flow-punched-tape': {
260
+ const a = SHAPE_DEFAULTS.wave;
261
+ const points = [...[{ x: 0, y: a }, ...wavePoints(0, w, a, a)], ...[{ x: w, y: h - a }, ...wavePoints(w, 0, h - a, -a)]];
262
+ return polyShape(points);
263
+ }
264
+ case 'flow-collate': {
265
+ // Two triangles meeting at a point. Worked with as the bowtie's outline,
266
+ // which passes through its own middle.
267
+ return {
268
+ polygon: [{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w / 2, y: h / 2 }, { x: w, y: h }, { x: 0, y: h }, { x: w / 2, y: h / 2 }],
269
+ path: `M0 0 H${r2(w)} L0 ${r2(h)} H${r2(w)} Z`,
270
+ detail: [],
271
+ };
272
+ }
273
+ default:
274
+ return polyShape([{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: 0, y: h }]);
275
+ }
276
+ }
277
+ /**
278
+ * A path written relative to a box's top-left, moved to (x, y). Only the
279
+ * absolute commands `shapeOf` writes are handled: M, L, H, V, Q, T, A and Z.
280
+ */
281
+ export function placePath(d, x, y) {
282
+ return d.replace(/([MLHVQTAZ])([^MLHVQTAZ]*)/g, (_all, command, rest) => {
283
+ const n = rest.trim().length === 0 ? [] : rest.trim().split(/[\s,]+/).map(Number);
284
+ const moved = n.map((value, i) => {
285
+ switch (command) {
286
+ case 'H':
287
+ return value + x;
288
+ case 'V':
289
+ return value + y;
290
+ case 'A':
291
+ // rx ry rotation large-arc sweep x y: only the end point moves.
292
+ return i % 7 === 5 ? value + x : i % 7 === 6 ? value + y : value;
293
+ default:
294
+ return i % 2 === 0 ? value + x : value + y;
295
+ }
296
+ });
297
+ return `${command}${moved.map(r2).join(' ')}${command === 'Z' ? '' : ' '}`;
298
+ }).trim();
299
+ }
300
+ // --- the middle, and size for text ------------------------------------------------
301
+ /**
302
+ * The shape's own middle, where its text sits and its lines aim: the center of
303
+ * its area. On a symmetric shape that is the middle of its box; a triangle's is
304
+ * a third of the way from its base.
305
+ */
306
+ export function middleOf(outline, look, w, h) {
307
+ if (isBoxy(outline) || outline === 'circle' || outline === 'flow-collate')
308
+ return { x: w / 2, y: h / 2 };
309
+ if (outline === 'flow-tape')
310
+ return { x: Math.min(h, w - SHAPE_DEFAULTS.tail) / 2, y: h / 2 };
311
+ return areaCentroid(shapeOf(outline, look, w, h).polygon);
312
+ }
313
+ function areaCentroid(points) {
314
+ let area = 0;
315
+ let cx = 0;
316
+ let cy = 0;
317
+ for (let i = 0; i < points.length; i++) {
318
+ const a = points[i];
319
+ const b = points[(i + 1) % points.length];
320
+ const cross = a.x * b.y - b.x * a.y;
321
+ area += cross;
322
+ cx += (a.x + b.x) * cross;
323
+ cy += (a.y + b.y) * cross;
324
+ }
325
+ if (Math.abs(area) < 1e-9)
326
+ return points[0] ?? { x: 0, y: 0 };
327
+ return { x: cx / (3 * area), y: cy / (3 * area) };
328
+ }
329
+ /** Whether a point is inside a polygon, by crossings. */
330
+ export function insideOutline(points, p) {
331
+ let within = false;
332
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
333
+ const a = points[i];
334
+ const b = points[j];
335
+ if (a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x)
336
+ within = !within;
337
+ }
338
+ return within;
339
+ }
340
+ /** Whether a point is more than `distance` from every edge of a polygon. */
341
+ export function clearOf(points, p, distance) {
342
+ for (let i = 0; i < points.length; i++) {
343
+ const a = points[i];
344
+ const b = points[(i + 1) % points.length];
345
+ const ex = b.x - a.x;
346
+ const ey = b.y - a.y;
347
+ const length = ex * ex + ey * ey;
348
+ const t = length === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * ex + (p.y - a.y) * ey) / length));
349
+ if (Math.hypot(p.x - (a.x + ex * t), p.y - (a.y + ey * t)) <= distance)
350
+ return false;
351
+ }
352
+ return true;
353
+ }
354
+ /** Whether a rectangle centered on the shape's middle fits inside its outline, checked round its edge. */
355
+ function holdsBlock(outline, look, w, h, tw, th) {
356
+ const polygon = shapeOf(outline, look, w, h).polygon;
357
+ const m = middleOf(outline, look, w, h);
358
+ const x0 = m.x - tw / 2;
359
+ const y0 = m.y - th / 2;
360
+ const n = 8;
361
+ for (let i = 0; i <= n; i++) {
362
+ const f = i / n;
363
+ for (const p of [{ x: x0 + tw * f, y: y0 }, { x: x0 + tw * f, y: y0 + th }, { x: x0, y: y0 + th * f }, { x: x0 + tw, y: y0 + th * f }]) {
364
+ // A hair in, so a corner the outline passes exactly through counts as inside.
365
+ const toward = { x: p.x + (m.x - p.x) * 1e-6, y: p.y + (m.y - p.y) * 1e-6 };
366
+ if (!insideOutline(polygon, toward))
367
+ return false;
368
+ }
369
+ }
370
+ return true;
371
+ }
372
+ /**
373
+ * The size an outline takes to hold a block `tw` by `th` (the text with its
374
+ * padding) at its middle: a first guess that is exact for most shapes, then
375
+ * grown a step at a time until the block fits. A rectangle-like outline and
376
+ * the round circle are sized by the resolver, as they always were.
377
+ */
378
+ export function sizeFor(outline, look, tw, th) {
379
+ const guess = guessSize(outline, look, tw, th);
380
+ if (outline === 'flow-collate')
381
+ return guess;
382
+ let { width, height } = guess;
383
+ for (let step = 0; step < 200 && !holdsBlock(outline, look, width, height, tw, th); step++) {
384
+ width *= 1.02;
385
+ height *= 1.02;
386
+ }
387
+ return { width, height };
388
+ }
389
+ function guessSize(outline, look, tw, th) {
390
+ switch (outline) {
391
+ case 'circle':
392
+ // A circle given two sizes is an ellipse; one sized for its text is round.
393
+ return { width: Math.hypot(tw, th), height: Math.hypot(tw, th) };
394
+ case 'diamond':
395
+ return { width: tw * 2, height: th * 2 };
396
+ case 'pill':
397
+ return { width: tw + th, height: th };
398
+ case 'parallelogram':
399
+ return { width: tw + (look.slant ?? SHAPE_DEFAULTS.slant), height: th };
400
+ case 'hexagon':
401
+ return { width: tw + 2 * (look.point ?? SHAPE_DEFAULTS.point), height: th };
402
+ case 'trapezoid':
403
+ return { width: tw + 2 * (look.taper ?? SHAPE_DEFAULTS.taper), height: th };
404
+ case 'triangle':
405
+ return look.aim === 'left' || look.aim === 'right' ? { width: tw * 2, height: th * 2.4 } : { width: tw * 2.4, height: th * 2 };
406
+ case 'cylinder': {
407
+ const c = look.cap ?? SHAPE_DEFAULTS.cap;
408
+ return look.lying ? { width: tw + 4 * c, height: th } : { width: tw, height: th + 4 * c };
409
+ }
410
+ case 'flow-document':
411
+ return { width: tw, height: th + 3 * SHAPE_DEFAULTS.wave };
412
+ case 'flow-off-page':
413
+ return { width: tw, height: th + SHAPE_DEFAULTS.offPage };
414
+ case 'flow-delay':
415
+ return { width: tw + th / 2, height: th };
416
+ case 'flow-manual-input':
417
+ return { width: tw, height: th + SHAPE_DEFAULTS.slope };
418
+ case 'flow-stored-data':
419
+ return { width: tw + 2 * SHAPE_DEFAULTS.curve, height: th };
420
+ case 'flow-loop-start':
421
+ case 'flow-loop-end':
422
+ return { width: tw + SHAPE_DEFAULTS.cut, height: th + SHAPE_DEFAULTS.cut / 2 };
423
+ case 'flow-card':
424
+ return { width: tw + SHAPE_DEFAULTS.cut / 2, height: th + SHAPE_DEFAULTS.cut / 2 };
425
+ case 'flow-display':
426
+ return { width: tw + th * 0.8, height: th };
427
+ case 'flow-tape': {
428
+ const d = Math.hypot(tw, th);
429
+ return { width: d + SHAPE_DEFAULTS.tail, height: d };
430
+ }
431
+ case 'flow-punched-tape':
432
+ return { width: tw, height: th + 4 * SHAPE_DEFAULTS.wave };
433
+ case 'flow-collate':
434
+ return { width: tw, height: Math.max(th * 1.6, th + 20) };
435
+ default:
436
+ return { width: tw, height: th };
437
+ }
438
+ }
439
+ // --- sides and corners ---------------------------------------------------------------
440
+ /**
441
+ * Where the ray from `from` heading `way` first leaves a polygon, measured as
442
+ * the distance along the ray, or undefined if it never crosses it.
443
+ */
444
+ export function rayExit(points, from, way) {
445
+ let best;
446
+ for (let i = 0; i < points.length; i++) {
447
+ const a = points[i];
448
+ const b = points[(i + 1) % points.length];
449
+ const t = rayHitsSegment(from, way, a, b);
450
+ if (t !== undefined && t > 1e-9 && (best === undefined || t > best))
451
+ best = t;
452
+ }
453
+ return best;
454
+ }
455
+ /** Every distance along the line through `from` heading `way`, either direction, at which it crosses a polygon. */
456
+ export function lineCrossings(points, from, way) {
457
+ const out = [];
458
+ for (let i = 0; i < points.length; i++) {
459
+ const t = rayHitsSegment(from, way, points[i], points[(i + 1) % points.length], true);
460
+ if (t !== undefined)
461
+ out.push(t);
462
+ }
463
+ return out;
464
+ }
465
+ function rayHitsSegment(from, way, a, b, both = false) {
466
+ const ex = b.x - a.x;
467
+ const ey = b.y - a.y;
468
+ const denom = way.x * ey - way.y * ex;
469
+ if (Math.abs(denom) < 1e-12)
470
+ return undefined;
471
+ const t = ((a.x - from.x) * ey - (a.y - from.y) * ex) / denom;
472
+ const s = ((a.x - from.x) * way.y - (a.y - from.y) * way.x) / denom;
473
+ if (s < -1e-9 || s > 1 + 1e-9)
474
+ return undefined;
475
+ if (!both && t < 0)
476
+ return undefined;
477
+ return t;
478
+ }
479
+ /** Where the line from a polygon's middle toward `target` crosses its outline: the farthest crossing, so a concave outline is met at its rim. */
480
+ export function towardOutline(points, middle, target) {
481
+ const dx = target.x - middle.x;
482
+ const dy = target.y - middle.y;
483
+ const length = Math.hypot(dx, dy);
484
+ if (length < 1e-9)
485
+ return middle;
486
+ const way = { x: dx / length, y: dy / length };
487
+ const t = rayExit(points, middle, way) ?? 0;
488
+ return { x: middle.x + way.x * t, y: middle.y + way.y * t };
489
+ }
490
+ /**
491
+ * The stretch of the outline that is one side: from where the line from the
492
+ * middle to one corner of the box meets it, round to where the line to the
493
+ * next corner does. Returned in reading order, left to right along a top or
494
+ * bottom side and top to bottom along a left or right one.
495
+ */
496
+ export function sideStretch(points, middle, box, side) {
497
+ const tl = { x: box.x, y: box.y };
498
+ const tr = { x: box.x + box.width, y: box.y };
499
+ const bl = { x: box.x, y: box.y + box.height };
500
+ const br = { x: box.x + box.width, y: box.y + box.height };
501
+ const [c0, c1] = { top: [tl, tr], right: [tr, br], bottom: [bl, br], left: [tl, bl] }[side];
502
+ const a0 = Math.atan2(c0.y - middle.y, c0.x - middle.x);
503
+ const a1 = Math.atan2(c1.y - middle.y, c1.x - middle.x);
504
+ // The angular wedge between the two corner rays that holds the side.
505
+ const span = (to) => {
506
+ let d = to - a0;
507
+ while (d < 0)
508
+ d += 2 * Math.PI;
509
+ while (d >= 2 * Math.PI)
510
+ d -= 2 * Math.PI;
511
+ return d;
512
+ };
513
+ let width = span(a1);
514
+ let clockwise = true;
515
+ const facing = { top: -Math.PI / 2, right: 0, bottom: Math.PI / 2, left: Math.PI }[side];
516
+ if (span(facing) > width) {
517
+ clockwise = false;
518
+ width = 2 * Math.PI - width;
519
+ }
520
+ const out = [];
521
+ const steps = 96;
522
+ for (let i = 0; i <= steps; i++) {
523
+ const angle = a0 + (clockwise ? 1 : -1) * (width * i) / steps;
524
+ const p = towardOutline(points, middle, { x: middle.x + Math.cos(angle), y: middle.y + Math.sin(angle) });
525
+ const last = out[out.length - 1];
526
+ if (!last || Math.hypot(p.x - last.x, p.y - last.y) > 1e-6)
527
+ out.push(p);
528
+ }
529
+ return out;
530
+ }
531
+ /** The point a fraction of the way along a polyline, by length. */
532
+ export function alongPolyline(points, fraction) {
533
+ let total = 0;
534
+ for (let i = 1; i < points.length; i++)
535
+ total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
536
+ let left = Math.max(0, Math.min(1, fraction)) * total;
537
+ for (let i = 1; i < points.length; i++) {
538
+ const a = points[i - 1];
539
+ const b = points[i];
540
+ const length = Math.hypot(b.x - a.x, b.y - a.y);
541
+ if (left <= length || i === points.length - 1) {
542
+ const f = length < 1e-9 ? 0 : Math.min(1, left / length);
543
+ return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
544
+ }
545
+ left -= length;
546
+ }
547
+ return points[0] ?? { x: 0, y: 0 };
548
+ }
549
+ /** Which side of the box a point on the outline is on, by the corner rays from the middle. */
550
+ export function sideOfPoint(middle, box, at) {
551
+ const angle = Math.atan2(at.y - middle.y, at.x - middle.x);
552
+ const corner = (x, y) => Math.atan2(y - middle.y, x - middle.x);
553
+ const tr = corner(box.x + box.width, box.y);
554
+ const br = corner(box.x + box.width, box.y + box.height);
555
+ const bl = corner(box.x, box.y + box.height);
556
+ const tl = corner(box.x, box.y);
557
+ if (angle >= tr && angle <= br)
558
+ return 'right';
559
+ if (angle >= br && angle <= bl)
560
+ return 'bottom';
561
+ if (angle <= tr && angle >= tl)
562
+ return 'top';
563
+ return 'left';
564
+ }
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;