@itsthecsguy/linework 0.1.3 → 0.1.4
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 -0
- package/dist/api.js +9 -6
- package/dist/cli.js +88 -43
- package/dist/lint/fixlabels.js +22 -14
- package/dist/render/browser.js +96 -32
- package/dist/render/fonts.js +65 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,6 +159,8 @@ Linework sizes every box from the real width of its text in the font it renders
|
|
|
159
159
|
|
|
160
160
|
Linework needs Node 20+ and a Chromium-based browser, which it uses headless to measure text and render. It tries Playwright's Chromium, then Chrome, then Edge. If none is found, run `linework install-browser`, or set `LINEWORK_CHROME` to a browser binary. If a browser is found but crashes on launch, an agent's sandbox is usually blocking it. Linework then retries Playwright's Chromium in single-process mode, which starts inside Codex's sandbox.
|
|
161
161
|
|
|
162
|
+
Linework keeps the font subsets it embeds in your cache folder (`~/Library/Caches/linework` on macOS, `~/.cache/linework` on Linux, `%LOCALAPPDATA%\linework` on Windows), so rebuilding a diagram doesn't compress the same fonts again. Set `LINEWORK_NO_CACHE=1` to turn that off.
|
|
163
|
+
|
|
162
164
|
## Connect over MCP
|
|
163
165
|
|
|
164
166
|
The MCP server is for chat apps and agents that can't run shell commands. Agents with a shell do better with the skill, since they can run the CLI and see its output directly.
|
package/dist/api.js
CHANGED
|
@@ -97,19 +97,22 @@ export async function build(specText, opts) {
|
|
|
97
97
|
}
|
|
98
98
|
else
|
|
99
99
|
xml = await straightenDetours(b, toMxfile(diagram, l), detourEdges(diagram, l));
|
|
100
|
-
|
|
100
|
+
const fixed = await fixLabels(b, xml);
|
|
101
|
+
xml = fixed.xml;
|
|
101
102
|
xml = stampEdgeRoutes(xml);
|
|
102
|
-
|
|
103
|
+
// Stamping routes changes attributes, not geometry: the cells fixLabels read still hold.
|
|
104
|
+
return finish(b, xml, { mode, warnings, direction: mode === 'merge' ? (old.meta.aiviz_direction ?? l.direction) : l.direction, edits, old, stats, cells: fixed.cells });
|
|
103
105
|
});
|
|
104
106
|
async function finish(b, xml, r) {
|
|
105
107
|
fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// Issued together: the browser renders the preview and lays out the lint pass
|
|
109
|
+
// while node subsets the fonts for the SVG.
|
|
110
|
+
const [file, , cells] = await Promise.all([kind === 'svg' ? b.renderSvg(xml) : xml, opts.png ? b.renderPng(xml, opts.png) : undefined, r.cells ?? b.inspect(xml)]);
|
|
111
|
+
fs.writeFileSync(opts.out, file);
|
|
109
112
|
return {
|
|
110
113
|
out: opts.out,
|
|
111
114
|
warnings: r.warnings,
|
|
112
|
-
check: check(
|
|
115
|
+
check: check(cells),
|
|
113
116
|
direction: r.direction,
|
|
114
117
|
mode: r.mode,
|
|
115
118
|
merge: r.stats,
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import module from 'node:module';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { build, checkFile, listIcons, render, show, tidy } from './api.js';
|
|
6
|
-
import { SpecError, jsonSchema } from './spec/parse.js';
|
|
7
|
-
import { KINDS, DIAGRAM_TYPES } from './spec/model.js';
|
|
8
|
-
import { buildLines, checkLines } from './report.js';
|
|
9
6
|
const USAGE = `linework: AI-written, hand-editable diagrams (YAML spec -> .drawio.svg)
|
|
10
7
|
|
|
11
8
|
Usage:
|
|
@@ -53,43 +50,89 @@ Usage:
|
|
|
53
50
|
Download the headless Chromium build that matches this version. Needed only
|
|
54
51
|
when no Chrome or Edge is installed, or to run inside an agent's sandbox.
|
|
55
52
|
`;
|
|
53
|
+
// Node 22.1+ can keep compiled bytecode for linework's modules on disk, which
|
|
54
|
+
// takes about a quarter off loading them on every run after the first. Older
|
|
55
|
+
// Node has no such call. NODE_DISABLE_COMPILE_CACHE=1 turns it off.
|
|
56
|
+
module.enableCompileCache?.();
|
|
56
57
|
async function main(argv) {
|
|
57
58
|
const [cmd, ...rest] = argv;
|
|
58
|
-
const { values, positionals } = parseArgs({
|
|
59
|
-
args: rest,
|
|
60
|
-
allowPositionals: true,
|
|
61
|
-
options: {
|
|
62
|
-
out: { type: 'string', short: 'o' },
|
|
63
|
-
png: { type: 'string' },
|
|
64
|
-
force: { type: 'boolean', default: false },
|
|
65
|
-
relayout: { type: 'boolean', default: false },
|
|
66
|
-
geometry: { type: 'boolean', default: false },
|
|
67
|
-
quiet: { type: 'boolean', short: 'q', default: false },
|
|
68
|
-
block: { type: 'string' },
|
|
69
|
-
scale: { type: 'string' },
|
|
70
|
-
'no-open': { type: 'boolean', default: false },
|
|
71
|
-
port: { type: 'string' },
|
|
72
|
-
root: { type: 'string' },
|
|
73
|
-
to: { type: 'string' },
|
|
74
|
-
pull: { type: 'boolean', default: false },
|
|
75
|
-
help: { type: 'boolean', short: 'h' },
|
|
76
|
-
},
|
|
77
|
-
});
|
|
59
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: OPTIONS });
|
|
78
60
|
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h' || values.help)
|
|
79
61
|
return void process.stdout.write(USAGE);
|
|
62
|
+
// Commands that draw start Chromium first and load the rest of linework while it
|
|
63
|
+
// boots and loads draw.io: the two take about the same time, so they overlap.
|
|
64
|
+
const needsBrowser = ['build', 'render', 'check', 'tidy', 'icons'].includes(cmd) || (cmd === 'import' && !!values.out);
|
|
65
|
+
const launching = needsBrowser ? import('./render/browser.js').then((m) => m.DrawioBrowser.launch({ scale: cmd === 'render' && values.scale ? Number(values.scale) : 2 })) : undefined;
|
|
66
|
+
// A failure here surfaces where the browser is awaited; don't report it twice.
|
|
67
|
+
launching?.catch(() => { });
|
|
68
|
+
try {
|
|
69
|
+
await run(cmd, values, positionals, launching);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
if (launching)
|
|
73
|
+
await (await launching.catch(() => undefined))?.close();
|
|
74
|
+
}
|
|
75
|
+
// The font subsetter's WebAssembly keeps node alive for ~100ms after the work
|
|
76
|
+
// is done, with nothing left to run. A drawing command is finished here.
|
|
77
|
+
if (launching)
|
|
78
|
+
await exitWhenFlushed();
|
|
79
|
+
}
|
|
80
|
+
/** A small spec with a group, labels and a cycle: enough to exercise ELK's main paths. */
|
|
81
|
+
const WARM_SPEC = 'type: architecture\ngroups: { g: G }\nnodes:\n a: { in: g }\n b: { in: g }\n c: {}\nedges:\n - a -> b: x\n - b -> c: y\n - c -> a\n';
|
|
82
|
+
async function warmUp() {
|
|
83
|
+
const [{ parseSpecText }, { layout }] = await Promise.all([import('./spec/parse.js'), import('./layout/layout.js')]);
|
|
84
|
+
await layout(parseSpecText(WARM_SPEC).diagram);
|
|
85
|
+
}
|
|
86
|
+
function exitWhenFlushed() {
|
|
87
|
+
return new Promise(() => {
|
|
88
|
+
let pending = 2;
|
|
89
|
+
const done = () => --pending === 0 && process.exit();
|
|
90
|
+
process.stdout.write('', done);
|
|
91
|
+
process.stderr.write('', done);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const OPTIONS = {
|
|
95
|
+
out: { type: 'string', short: 'o' },
|
|
96
|
+
png: { type: 'string' },
|
|
97
|
+
force: { type: 'boolean', default: false },
|
|
98
|
+
relayout: { type: 'boolean', default: false },
|
|
99
|
+
geometry: { type: 'boolean', default: false },
|
|
100
|
+
quiet: { type: 'boolean', short: 'q', default: false },
|
|
101
|
+
block: { type: 'string' },
|
|
102
|
+
scale: { type: 'string' },
|
|
103
|
+
'no-open': { type: 'boolean', default: false },
|
|
104
|
+
port: { type: 'string' },
|
|
105
|
+
root: { type: 'string' },
|
|
106
|
+
to: { type: 'string' },
|
|
107
|
+
pull: { type: 'boolean', default: false },
|
|
108
|
+
help: { type: 'boolean', short: 'h' },
|
|
109
|
+
};
|
|
110
|
+
async function run(cmd, values, positionals, launching) {
|
|
111
|
+
const browser = async () => {
|
|
112
|
+
// ELK compiles its code on first use. Laying out a tiny graph while Chromium
|
|
113
|
+
// is still starting takes that cost off the real layout.
|
|
114
|
+
if (launching && ['build', 'import'].includes(cmd))
|
|
115
|
+
await warmUp();
|
|
116
|
+
return launching;
|
|
117
|
+
};
|
|
118
|
+
const { buildLines } = await import('./report.js');
|
|
80
119
|
switch (cmd) {
|
|
81
120
|
case 'build': {
|
|
82
121
|
const src = positionals[0];
|
|
83
122
|
if (!src || !values.out)
|
|
84
123
|
fail('usage: linework build <spec.yaml|-> -o <out.drawio.svg>');
|
|
85
124
|
const text = src === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(src, 'utf8');
|
|
86
|
-
const
|
|
125
|
+
const { build } = await import('./api.js');
|
|
126
|
+
// Spec errors first: they shouldn't wait on, or hide behind, a browser problem.
|
|
127
|
+
(await import('./spec/parse.js')).parseSpecText(text);
|
|
128
|
+
const res = await build(text, { out: values.out, png: values.png, force: values.force, relayout: values.relayout, browser: await browser() });
|
|
87
129
|
for (const line of buildLines(res, values.png))
|
|
88
130
|
console.log(line);
|
|
89
131
|
return;
|
|
90
132
|
}
|
|
91
133
|
case 'show': {
|
|
92
134
|
const file = positionals[0] ?? fail('usage: linework show <file.drawio.svg>');
|
|
135
|
+
const { show } = await import('./api.js');
|
|
93
136
|
const res = show(file, { geometry: values.geometry });
|
|
94
137
|
process.stdout.write(res.yaml);
|
|
95
138
|
if (values.quiet)
|
|
@@ -107,30 +150,26 @@ async function main(argv) {
|
|
|
107
150
|
const file = positionals[0];
|
|
108
151
|
if (!file || !values.out)
|
|
109
152
|
fail('usage: linework render <file.drawio.svg> -o <out.png>');
|
|
110
|
-
const {
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
await render(file, values.out, b);
|
|
114
|
-
}
|
|
115
|
-
finally {
|
|
116
|
-
await b.close();
|
|
117
|
-
}
|
|
153
|
+
const { render } = await import('./api.js');
|
|
154
|
+
await render(file, values.out, await browser());
|
|
118
155
|
console.log(`wrote ${values.out}`);
|
|
119
156
|
return;
|
|
120
157
|
}
|
|
121
158
|
case 'check': {
|
|
122
159
|
const file = positionals[0] ?? fail('usage: linework check <file.drawio.svg>');
|
|
123
|
-
const
|
|
124
|
-
|
|
160
|
+
const { checkFile } = await import('./api.js');
|
|
161
|
+
const res = await checkFile(file, await browser());
|
|
162
|
+
await printCheck(res);
|
|
125
163
|
if (res.findings.some((f) => f.level === 'error'))
|
|
126
164
|
process.exitCode = 1;
|
|
127
165
|
return;
|
|
128
166
|
}
|
|
129
167
|
case 'tidy': {
|
|
130
168
|
const file = positionals[0] ?? fail('usage: linework tidy <file.drawio.svg>');
|
|
131
|
-
const
|
|
169
|
+
const { tidy } = await import('./api.js');
|
|
170
|
+
const res = await tidy(file, await browser());
|
|
132
171
|
console.log(res.fixed ? `rerouted ${res.fixed} edges in ${file}` : 'nothing to fix');
|
|
133
|
-
printCheck(res.check);
|
|
172
|
+
await printCheck(res.check);
|
|
134
173
|
return;
|
|
135
174
|
}
|
|
136
175
|
case 'edit': {
|
|
@@ -169,16 +208,18 @@ async function main(argv) {
|
|
|
169
208
|
const yaml = toYaml(res.diagram, 'imported from Mermaid');
|
|
170
209
|
if (!values.out)
|
|
171
210
|
return void process.stdout.write(yaml);
|
|
172
|
-
const
|
|
211
|
+
const { build } = await import('./api.js');
|
|
212
|
+
const built = await build(yaml, { out: values.out, png: values.png, relayout: values.relayout, force: values.force, browser: await browser() });
|
|
173
213
|
console.log(`wrote ${built.out} (${built.check.stats.nodes} nodes, ${built.check.stats.edges} edges, ${built.direction})`);
|
|
174
|
-
printCheck(built.check);
|
|
214
|
+
await printCheck(built.check);
|
|
175
215
|
return;
|
|
176
216
|
}
|
|
177
217
|
case 'icons': {
|
|
178
218
|
const provider = positionals[0];
|
|
179
219
|
if (!provider || !['aws', 'gcp', 'k8s', 'azure'].includes(provider))
|
|
180
220
|
fail('usage: linework icons <aws|gcp|k8s|azure> [filter]');
|
|
181
|
-
const
|
|
221
|
+
const { listIcons } = await import('./api.js');
|
|
222
|
+
const names = await listIcons(provider, positionals[1], await browser());
|
|
182
223
|
console.log(names.map((n) => `${provider}.${n}`).join('\n') || 'no matches');
|
|
183
224
|
return;
|
|
184
225
|
}
|
|
@@ -229,6 +270,7 @@ async function main(argv) {
|
|
|
229
270
|
return;
|
|
230
271
|
}
|
|
231
272
|
case 'kinds': {
|
|
273
|
+
const { KINDS, DIAGRAM_TYPES } = await import('./spec/model.js');
|
|
232
274
|
const types = (positionals.length ? positionals : DIAGRAM_TYPES);
|
|
233
275
|
for (const t of types) {
|
|
234
276
|
if (!KINDS[t])
|
|
@@ -239,9 +281,11 @@ async function main(argv) {
|
|
|
239
281
|
}
|
|
240
282
|
return;
|
|
241
283
|
}
|
|
242
|
-
case 'schema':
|
|
284
|
+
case 'schema': {
|
|
285
|
+
const { jsonSchema } = await import('./spec/parse.js');
|
|
243
286
|
console.log(JSON.stringify(jsonSchema(), null, 2));
|
|
244
287
|
return;
|
|
288
|
+
}
|
|
245
289
|
case 'install-browser': {
|
|
246
290
|
const { browserInstallCommand } = await import('./render/browser.js');
|
|
247
291
|
const { spawnSync } = await import('node:child_process');
|
|
@@ -255,7 +299,8 @@ async function main(argv) {
|
|
|
255
299
|
fail(`unknown command "${cmd}"\n\n${USAGE}`);
|
|
256
300
|
}
|
|
257
301
|
}
|
|
258
|
-
function printCheck(res) {
|
|
302
|
+
async function printCheck(res) {
|
|
303
|
+
const { checkLines } = await import('./report.js');
|
|
259
304
|
for (const line of checkLines(res))
|
|
260
305
|
console.log(line);
|
|
261
306
|
}
|
|
@@ -264,7 +309,7 @@ function fail(msg) {
|
|
|
264
309
|
process.exit(2);
|
|
265
310
|
}
|
|
266
311
|
main(process.argv.slice(2)).catch((e) => {
|
|
267
|
-
if (e
|
|
312
|
+
if (e?.name === 'SpecError') {
|
|
268
313
|
console.error('spec error:');
|
|
269
314
|
for (const i of e.issues)
|
|
270
315
|
console.error(` ${i.path ? i.path + ': ' : ''}${i.message}`);
|
package/dist/lint/fixlabels.js
CHANGED
|
@@ -42,14 +42,19 @@ export function runSpots(pts, label) {
|
|
|
42
42
|
function labelEdges(findings) {
|
|
43
43
|
return [...new Set(findings.filter((f) => LABEL_RULES.has(f.rule) && f.labelEdges).flatMap((f) => f.labelEdges))];
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the fixed XML and its cells. Only label backgrounds change after the
|
|
47
|
+
* cells are read, and those don't move anything, so the cells stay valid for it.
|
|
48
|
+
*/
|
|
45
49
|
export async function fixLabels(b, xml, maxRounds = 6) {
|
|
46
50
|
let cur = xml;
|
|
47
|
-
|
|
51
|
+
const labelScore = (cells) => check(cells).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
|
|
52
|
+
let score = labelScore(await b.inspect(cur));
|
|
48
53
|
let moved = 0;
|
|
49
54
|
for (let round = 0; round < maxRounds && score > 0; round++) {
|
|
55
|
+
// The cells of `cur`, kept current as labels move: a move changes only that label's box.
|
|
50
56
|
const cells = await b.inspect(cur);
|
|
51
|
-
const
|
|
52
|
-
const targets = labelEdges(res.findings);
|
|
57
|
+
const targets = labelEdges(check(cells).findings);
|
|
53
58
|
let improved = false;
|
|
54
59
|
for (const id of targets) {
|
|
55
60
|
const doc = new DOMParser().parseFromString(cur, 'text/xml');
|
|
@@ -59,19 +64,21 @@ export async function fixLabels(b, xml, maxRounds = 6) {
|
|
|
59
64
|
const base = Number(g.getAttribute('x') ?? 0);
|
|
60
65
|
const cell = cells.find((c) => c.id === id);
|
|
61
66
|
const spots = cell?.points && cell.textBox ? runSpots(cell.points, cell.textBox) : [];
|
|
62
|
-
const tries = [...spots, ...OFFSETS.map((off) => Math.max(-0.9, Math.min(0.9, base + off)))];
|
|
67
|
+
const tries = [...spots, ...OFFSETS.map((off) => Math.max(-0.9, Math.min(0.9, base + off)))].map((x) => Number(x.toFixed(3)));
|
|
68
|
+
const boxes = await b.labelBoxes(cur, id, tries);
|
|
63
69
|
let best;
|
|
64
|
-
for (
|
|
65
|
-
|
|
66
|
-
const candidate = new XMLSerializer().serializeToString(doc);
|
|
67
|
-
const s = check(await b.inspect(candidate)).findings.filter((f) => LABEL_RULES.has(f.rule)).length;
|
|
70
|
+
for (let i = 0; i < tries.length; i++) {
|
|
71
|
+
const s = labelScore(cells.map((c) => (c.id === id ? { ...c, textBox: boxes[i] } : c)));
|
|
68
72
|
if (s < (best?.score ?? score))
|
|
69
|
-
best = {
|
|
73
|
+
best = { x: tries[i], score: s, box: boxes[i] };
|
|
70
74
|
if (s === 0)
|
|
71
75
|
break;
|
|
72
76
|
}
|
|
73
77
|
if (best) {
|
|
74
|
-
|
|
78
|
+
g.setAttribute('x', best.x.toFixed(3));
|
|
79
|
+
cur = new XMLSerializer().serializeToString(doc);
|
|
80
|
+
if (cell)
|
|
81
|
+
cell.textBox = best.box;
|
|
75
82
|
score = best.score;
|
|
76
83
|
moved++;
|
|
77
84
|
improved = true;
|
|
@@ -82,16 +89,17 @@ export async function fixLabels(b, xml, maxRounds = 6) {
|
|
|
82
89
|
if (!improved)
|
|
83
90
|
break;
|
|
84
91
|
}
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
const cells = await b.inspect(cur);
|
|
93
|
+
cur = await matchLabelBackgrounds(b, cur, cells);
|
|
94
|
+
return { xml: cur, moved, cells };
|
|
87
95
|
}
|
|
88
96
|
/**
|
|
89
97
|
* A label's backing has to match what it sits on: the fill of the smallest group
|
|
90
98
|
* under it, or the canvas. Run after labels move, since a move can carry a label
|
|
91
99
|
* from the canvas onto a group or back.
|
|
92
100
|
*/
|
|
93
|
-
export async function matchLabelBackgrounds(b, xml) {
|
|
94
|
-
const cells = await b.inspect(xml);
|
|
101
|
+
export async function matchLabelBackgrounds(b, xml, known) {
|
|
102
|
+
const cells = known ?? (await b.inspect(xml));
|
|
95
103
|
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
|
96
104
|
const canvas = doc.getElementsByTagName('mxGraphModel')[0]?.getAttribute('background') ?? '#FFFFFF';
|
|
97
105
|
const styleOf = new Map();
|
package/dist/render/browser.js
CHANGED
|
@@ -5,10 +5,10 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
+
import http from 'node:http';
|
|
8
9
|
import { chromium } from 'playwright-core';
|
|
9
10
|
import { FONT_STACK } from '../drawio/theme.js';
|
|
10
11
|
import { FACES, embedFonts, fontFaceCss, fontsDir } from './fonts.js';
|
|
11
|
-
const ORIGIN = 'http://aiviz.local';
|
|
12
12
|
export function assetsDir() {
|
|
13
13
|
// dist/render/browser.js or src/render/browser.ts -> <pkg>/assets/drawio
|
|
14
14
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -90,6 +90,23 @@ window.aiviz = {
|
|
|
90
90
|
hasStencil: function (name) {
|
|
91
91
|
return mxStencilRegistry.getStencil(name) != null;
|
|
92
92
|
},
|
|
93
|
+
// Where an edge's label lands at each of several positions along the edge
|
|
94
|
+
// (draw.io's -1..1 x). Moving a label changes nothing else in the diagram, so
|
|
95
|
+
// one decode serves every candidate instead of one full inspect per try.
|
|
96
|
+
labelBoxes: function (xml, id, xs) {
|
|
97
|
+
return withGraph(xml, function (graph) {
|
|
98
|
+
var model = graph.getModel(), cell = model.getCell(id);
|
|
99
|
+
if (!cell) return xs.map(function () { return null; });
|
|
100
|
+
return xs.map(function (x) {
|
|
101
|
+
var g = cell.geometry.clone();
|
|
102
|
+
g.x = x;
|
|
103
|
+
model.setGeometry(cell, g);
|
|
104
|
+
var st = graph.view.getState(cell);
|
|
105
|
+
var tb = st && st.text && st.text.boundingBox;
|
|
106
|
+
return tb ? { x: tb.x, y: tb.y, w: tb.width, h: tb.height } : null;
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
},
|
|
93
110
|
inspect: function (xml) {
|
|
94
111
|
return withGraph(xml, function (graph) {
|
|
95
112
|
var model = graph.getModel(), out = [];
|
|
@@ -127,38 +144,34 @@ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/htm
|
|
|
127
144
|
export class DrawioBrowser {
|
|
128
145
|
browser;
|
|
129
146
|
page;
|
|
130
|
-
|
|
147
|
+
server;
|
|
148
|
+
constructor(browser, page, server) {
|
|
131
149
|
this.browser = browser;
|
|
132
150
|
this.page = page;
|
|
151
|
+
this.server = server;
|
|
133
152
|
}
|
|
134
153
|
static async launch(opts = {}) {
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await page.waitForFunction(() => window.ready === true, null, { timeout: 30000 });
|
|
157
|
-
// Measuring before a font loads would size every box for the fallback font.
|
|
158
|
-
const ok = await page.evaluate((faces) => Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 16px "${f.family}"`).then((r) => r.length > 0))), FACES.map(({ family, weight }) => ({ family, weight })));
|
|
159
|
-
if (ok.includes(false))
|
|
160
|
-
throw new Error('linework: bundled fonts failed to load (assets/fonts). Reinstall linework.');
|
|
161
|
-
return new DrawioBrowser(browser, page);
|
|
154
|
+
// Chromium starts while the asset server binds.
|
|
155
|
+
const [browser, server] = await Promise.all([launchChromium(), serveAssets()]);
|
|
156
|
+
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
157
|
+
try {
|
|
158
|
+
const page = await browser.newPage({ deviceScaleFactor: opts.scale ?? 2 });
|
|
159
|
+
// Nothing else may load: keeps rendering offline and deterministic.
|
|
160
|
+
const own = new RegExp(`^${origin.replace(/[.]/g, '\\.')}/`);
|
|
161
|
+
await page.route((url) => !own.test(url.href), (route) => route.abort());
|
|
162
|
+
await page.goto(`${origin}/host.html`);
|
|
163
|
+
await page.waitForFunction(() => window.ready === true, null, { timeout: 30000 });
|
|
164
|
+
// Measuring before a font loads would size every box for the fallback font.
|
|
165
|
+
const ok = await page.evaluate((faces) => Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 16px "${f.family}"`).then((r) => r.length > 0))), FACES.map(({ family, weight }) => ({ family, weight })));
|
|
166
|
+
if (ok.includes(false))
|
|
167
|
+
throw new Error('linework: bundled fonts failed to load (assets/fonts). Reinstall linework.');
|
|
168
|
+
return new DrawioBrowser(browser, page, server);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
await browser.close().catch(() => { });
|
|
172
|
+
server.close();
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
162
175
|
}
|
|
163
176
|
measure(reqs) {
|
|
164
177
|
return this.page.evaluate((r) => window.aiviz.measure(r), reqs);
|
|
@@ -182,12 +195,63 @@ export class DrawioBrowser {
|
|
|
182
195
|
hasStencil(name) {
|
|
183
196
|
return this.page.evaluate((n) => window.aiviz.hasStencil(n), name);
|
|
184
197
|
}
|
|
198
|
+
// inspect is a pure function of the XML, and a build asks about the same XML
|
|
199
|
+
// more than once (lint, label fixing, the final check).
|
|
200
|
+
seen = new Map();
|
|
185
201
|
inspect(xml) {
|
|
186
|
-
|
|
202
|
+
let hit = this.seen.get(xml);
|
|
203
|
+
if (!hit) {
|
|
204
|
+
hit = this.page.evaluate((x) => window.aiviz.inspect(x), xml);
|
|
205
|
+
hit.catch(() => this.seen.delete(xml));
|
|
206
|
+
if (this.seen.size >= 8)
|
|
207
|
+
this.seen.delete(this.seen.keys().next().value);
|
|
208
|
+
this.seen.set(xml, hit);
|
|
209
|
+
}
|
|
210
|
+
// Callers may change the cells; each gets its own copy.
|
|
211
|
+
return hit.then((cells) => structuredClone(cells));
|
|
187
212
|
}
|
|
188
|
-
|
|
189
|
-
|
|
213
|
+
/** Label boxes for edge `id` with its label at each draw.io position in `xs`. */
|
|
214
|
+
labelBoxes(xml, id, xs) {
|
|
215
|
+
return this.page.evaluate(([x, i, p]) => window.aiviz.labelBoxes(x, i, p), [xml, id, xs]);
|
|
190
216
|
}
|
|
217
|
+
async close() {
|
|
218
|
+
await this.browser.close();
|
|
219
|
+
this.server.closeAllConnections();
|
|
220
|
+
this.server.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The draw.io runtime is 16MB of script. Served from a local port, Chromium reads
|
|
225
|
+
* it straight off the socket; through Playwright's request interception every
|
|
226
|
+
* byte would cross the DevTools protocol first.
|
|
227
|
+
*/
|
|
228
|
+
function serveAssets() {
|
|
229
|
+
const dir = assetsDir();
|
|
230
|
+
const fonts = fontsDir();
|
|
231
|
+
const html = host();
|
|
232
|
+
const send = (res, status, type, body) => {
|
|
233
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
234
|
+
res.end(body);
|
|
235
|
+
};
|
|
236
|
+
const server = http.createServer((req, res) => {
|
|
237
|
+
const pathname = new URL(req.url ?? '/', 'http://x').pathname;
|
|
238
|
+
if (pathname === '/' || pathname === '/host.html')
|
|
239
|
+
return send(res, 200, 'text/html', html);
|
|
240
|
+
if (pathname.startsWith('/aiviz-fonts/')) {
|
|
241
|
+
const f = FACES.find((x) => `/aiviz-fonts/${x.file}` === pathname);
|
|
242
|
+
return f ? send(res, 200, 'font/woff2', fs.readFileSync(path.join(fonts, f.file))) : send(res, 404, 'text/plain', '');
|
|
243
|
+
}
|
|
244
|
+
const file = path.join(dir, path.normalize(decodeURIComponent(pathname)));
|
|
245
|
+
if (!file.startsWith(dir + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
246
|
+
return send(res, 404, 'text/plain', '');
|
|
247
|
+
send(res, 200, TYPES[path.extname(file)] ?? 'application/octet-stream', fs.readFileSync(file));
|
|
248
|
+
});
|
|
249
|
+
// A server that's only waiting on a browser that already exited shouldn't keep node alive.
|
|
250
|
+
server.unref();
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
server.once('error', reject);
|
|
253
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
254
|
+
});
|
|
191
255
|
}
|
|
192
256
|
/** Playwright-core's own installer, so the Chromium build always matches the version we run. */
|
|
193
257
|
export function browserInstallCommand() {
|
package/dist/render/fonts.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// SVG gets a subset of the faces it uses (only the glyphs in its text), so a
|
|
3
3
|
// diagram looks the same on GitHub, in a browser and in a doc, and text that fit
|
|
4
4
|
// when linework sized the boxes still fits wherever it is shown.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
6
8
|
import path from 'node:path';
|
|
7
9
|
import subsetFont from 'subset-font';
|
|
8
10
|
import { assetsDir } from './browser.js';
|
|
@@ -54,7 +56,7 @@ export async function embedFonts(svg) {
|
|
|
54
56
|
text = [...new Set(text + text.toUpperCase() + ' ')].join('');
|
|
55
57
|
const rules = [];
|
|
56
58
|
for (const f of FACES.filter((x) => families.includes(x.family))) {
|
|
57
|
-
const sub = await
|
|
59
|
+
const sub = await subset(f, text);
|
|
58
60
|
rules.push(`@font-face{font-family:"${f.family}";font-weight:${f.weight};font-style:normal;src:url(data:font/woff2;base64,${sub.toString('base64')}) format("woff2");}`);
|
|
59
61
|
}
|
|
60
62
|
const style = `<style id="aiviz-fonts" type="text/css">${rules.join('')}</style>`;
|
|
@@ -64,3 +66,65 @@ export async function embedFonts(svg) {
|
|
|
64
66
|
return svg.replace(/<defs[^>]*>/, (m) => m + style);
|
|
65
67
|
return svg.replace(/<svg\b[^>]*>/, (m) => `${m}<defs>${style}</defs>`);
|
|
66
68
|
}
|
|
69
|
+
// ---------- subset cache ----------
|
|
70
|
+
//
|
|
71
|
+
// Compressing a subset to WOFF2 is most of embedFonts' time, and rebuilding a
|
|
72
|
+
// diagram after a small edit usually needs the same characters again. A subset
|
|
73
|
+
// depends only on the font file and the set of characters (not their order), so
|
|
74
|
+
// that pair is the key. Kept in memory and in the user's own cache folder, never
|
|
75
|
+
// a shared temp dir: whatever is there ends up inside the SVGs.
|
|
76
|
+
const memo = new Map();
|
|
77
|
+
const fileHash = new Map();
|
|
78
|
+
/** Drop the in-memory copies, so the next lookup reads the disk cache. For tests. */
|
|
79
|
+
export function forgetSubsets() {
|
|
80
|
+
memo.clear();
|
|
81
|
+
}
|
|
82
|
+
function cacheDir() {
|
|
83
|
+
if (process.env.LINEWORK_NO_CACHE)
|
|
84
|
+
return undefined;
|
|
85
|
+
const home = os.homedir();
|
|
86
|
+
const base = process.env.XDG_CACHE_HOME ||
|
|
87
|
+
(process.platform === 'darwin' ? path.join(home, 'Library', 'Caches') : process.platform === 'win32' ? process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local') : path.join(home, '.cache'));
|
|
88
|
+
return path.join(base, 'linework', 'fonts');
|
|
89
|
+
}
|
|
90
|
+
async function subset(f, text) {
|
|
91
|
+
const font = read(f);
|
|
92
|
+
let fh = fileHash.get(f.file);
|
|
93
|
+
if (!fh)
|
|
94
|
+
fileHash.set(f.file, (fh = crypto.createHash('sha256').update(font).digest('hex')));
|
|
95
|
+
const chars = [...new Set(text)].sort().join('');
|
|
96
|
+
const key = crypto.createHash('sha256').update(`${fh}\0${chars}`).digest('hex').slice(0, 32);
|
|
97
|
+
const hit = memo.get(key);
|
|
98
|
+
if (hit)
|
|
99
|
+
return hit;
|
|
100
|
+
const dir = cacheDir();
|
|
101
|
+
const file = dir && path.join(dir, `${key}.woff2`);
|
|
102
|
+
let out;
|
|
103
|
+
try {
|
|
104
|
+
const b = file ? fs.readFileSync(file) : undefined;
|
|
105
|
+
// A WOFF2 file starts with its signature; anything else is a broken write.
|
|
106
|
+
if (b && b.subarray(0, 4).toString('latin1') === 'wOF2')
|
|
107
|
+
out = b;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// not cached yet
|
|
111
|
+
}
|
|
112
|
+
if (!out) {
|
|
113
|
+
out = await subsetFont(font, text, { targetFormat: 'woff2' });
|
|
114
|
+
if (file) {
|
|
115
|
+
try {
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
117
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
118
|
+
fs.writeFileSync(tmp, out);
|
|
119
|
+
fs.renameSync(tmp, file);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// A read-only or full disk only costs the cache.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (memo.size >= 64)
|
|
127
|
+
memo.delete(memo.keys().next().value);
|
|
128
|
+
memo.set(key, out);
|
|
129
|
+
return out;
|
|
130
|
+
}
|