reladraw 0.20.0 → 0.22.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/page.js ADDED
@@ -0,0 +1,412 @@
1
+ import { fileChecks, findings } from './checks.js';
2
+ import { SourceError } from './errors.js';
3
+ import { blame, parse } from './parser.js';
4
+ import { renderTagged, themeOf } from './render.js';
5
+ import { resolve } from './resolve.js';
6
+ import { luminance } from './themes.js';
7
+ import { PAN_ZOOM } from './viewer.js';
8
+ /**
9
+ * `reladraw render --page`: one diagram in one self-contained HTML page, made
10
+ * for looking at rather than embedding. The page is the diagram's own colors,
11
+ * headed with its title, and it pans, zooms, goes full screen, saves the SVG
12
+ * or a PNG, lists the findings, and names a box under the pointer. It needs
13
+ * nothing running and fetches nothing, so it can be attached, mailed or
14
+ * committed. `name` is the file's name, the title when the file states none.
15
+ */
16
+ export function drawPage(source, name, options = {}) {
17
+ const run = (text) => {
18
+ const layout = resolve(parse(text), options);
19
+ const { svg, tagged } = renderTagged(layout, options);
20
+ const found = options.checks === false ? [] : findings(layout, options.checks ? options.checks(fileChecks(layout)) : fileChecks(layout));
21
+ const title = layout.diagram['title'] ?? name;
22
+ return { html: page({ title, name, svg, tagged, findings: found, theme: themeOf(layout, options) }), findings: found };
23
+ };
24
+ try {
25
+ return run(source);
26
+ }
27
+ catch (error) {
28
+ throw error instanceof SourceError ? blame(source, error, run) : error;
29
+ }
30
+ }
31
+ function page({ title, name, svg, tagged, findings, theme }) {
32
+ const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
33
+ // The browser's own controls (scroll bars, focus rings) follow the page, so
34
+ // a dark diagram doesn't get a light one's. A color written by hand in a
35
+ // form that can't be measured leaves the choice to the browser.
36
+ const scheme = hex(theme.text) && hex(theme.background) ? (luminance(theme.text) > luminance(theme.background) ? 'dark' : 'light') : 'light dark';
37
+ const data = {
38
+ svg,
39
+ name,
40
+ findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
41
+ };
42
+ // Replaced by functions, so a `$` in a title or a drawing is taken as written.
43
+ // The zoom's code goes in first, so nothing a diagram says can be taken for its marker.
44
+ return PAGE.replace('{{panzoom}}', () => PAN_ZOOM)
45
+ .replace('{{scheme}}', () => scheme)
46
+ .replace('{{title}}', () => escapeHtml(title))
47
+ .replace('{{heading}}', () => escapeHtml(title))
48
+ .replace('{{colors}}', () => [
49
+ `--page: ${theme.background};`,
50
+ `--ink: ${theme.text};`,
51
+ `--muted: ${theme.mutedText};`,
52
+ `--rule: ${theme.boxStroke};`,
53
+ `--accent: ${theme.primary.color};`,
54
+ `--on-accent: ${theme.primary.on};`,
55
+ ].join(' '))
56
+ .replace('{{drawing}}', () => tagged)
57
+ .replace('{{data}}', () => scriptJson(data));
58
+ }
59
+ function escapeHtml(text) {
60
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
61
+ }
62
+ /** JSON that can sit inside a `<script>` without ending it. */
63
+ function scriptJson(value) {
64
+ return JSON.stringify(value)
65
+ .replace(/</g, '\\u003c');
66
+ }
67
+ const PAGE = `<!doctype html>
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="utf-8">
71
+ <meta name="viewport" content="width=device-width, initial-scale=1">
72
+ <meta name="color-scheme" content="{{scheme}}">
73
+ <meta name="generator" content="reladraw">
74
+ <title>{{title}}</title>
75
+ <style>
76
+ :root { {{colors}} }
77
+ * { box-sizing: border-box; }
78
+ html, body { margin: 0; height: 100%; }
79
+ body {
80
+ display: flex; flex-direction: column; height: 100vh; height: 100dvh;
81
+ background: var(--page); color: var(--ink);
82
+ font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
83
+ }
84
+ header {
85
+ display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px;
86
+ padding: 10px 16px;
87
+ border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
88
+ }
89
+ h1 { flex: 1 1 16rem; margin: 0; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
90
+ .tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
91
+ .tools .gap { width: 8px; }
92
+ button {
93
+ font: inherit; font-size: 13px; color: var(--ink); background: transparent;
94
+ border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 6px;
95
+ padding: 4px 10px; cursor: pointer; white-space: nowrap;
96
+ }
97
+ button:hover { background: color-mix(in srgb, var(--ink) 10%, transparent); }
98
+ button:disabled { opacity: 0.45; cursor: default; background: transparent; }
99
+ button[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 25%, transparent); border-color: var(--accent); }
100
+ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
101
+ .menu { position: relative; }
102
+ .menu-list {
103
+ position: absolute; right: 0; top: calc(100% + 4px); z-index: 5;
104
+ flex-direction: column; gap: 4px; padding: 6px;
105
+ background: var(--page); border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 6px;
106
+ }
107
+ .menu-list:not([hidden]) { display: flex; }
108
+ #scale .fitted { color: var(--accent); }
109
+ #scale { min-width: 6em; text-align: center; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
110
+ main { position: relative; flex: 1; display: flex; min-height: 0; }
111
+ #view { position: relative; flex: 1; overflow: hidden; touch-action: none; cursor: grab; }
112
+ #view.dragging { cursor: grabbing; }
113
+ #stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
114
+ #stage svg { display: block; }
115
+ #view g[data-name].hover { filter: drop-shadow(0 0 3px var(--accent)); }
116
+ #view g[data-name].lit { filter: drop-shadow(0 0 2px var(--accent)) drop-shadow(0 0 6px var(--accent)); animation: lit 0.6s ease-in-out 3 alternate; }
117
+ @keyframes lit { from { opacity: 1; } to { opacity: 0.45; } }
118
+ #tip {
119
+ position: absolute; pointer-events: none; display: none; z-index: 2;
120
+ padding: 2px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap;
121
+ background: var(--accent); color: var(--on-accent);
122
+ }
123
+ aside {
124
+ width: min(26rem, 38vw); display: flex; flex-direction: column; min-height: 0;
125
+ border-left: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
126
+ }
127
+ aside[hidden] { display: none; }
128
+ aside h2 { margin: 0; padding: 10px 16px 6px; font-size: 13px; font-weight: 600; }
129
+ aside h2 .what { display: block; font-weight: 400; font-size: 12px; color: var(--muted); }
130
+ aside ol { list-style: none; margin: 0; padding: 0 8px 12px; overflow: auto; }
131
+ aside li button {
132
+ display: block; width: 100%; text-align: left; white-space: normal; border-color: transparent;
133
+ padding: 6px 8px; margin: 2px 0;
134
+ }
135
+ aside li button.chosen { border-color: var(--accent); }
136
+ .where { color: var(--muted); font-variant-numeric: tabular-nums; }
137
+ .kind { color: var(--accent); }
138
+ .note { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
139
+ @media (max-width: 700px) {
140
+ main { flex-direction: column; }
141
+ aside { width: auto; max-height: 40%; border-left: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
142
+ }
143
+ </style>
144
+ </head>
145
+ <body>
146
+ <header>
147
+ <h1>{{heading}}</h1>
148
+ <div class="tools">
149
+ <button id="out" type="button" title="Zoom out (-)" aria-label="Zoom out">−</button>
150
+ <span id="scale" aria-live="polite">100%</span>
151
+ <button id="in" type="button" title="Zoom in (+)" aria-label="Zoom in">+</button>
152
+ <button id="fit" type="button" title="The whole diagram in view (0)">Fit</button>
153
+ <button id="actual" type="button" title="The drawing at its own scale (1)">100%</button>
154
+ <span class="gap"></span>
155
+ <button id="full" type="button" title="Full screen (f)">Full screen</button>
156
+ <span class="menu">
157
+ <button id="export" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="export-list">Export ▾</button>
158
+ <span id="export-list" class="menu-list" hidden>
159
+ <button id="copy-svg" type="button">Copy SVG</button>
160
+ <button id="save-svg" type="button" title="Save the picture as an SVG file">Save SVG</button>
161
+ <button id="save-png" type="button" title="Save the picture as a PNG, at twice its size">Save PNG</button>
162
+ </span>
163
+ </span>
164
+ <button id="toggle-findings" type="button" aria-controls="findings"></button>
165
+ </div>
166
+ </header>
167
+ <main>
168
+ <div id="view">
169
+ <div id="stage">
170
+ {{drawing}}
171
+ </div>
172
+ <div id="tip"></div>
173
+ </div>
174
+ <aside id="findings" aria-label="Findings">
175
+ <h2>Findings <span class="what">where the picture, drawn as written, may not read as meant</span></h2>
176
+ <ol id="finding-list"></ol>
177
+ </aside>
178
+ </main>
179
+ <script>
180
+ (function () {
181
+ var DATA = {{data}};
182
+ var view = document.getElementById('view');
183
+ var stage = document.getElementById('stage');
184
+ var svg = stage.querySelector('svg');
185
+ var tip = document.getElementById('tip');
186
+ var scaleLabel = document.getElementById('scale');
187
+ {{panzoom}}
188
+ var zoom = panZoom(view, stage, scaleLabel, {
189
+ out: document.getElementById('out'),
190
+ in: document.getElementById('in'),
191
+ fit: document.getElementById('fit'),
192
+ actual: document.getElementById('actual'),
193
+ });
194
+
195
+ /* ---- full screen ---- */
196
+
197
+ var full = document.getElementById('full');
198
+ if (!document.fullscreenEnabled) full.hidden = true;
199
+ function toggleFull() {
200
+ if (document.fullscreenElement) document.exitFullscreen();
201
+ else document.documentElement.requestFullscreen();
202
+ }
203
+ full.onclick = toggleFull;
204
+ document.addEventListener('fullscreenchange', function () {
205
+ full.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
206
+ });
207
+
208
+ /* ---- saving ---- */
209
+
210
+ function saveBlob(blob, name) {
211
+ var url = URL.createObjectURL(blob);
212
+ var link = document.createElement('a');
213
+ link.href = url;
214
+ link.download = name;
215
+ document.body.appendChild(link);
216
+ link.click();
217
+ link.remove();
218
+ // A tick, so the navigation has the URL before it is taken back.
219
+ setTimeout(function () { URL.revokeObjectURL(url); }, 0);
220
+ }
221
+
222
+ function flash(button, text) {
223
+ var was = button.textContent;
224
+ button.textContent = text;
225
+ setTimeout(function () { button.textContent = was; }, 1200);
226
+ }
227
+
228
+ // Copy SVG, Save SVG and Save PNG are under one Export button, as in the
229
+ // playground. The list stays open after a choice, so "Copied" can be read,
230
+ // and closes on a click elsewhere or Escape.
231
+ var exportButton = document.getElementById('export');
232
+ var exportList = document.getElementById('export-list');
233
+ function showExport(open) {
234
+ exportList.hidden = !open;
235
+ exportButton.setAttribute('aria-expanded', String(open));
236
+ }
237
+ exportButton.onclick = function () { showExport(exportList.hidden); };
238
+ document.addEventListener('click', function (event) {
239
+ if (!exportList.hidden && !event.target.closest('.menu')) showExport(false);
240
+ });
241
+
242
+ // The clipboard API needs a secure context; a page opened from disk may
243
+ // not count as one, so the old selection route is the fallback.
244
+ var copySvg = document.getElementById('copy-svg');
245
+ copySvg.onclick = function () {
246
+ function fallback() {
247
+ var area = document.createElement('textarea');
248
+ area.value = DATA.svg;
249
+ document.body.appendChild(area);
250
+ area.select();
251
+ var done = false;
252
+ try { done = document.execCommand('copy'); } catch (e) {}
253
+ area.remove();
254
+ flash(copySvg, done ? 'Copied' : 'Blocked');
255
+ }
256
+ if (navigator.clipboard && navigator.clipboard.writeText) {
257
+ navigator.clipboard.writeText(DATA.svg).then(function () { flash(copySvg, 'Copied'); }, fallback);
258
+ } else fallback();
259
+ };
260
+
261
+ document.getElementById('save-svg').onclick = function () {
262
+ saveBlob(new Blob([DATA.svg], { type: 'image/svg+xml' }), DATA.name + '.svg');
263
+ };
264
+
265
+ // The browser draws the SVG onto a canvas with the fonts the page shows it
266
+ // in, twice the size so it stays sharp on a high-density screen. A data URL
267
+ // rather than a blob one, which some browsers count as foreign and refuse
268
+ // to let the canvas be read back.
269
+ var savePng = document.getElementById('save-png');
270
+ savePng.onclick = function () {
271
+ var image = new Image();
272
+ image.onload = function () {
273
+ var box = svg.viewBox.baseVal;
274
+ var canvas = document.createElement('canvas');
275
+ canvas.width = box.width * 2;
276
+ canvas.height = box.height * 2;
277
+ canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
278
+ canvas.toBlob(function (blob) {
279
+ blob ? saveBlob(blob, DATA.name + '.png') : flash(savePng, 'Failed');
280
+ }, 'image/png');
281
+ };
282
+ image.onerror = function () { flash(savePng, 'Failed'); };
283
+ image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(DATA.svg);
284
+ };
285
+
286
+ /* ---- a thing's name under the pointer ---- */
287
+
288
+ var hovered = null;
289
+ view.addEventListener('pointerover', function (event) {
290
+ var group = event.target.closest && event.target.closest('g[data-name]');
291
+ if (group === hovered) return;
292
+ if (hovered) hovered.classList.remove('hover');
293
+ hovered = group;
294
+ if (!group) {
295
+ tip.style.display = 'none';
296
+ return;
297
+ }
298
+ group.classList.add('hover');
299
+ tip.textContent = group.getAttribute('data-name');
300
+ tip.style.display = 'block';
301
+ });
302
+ view.addEventListener('pointermove', function (event) {
303
+ if (!hovered) return;
304
+ var rect = view.getBoundingClientRect();
305
+ var x = event.clientX - rect.left + 14;
306
+ var y = event.clientY - rect.top + 16;
307
+ // Kept inside the view, so a name near the right edge isn't cut off.
308
+ x = Math.min(x, rect.width - tip.offsetWidth - 4);
309
+ y = Math.min(y, rect.height - tip.offsetHeight - 4);
310
+ tip.style.left = x + 'px';
311
+ tip.style.top = y + 'px';
312
+ });
313
+ view.addEventListener('pointerleave', function () {
314
+ if (hovered) hovered.classList.remove('hover');
315
+ hovered = null;
316
+ tip.style.display = 'none';
317
+ });
318
+
319
+ /* ---- findings ---- */
320
+
321
+ var panel = document.getElementById('findings');
322
+ var list = document.getElementById('finding-list');
323
+ var toggle = document.getElementById('toggle-findings');
324
+ var count = DATA.findings.length;
325
+
326
+ function showPanel(open) {
327
+ panel.hidden = !open;
328
+ toggle.setAttribute('aria-pressed', String(open));
329
+ }
330
+ if (count === 0) {
331
+ toggle.textContent = 'No findings';
332
+ toggle.disabled = true;
333
+ panel.hidden = true;
334
+ } else {
335
+ toggle.textContent = count === 1 ? '1 finding' : count + ' findings';
336
+ toggle.onclick = function () { showPanel(panel.hidden); };
337
+ showPanel(true);
338
+ }
339
+
340
+ var lit = [];
341
+ var chosen = null;
342
+ function unlight() {
343
+ lit.forEach(function (group) { group.classList.remove('lit'); });
344
+ lit = [];
345
+ if (chosen) chosen.classList.remove('chosen');
346
+ chosen = null;
347
+ }
348
+
349
+ // A finding names the line of the statement at fault; what that statement
350
+ // drew lights up, and the view moves to it if it is out of sight.
351
+ function light(finding, button) {
352
+ unlight();
353
+ chosen = button;
354
+ button.classList.add('chosen');
355
+ lit = Array.from(svg.querySelectorAll('g[data-line="' + finding.line + '"]'));
356
+ lit.forEach(function (group) {
357
+ group.classList.remove('lit');
358
+ void group.getBoundingClientRect();
359
+ group.classList.add('lit');
360
+ });
361
+ if (lit.length === 0) return;
362
+ var room = view.getBoundingClientRect();
363
+ var r = null;
364
+ lit.forEach(function (group) {
365
+ var b = group.getBoundingClientRect();
366
+ r = r ? { l: Math.min(r.l, b.left), t: Math.min(r.t, b.top), r: Math.max(r.r, b.right), b: Math.max(r.b, b.bottom) }
367
+ : { l: b.left, t: b.top, r: b.right, b: b.bottom };
368
+ });
369
+ var inside = r.l >= room.left && r.r <= room.right && r.t >= room.top && r.b <= room.bottom;
370
+ if (inside) return;
371
+ zoom.pan((room.left + room.width / 2) - (r.l + r.r) / 2, (room.top + room.height / 2) - (r.t + r.b) / 2);
372
+ }
373
+
374
+ DATA.findings.forEach(function (finding) {
375
+ var item = document.createElement('li');
376
+ var button = document.createElement('button');
377
+ button.type = 'button';
378
+ var where = document.createElement('span');
379
+ where.className = 'where';
380
+ where.textContent = 'line ' + finding.line + ' ';
381
+ var kind = document.createElement('span');
382
+ kind.className = 'kind';
383
+ kind.textContent = finding.kind;
384
+ button.append(where, kind, document.createElement('br'), document.createTextNode(finding.message));
385
+ if (!svg.querySelector('g[data-line="' + finding.line + '"]')) {
386
+ var note = document.createElement('span');
387
+ note.className = 'note';
388
+ note.textContent = 'Nothing drawn comes from that line.';
389
+ button.append(note);
390
+ }
391
+ button.onclick = function () { light(finding, button); };
392
+ item.append(button);
393
+ list.append(item);
394
+ });
395
+
396
+ /* ---- keys, beyond the zoom's own ---- */
397
+
398
+ document.addEventListener('keydown', function (event) {
399
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
400
+ if (event.target.closest && event.target.closest('input, textarea')) return;
401
+ if (event.key === 'f' && !full.hidden) toggleFull();
402
+ else if (event.key === 'Escape') { showExport(false); unlight(); }
403
+ else return;
404
+ event.preventDefault();
405
+ });
406
+
407
+ zoom.show();
408
+ })();
409
+ </script>
410
+ </body>
411
+ </html>
412
+ `;
package/dist/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BUNDLE_KEYS, COLOR_KEYS, POINT_KEYS, REGION_KEYS, WAYS, describePoint, fractionAxis, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, TEXT_KEYS, MARK_TEXT_KEYS, POINT_TEXT_SIDES, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, EDGE_ORDER_KEYS, LAYOUT_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
1
+ import { BUNDLE_KEYS, COLOR_KEYS, POINT_KEYS, REGION_KEYS, WAYS, describePoint, fractionAxis, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, TEXT_KEYS, MARK_TEXT_KEYS, POINT_TEXT_SIDES, LINE_KEYS, LINE_VALUES, BORDER_KEYS, CONTENTS_KEYS, EDGE_ORDER_KEYS, LAYOUT_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { CHECK_DEFAULTS, CHECK_KINDS, setCheck } from './checks.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { STATEMENT_KEYWORDS } from './grammar.js';
@@ -309,6 +309,7 @@ const BRACKET_KEYS = {
309
309
  text: TEXT_KEYS,
310
310
  contents: CONTENTS_KEYS,
311
311
  line: LINE_KEYS,
312
+ border: BORDER_KEYS,
312
313
  checks: CHECK_KINDS,
313
314
  };
314
315
  /** How each bracketed key's error quotes itself back, and what it is about. */
@@ -316,6 +317,7 @@ const BRACKET_ABOUT = {
316
317
  text: { kind: 'a text', example: 'color: theme-muted' },
317
318
  contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
318
319
  line: { kind: 'a line', example: 'path: square' },
320
+ border: { kind: 'a border', example: 'thickness: thick' },
319
321
  checks: { kind: 'a `checks:` bracket', example: 'edge-crossings: on' },
320
322
  };
321
323
  /**
@@ -702,7 +704,8 @@ function readAttr(tokens, at, attrs, line, subject) {
702
704
  // A line's color is stored where `line: red` puts it, so the short form
703
705
  // and the bracket are one key: a style's `line: red` merges under an
704
706
  // edge's `line: (color: blue)`, and writing both on one line is a repeat.
705
- const stored = key === 'line' && inner === 'color' ? 'line' : `${key}.${inner}`;
707
+ // A border's color the same.
708
+ const stored = (key === 'line' || key === 'border') && inner === 'color' ? key : `${key}.${inner}`;
706
709
  setOnce(attrs, stored, value, subject, line, { key: `${key}: (${inner}: …)`, value: (v) => v });
707
710
  }
708
711
  return read.next;
@@ -720,6 +723,16 @@ function readAttr(tokens, at, attrs, line, subject) {
720
723
  setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
721
724
  return at + 2;
722
725
  }
726
+ if (key === 'title') {
727
+ // A title is a text, and every text is quoted. Unquoted, its first word
728
+ // would be the title and the rest read as stray words.
729
+ const value = tokens[at + 1];
730
+ if (!value || !value.quoted) {
731
+ throw new SourceError(`${subject}: a title is written in quotes — \`title: "How a login reaches the database"\``, line);
732
+ }
733
+ setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
734
+ return at + 2;
735
+ }
723
736
  if (key === 'style') {
724
737
  // `style: base, critical and alarm` — several bundles, applied in the order
725
738
  // written, a later one winning where two set the same key. The list reads
@@ -768,7 +781,7 @@ function readAttr(tokens, at, attrs, line, subject) {
768
781
  });
769
782
  return next;
770
783
  }
771
- if (bracketKeys !== undefined && key !== 'text' && key !== 'line') {
784
+ if (bracketKeys !== undefined && key !== 'text' && key !== 'line' && key !== 'border') {
772
785
  // `contents: match` names the part and then says one of its two properties
773
786
  // without saying which. The brackets are what make the level shift visible,
774
787
  // so there is no unbracketed spelling to fall back to.
@@ -821,6 +834,11 @@ function readAttr(tokens, at, attrs, line, subject) {
821
834
  `\`line: (${property}: ${valueToken.text})\``, line);
822
835
  }
823
836
  }
837
+ if (key === 'border' && !valueToken.quoted && /^(thin|normal|thick|\d*\.?\d+(px)?)$/.test(valueToken.text)) {
838
+ // `border: red` is the short form of the color, as `line: red` is.
839
+ throw new SourceError(`\`border: ${valueToken.text}\` — \`border:\` on its own takes a color. Its thickness goes in its bracket: ` +
840
+ `\`border: (thickness: ${valueToken.text})\``, line);
841
+ }
824
842
  if (key === 'align' && valueToken.text === 'widths') {
825
843
  // Removed in 0.3.0. It was a size operation wearing an alignment's name,
826
844
  // and its value set had one member — a flag in a property's clothes. Its
package/dist/place.d.ts CHANGED
@@ -153,6 +153,8 @@ export declare class Placer {
153
153
  private readonly origins;
154
154
  /** Containers whose anchor is being placed on the page, so a box outside them is measured on the page rather than in their frame. */
155
155
  private readonly onPage;
156
+ /** Boxes that anchor their frame on one axis only, when no box in it says nothing at all (`splitAnchors`). */
157
+ private readonly axisAnchors;
156
158
  /** Named points and regions being worked out, innermost last, to refuse one defined through itself. */
157
159
  private readonly expanding;
158
160
  constructor(roots: LayoutNode[], byName: Map<string, LayoutNode>, facts: Map<LayoutNode, NodeFacts>,
@@ -534,6 +536,23 @@ export declare class Placer {
534
536
  * title something is placed against anchors the frame instead.
535
537
  */
536
538
  private checkAnchors;
539
+ /**
540
+ * Every box in a frame says something about where it goes, so the frame is
541
+ * anchored one axis at a time (design.org 5.3): across by the one box that
542
+ * says nothing across, up and down by the one that says nothing up and
543
+ * down. A lone direction's centering counts as saying nothing when its
544
+ * target's place on that axis is worked out from the box itself, as with
545
+ * `b right of a` and `a even with b.1`: the centering gives way to what was
546
+ * written. An axis nobody leaves free is a circle, reported when placing,
547
+ * and an axis a container is placed on by what it holds needs no anchor.
548
+ */
549
+ private splitAnchors;
550
+ /** 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. */
551
+ private loneGivesWay;
552
+ /** The boxes a placement names, through points and regions. */
553
+ private namedFrom;
554
+ /** Whether a box's place on one axis is worked out, however indirectly, from `from` or something inside it. */
555
+ private placedFrom;
537
556
  /**
538
557
  * A box in a venn's part may be placed against a box in another part, and
539
558
  * then it says where its whole part goes up and down: so it must be the
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;