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/README.md +2 -2
- package/SYNTAX.md +31 -4
- package/dist/ast.d.ts +9 -2
- package/dist/ast.js +9 -2
- package/dist/cli.js +35 -12
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +8 -0
- package/dist/constrain.d.ts +56 -0
- package/dist/constrain.js +95 -0
- package/dist/model.d.ts +13 -0
- package/dist/model.js +7 -0
- package/dist/page.d.ts +14 -0
- package/dist/page.js +488 -0
- package/dist/parser.js +21 -3
- package/dist/place.d.ts +19 -0
- package/dist/place.js +67 -8
- package/dist/render.d.ts +17 -0
- package/dist/render.js +101 -29
- package/dist/resolve.d.ts +14 -0
- package/dist/resolve.js +33 -7
- package/dist/themes.d.ts +1 -0
- package/dist/themes.js +1 -1
- package/package.json +1 -1
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.
|
|
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 (
|
|
2238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '"')}" 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
|
|
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
|
|
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="${
|
|
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="${
|
|
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
|
|
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;
|