@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/raster.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cacheKeyOf, FileCache, type RenderCache } from "./cache.js";
|
|
6
|
+
import {
|
|
7
|
+
type EmbeddedFont,
|
|
8
|
+
missingCodePoints,
|
|
9
|
+
parseEmbeddedFonts,
|
|
10
|
+
textCodePoints,
|
|
11
|
+
} from "./d2/fonts.js";
|
|
12
|
+
import type { RenderedSvg } from "./d2/runner.js";
|
|
13
|
+
import { CommandCancelledError } from "./process.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Draws a D2 SVG as a PNG. D2's own PNG export drives a headless browser it downloads on first
|
|
17
|
+
* use; resvg needs no browser and no network.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Twice the natural size keeps labels readable once the terminal scales the image to cells. */
|
|
21
|
+
const SCALE = 2;
|
|
22
|
+
const MIN_WIDTH_PX = 480;
|
|
23
|
+
const MAX_WIDTH_PX = 1600;
|
|
24
|
+
const MAX_HEIGHT_PX = 2400;
|
|
25
|
+
const MAX_PNG_BYTES = 4 * 1024 * 1024;
|
|
26
|
+
const DEFAULT_FONT_FAMILY = "Source Sans Pro";
|
|
27
|
+
|
|
28
|
+
/** Everything besides the SVG and resvg itself that decides the picture. */
|
|
29
|
+
const IMAGE_POLICY = [
|
|
30
|
+
"png",
|
|
31
|
+
String(SCALE),
|
|
32
|
+
String(MIN_WIDTH_PX),
|
|
33
|
+
String(MAX_WIDTH_PX),
|
|
34
|
+
String(MAX_HEIGHT_PX),
|
|
35
|
+
DEFAULT_FONT_FAMILY,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
39
|
+
|
|
40
|
+
declare const renderedPngBrand: unique symbol;
|
|
41
|
+
|
|
42
|
+
/** Bytes that passed `parseRenderedPng`, so they really are a PNG of a known size. */
|
|
43
|
+
type RenderedPng = Uint8Array & { readonly [renderedPngBrand]: true };
|
|
44
|
+
|
|
45
|
+
export interface RasterRequest {
|
|
46
|
+
readonly svg: RenderedSvg;
|
|
47
|
+
readonly signal: AbortSignal | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RasterImage {
|
|
51
|
+
readonly png: RenderedPng;
|
|
52
|
+
readonly widthPx: number;
|
|
53
|
+
readonly heightPx: number;
|
|
54
|
+
/** True when the diagram's own font could not draw every label, so system fonts were added. */
|
|
55
|
+
readonly systemFonts: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SvgRasterizer {
|
|
59
|
+
rasterize(request: RasterRequest): Promise<RasterImage>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The diagram is fine and only the image failed, so the caller shows text rather than failing. */
|
|
63
|
+
export class ImageRenderUnavailableError extends Error {
|
|
64
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
65
|
+
super(message, options);
|
|
66
|
+
this.name = "ImageRenderUnavailableError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** resvg reporting success is not proof the bytes are a usable PNG of the size asked for. */
|
|
71
|
+
export function parseRenderedPng(bytes: Uint8Array, expectedWidthPx: number): RasterImage {
|
|
72
|
+
if (bytes.length > MAX_PNG_BYTES) {
|
|
73
|
+
throw new ImageRenderUnavailableError(
|
|
74
|
+
`The image is ${bytes.length} bytes, past the ${MAX_PNG_BYTES} byte limit.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
78
|
+
if (buffer.length < 24 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
79
|
+
throw new ImageRenderUnavailableError("The renderer did not return a PNG.");
|
|
80
|
+
}
|
|
81
|
+
if (buffer.toString("ascii", 12, 16) !== "IHDR") {
|
|
82
|
+
throw new ImageRenderUnavailableError("The PNG does not start with an image header.");
|
|
83
|
+
}
|
|
84
|
+
if (buffer.toString("ascii", buffer.length - 8, buffer.length - 4) !== "IEND") {
|
|
85
|
+
throw new ImageRenderUnavailableError("The PNG is truncated.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const widthPx = buffer.readUInt32BE(16);
|
|
89
|
+
const heightPx = buffer.readUInt32BE(20);
|
|
90
|
+
if (widthPx === 0 || heightPx === 0) {
|
|
91
|
+
throw new ImageRenderUnavailableError("The PNG has no area.");
|
|
92
|
+
}
|
|
93
|
+
// A silently ignored size option would otherwise reach the terminal as an unreadable image.
|
|
94
|
+
if (Math.abs(widthPx - expectedWidthPx) > 1) {
|
|
95
|
+
throw new ImageRenderUnavailableError(
|
|
96
|
+
`The PNG is ${widthPx} pixels wide, not the ${expectedWidthPx} that were asked for.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return { png: bytes as RenderedPng, widthPx, heightPx, systemFonts: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Enough resolution to scale down cleanly, never a pathological canvas. */
|
|
103
|
+
export function parseTargetWidth(naturalWidthPx: number, naturalHeightPx: number): number {
|
|
104
|
+
if (
|
|
105
|
+
!Number.isFinite(naturalWidthPx) ||
|
|
106
|
+
!Number.isFinite(naturalHeightPx) ||
|
|
107
|
+
naturalWidthPx < 1 ||
|
|
108
|
+
naturalHeightPx < 1
|
|
109
|
+
) {
|
|
110
|
+
throw new ImageRenderUnavailableError(
|
|
111
|
+
`The SVG reports a ${naturalWidthPx} by ${naturalHeightPx} canvas.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The limits come last, so a tall diagram is drawn smaller rather than past the canvas bound.
|
|
116
|
+
const byHeight = (MAX_HEIGHT_PX / naturalHeightPx) * naturalWidthPx;
|
|
117
|
+
const wanted = Math.max(naturalWidthPx * SCALE, MIN_WIDTH_PX);
|
|
118
|
+
return Math.max(1, Math.round(Math.min(wanted, MAX_WIDTH_PX, byHeight)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The store holds text, so an image travels as base64 behind the sizes it was drawn at. */
|
|
122
|
+
function formatCachedImage(image: RasterImage): string {
|
|
123
|
+
const drawn = `${image.widthPx} ${image.heightPx} ${image.systemFonts ? 1 : 0}`;
|
|
124
|
+
return `${drawn}\n${Buffer.from(image.png).toString("base64")}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Parsed again on the way out, so a corrupt entry is drawn again rather than displayed. */
|
|
128
|
+
export function parseCachedImage(entry: string): RasterImage {
|
|
129
|
+
const split = entry.indexOf("\n");
|
|
130
|
+
const [width, height, fonts] = entry.slice(0, Math.max(split, 0)).split(" ");
|
|
131
|
+
const widthPx = Number(width);
|
|
132
|
+
const heightPx = Number(height);
|
|
133
|
+
const sized =
|
|
134
|
+
Number.isSafeInteger(widthPx) && widthPx > 0 && Number.isSafeInteger(heightPx) && heightPx > 0;
|
|
135
|
+
if (split === -1 || !sized || (fonts !== "0" && fonts !== "1")) {
|
|
136
|
+
throw new ImageRenderUnavailableError("The stored image does not say how it was drawn.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const image = parseRenderedPng(Buffer.from(entry.slice(split + 1), "base64"), widthPx);
|
|
140
|
+
if (image.heightPx !== heightPx) {
|
|
141
|
+
throw new ImageRenderUnavailableError(
|
|
142
|
+
`The stored image is ${image.heightPx} pixels tall, not the ${heightPx} it was drawn at.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return { ...image, systemFonts: fonts === "1" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type ResvgModule = typeof import("@resvg/resvg-js");
|
|
149
|
+
|
|
150
|
+
let loaded: Promise<ResvgModule> | undefined;
|
|
151
|
+
let installed: Promise<string | undefined> | undefined;
|
|
152
|
+
|
|
153
|
+
/** A native binary per platform, loaded lazily so an unsupported one cannot break text diagrams. */
|
|
154
|
+
async function load(): Promise<ResvgModule> {
|
|
155
|
+
loaded ??= import("@resvg/resvg-js");
|
|
156
|
+
try {
|
|
157
|
+
return await loaded;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
loaded = undefined;
|
|
160
|
+
throw new ImageRenderUnavailableError(
|
|
161
|
+
`The SVG rasterizer could not be loaded: ${(error as Error).message}`,
|
|
162
|
+
{ cause: error },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class ResvgRasterizer implements SvgRasterizer {
|
|
168
|
+
private readonly cache: RenderCache;
|
|
169
|
+
|
|
170
|
+
constructor(dependencies: { readonly cache?: RenderCache } = {}) {
|
|
171
|
+
this.cache = dependencies.cache ?? new FileCache();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async rasterize(request: RasterRequest): Promise<RasterImage> {
|
|
175
|
+
if (request.signal?.aborted === true) {
|
|
176
|
+
throw new CommandCancelledError("Drawing the diagram");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const key = await imageKey(request.svg);
|
|
180
|
+
const stored = key === undefined ? undefined : await this.cache.read(key);
|
|
181
|
+
if (stored !== undefined) {
|
|
182
|
+
try {
|
|
183
|
+
return parseCachedImage(stored);
|
|
184
|
+
} catch {
|
|
185
|
+
// An entry this build cannot read is no better than a missing one.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const image = await draw(request.svg);
|
|
190
|
+
if (key !== undefined) {
|
|
191
|
+
await this.cache.write(key, formatCachedImage(image));
|
|
192
|
+
}
|
|
193
|
+
return image;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Undefined when the version is unknown, which keeps that image out of the store. */
|
|
198
|
+
async function imageKey(svg: RenderedSvg): Promise<string | undefined> {
|
|
199
|
+
const version = await resvgVersion();
|
|
200
|
+
return version === undefined ? undefined : cacheKeyOf([...IMAGE_POLICY, version, svg]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** resvg draws differently between versions, so a stored image belongs to the one that drew it. */
|
|
204
|
+
async function resvgVersion(): Promise<string | undefined> {
|
|
205
|
+
installed ??= (async (): Promise<string | undefined> => {
|
|
206
|
+
try {
|
|
207
|
+
const manifest = createRequire(import.meta.url).resolve("@resvg/resvg-js/package.json");
|
|
208
|
+
const parsed: unknown = JSON.parse(await readFile(manifest, "utf8"));
|
|
209
|
+
const found = (parsed as { version?: unknown }).version;
|
|
210
|
+
return typeof found === "string" ? found : undefined;
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
return installed;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function draw(svg: RenderedSvg): Promise<RasterImage> {
|
|
219
|
+
const { Resvg } = await load();
|
|
220
|
+
const fonts = parseEmbeddedFonts(svg);
|
|
221
|
+
const missing = missingCodePoints(fonts, textCodePoints(svg));
|
|
222
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-diagram-fonts-"));
|
|
223
|
+
try {
|
|
224
|
+
const fontFiles = await writeFonts(directory, fonts);
|
|
225
|
+
const font = {
|
|
226
|
+
fontFiles,
|
|
227
|
+
// Labels the diagram's own font cannot draw would otherwise be empty boxes.
|
|
228
|
+
loadSystemFonts: missing.length > 0,
|
|
229
|
+
defaultFontFamily: DEFAULT_FONT_FAMILY,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const probe = new Resvg(svg, { font });
|
|
233
|
+
const widthPx = parseTargetWidth(probe.width, probe.height);
|
|
234
|
+
const drawn = new Resvg(svg, { font, fitTo: { mode: "width", value: widthPx } });
|
|
235
|
+
const image = parseRenderedPng(drawn.render().asPng(), widthPx);
|
|
236
|
+
return { ...image, systemFonts: missing.length > 0 };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error instanceof ImageRenderUnavailableError || error instanceof CommandCancelledError) {
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
throw new ImageRenderUnavailableError(
|
|
242
|
+
`The SVG could not be drawn as an image: ${(error as Error).message}`,
|
|
243
|
+
{ cause: error },
|
|
244
|
+
);
|
|
245
|
+
} finally {
|
|
246
|
+
await rm(directory, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function writeFonts(directory: string, fonts: readonly EmbeddedFont[]): Promise<string[]> {
|
|
251
|
+
const paths: string[] = [];
|
|
252
|
+
for (const [index, font] of fonts.entries()) {
|
|
253
|
+
const path = join(directory, `face-${index}.ttf`);
|
|
254
|
+
await writeFile(path, font.bytes, { mode: 0o600 });
|
|
255
|
+
paths.push(path);
|
|
256
|
+
}
|
|
257
|
+
return paths;
|
|
258
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ArtifactFormat,
|
|
3
|
+
parseArtifactNames,
|
|
4
|
+
parseArtifactTarget,
|
|
5
|
+
type WrittenArtifact,
|
|
6
|
+
writeArtifacts,
|
|
7
|
+
} from "./artifacts.js";
|
|
8
|
+
import { type Diagnostic, DiagramSourceError } from "./d2/diagnostics.js";
|
|
9
|
+
import { parseSafeSource, type SafeD2Source } from "./d2/preflight.js";
|
|
10
|
+
import { type ProfileName, parseProfile } from "./d2/profiles.js";
|
|
11
|
+
import {
|
|
12
|
+
type AsciiMode,
|
|
13
|
+
type D2Renderer,
|
|
14
|
+
type SupportedD2Version,
|
|
15
|
+
TextRenderUnavailableError,
|
|
16
|
+
} from "./d2/runner.js";
|
|
17
|
+
import { normalizeSource, parseTitle, type SafeTitle } from "./normalize.js";
|
|
18
|
+
import {
|
|
19
|
+
ImageRenderUnavailableError,
|
|
20
|
+
type RasterImage,
|
|
21
|
+
ResvgRasterizer,
|
|
22
|
+
type SvgRasterizer,
|
|
23
|
+
} from "./raster.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* D2's text renderer is beta, so a diagram it cannot draw has to fail in a way the user can
|
|
27
|
+
* understand rather than turning into broken box art or a different graph.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** How the diagram is drawn as text. */
|
|
31
|
+
export type Representation = "unicode" | "ascii" | "source";
|
|
32
|
+
|
|
33
|
+
/** Bounds for one transcript diagram. */
|
|
34
|
+
const MAX_LINES = 300;
|
|
35
|
+
const MAX_COLUMNS = 400;
|
|
36
|
+
const MAX_BYTES = 32 * 1024;
|
|
37
|
+
|
|
38
|
+
export interface DiagramRequest {
|
|
39
|
+
readonly source: unknown;
|
|
40
|
+
readonly title?: unknown;
|
|
41
|
+
readonly profile?: unknown;
|
|
42
|
+
readonly render?: unknown;
|
|
43
|
+
readonly formats?: unknown;
|
|
44
|
+
readonly save?: unknown;
|
|
45
|
+
readonly cwd?: unknown;
|
|
46
|
+
/** Whether the host can display an image at all. Only `true` enables the raster path. */
|
|
47
|
+
readonly images?: unknown;
|
|
48
|
+
readonly signal?: AbortSignal | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface DiagramImage {
|
|
52
|
+
/** Absolute path in the temp store. */
|
|
53
|
+
readonly path: string;
|
|
54
|
+
readonly widthPx: number;
|
|
55
|
+
readonly heightPx: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DiagramRendering {
|
|
59
|
+
readonly profile: ProfileName;
|
|
60
|
+
readonly renderedAs: Representation;
|
|
61
|
+
readonly text: string;
|
|
62
|
+
/** The D2 source that was drawn, for the expanded view. */
|
|
63
|
+
readonly source: string;
|
|
64
|
+
/** Why the text came out the way it did. Empty when nothing went wrong. */
|
|
65
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
66
|
+
readonly image: DiagramImage | undefined;
|
|
67
|
+
readonly title: SafeTitle | undefined;
|
|
68
|
+
readonly sourceHash: string;
|
|
69
|
+
readonly lineCount: number;
|
|
70
|
+
readonly widthCells: number;
|
|
71
|
+
readonly d2Version: SupportedD2Version | undefined;
|
|
72
|
+
readonly saved: readonly WrittenArtifact[];
|
|
73
|
+
readonly notes: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `image` and `auto` choose the text that goes with the image. Whether a terminal can show one is
|
|
78
|
+
* only decided when the result is displayed, so both are always prepared.
|
|
79
|
+
*/
|
|
80
|
+
export function parseRepresentation(requested: unknown): Representation {
|
|
81
|
+
switch (requested) {
|
|
82
|
+
case undefined:
|
|
83
|
+
case "auto":
|
|
84
|
+
case "image":
|
|
85
|
+
case "unicode":
|
|
86
|
+
return "unicode";
|
|
87
|
+
case "ascii":
|
|
88
|
+
return "ascii";
|
|
89
|
+
case "source":
|
|
90
|
+
return "source";
|
|
91
|
+
default:
|
|
92
|
+
throw new DiagramSourceError("Unsupported render mode.", [
|
|
93
|
+
{
|
|
94
|
+
code: "D2_SOURCE",
|
|
95
|
+
message: `${JSON.stringify(requested)} is not a render mode.`,
|
|
96
|
+
hint: "Use auto, image, unicode, ascii, or source.",
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Asking for a text representation suppresses the image, rather than producing both. */
|
|
103
|
+
function wantsImage(request: DiagramRequest): boolean {
|
|
104
|
+
if (request.images !== true) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return request.render === undefined || request.render === "auto" || request.render === "image";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function renderDiagram(
|
|
111
|
+
request: DiagramRequest,
|
|
112
|
+
renderer: D2Renderer,
|
|
113
|
+
rasterizer: SvgRasterizer = new ResvgRasterizer(),
|
|
114
|
+
): Promise<DiagramRendering> {
|
|
115
|
+
const normalized = normalizeSource(request.source);
|
|
116
|
+
const source = parseSafeSource(normalized.text);
|
|
117
|
+
const representation = parseRepresentation(request.render);
|
|
118
|
+
const profile = parseProfile(request.profile);
|
|
119
|
+
const title = parseTitle(request.title);
|
|
120
|
+
// Everything the request asks for is parsed before D2 starts, so a bad save path costs nothing.
|
|
121
|
+
const wantsFiles = request.save !== undefined || request.formats !== undefined;
|
|
122
|
+
const names = wantsFiles
|
|
123
|
+
? parseArtifactNames(
|
|
124
|
+
{ formats: request.formats, save: request.save },
|
|
125
|
+
{ title, hash: normalized.hash },
|
|
126
|
+
)
|
|
127
|
+
: undefined;
|
|
128
|
+
const target = names === undefined ? undefined : await parseArtifactTarget(request.cwd, names);
|
|
129
|
+
|
|
130
|
+
const notes: string[] = [];
|
|
131
|
+
const showsImage = wantsImage(request);
|
|
132
|
+
const savesPng = names?.formats.includes("png") === true;
|
|
133
|
+
const wantsText = representation !== "source" || names?.formats.includes("txt") === true;
|
|
134
|
+
let mode: AsciiMode = representation === "ascii" ? "standard" : "extended";
|
|
135
|
+
let drawn = wantsText ? await tryRender(renderer, source, mode, request.signal) : undefined;
|
|
136
|
+
|
|
137
|
+
if (drawn instanceof TextRenderUnavailableError && mode === "extended") {
|
|
138
|
+
// Exactly one fallback attempt, so a beta renderer cannot be retried indefinitely.
|
|
139
|
+
const retry = await tryRender(renderer, source, "standard", request.signal);
|
|
140
|
+
if (!(retry instanceof TextRenderUnavailableError)) {
|
|
141
|
+
notes.push("Unicode output failed, so this diagram is drawn in plain ASCII.");
|
|
142
|
+
mode = "standard";
|
|
143
|
+
drawn = retry;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const textFailure = drawn instanceof TextRenderUnavailableError ? drawn : undefined;
|
|
148
|
+
const text = drawn instanceof TextRenderUnavailableError ? undefined : drawn?.text;
|
|
149
|
+
|
|
150
|
+
const svg =
|
|
151
|
+
names?.formats.includes("svg") === true || showsImage || savesPng
|
|
152
|
+
? await renderer.renderSvg({ source, profile, signal: request.signal })
|
|
153
|
+
: undefined;
|
|
154
|
+
|
|
155
|
+
let raster: RasterImage | undefined;
|
|
156
|
+
if (svg !== undefined && (showsImage || savesPng)) {
|
|
157
|
+
raster = await tryRasterize(rasterizer, svg.svg, request.signal, notes);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let saved: readonly WrittenArtifact[] = [];
|
|
161
|
+
if (target !== undefined && names !== undefined) {
|
|
162
|
+
// Every text artifact ends with a newline, the way any other checked-in text file does.
|
|
163
|
+
const contents = new Map<ArtifactFormat, string | Uint8Array>([
|
|
164
|
+
["source", await sourceToSave(renderer, source, request.signal)],
|
|
165
|
+
]);
|
|
166
|
+
if (svg !== undefined) {
|
|
167
|
+
contents.set("svg", `${svg.svg}\n`);
|
|
168
|
+
}
|
|
169
|
+
if (raster !== undefined) {
|
|
170
|
+
contents.set("png", raster.png);
|
|
171
|
+
} else if (savesPng) {
|
|
172
|
+
notes.push("No .png was written, because the diagram could not be drawn as an image.");
|
|
173
|
+
}
|
|
174
|
+
if (text !== undefined) {
|
|
175
|
+
contents.set("txt", `${text}\n`);
|
|
176
|
+
} else if (names.formats.includes("txt")) {
|
|
177
|
+
notes.push("No .txt was written, because D2 could not draw this diagram as text.");
|
|
178
|
+
}
|
|
179
|
+
saved = await writeArtifacts(target, contents);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const image =
|
|
183
|
+
raster === undefined || !showsImage
|
|
184
|
+
? undefined
|
|
185
|
+
: await keepImage(raster, title, normalized.hash, notes);
|
|
186
|
+
|
|
187
|
+
// Work that came out fine is not discarded because the text renderer choked.
|
|
188
|
+
if (textFailure !== undefined && representation !== "source") {
|
|
189
|
+
if (saved.length === 0 && image === undefined) {
|
|
190
|
+
throw explain(textFailure);
|
|
191
|
+
}
|
|
192
|
+
notes.push("The diagram is shown as source, because D2 could not draw it as text.");
|
|
193
|
+
return {
|
|
194
|
+
title,
|
|
195
|
+
profile: profile.name,
|
|
196
|
+
sourceHash: normalized.hash,
|
|
197
|
+
...measure(source, MAX_COLUMNS),
|
|
198
|
+
renderedAs: "source",
|
|
199
|
+
text: source,
|
|
200
|
+
source,
|
|
201
|
+
diagnostics: textFailure.diagnostics,
|
|
202
|
+
image,
|
|
203
|
+
d2Version: svg?.version,
|
|
204
|
+
saved,
|
|
205
|
+
notes,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Text may have been drawn only to write a .txt sidecar, which must not override the
|
|
210
|
+
// representation the caller asked for.
|
|
211
|
+
const showSource = representation === "source" || text === undefined;
|
|
212
|
+
return {
|
|
213
|
+
title,
|
|
214
|
+
profile: profile.name,
|
|
215
|
+
sourceHash: normalized.hash,
|
|
216
|
+
...measure(showSource ? source : (text as string), MAX_COLUMNS),
|
|
217
|
+
renderedAs: showSource ? "source" : mode === "standard" ? "ascii" : "unicode",
|
|
218
|
+
text: showSource ? source : (text as string),
|
|
219
|
+
source,
|
|
220
|
+
diagnostics: [],
|
|
221
|
+
image,
|
|
222
|
+
d2Version:
|
|
223
|
+
drawn instanceof TextRenderUnavailableError ? svg?.version : (drawn?.version ?? svg?.version),
|
|
224
|
+
saved,
|
|
225
|
+
notes,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A checked-in `.d2` is read and edited by people later, so it is saved formatted. */
|
|
230
|
+
async function sourceToSave(
|
|
231
|
+
renderer: D2Renderer,
|
|
232
|
+
source: SafeD2Source,
|
|
233
|
+
signal: AbortSignal | undefined,
|
|
234
|
+
): Promise<string> {
|
|
235
|
+
try {
|
|
236
|
+
const formatted = await renderer.formatSource({ source, signal });
|
|
237
|
+
// Parsed again, because nothing reaches the workspace without passing the safe subset.
|
|
238
|
+
return `${parseSafeSource(normalizeSource(formatted).text)}\n`;
|
|
239
|
+
} catch {
|
|
240
|
+
// Formatting is cosmetic, so what the model wrote is saved as it is.
|
|
241
|
+
return `${source}\n`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** An image that cannot be drawn is a display fallback, not a failure the model should correct. */
|
|
246
|
+
async function tryRasterize(
|
|
247
|
+
rasterizer: SvgRasterizer,
|
|
248
|
+
svg: Parameters<SvgRasterizer["rasterize"]>[0]["svg"],
|
|
249
|
+
signal: AbortSignal | undefined,
|
|
250
|
+
notes: string[],
|
|
251
|
+
): Promise<RasterImage | undefined> {
|
|
252
|
+
try {
|
|
253
|
+
const raster = await rasterizer.rasterize({ svg, signal });
|
|
254
|
+
if (raster.systemFonts) {
|
|
255
|
+
notes.push(
|
|
256
|
+
"Some labels use characters the diagram's own font does not carry, so the image was " +
|
|
257
|
+
"drawn with the fonts installed on this machine.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return raster;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!(error instanceof ImageRenderUnavailableError)) {
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
notes.push(`${error.message} The diagram is shown as text instead.`);
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The temp store keeps the bytes out of the model's context and out of the repository. */
|
|
271
|
+
async function keepImage(
|
|
272
|
+
raster: RasterImage,
|
|
273
|
+
title: SafeTitle | undefined,
|
|
274
|
+
hash: string,
|
|
275
|
+
notes: string[],
|
|
276
|
+
): Promise<DiagramImage | undefined> {
|
|
277
|
+
try {
|
|
278
|
+
const names = parseArtifactNames({ formats: ["png"] }, { title, hash });
|
|
279
|
+
const target = await parseArtifactTarget(undefined, names);
|
|
280
|
+
const [written] = await writeArtifacts(target, new Map([["png", raster.png]]));
|
|
281
|
+
if (written === undefined) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
return { path: written.path, widthPx: raster.widthPx, heightPx: raster.heightPx };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
notes.push(`The image could not be stored: ${(error as Error).message}`);
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Returns a text-renderer failure as a value so the caller can decide whether to retry.
|
|
293
|
+
* Everything else, including source errors and cancellation, still throws.
|
|
294
|
+
*/
|
|
295
|
+
async function tryRender(
|
|
296
|
+
renderer: D2Renderer,
|
|
297
|
+
source: SafeD2Source,
|
|
298
|
+
asciiMode: AsciiMode,
|
|
299
|
+
signal: AbortSignal | undefined,
|
|
300
|
+
): Promise<Awaited<ReturnType<D2Renderer["renderText"]>> | TextRenderUnavailableError> {
|
|
301
|
+
try {
|
|
302
|
+
return await renderer.renderText({ source, asciiMode, signal });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof TextRenderUnavailableError) {
|
|
305
|
+
return error;
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function explain(failure: TextRenderUnavailableError): TextRenderUnavailableError {
|
|
312
|
+
return new TextRenderUnavailableError(
|
|
313
|
+
`${failure.message} D2's text renderer is beta and cannot draw every diagram. ` +
|
|
314
|
+
'Try a simpler diagram, or ask for `render: "source"` to see the D2 source instead.',
|
|
315
|
+
failure.diagnostics,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Throws if the drawing is too big to belong in a transcript. */
|
|
320
|
+
function measure(text: string, maxColumns: number): { lineCount: number; widthCells: number } {
|
|
321
|
+
const lines = text.split("\n");
|
|
322
|
+
let widthCells = 0;
|
|
323
|
+
for (const line of lines) {
|
|
324
|
+
widthCells = Math.max(widthCells, Array.from(line).length);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
328
|
+
if (lines.length > MAX_LINES || widthCells > maxColumns || bytes > MAX_BYTES) {
|
|
329
|
+
throw new DiagramSourceError("The rendered diagram is too big for the transcript.", [
|
|
330
|
+
{
|
|
331
|
+
code: "D2_TOO_LARGE",
|
|
332
|
+
message: `It is ${lines.length} lines by ${widthCells} columns (${bytes} bytes); the limit is ${MAX_LINES} by ${maxColumns} (${MAX_BYTES} bytes).`,
|
|
333
|
+
hint: "Show fewer nodes, shorten labels, or split it into several diagrams.",
|
|
334
|
+
},
|
|
335
|
+
]);
|
|
336
|
+
}
|
|
337
|
+
return { lineCount: lines.length, widthCells };
|
|
338
|
+
}
|