reladraw 0.0.1 → 0.1.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/LICENSE +202 -0
- package/NOTICE +14 -0
- package/README.md +133 -2
- package/SYNTAX.md +534 -0
- package/dist/ast.d.ts +160 -0
- package/dist/ast.js +78 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +72 -0
- package/dist/constants.d.ts +142 -0
- package/dist/constants.js +187 -0
- package/dist/constrain.d.ts +56 -0
- package/dist/constrain.js +95 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +14 -0
- package/dist/grammar.d.ts +103 -0
- package/dist/grammar.js +215 -0
- package/dist/icons.d.ts +86 -0
- package/dist/icons.js +166 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +16 -0
- package/dist/lexer.d.ts +25 -0
- package/dist/lexer.js +91 -0
- package/dist/measure.d.ts +38 -0
- package/dist/measure.js +66 -0
- package/dist/model.d.ts +78 -0
- package/dist/model.js +1 -0
- package/dist/parser.d.ts +3 -0
- package/dist/parser.js +418 -0
- package/dist/render.d.ts +31 -0
- package/dist/render.js +1164 -0
- package/dist/resolve.d.ts +22 -0
- package/dist/resolve.js +1012 -0
- package/package.json +42 -4
package/dist/icons.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in icon set.
|
|
3
|
+
*
|
|
4
|
+
* Every glyph is path data written into the SVG, and that is the whole reason
|
|
5
|
+
* this file exists rather than a dependency. The output contract is a standalone
|
|
6
|
+
* document: an icon font would render as blank boxes on any machine that does
|
|
7
|
+
* not have the font, and an `<image href>` would need the file to travel beside
|
|
8
|
+
* the SVG. Inline paths cost a few hundred bytes each and always arrive.
|
|
9
|
+
*
|
|
10
|
+
* A name here says what the thing *is*, never what the picture looks like. The
|
|
11
|
+
* same discipline as `gap: wide` over `gap: 110` and `muted` over a hex value:
|
|
12
|
+
* the word is the whole interface, so it has to carry meaning rather than
|
|
13
|
+
* geometry, and naming the meaning is what lets the drawing be improved later
|
|
14
|
+
* without every diagram that uses it changing sense.
|
|
15
|
+
*
|
|
16
|
+
* The set is deliberately small. In a drawing tool you pick a shape out of a
|
|
17
|
+
* visual palette and hundreds are browsable; here you type the word from memory,
|
|
18
|
+
* which caps the useful vocabulary at something that fits in a head. Add a name
|
|
19
|
+
* when a diagram asks for a distinction it cannot otherwise make.
|
|
20
|
+
*/
|
|
21
|
+
import { SourceError } from './errors.js';
|
|
22
|
+
const GRID = 24;
|
|
23
|
+
/** Line width on the 24-unit grid, scaled with everything else. */
|
|
24
|
+
export const ICON_STROKE = 1.1;
|
|
25
|
+
/** A full circle as one path, so the data below can stay declarative. */
|
|
26
|
+
function circle(cx, cy, r) {
|
|
27
|
+
return `M${cx - r} ${cy} a${r} ${r} 0 1 0 ${r * 2} 0 a${r} ${r} 0 1 0 ${-r * 2} 0 Z`;
|
|
28
|
+
}
|
|
29
|
+
/** The three visible faces of an isometric cube, top face centred on `cx, cy`. */
|
|
30
|
+
function cube(cx, cy, s) {
|
|
31
|
+
const half = s / 2;
|
|
32
|
+
return [
|
|
33
|
+
{ d: `M${cx} ${cy} L${cx + s} ${cy + half} L${cx} ${cy + s} L${cx - s} ${cy + half} Z`, fill: 'shade', stroke: 'ink' },
|
|
34
|
+
{ d: `M${cx - s} ${cy + half} L${cx} ${cy + s} L${cx} ${cy + s * 2} L${cx - s} ${cy + s * 1.5} Z`, fill: 'shade', stroke: 'ink' },
|
|
35
|
+
{ d: `M${cx + s} ${cy + half} L${cx} ${cy + s} L${cx} ${cy + s * 2} L${cx + s} ${cy + s * 1.5} Z`, fill: 'shade', stroke: 'ink' },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
export const ICONS = {
|
|
39
|
+
/** A spinning disk: the physical drive, not the filesystem on it. */
|
|
40
|
+
disk: {
|
|
41
|
+
grid: GRID,
|
|
42
|
+
paths: [
|
|
43
|
+
{ d: 'M4 2 h16 a1.6 1.6 0 0 1 1.6 1.6 v16.8 a1.6 1.6 0 0 1 -1.6 1.6 h-16 a1.6 1.6 0 0 1 -1.6 -1.6 v-16.8 a1.6 1.6 0 0 1 1.6 -1.6 Z', fill: 'shade', stroke: 'ink' },
|
|
44
|
+
{ d: circle(12, 10.6, 6.2), fill: 'void', stroke: 'ink' },
|
|
45
|
+
{ d: circle(12, 10.6, 1.9), fill: 'ink' },
|
|
46
|
+
{ d: 'M11.5 12.6 L12.9 13.4 L8.8 19.6 a1.25 1.25 0 0 1 -2.1 -1.35 Z', fill: 'ink' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
/** A workstation: monitor, keyboard and tower. */
|
|
50
|
+
desktop: {
|
|
51
|
+
grid: GRID,
|
|
52
|
+
paths: [
|
|
53
|
+
{ d: 'M1.2 2.6 h12.4 v9.2 h-12.4 Z', fill: 'shade', stroke: 'ink' },
|
|
54
|
+
{ d: 'M6.3 11.8 h2.2 v1.7 h-2.2 Z', fill: 'ink' },
|
|
55
|
+
{ d: 'M4.2 13.5 h6.4 v1.1 h-6.4 Z', fill: 'ink' },
|
|
56
|
+
{ d: 'M1.2 16.4 h12.4 v3.2 h-12.4 Z', fill: 'shade', stroke: 'ink' },
|
|
57
|
+
{ d: 'M2.6 17.6 h7.8 v0.9 h-7.8 Z', fill: 'ink' },
|
|
58
|
+
{ d: 'M16.2 2.6 h6.6 v17 h-6.6 Z', fill: 'shade', stroke: 'ink' },
|
|
59
|
+
{ d: circle(19.5, 5.2, 0.9), fill: 'ink' },
|
|
60
|
+
{ d: 'M17.4 9.4 h4.2 v0.7 h-4.2 Z M17.4 11.4 h4.2 v0.7 h-4.2 Z M17.4 13.4 h4.2 v0.7 h-4.2 Z', fill: 'ink' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
/** A portable machine. Open lid, so the screen is the page showing through. */
|
|
64
|
+
laptop: {
|
|
65
|
+
grid: GRID,
|
|
66
|
+
paths: [
|
|
67
|
+
{ d: 'M4 3.6 h16 v11.4 h-16 Z', fill: 'void', stroke: 'ink' },
|
|
68
|
+
{ d: 'M2.2 16 h19.6 l1.6 2.6 a0.7 0.7 0 0 1 -0.6 1.1 h-21.6 a0.7 0.7 0 0 1 -0.6 -1.1 Z', fill: 'ink' },
|
|
69
|
+
{ d: 'M9.6 17.2 h4.8 v1 h-4.8 Z', fill: 'shade' },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
/** Something stored as a whole rather than run: an archive, a bucket, a sync root. */
|
|
73
|
+
package: {
|
|
74
|
+
grid: GRID,
|
|
75
|
+
paths: [
|
|
76
|
+
{ d: 'M12 2.6 L22 7.4 L12 12.2 L2 7.4 Z', fill: 'shade', stroke: 'ink' },
|
|
77
|
+
{ d: 'M2 7.4 L12 12.2 L12 21 L2 16.2 Z', fill: 'shade', stroke: 'ink' },
|
|
78
|
+
{ d: 'M22 7.4 L12 12.2 L12 21 L22 16.2 Z', fill: 'shade', stroke: 'ink' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
/**
|
|
82
|
+
* Several interchangeable units of the same kind, as a group. Three rather
|
|
83
|
+
* than any particular number: this is the symbol for "several", and at two
|
|
84
|
+
* line-heights square a literal count turns to mush. Where the count carries
|
|
85
|
+
* meaning — where one of them is the end of an arrow — they are nodes, and
|
|
86
|
+
* `shape: instance` is how you draw them.
|
|
87
|
+
*/
|
|
88
|
+
cubes: {
|
|
89
|
+
grid: GRID,
|
|
90
|
+
paths: [...cube(6.6, 2.6, 4.4), ...cube(17.4, 2.6, 4.4), ...cube(12, 11.6, 4.4)],
|
|
91
|
+
},
|
|
92
|
+
/** One unit of the kind `cubes` shows several of. */
|
|
93
|
+
instance: {
|
|
94
|
+
grid: GRID,
|
|
95
|
+
paths: cube(12, 2.6, 8.4),
|
|
96
|
+
},
|
|
97
|
+
/** A store queried rather than read as files. */
|
|
98
|
+
database: {
|
|
99
|
+
grid: GRID,
|
|
100
|
+
paths: [
|
|
101
|
+
{ d: 'M3 6.4 v11.2 a9 3.4 0 0 0 18 0 v-11.2 Z', fill: 'shade', stroke: 'ink' },
|
|
102
|
+
{ d: 'M3 6.4 a9 3.4 0 0 1 18 0 a9 3.4 0 0 1 -18 0 Z', fill: 'shade', stroke: 'ink' },
|
|
103
|
+
{ d: 'M3 11 a9 3.4 0 0 0 18 0', stroke: 'ink' },
|
|
104
|
+
{ d: 'M3 15.6 a9 3.4 0 0 0 18 0', stroke: 'ink' },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
export const ICON_NAMES = Object.keys(ICONS);
|
|
109
|
+
/**
|
|
110
|
+
* The icon a node asks for, or nothing. Called by the resolver, which reserves
|
|
111
|
+
* the room, and by the renderer, which fills it, so the two cannot disagree
|
|
112
|
+
* about whether there is an icon at all.
|
|
113
|
+
*
|
|
114
|
+
* An unknown name is refused rather than dropped. That is the same rule
|
|
115
|
+
* `DIAGRAM_KEYS` follows and it is here for the same reason: a misspelt
|
|
116
|
+
* `icon: laptp` that quietly draws nothing is indistinguishable from the tool
|
|
117
|
+
* being broken, and an author will stare at the file looking for the mistake in
|
|
118
|
+
* the wrong place. Since the vocabulary is closed and short, the error can list
|
|
119
|
+
* the whole of it.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* The outlines a box can take. `box` is the plain rectangle and needs no word.
|
|
123
|
+
*
|
|
124
|
+
* Named for what a node *is*, never for the geometry, which is the same rule the
|
|
125
|
+
* icon names follow: `document` and not `folded-corner`. A shape carrying a
|
|
126
|
+
* conventional meaning is a second channel alongside colour, and a stronger one
|
|
127
|
+
* — a fill is whatever the author assigned and has to be learnt from the
|
|
128
|
+
* diagram, while a folded corner has meant "an artifact, not a process" in
|
|
129
|
+
* flowcharts for decades and reads with no legend at all.
|
|
130
|
+
*/
|
|
131
|
+
export const BOX_SHAPES = ['document'];
|
|
132
|
+
const PLAIN = { outline: 'box' };
|
|
133
|
+
/**
|
|
134
|
+
* What a node is drawn as. Shared by the resolver, which sizes it, and the
|
|
135
|
+
* renderer, which draws it.
|
|
136
|
+
*
|
|
137
|
+
* The value is either a box outline or the name of a glyph. Those are the two
|
|
138
|
+
* things "what is this drawn as" can answer, and the author has no reason to
|
|
139
|
+
* care which category their answer fell into. The test that separates them is
|
|
140
|
+
* whether the node still sizes itself from its label: a `document` does, a
|
|
141
|
+
* glyph does not.
|
|
142
|
+
*/
|
|
143
|
+
export function shapeFor(appearance, line) {
|
|
144
|
+
const named = appearance['shape'];
|
|
145
|
+
if (named === undefined)
|
|
146
|
+
return PLAIN;
|
|
147
|
+
if (named === 'box')
|
|
148
|
+
return PLAIN;
|
|
149
|
+
if (BOX_SHAPES.includes(named))
|
|
150
|
+
return { outline: named };
|
|
151
|
+
const glyph = ICONS[named];
|
|
152
|
+
if (glyph !== undefined)
|
|
153
|
+
return { outline: 'box', body: glyph };
|
|
154
|
+
throw new SourceError(`there is no shape called "${named}". The shapes are box, ${BOX_SHAPES.join(', ')}, ` +
|
|
155
|
+
`and any icon drawn as the node itself: ${ICON_NAMES.join(', ')}`, line);
|
|
156
|
+
}
|
|
157
|
+
export function iconFor(appearance, line) {
|
|
158
|
+
const named = appearance['icon'];
|
|
159
|
+
if (named === undefined)
|
|
160
|
+
return undefined;
|
|
161
|
+
const icon = ICONS[named];
|
|
162
|
+
if (icon === undefined) {
|
|
163
|
+
throw new SourceError(`there is no icon called "${named}". The icons are ${ICON_NAMES.join(', ')}`, line);
|
|
164
|
+
}
|
|
165
|
+
return icon;
|
|
166
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './ast.js';
|
|
2
|
+
export * from './constants.js';
|
|
3
|
+
export * from './errors.js';
|
|
4
|
+
export * from './grammar.js';
|
|
5
|
+
export * from './measure.js';
|
|
6
|
+
export * from './model.js';
|
|
7
|
+
export { parse } from './parser.js';
|
|
8
|
+
export { resolve, type ResolveOptions } from './resolve.js';
|
|
9
|
+
export { render, DARK_THEME, type RenderOptions, type Theme } from './render.js';
|
|
10
|
+
import { type RenderOptions } from './render.js';
|
|
11
|
+
import { type ResolveOptions } from './resolve.js';
|
|
12
|
+
/** Source text in, SVG out. The whole pipeline in one call. */
|
|
13
|
+
export declare function compile(source: string, options?: ResolveOptions & RenderOptions): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './ast.js';
|
|
2
|
+
export * from './constants.js';
|
|
3
|
+
export * from './errors.js';
|
|
4
|
+
export * from './grammar.js';
|
|
5
|
+
export * from './measure.js';
|
|
6
|
+
export * from './model.js';
|
|
7
|
+
export { parse } from './parser.js';
|
|
8
|
+
export { resolve } from './resolve.js';
|
|
9
|
+
export { render, DARK_THEME } from './render.js';
|
|
10
|
+
import { parse } from './parser.js';
|
|
11
|
+
import { render } from './render.js';
|
|
12
|
+
import { resolve } from './resolve.js';
|
|
13
|
+
/** Source text in, SVG out. The whole pipeline in one call. */
|
|
14
|
+
export function compile(source, options = {}) {
|
|
15
|
+
return render(resolve(parse(source), options), options);
|
|
16
|
+
}
|
package/dist/lexer.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface Token {
|
|
2
|
+
text: string;
|
|
3
|
+
/** True when the token came from a quoted string, so `foo:` inside it is literal. */
|
|
4
|
+
quoted: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Split one line into tokens. Whitespace separates; double quotes group, with
|
|
8
|
+
* `\"` and `\\` as the only escapes. A `//` outside quotes starts a comment and
|
|
9
|
+
* runs to the end of the line, so a comment may trail a statement.
|
|
10
|
+
*
|
|
11
|
+
* A lone `/` is an ordinary character, which keeps a path or a ratio writable
|
|
12
|
+
* unquoted. `#` is ordinary too: it opens a hex colour, which is why comments
|
|
13
|
+
* are spelled `//` rather than the `#` an earlier version used.
|
|
14
|
+
*
|
|
15
|
+
* Parentheses group the modifiers on a placement — `left of hub (gap: wide)` —
|
|
16
|
+
* and are tokens in their own right so that `(gap:` does not read as one word
|
|
17
|
+
* ending in a colon. They are deliberately *not* punctuation everywhere: an
|
|
18
|
+
* opening bracket counts only where a token starts, and a closing one only
|
|
19
|
+
* while a group is open, so an unquoted `rgb(20,20,20)` stays a single token.
|
|
20
|
+
*
|
|
21
|
+
* Returns an empty array for a blank or comment-only line.
|
|
22
|
+
*/
|
|
23
|
+
export declare function tokenizeLine(line: string, lineNumber: number): Token[];
|
|
24
|
+
/** A bare token ending in `:` opens the attribute section of a statement. */
|
|
25
|
+
export declare function isAttrKey(token: Token): boolean;
|
package/dist/lexer.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { SourceError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Split one line into tokens. Whitespace separates; double quotes group, with
|
|
4
|
+
* `\"` and `\\` as the only escapes. A `//` outside quotes starts a comment and
|
|
5
|
+
* runs to the end of the line, so a comment may trail a statement.
|
|
6
|
+
*
|
|
7
|
+
* A lone `/` is an ordinary character, which keeps a path or a ratio writable
|
|
8
|
+
* unquoted. `#` is ordinary too: it opens a hex colour, which is why comments
|
|
9
|
+
* are spelled `//` rather than the `#` an earlier version used.
|
|
10
|
+
*
|
|
11
|
+
* Parentheses group the modifiers on a placement — `left of hub (gap: wide)` —
|
|
12
|
+
* and are tokens in their own right so that `(gap:` does not read as one word
|
|
13
|
+
* ending in a colon. They are deliberately *not* punctuation everywhere: an
|
|
14
|
+
* opening bracket counts only where a token starts, and a closing one only
|
|
15
|
+
* while a group is open, so an unquoted `rgb(20,20,20)` stays a single token.
|
|
16
|
+
*
|
|
17
|
+
* Returns an empty array for a blank or comment-only line.
|
|
18
|
+
*/
|
|
19
|
+
export function tokenizeLine(line, lineNumber) {
|
|
20
|
+
const tokens = [];
|
|
21
|
+
let i = 0;
|
|
22
|
+
let depth = 0;
|
|
23
|
+
while (i < line.length) {
|
|
24
|
+
const ch = line[i];
|
|
25
|
+
if (ch === ' ' || ch === '\t') {
|
|
26
|
+
i += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ch === '/' && line[i + 1] === '/')
|
|
30
|
+
break;
|
|
31
|
+
if (ch === '(') {
|
|
32
|
+
depth += 1;
|
|
33
|
+
tokens.push({ text: '(', quoted: false });
|
|
34
|
+
i += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === ')' && depth > 0) {
|
|
38
|
+
depth -= 1;
|
|
39
|
+
tokens.push({ text: ')', quoted: false });
|
|
40
|
+
i += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (ch === '"') {
|
|
44
|
+
let text = '';
|
|
45
|
+
i += 1;
|
|
46
|
+
let closed = false;
|
|
47
|
+
while (i < line.length) {
|
|
48
|
+
const c = line[i];
|
|
49
|
+
if (c === '\\' && i + 1 < line.length) {
|
|
50
|
+
const next = line[i + 1];
|
|
51
|
+
// `\/` is the one escape that must survive tokenizing. The line break
|
|
52
|
+
// it escapes is not resolved until `splitLines`, long after this, so
|
|
53
|
+
// collapsing it to a bare `/` here would lose the fact that the
|
|
54
|
+
// author asked for a literal. Every other escape resolves now.
|
|
55
|
+
text += next === '/' ? '\\/' : next;
|
|
56
|
+
i += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (c === '"') {
|
|
60
|
+
closed = true;
|
|
61
|
+
i += 1;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
text += c;
|
|
65
|
+
i += 1;
|
|
66
|
+
}
|
|
67
|
+
if (!closed)
|
|
68
|
+
throw new SourceError('unterminated string', lineNumber);
|
|
69
|
+
tokens.push({ text, quoted: true });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
let text = '';
|
|
73
|
+
while (i < line.length) {
|
|
74
|
+
const c = line[i];
|
|
75
|
+
if (c === ' ' || c === '\t' || c === '"')
|
|
76
|
+
break;
|
|
77
|
+
if (c === '/' && line[i + 1] === '/')
|
|
78
|
+
break;
|
|
79
|
+
if (c === ')' && depth > 0)
|
|
80
|
+
break;
|
|
81
|
+
text += c;
|
|
82
|
+
i += 1;
|
|
83
|
+
}
|
|
84
|
+
tokens.push({ text, quoted: false });
|
|
85
|
+
}
|
|
86
|
+
return tokens;
|
|
87
|
+
}
|
|
88
|
+
/** A bare token ending in `:` opens the attribute section of a statement. */
|
|
89
|
+
export function isAttrKey(token) {
|
|
90
|
+
return !token.quoted && token.text.length > 1 && token.text.endsWith(':');
|
|
91
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text measurement, kept behind one interface on purpose.
|
|
3
|
+
*
|
|
4
|
+
* The parser and resolver are pure — text in, geometry out — and the only part
|
|
5
|
+
* of the system that needs to know about fonts is this. In Node there is no
|
|
6
|
+
* rendering engine to ask, so the default implementation assumes a monospace
|
|
7
|
+
* face, where every glyph has the same advance width and the answer is
|
|
8
|
+
* arithmetic. In a browser the same interface can be backed by the DOM, which
|
|
9
|
+
* measures exactly.
|
|
10
|
+
*/
|
|
11
|
+
export interface TextBox {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
lines: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface Measurer {
|
|
17
|
+
/** Font family written into the SVG. Must match what was measured. */
|
|
18
|
+
readonly fontFamily: string;
|
|
19
|
+
measure(text: string, fontSize: number): TextBox;
|
|
20
|
+
lineHeight(fontSize: number): number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A slash with whitespace on both sides marks a line break, so a label is
|
|
24
|
+
* really a short stack of lines. Each line is trimmed; empty ones are dropped.
|
|
25
|
+
*
|
|
26
|
+
* The whitespace is what makes the marker safe. Splitting on a bare `/` meant
|
|
27
|
+
* no label could contain one, so `TCP/IP` came out as two lines, and so did
|
|
28
|
+
* `16/9`, `I/O` and every path or URL. Requiring the spaces keeps the marker
|
|
29
|
+
* legible where it is meant — `"Computer 1 / Ubuntu"` — while a
|
|
30
|
+
* slash inside a word stays an ordinary character.
|
|
31
|
+
*
|
|
32
|
+
* That leaves the label that wants a spaced slash and no break — `Before / After`
|
|
33
|
+
* — which writes it `\/`. The lexer preserves that escape rather than resolving
|
|
34
|
+
* it, so the backslash is still here to suppress the split, and is dropped once
|
|
35
|
+
* the splitting is done.
|
|
36
|
+
*/
|
|
37
|
+
export declare function splitLines(text: string): string[];
|
|
38
|
+
export declare function monospaceMeasurer(fontFamily?: string, advanceRatio?: number): Measurer;
|
package/dist/measure.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text measurement, kept behind one interface on purpose.
|
|
3
|
+
*
|
|
4
|
+
* The parser and resolver are pure — text in, geometry out — and the only part
|
|
5
|
+
* of the system that needs to know about fonts is this. In Node there is no
|
|
6
|
+
* rendering engine to ask, so the default implementation assumes a monospace
|
|
7
|
+
* face, where every glyph has the same advance width and the answer is
|
|
8
|
+
* arithmetic. In a browser the same interface can be backed by the DOM, which
|
|
9
|
+
* measures exactly.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A slash with whitespace on both sides marks a line break, so a label is
|
|
13
|
+
* really a short stack of lines. Each line is trimmed; empty ones are dropped.
|
|
14
|
+
*
|
|
15
|
+
* The whitespace is what makes the marker safe. Splitting on a bare `/` meant
|
|
16
|
+
* no label could contain one, so `TCP/IP` came out as two lines, and so did
|
|
17
|
+
* `16/9`, `I/O` and every path or URL. Requiring the spaces keeps the marker
|
|
18
|
+
* legible where it is meant — `"Computer 1 / Ubuntu"` — while a
|
|
19
|
+
* slash inside a word stays an ordinary character.
|
|
20
|
+
*
|
|
21
|
+
* That leaves the label that wants a spaced slash and no break — `Before / After`
|
|
22
|
+
* — which writes it `\/`. The lexer preserves that escape rather than resolving
|
|
23
|
+
* it, so the backslash is still here to suppress the split, and is dropped once
|
|
24
|
+
* the splitting is done.
|
|
25
|
+
*/
|
|
26
|
+
export function splitLines(text) {
|
|
27
|
+
const lines = text
|
|
28
|
+
.split(/\s+\/\s+/)
|
|
29
|
+
.map((part) => part.trim().replace(/\\\//g, '/'))
|
|
30
|
+
.filter((part) => part.length > 0);
|
|
31
|
+
return lines.length > 0 ? lines : [''];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Every monospace face used here is assumed to advance 0.6 em per character.
|
|
35
|
+
* DejaVu Sans Mono and Menlo both sit at 0.602; Consolas is narrower at 0.55,
|
|
36
|
+
* so a diagram viewed with Consolas substituted in comes out uniformly tight
|
|
37
|
+
* rather than raggedly wrong.
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULT_ADVANCE_RATIO = 0.6;
|
|
40
|
+
const LINE_HEIGHT_RATIO = 1.35;
|
|
41
|
+
/**
|
|
42
|
+
* Named families only, in order of likelihood, ending in the generic.
|
|
43
|
+
* Deliberately no `ui-monospace`: it is a CSS keyword many SVG renderers do
|
|
44
|
+
* not know, and it means "whatever this interface uses", which is not
|
|
45
|
+
* guaranteed to be monospace at all. Every width here is computed on the
|
|
46
|
+
* assumption that it is.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_STACK = '"DejaVu Sans Mono", "Menlo", "Consolas", "Liberation Mono", "Courier New", monospace';
|
|
49
|
+
export function monospaceMeasurer(fontFamily = DEFAULT_STACK, advanceRatio = DEFAULT_ADVANCE_RATIO) {
|
|
50
|
+
return {
|
|
51
|
+
fontFamily,
|
|
52
|
+
lineHeight(fontSize) {
|
|
53
|
+
return Math.round(fontSize * LINE_HEIGHT_RATIO);
|
|
54
|
+
},
|
|
55
|
+
measure(text, fontSize) {
|
|
56
|
+
const lines = splitLines(text);
|
|
57
|
+
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
58
|
+
const lineHeight = Math.round(fontSize * LINE_HEIGHT_RATIO);
|
|
59
|
+
return {
|
|
60
|
+
width: Math.ceil(longest * fontSize * advanceRatio),
|
|
61
|
+
height: lines.length * lineHeight,
|
|
62
|
+
lines,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Attrs, Axis, Placement } from './ast.js';
|
|
2
|
+
/** A `between` clause with its targets resolved. Mirrors `Passage` in `ast.ts`. */
|
|
3
|
+
export interface LayoutPassage {
|
|
4
|
+
nodes: [LayoutNode, LayoutNode];
|
|
5
|
+
/** Which gap, where the pair has two. Absent when the pair leaves no doubt. */
|
|
6
|
+
axis?: Axis;
|
|
7
|
+
}
|
|
8
|
+
/** A node with its geometry solved. Coordinates are absolute, origin top-left. */
|
|
9
|
+
export interface LayoutNode {
|
|
10
|
+
name: string;
|
|
11
|
+
kind: 'box' | 'note';
|
|
12
|
+
/** The label as written, before line splitting. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** The label split into the lines that will be drawn. */
|
|
15
|
+
lines: string[];
|
|
16
|
+
parent?: LayoutNode;
|
|
17
|
+
children: LayoutNode[];
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
/**
|
|
23
|
+
* Distance from the node's outer box to its drawn face. Zero for everything
|
|
24
|
+
* except a deck, where the offset copies sit in that margin.
|
|
25
|
+
*/
|
|
26
|
+
inset: number;
|
|
27
|
+
/** One label per copy behind this node, back to front. Empty for most nodes. */
|
|
28
|
+
deckLabels: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Vertical space this node's own label and icon occupy, at whichever end of
|
|
31
|
+
* the box `label.at` puts them. Zero for leaves.
|
|
32
|
+
*/
|
|
33
|
+
headerHeight: number;
|
|
34
|
+
/** The label's bracketed modifiers, as written. Usually empty. */
|
|
35
|
+
label: Attrs;
|
|
36
|
+
attrs: Attrs;
|
|
37
|
+
/** Style attributes merged in from a named style, then overridden by the node's own. */
|
|
38
|
+
appearance: Attrs;
|
|
39
|
+
/** What the author said about where it goes, kept so diagnostics can quote the source back. */
|
|
40
|
+
placements: Placement[];
|
|
41
|
+
line: number;
|
|
42
|
+
}
|
|
43
|
+
export interface LayoutLink {
|
|
44
|
+
from: LayoutNode;
|
|
45
|
+
to: LayoutNode;
|
|
46
|
+
both: boolean;
|
|
47
|
+
label?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The gap a `between` clause named, with its two nodes resolved. Nothing in
|
|
50
|
+
* the resolver uses this — a corridor is measured off the solved layout
|
|
51
|
+
* rather than solved for, so links stay out of the constraint system entirely.
|
|
52
|
+
*/
|
|
53
|
+
between?: LayoutPassage;
|
|
54
|
+
attrs: Attrs;
|
|
55
|
+
appearance: Attrs;
|
|
56
|
+
line: number;
|
|
57
|
+
}
|
|
58
|
+
export interface Layout {
|
|
59
|
+
/** Every node, containers and children alike, in declaration order. */
|
|
60
|
+
nodes: LayoutNode[];
|
|
61
|
+
/** Top-level nodes only, in declaration order. */
|
|
62
|
+
roots: LayoutNode[];
|
|
63
|
+
links: LayoutLink[];
|
|
64
|
+
/**
|
|
65
|
+
* What the `diagram` statement said, as written. Nothing here affects
|
|
66
|
+
* geometry; it rides along so the renderer sees the whole compiled document
|
|
67
|
+
* and not only the shapes.
|
|
68
|
+
*/
|
|
69
|
+
diagram: Attrs;
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
/**
|
|
73
|
+
* The clear band left around the drawing. Kept so the renderer can hold the
|
|
74
|
+
* same band open around a link that leaves the boxes' bounds — a curve out of
|
|
75
|
+
* a `top` side does exactly that, and the canvas has to grow to hold it.
|
|
76
|
+
*/
|
|
77
|
+
margin: number;
|
|
78
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/parser.d.ts
ADDED