@mcuste/pi-diagram 0.0.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 +21 -0
- package/README.md +240 -0
- package/dist/artifacts.d.ts +59 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +274 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/cache.d.ts +43 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +0 -0
- package/dist/cache.js.map +1 -0
- package/dist/d2/diagnostics.d.ts +25 -0
- package/dist/d2/diagnostics.d.ts.map +1 -0
- package/dist/d2/diagnostics.js +77 -0
- package/dist/d2/diagnostics.js.map +1 -0
- package/dist/d2/fonts.d.ts +23 -0
- package/dist/d2/fonts.d.ts.map +1 -0
- package/dist/d2/fonts.js +255 -0
- package/dist/d2/fonts.js.map +1 -0
- package/dist/d2/preflight.d.ts +20 -0
- package/dist/d2/preflight.d.ts.map +1 -0
- package/dist/d2/preflight.js +217 -0
- package/dist/d2/preflight.js.map +1 -0
- package/dist/d2/profiles.d.ts +34 -0
- package/dist/d2/profiles.d.ts.map +1 -0
- package/dist/d2/profiles.js +118 -0
- package/dist/d2/profiles.js.map +1 -0
- package/dist/d2/runner.d.ts +95 -0
- package/dist/d2/runner.d.ts.map +1 -0
- package/dist/d2/runner.js +350 -0
- package/dist/d2/runner.js.map +1 -0
- package/dist/display.d.ts +48 -0
- package/dist/display.d.ts.map +1 -0
- package/dist/display.js +120 -0
- package/dist/display.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +23 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +83 -0
- package/dist/normalize.js.map +1 -0
- package/dist/process.d.ts +38 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +87 -0
- package/dist/process.js.map +1 -0
- package/dist/raster.d.ts +40 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js +193 -0
- package/dist/raster.js.map +1 -0
- package/dist/render.d.ts +55 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +229 -0
- package/dist/render.js.map +1 -0
- package/dist/tools.d.ts +58 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +284 -0
- package/dist/tools.js.map +1 -0
- package/package.json +101 -0
- package/src/artifacts.ts +418 -0
- package/src/cache.ts +0 -0
- package/src/d2/diagnostics.ts +114 -0
- package/src/d2/fonts.ts +289 -0
- package/src/d2/preflight.ts +270 -0
- package/src/d2/profiles.ts +157 -0
- package/src/d2/runner.ts +513 -0
- package/src/display.ts +201 -0
- package/src/index.ts +5 -0
- package/src/normalize.ts +119 -0
- package/src/process.ts +134 -0
- package/src/raster.ts +258 -0
- package/src/render.ts +338 -0
- package/src/tools.ts +455 -0
package/src/d2/fonts.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { inflateSync } from "node:zlib";
|
|
2
|
+
import type { RenderedSvg } from "./runner.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recovers the fonts D2 embeds in its SVG. D2 sizes every box around the label it measured in
|
|
6
|
+
* those faces, so drawing the image with any other font makes labels overflow.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MAX_FACES = 8;
|
|
10
|
+
const MAX_FACE_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
const MAX_TABLES = 64;
|
|
12
|
+
/** A subset face covers a handful of glyphs; this only stops a malformed cmap looping. */
|
|
13
|
+
const MAX_COVERAGE = 65_536;
|
|
14
|
+
|
|
15
|
+
const FACE_BLOCK = /@font-face\s*\{([^}]*)\}/gu;
|
|
16
|
+
const FACE_FAMILY = /font-family:\s*([\w-]+)/u;
|
|
17
|
+
const FACE_WOFF = /url\(\s*["']?data:application\/font-woff;base64,([A-Za-z0-9+/=]+)/u;
|
|
18
|
+
const TEXT_ELEMENT = /<text\b[^>]*>([\s\S]*?)<\/text>/giu;
|
|
19
|
+
const INNER_TAG = /<[^>]*>/gu;
|
|
20
|
+
|
|
21
|
+
declare const sfntBrand: unique symbol;
|
|
22
|
+
|
|
23
|
+
/** A font rebuilt from a WOFF container, with a table directory this module checked. */
|
|
24
|
+
type SfntFont = Uint8Array & { readonly [sfntBrand]: true };
|
|
25
|
+
|
|
26
|
+
export interface EmbeddedFont {
|
|
27
|
+
readonly family: string;
|
|
28
|
+
readonly bytes: SfntFont;
|
|
29
|
+
/** Code points the face can draw, or `undefined` when its cmap is not one this module reads. */
|
|
30
|
+
readonly coverage: ReadonlySet<number> | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Faces that could be rebuilt and understood. One that could not is left out rather than guessed
|
|
35
|
+
* at, and reported through its missing coverage instead.
|
|
36
|
+
*/
|
|
37
|
+
export function parseEmbeddedFonts(svg: RenderedSvg): readonly EmbeddedFont[] {
|
|
38
|
+
const fonts: EmbeddedFont[] = [];
|
|
39
|
+
for (const block of svg.matchAll(FACE_BLOCK)) {
|
|
40
|
+
if (fonts.length >= MAX_FACES) {
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
const declarations = block[1];
|
|
44
|
+
if (declarations === undefined) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const family = FACE_FAMILY.exec(declarations)?.[1];
|
|
48
|
+
const encoded = FACE_WOFF.exec(declarations)?.[1];
|
|
49
|
+
if (family === undefined || encoded === undefined || encoded.length > MAX_FACE_BYTES) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const bytes = rebuild(Buffer.from(encoded, "base64"));
|
|
54
|
+
fonts.push({ family, bytes, coverage: readCoverage(bytes) });
|
|
55
|
+
} catch {
|
|
56
|
+
// A face that cannot be rebuilt is left out rather than reported.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return fonts;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Code points that have to be drawable, taken from the SVG's own text elements. */
|
|
63
|
+
export function textCodePoints(svg: RenderedSvg): ReadonlySet<number> {
|
|
64
|
+
const needed = new Set<number>();
|
|
65
|
+
for (const element of svg.matchAll(TEXT_ELEMENT)) {
|
|
66
|
+
const content = element[1];
|
|
67
|
+
if (content === undefined) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const char of decodeEntities(content.replace(INNER_TAG, ""))) {
|
|
71
|
+
const code = char.codePointAt(0);
|
|
72
|
+
// Whitespace needs no glyph, and a missing space would report a false gap on every label.
|
|
73
|
+
if (code !== undefined && !/\s/u.test(char)) {
|
|
74
|
+
needed.add(code);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return needed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Code points no embedded face can draw. */
|
|
82
|
+
export function missingCodePoints(
|
|
83
|
+
fonts: readonly EmbeddedFont[],
|
|
84
|
+
needed: ReadonlySet<number>,
|
|
85
|
+
): readonly number[] {
|
|
86
|
+
if (fonts.length === 0) {
|
|
87
|
+
return [...needed];
|
|
88
|
+
}
|
|
89
|
+
if (fonts.some((font) => font.coverage === undefined)) {
|
|
90
|
+
// One unreadable cmap makes the whole answer a guess, so nothing is claimed to be missing.
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
return [...needed].filter((code) => !fonts.some((font) => font.coverage?.has(code) === true));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function decodeEntities(text: string): string {
|
|
97
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/giu, (entity, body: string) => {
|
|
98
|
+
const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
99
|
+
if (body.startsWith("#")) {
|
|
100
|
+
const code = Number.parseInt(
|
|
101
|
+
body.slice(body.startsWith("#x") || body.startsWith("#X") ? 2 : 1),
|
|
102
|
+
body.startsWith("#x") || body.startsWith("#X") ? 16 : 10,
|
|
103
|
+
);
|
|
104
|
+
return Number.isSafeInteger(code) && code > 0 && code <= 0x10ffff
|
|
105
|
+
? String.fromCodePoint(code)
|
|
106
|
+
: entity;
|
|
107
|
+
}
|
|
108
|
+
return named[body.toLowerCase()] ?? entity;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every offset is checked against the buffer: the container is only as trustworthy as its SVG. */
|
|
113
|
+
function rebuild(woff: Buffer): SfntFont {
|
|
114
|
+
if (woff.length < 44 || woff.toString("ascii", 0, 4) !== "wOFF") {
|
|
115
|
+
throw new Error("Not a WOFF container.");
|
|
116
|
+
}
|
|
117
|
+
const numTables = woff.readUInt16BE(12);
|
|
118
|
+
if (numTables === 0 || numTables > MAX_TABLES || 44 + numTables * 20 > woff.length) {
|
|
119
|
+
throw new Error(`WOFF declares ${numTables} tables.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const tables: { tag: string; checksum: number; data: Buffer }[] = [];
|
|
123
|
+
let decompressed = 0;
|
|
124
|
+
for (let index = 0; index < numTables; index += 1) {
|
|
125
|
+
const record = 44 + index * 20;
|
|
126
|
+
const offset = woff.readUInt32BE(record + 4);
|
|
127
|
+
const compressed = woff.readUInt32BE(record + 8);
|
|
128
|
+
const original = woff.readUInt32BE(record + 12);
|
|
129
|
+
decompressed += original;
|
|
130
|
+
if (offset + compressed > woff.length || original > MAX_FACE_BYTES) {
|
|
131
|
+
throw new Error("WOFF table lies outside the container.");
|
|
132
|
+
}
|
|
133
|
+
if (decompressed > MAX_FACE_BYTES) {
|
|
134
|
+
throw new Error("WOFF tables decompress past the size limit.");
|
|
135
|
+
}
|
|
136
|
+
const raw = woff.subarray(offset, offset + compressed);
|
|
137
|
+
const data = compressed === original ? raw : inflateSync(raw);
|
|
138
|
+
if (data.length !== original) {
|
|
139
|
+
throw new Error("WOFF table does not decompress to its declared length.");
|
|
140
|
+
}
|
|
141
|
+
tables.push({
|
|
142
|
+
tag: woff.toString("ascii", record, record + 4),
|
|
143
|
+
checksum: woff.readUInt32BE(record + 16),
|
|
144
|
+
data,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
tables.sort((left, right) => (left.tag < right.tag ? -1 : 1));
|
|
148
|
+
|
|
149
|
+
const header = Buffer.alloc(12 + numTables * 16);
|
|
150
|
+
header.writeUInt32BE(woff.readUInt32BE(4), 0);
|
|
151
|
+
header.writeUInt16BE(numTables, 4);
|
|
152
|
+
const highest = 2 ** Math.floor(Math.log2(numTables));
|
|
153
|
+
header.writeUInt16BE(highest * 16, 6);
|
|
154
|
+
header.writeUInt16BE(Math.log2(highest), 8);
|
|
155
|
+
header.writeUInt16BE(numTables * 16 - highest * 16, 10);
|
|
156
|
+
|
|
157
|
+
let position = header.length;
|
|
158
|
+
const body: Buffer[] = [];
|
|
159
|
+
tables.forEach((table, index) => {
|
|
160
|
+
const record = 12 + index * 16;
|
|
161
|
+
header.write(table.tag, record, 4, "ascii");
|
|
162
|
+
header.writeUInt32BE(table.checksum, record + 4);
|
|
163
|
+
header.writeUInt32BE(position, record + 8);
|
|
164
|
+
header.writeUInt32BE(table.data.length, record + 12);
|
|
165
|
+
const padding = (4 - (table.data.length % 4)) % 4;
|
|
166
|
+
body.push(table.data, Buffer.alloc(padding));
|
|
167
|
+
position += table.data.length + padding;
|
|
168
|
+
});
|
|
169
|
+
return Buffer.concat([header, ...body]) as unknown as SfntFont;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `undefined` when the face carries no cmap subtable in a format this module reads. */
|
|
173
|
+
function readCoverage(font: SfntFont): ReadonlySet<number> | undefined {
|
|
174
|
+
const view = Buffer.from(font.buffer, font.byteOffset, font.byteLength);
|
|
175
|
+
const cmap = findTable(view, "cmap");
|
|
176
|
+
if (cmap === undefined || cmap + 4 > view.length) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const subtables = view.readUInt16BE(cmap + 2);
|
|
181
|
+
let best: { format: number; offset: number } | undefined;
|
|
182
|
+
for (let index = 0; index < subtables; index += 1) {
|
|
183
|
+
const record = cmap + 4 + index * 8;
|
|
184
|
+
if (record + 8 > view.length) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const offset = cmap + view.readUInt32BE(record + 4);
|
|
188
|
+
if (offset + 2 > view.length) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
const format = view.readUInt16BE(offset);
|
|
192
|
+
// Format 12 reaches beyond the BMP, so it wins when a face offers both.
|
|
193
|
+
if (format === 12 || (format === 4 && best === undefined)) {
|
|
194
|
+
best = { format, offset };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (best === undefined) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
return best.format === 12 ? readFormat12(view, best.offset) : readFormat4(view, best.offset);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function findTable(font: Buffer, tag: string): number | undefined {
|
|
204
|
+
if (font.length < 12) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const numTables = font.readUInt16BE(4);
|
|
208
|
+
if (12 + numTables * 16 > font.length) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
for (let index = 0; index < numTables; index += 1) {
|
|
212
|
+
const record = 12 + index * 16;
|
|
213
|
+
if (font.toString("ascii", record, record + 4) === tag) {
|
|
214
|
+
return font.readUInt32BE(record + 8);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Windows BMP segment mapping, which is what D2's subset faces carry. */
|
|
221
|
+
function readFormat4(font: Buffer, offset: number): ReadonlySet<number> | undefined {
|
|
222
|
+
if (offset + 14 > font.length) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
const segments = font.readUInt16BE(offset + 6) / 2;
|
|
226
|
+
const endCodes = offset + 14;
|
|
227
|
+
const startCodes = endCodes + segments * 2 + 2;
|
|
228
|
+
const deltas = startCodes + segments * 2;
|
|
229
|
+
const rangeOffsets = deltas + segments * 2;
|
|
230
|
+
if (!Number.isSafeInteger(segments) || rangeOffsets + segments * 2 > font.length) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const covered = new Set<number>();
|
|
235
|
+
for (let segment = 0; segment < segments; segment += 1) {
|
|
236
|
+
const end = font.readUInt16BE(endCodes + segment * 2);
|
|
237
|
+
const start = font.readUInt16BE(startCodes + segment * 2);
|
|
238
|
+
const delta = font.readInt16BE(deltas + segment * 2);
|
|
239
|
+
const rangeOffset = font.readUInt16BE(rangeOffsets + segment * 2);
|
|
240
|
+
if (start > end || covered.size + (end - start) > MAX_COVERAGE) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
for (let code = start; code <= end && code !== 0xffff; code += 1) {
|
|
244
|
+
let glyph: number;
|
|
245
|
+
if (rangeOffset === 0) {
|
|
246
|
+
glyph = (code + delta) & 0xffff;
|
|
247
|
+
} else {
|
|
248
|
+
const at = rangeOffsets + segment * 2 + rangeOffset + (code - start) * 2;
|
|
249
|
+
if (at + 2 > font.length) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
const found = font.readUInt16BE(at);
|
|
253
|
+
glyph = found === 0 ? 0 : (found + delta) & 0xffff;
|
|
254
|
+
}
|
|
255
|
+
if (glyph !== 0) {
|
|
256
|
+
covered.add(code);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return covered;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readFormat12(font: Buffer, offset: number): ReadonlySet<number> | undefined {
|
|
264
|
+
if (offset + 16 > font.length) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
const groups = font.readUInt32BE(offset + 12);
|
|
268
|
+
if (offset + 16 + groups * 12 > font.length) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const covered = new Set<number>();
|
|
273
|
+
for (let index = 0; index < groups; index += 1) {
|
|
274
|
+
const record = offset + 16 + index * 12;
|
|
275
|
+
const start = font.readUInt32BE(record);
|
|
276
|
+
const end = font.readUInt32BE(record + 4);
|
|
277
|
+
const glyph = font.readUInt32BE(record + 8);
|
|
278
|
+
if (start > end || end > 0x10ffff || covered.size + (end - start) > MAX_COVERAGE) {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
if (glyph === 0) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
for (let code = start; code <= end; code += 1) {
|
|
285
|
+
covered.add(code);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return covered;
|
|
289
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { NormalizedD2Source } from "../normalize.js";
|
|
2
|
+
import { type Diagnostic, DiagramSourceError } from "./diagnostics.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The safe D2 subset. `@` imports read files, `icon` loads local paths and remote URLs, and
|
|
6
|
+
* source can override the layout engine. A relative import was verified against d2 0.8.1 to
|
|
7
|
+
* read a sibling file and draw its contents, so this is a live file-read channel.
|
|
8
|
+
*
|
|
9
|
+
* Anything ambiguous is refused rather than guessed at. See docs/safety.md for the full policy.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
declare const safeSourceBrand: unique symbol;
|
|
13
|
+
|
|
14
|
+
/** The renderer accepts nothing else, so unchecked source cannot reach D2. */
|
|
15
|
+
export type SafeD2Source = NormalizedD2Source & { readonly [safeSourceBrand]: true };
|
|
16
|
+
|
|
17
|
+
/** D2's documented shapes, less `image`, which loads a file or a URL. */
|
|
18
|
+
const ALLOWED_SHAPES: ReadonlySet<string> = new Set([
|
|
19
|
+
"rectangle",
|
|
20
|
+
"square",
|
|
21
|
+
"page",
|
|
22
|
+
"parallelogram",
|
|
23
|
+
"document",
|
|
24
|
+
"cylinder",
|
|
25
|
+
"queue",
|
|
26
|
+
"package",
|
|
27
|
+
"step",
|
|
28
|
+
"callout",
|
|
29
|
+
"stored_data",
|
|
30
|
+
"person",
|
|
31
|
+
"diamond",
|
|
32
|
+
"oval",
|
|
33
|
+
"circle",
|
|
34
|
+
"hexagon",
|
|
35
|
+
"cloud",
|
|
36
|
+
"text",
|
|
37
|
+
"code",
|
|
38
|
+
"class",
|
|
39
|
+
"sql_table",
|
|
40
|
+
"sequence_diagram",
|
|
41
|
+
"c4-person",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** D2 treats `user@example.com` as label text, so only a token-initial `@` is an import. */
|
|
45
|
+
const IMPORT = /(?:^|[\s:{};,])@|\.\.\.[ \t]*@/gu;
|
|
46
|
+
const ASSET_KEY = /(?:^|[\s{};,.])(icon|link)[ \t]*:/gu;
|
|
47
|
+
const SHAPE_KEY = /(?:^|[\s{};,.])shape[ \t]*:[ \t]*(\S*)/gu;
|
|
48
|
+
const CONFIG_KEY = /(?:^|[\s{};,.])(d2-config|layout-engine)[ \t]*:/gu;
|
|
49
|
+
|
|
50
|
+
interface Located {
|
|
51
|
+
readonly line: number;
|
|
52
|
+
readonly column: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Throws with one diagnostic per problem found, rather than at the first. */
|
|
56
|
+
export function parseSafeSource(source: NormalizedD2Source): SafeD2Source {
|
|
57
|
+
const diagnostics = inspect(source);
|
|
58
|
+
if (diagnostics.length > 0) {
|
|
59
|
+
throw new DiagramSourceError(
|
|
60
|
+
"Diagram source uses D2 features this tool does not allow.",
|
|
61
|
+
diagnostics,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return source as SafeD2Source;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Exported so tests can read the diagnostics without catching the error. */
|
|
68
|
+
export function inspect(source: NormalizedD2Source): readonly Diagnostic[] {
|
|
69
|
+
const lineStarts = buildLineStarts(source);
|
|
70
|
+
const locate = (offset: number): Located => locateOffset(lineStarts, offset);
|
|
71
|
+
const { masked, diagnostics } = mask(source, locate);
|
|
72
|
+
if (diagnostics.length > 0) {
|
|
73
|
+
// Masking stopped early, so the rest of the source cannot be read reliably.
|
|
74
|
+
return diagnostics;
|
|
75
|
+
}
|
|
76
|
+
return scan(masked, locate);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Blanks the inside of comments and quoted strings, keeping delimiters and every offset so
|
|
81
|
+
* diagnostics can carry a real line and column. Stops at a block string, where code and content
|
|
82
|
+
* can no longer be told apart, and at an unterminated string, which D2 also rejects.
|
|
83
|
+
*/
|
|
84
|
+
function mask(
|
|
85
|
+
source: string,
|
|
86
|
+
locate: (offset: number) => Located,
|
|
87
|
+
): { masked: string; diagnostics: readonly Diagnostic[] } {
|
|
88
|
+
const out: string[] = [];
|
|
89
|
+
let index = 0;
|
|
90
|
+
|
|
91
|
+
while (index < source.length) {
|
|
92
|
+
const char = source[index] as string;
|
|
93
|
+
|
|
94
|
+
if (char === "#") {
|
|
95
|
+
out.push("#");
|
|
96
|
+
index += 1;
|
|
97
|
+
while (index < source.length && source[index] !== "\n") {
|
|
98
|
+
out.push(" ");
|
|
99
|
+
index += 1;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (char === "|") {
|
|
105
|
+
return {
|
|
106
|
+
masked: out.join(""),
|
|
107
|
+
diagnostics: [
|
|
108
|
+
{
|
|
109
|
+
code: "D2_BLOCK_STRING",
|
|
110
|
+
message: "Block strings and Markdown, LaTeX, or code labels are not allowed.",
|
|
111
|
+
hint: "Use a plain quoted label. They also render as an empty box in text output.",
|
|
112
|
+
...locate(index),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (char === '"' || char === "'") {
|
|
119
|
+
const closed = maskString(source, index, char, out);
|
|
120
|
+
if (closed === undefined) {
|
|
121
|
+
return {
|
|
122
|
+
masked: out.join(""),
|
|
123
|
+
diagnostics: [
|
|
124
|
+
{
|
|
125
|
+
code: "D2_UNTERMINATED",
|
|
126
|
+
message: `Unterminated ${char === '"' ? "double" : "single"}-quoted string.`,
|
|
127
|
+
hint: "Close the quote. D2 rejects strings that span lines.",
|
|
128
|
+
...locate(index),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
index = closed;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
out.push(char);
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { masked: out.join(""), diagnostics: [] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Returns the offset past the closing quote, or `undefined` if it never closes on its line.
|
|
146
|
+
* Double quotes honour backslash escapes; single quotes are raw.
|
|
147
|
+
*/
|
|
148
|
+
function maskString(
|
|
149
|
+
source: string,
|
|
150
|
+
start: number,
|
|
151
|
+
quote: string,
|
|
152
|
+
out: string[],
|
|
153
|
+
): number | undefined {
|
|
154
|
+
out.push(quote);
|
|
155
|
+
let index = start + 1;
|
|
156
|
+
while (index < source.length) {
|
|
157
|
+
const char = source[index] as string;
|
|
158
|
+
if (char === "\n") {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
if (quote === '"' && char === "\\" && index + 1 < source.length) {
|
|
162
|
+
out.push(" ", " ");
|
|
163
|
+
index += 2;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (char === quote) {
|
|
167
|
+
out.push(quote);
|
|
168
|
+
return index + 1;
|
|
169
|
+
}
|
|
170
|
+
out.push(" ");
|
|
171
|
+
index += 1;
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function scan(masked: string, locate: (offset: number) => Located): readonly Diagnostic[] {
|
|
177
|
+
const diagnostics: Diagnostic[] = [];
|
|
178
|
+
|
|
179
|
+
for (const match of masked.matchAll(IMPORT)) {
|
|
180
|
+
const offset = match.index + match[0].indexOf("@");
|
|
181
|
+
diagnostics.push({
|
|
182
|
+
code: "D2_IMPORT",
|
|
183
|
+
message: "Imports are not allowed. They can read any file on this machine.",
|
|
184
|
+
hint: "Write the whole diagram in this call, and quote the label if you meant text.",
|
|
185
|
+
...locate(offset),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const match of masked.matchAll(ASSET_KEY)) {
|
|
190
|
+
const key = match[1] as string;
|
|
191
|
+
diagnostics.push(
|
|
192
|
+
key === "icon"
|
|
193
|
+
? {
|
|
194
|
+
code: "D2_ICON",
|
|
195
|
+
message: "Icons are not allowed. They load local files or remote URLs.",
|
|
196
|
+
hint: "Use a built-in shape and a label instead.",
|
|
197
|
+
...locate(match.index + match[0].indexOf(key)),
|
|
198
|
+
}
|
|
199
|
+
: {
|
|
200
|
+
code: "D2_LINK",
|
|
201
|
+
message: "Links are not allowed.",
|
|
202
|
+
hint: "Put the destination in the label text if it matters.",
|
|
203
|
+
...locate(match.index + match[0].indexOf(key)),
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const match of masked.matchAll(SHAPE_KEY)) {
|
|
209
|
+
const value = (match[1] ?? "").replace(/[{};,]+$/u, "");
|
|
210
|
+
if (ALLOWED_SHAPES.has(value)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const offset = match.index + match[0].indexOf("shape");
|
|
214
|
+
diagnostics.push(
|
|
215
|
+
value === "image"
|
|
216
|
+
? {
|
|
217
|
+
code: "D2_IMAGE_SHAPE",
|
|
218
|
+
message: "`shape: image` is not allowed. It loads a local file or a remote URL.",
|
|
219
|
+
hint: "Use a built-in shape such as `rectangle` or `cylinder`.",
|
|
220
|
+
...locate(offset),
|
|
221
|
+
}
|
|
222
|
+
: {
|
|
223
|
+
code: "D2_UNKNOWN_SHAPE",
|
|
224
|
+
message: `Unsupported shape ${JSON.stringify(value)}.`,
|
|
225
|
+
hint: `Allowed shapes: ${[...ALLOWED_SHAPES].join(", ")}.`,
|
|
226
|
+
...locate(offset),
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const match of masked.matchAll(CONFIG_KEY)) {
|
|
232
|
+
const key = match[1] as string;
|
|
233
|
+
diagnostics.push({
|
|
234
|
+
code: "D2_CONFIG",
|
|
235
|
+
message: `Renderer configuration (${key}) cannot be set from diagram source.`,
|
|
236
|
+
hint: "Layout and theme are chosen by this tool.",
|
|
237
|
+
...locate(match.index + match[0].indexOf(key)),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return diagnostics.sort(byPosition);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function byPosition(left: Diagnostic, right: Diagnostic): number {
|
|
245
|
+
return (left.line ?? 0) - (right.line ?? 0) || (left.column ?? 0) - (right.column ?? 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildLineStarts(source: string): readonly number[] {
|
|
249
|
+
const starts = [0];
|
|
250
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
251
|
+
if (source[index] === "\n") {
|
|
252
|
+
starts.push(index + 1);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return starts;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function locateOffset(lineStarts: readonly number[], offset: number): Located {
|
|
259
|
+
let low = 0;
|
|
260
|
+
let high = lineStarts.length - 1;
|
|
261
|
+
while (low < high) {
|
|
262
|
+
const middle = Math.ceil((low + high) / 2);
|
|
263
|
+
if ((lineStarts[middle] as number) <= offset) {
|
|
264
|
+
low = middle;
|
|
265
|
+
} else {
|
|
266
|
+
high = middle - 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { line: low + 1, column: offset - (lineStarts[low] as number) + 1 };
|
|
270
|
+
}
|