reladraw 0.20.0 → 0.21.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/place.js CHANGED
@@ -130,6 +130,8 @@ export class Placer {
130
130
  origins = new Map();
131
131
  /** Containers whose anchor is being placed on the page, so a box outside them is measured on the page rather than in their frame. */
132
132
  onPage = new Set();
133
+ /** Boxes that anchor their frame on one axis only, when no box in it says nothing at all (`splitAnchors`). */
134
+ axisAnchors = new Set();
133
135
  /** Named points and regions being worked out, innermost last, to refuse one defined through itself. */
134
136
  expanding = [];
135
137
  constructor(roots, byName, facts,
@@ -286,7 +288,7 @@ export class Placer {
286
288
  if (this.frameAnchor(node, axis))
287
289
  return this.anchorOnPage(node, axis);
288
290
  const above = node.parent ? this.origin(node.parent, axis) : 0;
289
- if (this.floats(node))
291
+ if (this.floats(node, axis))
290
292
  return above;
291
293
  this.refusePlacedTwice(node, axis);
292
294
  return above + this.start(node, axis) - this.frame(node, axis).boxStart;
@@ -346,9 +348,11 @@ export class Placer {
346
348
  * goes, and nothing inside it is placed against a box outside it in its
347
349
  * stead. Its frame's origin is its own container's.
348
350
  */
349
- floats(node) {
351
+ floats(node, axis) {
350
352
  const parent = node.parent;
351
- if (node.placements.length > 0 || node.children.length === 0)
353
+ if (node.children.length === 0)
354
+ return false;
355
+ if (node.placements.length > 0 && !this.axisAnchors.has(`${node.name}\n${axis}`))
352
356
  return false;
353
357
  if (parent && (parent.heading === node || joinedToText(node) || this.facts.get(parent).layout || this.vennPart(node) !== undefined)) {
354
358
  return false;
@@ -609,7 +613,7 @@ export class Placer {
609
613
  }
610
614
  // A container that anchors its frame shares its container's origin, so
611
615
  // its box starts where its outline does.
612
- if (this.floats(node))
616
+ if (this.floats(node, axis))
613
617
  return this.frame(node, axis).boxStart;
614
618
  if (node.placements.length === 0 || this.acrossParts(node)) {
615
619
  // The anchor of its frame. One placed against a box in another part of
@@ -628,7 +632,7 @@ export class Placer {
628
632
  placed(node, axis) {
629
633
  const statements = this.statements(node, axis);
630
634
  if (statements.length === 0)
631
- return this.lone(node, axis);
635
+ return this.axisAnchors.has(`${node.name}\n${axis}`) ? 0 : this.lone(node, axis);
632
636
  if (statements.length === 1) {
633
637
  const [only] = statements;
634
638
  return this.apply(node, axis, only);
@@ -2234,9 +2238,8 @@ export class Placer {
2234
2238
  const content = members.filter((member) => !this.framed(member));
2235
2239
  const unplaced = content.filter((member) => this.unplaced(member));
2236
2240
  const onTitle = content.find((member) => this.onTitle(member));
2237
- if (!parent && unplaced.length === 0) {
2238
- throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
2239
- }
2241
+ if (unplaced.length === 0 && !(parent && this.anchoredOnTitle(parent)))
2242
+ this.splitAnchors(content, parent);
2240
2243
  if (unplaced.length > 1) {
2241
2244
  const names = unplaced.map((node) => `"${node.name}"`).join(', ');
2242
2245
  throw new SourceError(parent
@@ -2253,6 +2256,62 @@ export class Placer {
2253
2256
  for (const member of all)
2254
2257
  this.checkAnchors(member.children, member);
2255
2258
  }
2259
+ /**
2260
+ * Every box in a frame says something about where it goes, so the frame is
2261
+ * anchored one axis at a time (design.org 5.3): across by the one box that
2262
+ * says nothing across, up and down by the one that says nothing up and
2263
+ * down. A lone direction's centering counts as saying nothing when its
2264
+ * target's place on that axis is worked out from the box itself, as with
2265
+ * `b right of a` and `a even with b.1`: the centering gives way to what was
2266
+ * written. An axis nobody leaves free is a circle, reported when placing,
2267
+ * and an axis a container is placed on by what it holds needs no anchor.
2268
+ */
2269
+ splitAnchors(members, parent) {
2270
+ const free = BOTH_AXES.map((axis) => parent && this.frameAnchor(parent, axis)
2271
+ ? []
2272
+ : members.filter((member) => this.statements(member, axis).length === 0 && this.loneGivesWay(member, axis)));
2273
+ if (!parent && free.every((on) => on.length === 0)) {
2274
+ throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
2275
+ }
2276
+ BOTH_AXES.forEach((axis, index) => {
2277
+ const on = free[index];
2278
+ // With the other axis bound all round there is a circle, which placing reports better.
2279
+ if (on.length > 1 && free.every((other) => other.length > 0)) {
2280
+ const names = on.map((node) => `"${node.name}"`).join(', ');
2281
+ throw new SourceError(`exactly one node may say nothing about where it goes ${AXIS_SAID[axis]}, but ${on.length} do: ${names}`, on[1].line);
2282
+ }
2283
+ if (on.length === 1)
2284
+ this.axisAnchors.add(`${on[0].name}\n${axis}`);
2285
+ });
2286
+ }
2287
+ /** Whether a box with nothing stated on this axis is left free there: no lone direction centers it, or the one that would is placed from the box itself. */
2288
+ loneGivesWay(node, axis) {
2289
+ const offsets = node.placements.filter((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined);
2290
+ if (offsets.length === 0)
2291
+ return true;
2292
+ if (offsets.length > 1)
2293
+ return false;
2294
+ return this.namedFrom(offsets[0]).some((named) => this.placedFrom(named, axis, node, new Set()));
2295
+ }
2296
+ /** The boxes a placement names, through points and regions. */
2297
+ namedFrom(placement) {
2298
+ return placement.targets
2299
+ .flatMap((target) => this.boxesNamed(target, []))
2300
+ .flatMap((name) => this.byName.get(name) ?? []);
2301
+ }
2302
+ /** Whether a box's place on one axis is worked out, however indirectly, from `from` or something inside it. */
2303
+ placedFrom(node, axis, from, seen) {
2304
+ if (node === from || isWithin(node, from))
2305
+ return true;
2306
+ if (seen.has(node))
2307
+ return false;
2308
+ seen.add(node);
2309
+ const stated = this.statements(node, axis).map((statement) => statement.placement);
2310
+ const placements = stated.length > 0
2311
+ ? stated
2312
+ : node.placements.filter((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined);
2313
+ return placements.some((placement) => this.namedFrom(placement).some((named) => this.placedFrom(named, axis, from, seen)));
2314
+ }
2256
2315
  /**
2257
2316
  * A box in a venn's part may be placed against a box in another part, and
2258
2317
  * then it says where its whole part goes up and down: so it must be the
package/dist/render.d.ts CHANGED
@@ -10,3 +10,20 @@ export interface RenderOptions {
10
10
  }
11
11
  /** Turn solved geometry into a standalone SVG document. */
12
12
  export declare function render(layout: Layout, options?: RenderOptions): string;
13
+ /**
14
+ * The SVG as `render` gives it, and the same drawing with each thing wrapped in
15
+ * a group naming it and its statement's line, for a page that points at things
16
+ * (`--page`). Drawn once, so the two can't disagree.
17
+ */
18
+ export declare function renderTagged(layout: Layout, options?: RenderOptions): {
19
+ svg: string;
20
+ tagged: string;
21
+ };
22
+ /**
23
+ * The colors a drawing is made in. A theme passed in — the command line's
24
+ * `--theme` — beats the one the file names, so one source renders in either.
25
+ * `diagram background:` and `text:` are the author overruling a color of
26
+ * whichever theme that is, and a color written by hand wins over any theme, so
27
+ * they are folded in afterwards and everything downstream sees one theme.
28
+ */
29
+ export declare function themeOf(layout: Layout, options?: RenderOptions): Theme;
package/dist/render.js CHANGED
@@ -3,7 +3,7 @@ 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';
5
5
  import { monospaceMeasurer } from './measure.js';
6
- import { joinedToText } from './model.js';
6
+ import { joinedToText, outlineStated } from './model.js';
7
7
  import { DARK_THEME, THEMES, overlapFill, regionTint, textOnFill, themeColor } from './themes.js';
8
8
  import { crossingPoints, planLines, traceRoute, visibleRuns } from './lines.js';
9
9
  import { patchPadding, placeLineTexts } from './linetexts.js';
@@ -11,22 +11,21 @@ import { plain } from './text.js';
11
11
  const CORNER = 8;
12
12
  /** Turn solved geometry into a standalone SVG document. */
13
13
  export function render(layout, options = {}) {
14
+ return drawing(layout, options)(false);
15
+ }
16
+ /**
17
+ * The SVG as `render` gives it, and the same drawing with each thing wrapped in
18
+ * a group naming it and its statement's line, for a page that points at things
19
+ * (`--page`). Drawn once, so the two can't disagree.
20
+ */
21
+ export function renderTagged(layout, options = {}) {
22
+ const assemble = drawing(layout, options);
23
+ return { svg: assemble(false), tagged: assemble(true) };
24
+ }
25
+ function drawing(layout, options) {
14
26
  const measurer = options.measurer ?? monospaceMeasurer();
15
27
  const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
16
- // A theme passed in — the command line's `--theme` — beats the one the file
17
- // names, so one source renders in either. `diagram background:` and `text:`
18
- // are the author overruling a color of whichever theme that is, and a color
19
- // written by hand wins over any theme, so they are folded in afterwards and
20
- // everything downstream sees one theme.
21
- const named = layout.diagram['theme'];
22
- const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
23
- const page = layout.diagram['background'];
24
- const words = layout.diagram['text.color'];
25
- const theme = {
26
- ...base,
27
- ...(page !== undefined && { background: themeColor(page, base) }),
28
- ...(words !== undefined && { text: themeColor(words, base) }),
29
- };
28
+ const theme = themeOf(layout, options);
30
29
  // Guides are shown by the viewer (`--guides`), or by a file that explains
31
30
  // its own construction (`diagram guides: show`).
32
31
  const guides = {
@@ -110,8 +109,8 @@ export function render(layout, options = {}) {
110
109
  }
111
110
  const lineSvg = new Map(lines.map((line) => [line.key, line.svg]));
112
111
  const body = sequence
113
- .map((item) => lineSvg.get(item.key) ?? item.svg)
114
- .filter((svg) => svg.length > 0);
112
+ .map((item) => ({ item, svg: lineSvg.get(item.key) ?? item.svg }))
113
+ .filter(({ svg }) => svg.length > 0);
115
114
  // An edge's geometry is measured rather than solved for, so the resolver sized
116
115
  // the canvas from the boxes alone. A curve out of a `top` side, or a text
117
116
  // riding above one, lands outside that — so the page grows to hold it and the
@@ -135,8 +134,11 @@ export function render(layout, options = {}) {
135
134
  markers.set(markerId(mark, color, free), [mark, color, free]);
136
135
  }
137
136
  }
138
- const svg = [
137
+ return (tagged) => ownIds([
139
138
  `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}" viewBox="${canvas.x} ${canvas.y} ${canvas.width} ${canvas.height}" font-family=${quote(measurer.fontFamily)} font-size="${fontSize}px">`,
139
+ // `diagram title:` — what a browser shows on the tab when the SVG is opened
140
+ // on its own, and what a screen reader announces.
141
+ ...(layout.diagram['title'] === undefined ? [] : [` <title>${escapeXml(layout.diagram['title'])}</title>`]),
140
142
  ' <defs>',
141
143
  ...[...markers.values()].map(([mark, color, free]) => markMarker(mark, color, theme.background, free)),
142
144
  // Each mask covers the whole page, in page units: the default region is the
@@ -150,11 +152,51 @@ export function render(layout, options = {}) {
150
152
  ].join('\n')),
151
153
  ' </defs>',
152
154
  ` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
153
- ...body,
155
+ ...body.map(({ item, svg }) => (tagged ? tag(layout, item, svg) : svg)),
154
156
  '</svg>',
155
157
  '',
156
- ].join('\n');
157
- return ownIds(svg);
158
+ ].join('\n'));
159
+ }
160
+ /**
161
+ * The colors a drawing is made in. A theme passed in — the command line's
162
+ * `--theme` — beats the one the file names, so one source renders in either.
163
+ * `diagram background:` and `text:` are the author overruling a color of
164
+ * whichever theme that is, and a color written by hand wins over any theme, so
165
+ * they are folded in afterwards and everything downstream sees one theme.
166
+ */
167
+ export function themeOf(layout, options = {}) {
168
+ const named = layout.diagram['theme'];
169
+ const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
170
+ const page = layout.diagram['background'];
171
+ const words = layout.diagram['text.color'];
172
+ return {
173
+ ...base,
174
+ ...(page !== undefined && { background: themeColor(page, base) }),
175
+ ...(words !== undefined && { text: themeColor(words, base) }),
176
+ };
177
+ }
178
+ /**
179
+ * `<g data-name="api" data-line="12">` round each thing drawn: what a page
180
+ * shows on hover, and what a finding about that line lights up.
181
+ */
182
+ function tag(layout, item, svg) {
183
+ let name;
184
+ let line;
185
+ if (item.node) {
186
+ ({ name, line } = item.node);
187
+ }
188
+ else if (item.key.startsWith('edge ')) {
189
+ const edge = layout.edges[Number(item.key.slice('edge '.length))];
190
+ name = edge.name ?? `${edge.from.name} ${edge.arrow} ${edge.to.name}`;
191
+ line = edge.line;
192
+ }
193
+ else {
194
+ const mark = layout.marks.find((one) => one.name === item.key);
195
+ if (!mark)
196
+ return svg;
197
+ ({ name, line } = mark);
198
+ }
199
+ return `<g data-name="${escapeXml(name).replace(/"/g, '&quot;')}" data-line="${line}">\n${svg}\n</g>`;
158
200
  }
159
201
  /**
160
202
  * Gives every id in a drawing a prefix of its own, taken from a hash of the
@@ -305,7 +347,7 @@ function drawNode(node, theme, measurer, fontSize, markup, guides, inherited) {
305
347
  * cards drawn back to front, so the first, the front card, is on top.
306
348
  */
307
349
  function drawDeck(node, theme, measurer, fontSize, markup, guides, url) {
308
- const outlined = node.appearance['fill'] !== undefined || node.appearance['border'] !== undefined;
350
+ const outlined = outlineStated(node.appearance);
309
351
  const drawn = outlined ? node : { ...node, appearance: { ...node.appearance, fill: 'none', border: 'none' } };
310
352
  const cards = node.children.filter((child) => child !== node.heading && !joinedToText(child));
311
353
  // The cards are the same thing, so each looks like the front one unless it says otherwise.
@@ -364,8 +406,29 @@ function borderSvg(node, theme) {
364
406
  if (node.body.kind !== 'shape')
365
407
  return '';
366
408
  const face = faceOf(node);
409
+ const d = outlinePath(node.body.outline, face.x, face.y, face.width, face.height, node.corners, node.body.look);
410
+ const stroke = borderStroke(node.borderWidth, `<path d="${d}"/>`, `rim-${node.name.replace(/[^\w.-]/g, '_')}`);
367
411
  const border = borderOf(node.appearance, theme, theme.containerStroke);
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}"/>`;
412
+ return `${stroke.clip} <path d="${d}" fill="none" stroke="${border}"${stroke.attr}/>`;
413
+ }
414
+ /**
415
+ * How a border is stroked. A thick border lies wholly inside the outline: the
416
+ * outline is stroked twice as wide and clipped to itself, so only the inner
417
+ * half shows, and its outer edge is the outline exactly, along every side and
418
+ * at every corner and point of every shape, where a line ends and a gap is
419
+ * measured from (design.org 9.1). A normal border straddles the outline, as
420
+ * every box's always did, and writes nothing here. `detail` is for the lines
421
+ * drawn inside a shape, such as a cylinder's rim, which are as wide as the
422
+ * border and straddle where they run.
423
+ */
424
+ function borderStroke(width, outline, id) {
425
+ if (width === undefined)
426
+ return { clip: '', attr: '', detail: '' };
427
+ return {
428
+ clip: ` <clipPath id="${id}">${outline}</clipPath>\n`,
429
+ attr: ` stroke-width="${round(width * 2)}" clip-path="url(#${id})"`,
430
+ detail: ` stroke-width="${round(width)}" clip-path="url(#${id})"`,
431
+ };
369
432
  }
370
433
  /** What a node draws of its own, without its children, which are drawn as items of their own. */
371
434
  function nodeSvg(node, theme, measurer, fontSize, markup) {
@@ -431,18 +494,22 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
431
494
  // and the fill, drawn without a stroke, still shows behind the title's inner
432
495
  // half.
433
496
  const onBorder = node.lines.length > 0 && node.textAttrs['place']?.startsWith('on ') === true;
497
+ const d = outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look);
498
+ const stroke = borderStroke(node.borderWidth, `<path d="${d}"/>`, `border-${node.name.replace(/[^\w.-]/g, '_')}`);
499
+ if (stroke.clip)
500
+ parts.push(stroke.clip.trimEnd());
434
501
  if (onBorder) {
435
502
  const id = `title-${node.name.replace(/[^\w.-]/g, '_')}`;
436
503
  const room = PAD / 2;
437
504
  const hole = node.textBox;
438
505
  const grown = { x: face.x - PAD, y: face.y - PAD, w: face.width + PAD * 2, h: face.height + PAD * 2 };
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})"/>`);
506
+ 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="${d}" fill="${fill}" stroke="none"/>`, ` <path d="${d}" fill="none" stroke="${border}"${stroke.attr} mask="url(#${id})"/>`);
440
507
  }
441
508
  else {
442
- parts.push(` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look)}" fill="${fill}" stroke="${border}"/>`);
509
+ parts.push(` <path d="${d}" fill="${fill}" stroke="${border}"${stroke.attr}/>`);
443
510
  }
444
511
  for (const extra of outlineDetail(outline, face.x, face.y, face.width, face.height, look)) {
445
- parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
512
+ parts.push(` <path d="${extra}" fill="none" stroke="${border}"${stroke.detail}/>`);
446
513
  }
447
514
  // A leaf's text defaults to the middle of its box, a container's to the top
448
515
  // left of the band; both then read `at` for where it really goes. Where the
@@ -493,6 +560,11 @@ function markSvg(mark, theme, guides, markup, fontSize) {
493
560
  ? `d="M${round(mark.x)} ${round(mark.y)}L${round(mark.x + mark.width)} ${round(mark.y + mark.height)}"`
494
561
  : `x="${round(mark.x)}" y="${round(mark.y)}" width="${round(mark.width)}" height="${round(mark.height)}" rx="4"`;
495
562
  const shape = flat ? 'path' : 'rect';
563
+ // A thick border lies inside the region's stated edge, as a box's does; one
564
+ // drawn as a line has no inside, so it is only wider.
565
+ const edge = flat
566
+ ? { clip: '', attr: mark.borderWidth === undefined ? '' : ` stroke-width="${round(mark.borderWidth)}"` }
567
+ : borderStroke(mark.borderWidth, `<rect ${box}/>`, `edge-${mark.name.replace(/[^\w.-]/g, '_')}`);
496
568
  // A text on the border breaks it, as a title on a box's border does.
497
569
  const hole = mark.text && mark.text.textAttrs['place']?.startsWith('on ') ? mark.text.box : undefined;
498
570
  const id = `region-${mark.name.replace(/[^\w.-]/g, '_')}`;
@@ -506,13 +578,13 @@ function markSvg(mark, theme, guides, markup, fontSize) {
506
578
  if (mark.guide) {
507
579
  const fill = color(said('fill')) ?? 'none';
508
580
  const border = color(said('border')) ?? guides.color;
509
- return `${masked} <${shape} ${box} fill="${fill}" stroke="${border}" stroke-dasharray="${GUIDE_DASH}"${mask}/>${label}`;
581
+ return `${edge.clip}${masked} <${shape} ${box} fill="${fill}" stroke="${border}"${edge.attr} stroke-dasharray="${GUIDE_DASH}"${mask}/>${label}`;
510
582
  }
511
583
  const fill = regionFill(mark, theme);
512
584
  const border = color(said('border'));
513
585
  const opacity = fill.color === 'none' || fill.opacity >= 1 ? '' : ` fill-opacity="${round(fill.opacity)}"`;
514
- return (`${masked} <${shape} ${box} fill="${fill.color}"${opacity}` +
515
- ` stroke="${border ?? theme.primary.color}"${border === undefined ? ' stroke-opacity="0.5"' : ''} stroke-width="1"${mask}/>${label}`);
586
+ return (`${edge.clip}${masked} <${shape} ${box} fill="${fill.color}"${opacity}` +
587
+ ` stroke="${border ?? theme.primary.color}"${border === undefined ? ' stroke-opacity="0.5"' : ''}${edge.attr || ' stroke-width="1"'}${mask}/>${label}`);
516
588
  }
517
589
  /**
518
590
  * A region's fill (design.org 8.3): what it states, or the theme's tint, at
package/dist/resolve.d.ts CHANGED
@@ -26,3 +26,17 @@ export interface ResolveOptions {
26
26
  * sits on is ever decided here, because the author wrote it down.
27
27
  */
28
28
  export declare function resolve(doc: Document, options?: ResolveOptions): Layout;
29
+ /**
30
+ * How a node's corners are drawn. A value is checked wherever it came from — a
31
+ * style's `corners: sharpp` is as much a typo as a node's — but a circle refuses
32
+ * `corners:` only when the node itself says it, since a circle has none. From a
33
+ * style or a default it lands on the outlines that have corners, the way a
34
+ * line's `corners: rounded` from a style lands only on lines with corners. A
35
+ * node drawn as a picture or with no body has no outline; `checkAttrs` has
36
+ * already refused the word written on one, and a style's is unused.
37
+ */
38
+ /**
39
+ * A border's thickness in pixels: `thin`, `normal`, `thick`, a multiple of
40
+ * normal, or `px`, read as a line's is (design.org 3.8).
41
+ */
42
+ export declare function borderWidthOf(what: string, value: string | undefined, line: number): number;
package/dist/resolve.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { TEXT_GAP } from './linetexts.js';
2
2
  import { POINT_KEYS, REGION_KEYS, ALL_ATTR_KEYS, ATTR_KEYS, MARKS, COLOR_KEYS, COLOR_PARTS, CHILD_ALIGNMENTS, GATHER_PLACES, CONTENT_WIDTHS, SIZE_KEY_NAMES, LINE_VALUES, NODE_CORNERS, PADDING_SIDES, describePlacement, nameTarget, partWordHint, } from './ast.js';
3
- import { DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, THICKNESS, fontSizeFor, widestLine, } from './constants.js';
3
+ import { DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, THICKNESS, BORDER_THICKNESS, BORDER_WIDTH, fontSizeFor, widestLine, } from './constants.js';
4
4
  import { SourceError } from './errors.js';
5
5
  import { bodyFor, pastedIcon } from './icons.js';
6
6
  import { monospaceMeasurer } from './measure.js';
7
7
  import { isBoxy, middleOf, sizeFor, SUBPROCESS_INSET } from './outline.js';
8
8
  import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
9
- import { joinedToText } from './model.js';
9
+ import { joinedToText, outlineStated } from './model.js';
10
10
  import { checkOpacity, gapPixels, Placer, regionTextAt } from './place.js';
11
11
  /**
12
12
  * What a node's own contents make of it, before anything is placed: a leaf's
@@ -31,12 +31,15 @@ function factsFor(node, measurer, fontSize) {
31
31
  const venn = vennOf(node);
32
32
  const circle = isCircle(node);
33
33
  // A deck that draws no outline of its own is its cards, with nothing round them.
34
- const undrawnDeck = node.appearance['layout'] === 'deck' && node.appearance['fill'] === undefined && node.appearance['border'] === undefined;
34
+ const undrawnDeck = node.appearance['layout'] === 'deck' && !outlineStated(node.appearance);
35
+ // A thick border is drawn inside the outline, so the padding is measured
36
+ // from its inner edge and the box grows by the extra (design.org 9.1).
37
+ const extra = (node.borderWidth ?? BORDER_WIDTH) - BORDER_WIDTH;
35
38
  const padding = node.body.kind === 'none' || (undrawnDeck && node.appearance['padding'] === undefined)
36
39
  ? 0
37
- : node.appearance['padding'] !== undefined
40
+ : (node.appearance['padding'] !== undefined
38
41
  ? gapPixels(node.appearance['padding'], `"${node.name}" padding:`, node.line)
39
- : PAD;
42
+ : PAD) + extra;
40
43
  // `padding-right: 4`: one side's padding, over `padding:` there.
41
44
  const pad = { top: padding, right: padding, bottom: padding, left: padding };
42
45
  for (const side of PADDING_SIDES) {
@@ -46,7 +49,7 @@ function factsFor(node, measurer, fontSize) {
46
49
  if (circle) {
47
50
  throw new SourceError(`"${node.name}" padding-${side}: a circle has no sides to pad one of — use padding:`, node.line);
48
51
  }
49
- pad[side] = gapPixels(written, `"${node.name}" padding-${side}:`, node.line);
52
+ pad[side] = gapPixels(written, `"${node.name}" padding-${side}:`, node.line) + extra;
50
53
  }
51
54
  // A subprocess's bars stand inside its sides, so its text keeps its padding clear of them.
52
55
  if (node.body.kind === 'shape' && node.body.outline === 'flow-subprocess') {
@@ -486,6 +489,7 @@ function buildMarks(named, placed, styles, texts = new Map()) {
486
489
  else if (text) {
487
490
  box = pointTextBox(at.x, at.y, text);
488
491
  }
492
+ const borderWidth = borderWidthOf(`region "${name}"`, appearance['border.thickness'], stmt.line);
489
493
  marks.push({
490
494
  kind: stmt.kind,
491
495
  name,
@@ -493,6 +497,7 @@ function buildMarks(named, placed, styles, texts = new Map()) {
493
497
  names,
494
498
  appearance,
495
499
  guide: stmt.guide === true,
500
+ ...(borderWidth !== BORDER_WIDTH ? { borderWidth } : {}),
496
501
  ...(text && box ? { text: { lines: text.lines, textAttrs: text.textAttrs, size: text.size, lineHeight: text.lineHeight, box } } : {}),
497
502
  line: stmt.line,
498
503
  });
@@ -781,6 +786,10 @@ function buildTree(statements, styles, defaults, icons) {
781
786
  // the renderer does for its colors; its own words beat both.
782
787
  const guideLook = stmt.guide ? defaults.get('guide node')?.['corners'] : undefined;
783
788
  node.corners = cornersOf(node, stmt.attrs['corners'] ?? guideLook ?? appearance['corners'], stmt.attrs['corners'] !== undefined);
789
+ const guideBorder = stmt.guide ? defaults.get('guide node')?.['border.thickness'] : undefined;
790
+ const borderWidth = borderWidthOf(`"${node.name}"`, stmt.attrs['border.thickness'] ?? guideBorder ?? appearance['border.thickness'], stmt.line);
791
+ if (borderWidth !== BORDER_WIDTH)
792
+ node.borderWidth = borderWidth;
784
793
  const cut = stmt.name.lastIndexOf('.');
785
794
  if (cut === -1) {
786
795
  roots.push(node);
@@ -1097,7 +1106,7 @@ function checkAttrs(kind, name, attrs, line) {
1097
1106
  // Quoted back the way it was written. A bracketed value arrives one dotted
1098
1107
  // key at a time, and `contents: match` is not a line anybody could look for.
1099
1108
  // A deck's cards are stored joined on a line break; quote them back as written.
1100
- const shown = key === 'cards' ? value.split('\n').map((text) => `"${text}"`).join(' ') : value;
1109
+ const shown = key === 'cards' ? value.split('\n').map((text) => `"${text}"`).join(' ') : key === 'title' ? `"${value}"` : value;
1101
1110
  const wrote = written === key ? `${key}: ${shown}` : `${key}: (${written.slice(key.length + 1)}: ${shown})`;
1102
1111
  if (!ALL_ATTR_KEYS.includes(key)) {
1103
1112
  // Nothing anywhere in the language answers to this word, so the only
@@ -1144,6 +1153,23 @@ function checkAttrs(kind, name, attrs, line) {
1144
1153
  * node drawn as a picture or with no body has no outline; `checkAttrs` has
1145
1154
  * already refused the word written on one, and a style's is unused.
1146
1155
  */
1156
+ /**
1157
+ * A border's thickness in pixels: `thin`, `normal`, `thick`, a multiple of
1158
+ * normal, or `px`, read as a line's is (design.org 3.8).
1159
+ */
1160
+ export function borderWidthOf(what, value, line) {
1161
+ if (value === undefined)
1162
+ return BORDER_WIDTH;
1163
+ const named = BORDER_THICKNESS[value];
1164
+ if (named !== undefined)
1165
+ return named;
1166
+ const number = /^(\d*\.?\d+)(px)?$/.exec(value);
1167
+ if (!number || Number(number[1]) <= 0) {
1168
+ throw new SourceError(`${what}: "border: (thickness: ${value})" — a thickness is thin, normal, thick, ` +
1169
+ 'a multiple of normal greater than zero such as `thickness: 2`, or pixels such as `thickness: 3px`', line);
1170
+ }
1171
+ return number[2] ? Number(number[1]) : BORDER_WIDTH * Number(number[1]);
1172
+ }
1147
1173
  function cornersOf(node, value, written) {
1148
1174
  if (node.body.kind !== 'shape' || value === undefined)
1149
1175
  return NODE_CORNERS[0];
package/dist/themes.d.ts CHANGED
@@ -72,6 +72,7 @@ export declare function themeColor(value: string, theme: Theme): string;
72
72
  * A hand-picked fill is left alone — the theme has no say over it.
73
73
  */
74
74
  export declare function textOnFill(fill: string, theme: Theme): string | undefined;
75
+ export declare function luminance(hex: string): number;
75
76
  /**
76
77
  * Sampled out of `examples/reference/arch.png` rather than invented,
77
78
  * so the benchmark render and the drawing it is measured against differ by
package/dist/themes.js CHANGED
@@ -86,7 +86,7 @@ export function textOnFill(fill, theme) {
86
86
  const apart = (color) => Math.abs(luminance(color) - under);
87
87
  return apart(theme.text) >= apart(theme.background) ? theme.text : theme.background;
88
88
  }
89
- function luminance(hex) {
89
+ export function luminance(hex) {
90
90
  const [r, g, b] = [1, 3, 5].map((at) => {
91
91
  const c = parseInt(hex.slice(at, at + 2), 16) / 255;
92
92
  return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {