@ai-gui/image 0.31.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/dist/index.js ADDED
@@ -0,0 +1,371 @@
1
+ import { baseCss, collectPluginStyles, createParserWithMetadata } from "@ai-gui/core";
2
+ import { chart } from "@ai-gui/plugin-chart";
3
+ import { dashboard } from "@ai-gui/plugin-dashboard";
4
+ import { katex } from "@ai-gui/plugin-katex";
5
+ import { mermaid } from "@ai-gui/plugin-mermaid";
6
+ import { mkdir } from "node:fs/promises";
7
+ import { createRequire } from "node:module";
8
+ import { dirname, join } from "node:path";
9
+ import { readFileSync, readdirSync } from "node:fs";
10
+ import { katexInlineCss } from "@ai-gui/plugin-katex/inline-css";
11
+
12
+ //#region src/types.ts
13
+ const DEFAULT_KINDS = [
14
+ "chart",
15
+ "mermaid",
16
+ "dashboard",
17
+ "card",
18
+ "math",
19
+ "table"
20
+ ];
21
+ const DEFAULT_WIDTH = 720;
22
+ const DEFAULT_SCALE = 2;
23
+ const DEFAULT_MAX = 6;
24
+ const DEFAULT_TIMEOUT_MS = 1e4;
25
+ const DEFAULT_IDLE_SHUTDOWN_MS = 3e5;
26
+
27
+ //#endregion
28
+ //#region src/plugins.ts
29
+ /**
30
+ * The plugins an image render understands.
31
+ *
32
+ * `interactive: false` is not a preference. It makes plugin-chart return an SSR SVG in the same
33
+ * tick rather than mounting a live ECharts instance with animations to wait out.
34
+ *
35
+ * The chart is sized to the page rather than left at the plugin's 600x400 default, which would
36
+ * otherwise sit in a 720px column with a band of dead space beside it.
37
+ */
38
+ function imagePlugins(width = DEFAULT_WIDTH) {
39
+ const inner = Math.max(200, width - 32);
40
+ return [
41
+ chart({
42
+ interactive: false,
43
+ width: inner,
44
+ height: Math.round(inner * .625)
45
+ }),
46
+ mermaid(),
47
+ katex({ css: "" }),
48
+ dashboard()
49
+ ];
50
+ }
51
+
52
+ //#endregion
53
+ //#region src/blocks.ts
54
+ /**
55
+ * A cheap "might there be a picture in here?" test.
56
+ *
57
+ * The point is to spend nothing on the overwhelming majority of replies, which are prose. It is
58
+ * intentionally loose — a sentence that happens to start with a pipe costs one markdown parse,
59
+ * and the parse is what actually decides. Nothing launches a browser on the strength of this.
60
+ *
61
+ * Loose in the right direction, though. A false positive costs a parse; a false negative silently
62
+ * drops a picture the reader asked for. Two shapes earned their own branch for that reason:
63
+ * models write tables without leading pipes at least as often as with them, and they escalate a
64
+ * fence to four backticks whenever the payload contains three.
65
+ */
66
+ const TRIGGER = /^ {0,3}(?:(?:`{3,}|~{3,})[ \t]*(?:chart|mermaid|dashboard|card:)|\$\$|\||:?-+:?[ \t]*\|)/m;
67
+ function hasTrigger(markdown) {
68
+ return TRIGGER.test(markdown);
69
+ }
70
+ /**
71
+ * Which picture, if any, a parsed node represents.
72
+ *
73
+ * Charts, diagrams and dashboards announce themselves through the node type, because their
74
+ * plugins register node renderers. Math and tables do not: KaTeX extends markdown-it rather than
75
+ * registering a renderer, and tables are plain markdown-it, so both arrive as generic `html`
76
+ * nodes carrying already-rendered markup. They have to be recognised by what is in that markup.
77
+ */
78
+ function classify(node) {
79
+ if (node.type === "chart" || node.type === "mermaid" || node.type === "dashboard") return node.complete ? node.type : void 0;
80
+ if (node.type === "card") return node.card?.complete && node.card.valid ? "card" : void 0;
81
+ if (node.type !== "html") return void 0;
82
+ const html = node.content ?? "";
83
+ if (/class="[^"]*\bkatex-display\b/.test(html)) return "math";
84
+ if (/<table[\s>]/.test(html)) return "table";
85
+ return void 0;
86
+ }
87
+ function selectRenderableBlocks(markdown, options = {}) {
88
+ const kinds = new Set(options.kinds ?? DEFAULT_KINDS);
89
+ const max = options.max ?? DEFAULT_MAX;
90
+ const parse = createParserWithMetadata({
91
+ plugins: imagePlugins(),
92
+ registry: options.registry
93
+ });
94
+ const { nodes, blocks } = parse(markdown);
95
+ const selections = [];
96
+ for (const block of blocks) {
97
+ if (selections.length >= max) break;
98
+ for (let i = block.nodeStart; i < block.nodeEnd; i++) {
99
+ const kind = classify(nodes[i]);
100
+ if (!kind || !kinds.has(kind)) continue;
101
+ selections.push({
102
+ kind,
103
+ start: block.start,
104
+ end: block.end
105
+ });
106
+ break;
107
+ }
108
+ }
109
+ return selections;
110
+ }
111
+ /**
112
+ * Cut the rendered blocks out of the text.
113
+ *
114
+ * Back to front, because slicing from the front shifts every offset behind it and silently
115
+ * corrupts the second cut onwards. Runs of blank lines left by the cuts collapse to one, so a
116
+ * message that was mostly pictures does not arrive as a column of empty lines. The collapse has
117
+ * to know about `\r\n` — matching bare `\n` leaves a stray blank line in every CRLF message.
118
+ */
119
+ function stripBlocks(markdown, selections) {
120
+ let out = markdown;
121
+ for (const selection of [...selections].sort((a, b) => b.start - a.start)) out = out.slice(0, selection.start) + out.slice(selection.end);
122
+ return out.replace(/(?:\r?\n){3,}/g, "\n\n").trim();
123
+ }
124
+
125
+ //#endregion
126
+ //#region src/browser.ts
127
+ /** Playwright is an optional peer. Its absence is a configuration fact, not a bug. */
128
+ var BrowserUnavailableError = class extends Error {
129
+ constructor(cause) {
130
+ super("Playwright is not installed. Run `pnpm add playwright` and `pnpm exec playwright install chromium`.");
131
+ this.name = "BrowserUnavailableError";
132
+ this.cause = cause;
133
+ }
134
+ };
135
+ const defaultLauncher = async () => {
136
+ const playwright = await import("playwright");
137
+ return await playwright.chromium.launch({ args: ["--font-render-hinting=none"] });
138
+ };
139
+ let browser;
140
+ let launching;
141
+ let leases = 0;
142
+ let idleTimer;
143
+ function cancelIdle() {
144
+ if (idleTimer === void 0) return;
145
+ clearTimeout(idleTimer);
146
+ idleTimer = void 0;
147
+ }
148
+ /**
149
+ * Close the browser once nobody has wanted one for a while.
150
+ *
151
+ * A gateway can go hours between charts, and a resident Chromium is a few hundred megabytes of
152
+ * nothing. Launching costs about a second, which is affordable on the first chart of a burst and
153
+ * free on the rest.
154
+ */
155
+ function scheduleIdleShutdown(idleShutdownMs) {
156
+ cancelIdle();
157
+ idleTimer = setTimeout(() => {
158
+ if (leases > 0) return;
159
+ closeBrowser();
160
+ }, idleShutdownMs);
161
+ idleTimer.unref?.();
162
+ }
163
+ async function acquirePage(options = {}) {
164
+ const launcher = options.launcher ?? defaultLauncher;
165
+ const idleShutdownMs = options.idleShutdownMs ?? DEFAULT_IDLE_SHUTDOWN_MS;
166
+ cancelIdle();
167
+ if (!browser) try {
168
+ launching ??= launcher();
169
+ browser = await launching;
170
+ } catch (error) {
171
+ throw new BrowserUnavailableError(error);
172
+ } finally {
173
+ launching = void 0;
174
+ }
175
+ let page;
176
+ try {
177
+ page = await browser.newPage({ deviceScaleFactor: options.deviceScaleFactor ?? DEFAULT_SCALE });
178
+ } catch (error) {
179
+ browser = void 0;
180
+ throw error;
181
+ }
182
+ leases++;
183
+ let released = false;
184
+ return {
185
+ page,
186
+ async release() {
187
+ if (released) return;
188
+ released = true;
189
+ leases--;
190
+ await page.close().catch(() => {});
191
+ if (leases === 0) scheduleIdleShutdown(idleShutdownMs);
192
+ }
193
+ };
194
+ }
195
+ async function closeBrowser() {
196
+ cancelIdle();
197
+ const current = browser;
198
+ browser = void 0;
199
+ launching = void 0;
200
+ await current?.close().catch(() => {});
201
+ }
202
+
203
+ //#endregion
204
+ //#region src/page/fonts.ts
205
+ const PLACEHOLDER = "AIGUI_KATEX_FONTS/";
206
+ /**
207
+ * KaTeX's stylesheet with its fonts inlined as data URIs.
208
+ *
209
+ * Two problems get solved together. The plugin's default `css` is an `@import` of a bare npm
210
+ * specifier, which resolves to nothing inside `page.setContent` — without the real stylesheet a
211
+ * formula renders as flat text, so `\frac{a}{b}` arrives as "ba". And `katexInlineCss({ fontBase })`
212
+ * alone is not enough either: Chromium refuses `file://` subresources from an `about:blank`
213
+ * document, so all twenty faces fail and the maths falls back to a serif. That fallback is
214
+ * legible, but it has no blackboard bold or script faces — `\mathbb{R}` degrades to a bold R.
215
+ *
216
+ * Data URIs need no origin and no network, so the fonts simply work. 296 kB of woff2 becomes
217
+ * roughly 368 kB of CSS, read once and kept for the life of the process.
218
+ */
219
+ let cached;
220
+ function katexCss() {
221
+ if (cached !== void 0) return cached;
222
+ const require_$1 = createRequire(import.meta.url);
223
+ const fontDir = join(dirname(require_$1.resolve("katex/package.json")), "dist", "fonts");
224
+ const inlined = new Map();
225
+ for (const file of readdirSync(fontDir)) {
226
+ if (!file.endsWith(".woff2")) continue;
227
+ inlined.set(file, `data:font/woff2;base64,${readFileSync(join(fontDir, file)).toString("base64")}`);
228
+ }
229
+ let css = katexInlineCss({ fontBase: PLACEHOLDER });
230
+ css = css.replace(new RegExp(`url\\(${PLACEHOLDER}([^)]+?)\\.woff2\\)`, "g"), (whole, name) => {
231
+ const uri = inlined.get(`${name}.woff2`);
232
+ return uri ? `url(${uri})` : whole;
233
+ });
234
+ css = css.replace(new RegExp(`,\\s*url\\(${PLACEHOLDER}[^)]+?\\.(?:woff|ttf)\\)\\s*format\\("(?:woff|truetype)"\\)`, "g"), "");
235
+ cached = css;
236
+ return cached;
237
+ }
238
+
239
+ //#endregion
240
+ //#region src/page/html.ts
241
+ const THEMES = {
242
+ light: {
243
+ bg: "#ffffff",
244
+ fg: "#1a1a1a"
245
+ },
246
+ dark: {
247
+ bg: "#161616",
248
+ fg: "#e8e8e8"
249
+ }
250
+ };
251
+ /**
252
+ * The document a block is drawn into.
253
+ *
254
+ * Animation is disabled globally. ECharts is already static here, but Mermaid and the dashboard
255
+ * plugin animate on entry, and an animating element is a coin flip between a finished picture and
256
+ * a half-faded one. The font stack names CJK families explicitly: a screenshot has no fallback
257
+ * chain to fall back to at read time, so a missing face is permanent tofu in the delivered image.
258
+ */
259
+ function pageHtml(options = {}) {
260
+ const theme = THEMES[options.theme ?? "light"];
261
+ const pluginCss = collectPluginStyles(imagePlugins(options.width)).map((style) => style.css).join("\n");
262
+ return `<!doctype html>
263
+ <html><head><meta charset="utf-8"><style>
264
+ *,*::before,*::after{animation:none!important;transition:none!important}
265
+ html,body{margin:0;padding:0;background:${theme.bg};color:${theme.fg}}
266
+ body{font-family:-apple-system,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Noto Sans SC",system-ui,sans-serif;font-size:16px;line-height:1.6}
267
+ #root{display:inline-block;padding:16px;box-sizing:border-box;max-width:${options.width ?? 720}px}
268
+ ${baseCss}
269
+ ${katexCss()}
270
+ ${pluginCss}
271
+ </style></head><body><div id="root"></div></body></html>`;
272
+ }
273
+
274
+ //#endregion
275
+ //#region src/render.ts
276
+ const require_ = createRequire(import.meta.url);
277
+ /** The built browser bundle that ships alongside this module. */
278
+ function pageBundlePath() {
279
+ return join(require_.resolve("@ai-gui/image/package.json"), "..", "dist", "page", "entry.js");
280
+ }
281
+ function withTimeout(promise, ms, what) {
282
+ return new Promise((resolve, reject) => {
283
+ const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
284
+ promise.then((value) => {
285
+ clearTimeout(timer);
286
+ resolve(value);
287
+ }, (error) => {
288
+ clearTimeout(timer);
289
+ reject(error);
290
+ });
291
+ });
292
+ }
293
+ /**
294
+ * Draw every renderable block in `markdown` and return the leftover text plus the pictures.
295
+ *
296
+ * A block that fails is left alone: its source stays in the text, so a reader gets the raw fence
297
+ * rather than a silently missing answer. Only blocks that actually produced a file are stripped.
298
+ */
299
+ async function renderMarkdownToImages(markdown, options) {
300
+ const selections = selectRenderableBlocks(markdown, {
301
+ kinds: options.kinds,
302
+ registry: options.registry,
303
+ max: options.max
304
+ });
305
+ if (selections.length === 0) return {
306
+ text: markdown,
307
+ images: []
308
+ };
309
+ await mkdir(options.outDir, { recursive: true });
310
+ const acquire = options.acquire ?? ((opts) => acquirePage(opts));
311
+ const width = options.width ?? DEFAULT_WIDTH;
312
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
313
+ const lease = await acquire({
314
+ idleShutdownMs: options.idleShutdownMs,
315
+ deviceScaleFactor: options.scale ?? DEFAULT_SCALE
316
+ });
317
+ const page = lease.page;
318
+ const images = [];
319
+ const rendered = [];
320
+ try {
321
+ await page.setViewportSize({
322
+ width,
323
+ height: 800
324
+ });
325
+ await page.setContent(pageHtml({
326
+ theme: options.theme,
327
+ width
328
+ }));
329
+ await page.addScriptTag({ path: pageBundlePath() });
330
+ for (const [index, selection] of selections.entries()) {
331
+ const source = markdown.slice(selection.start, selection.end);
332
+ const path = join(options.outDir, `aigui-${selection.kind}-${index}-${process.pid}-${images.length}.png`);
333
+ try {
334
+ const size = await withTimeout(
335
+ // A real function, not a string. Playwright evaluates a string as an *expression*: it
336
+ // would produce the function object and never call it, so every render silently
337
+ // returned undefined. Verified in a live browser — the fake page in the unit tests
338
+ // cannot distinguish the two, which is exactly why it went unnoticed.
339
+ page.evaluate((arg) => window.__aiguiRenderBlock(arg.source, {
340
+ width: arg.width,
341
+ theme: arg.theme
342
+ }), {
343
+ source,
344
+ width,
345
+ theme: options.theme
346
+ }),
347
+ timeoutMs,
348
+ `rendering ${selection.kind}`
349
+ );
350
+ if (size.failed) throw new Error(`${selection.kind} failed to draw`);
351
+ await withTimeout(page.locator("#root").screenshot({ path }), timeoutMs, `screenshotting ${selection.kind}`);
352
+ images.push({
353
+ kind: selection.kind,
354
+ path,
355
+ width: size.width,
356
+ height: size.height
357
+ });
358
+ rendered.push(selection);
359
+ } catch {}
360
+ }
361
+ } finally {
362
+ await lease.release();
363
+ }
364
+ return {
365
+ text: stripBlocks(markdown, rendered),
366
+ images
367
+ };
368
+ }
369
+
370
+ //#endregion
371
+ export { BrowserUnavailableError, DEFAULT_IDLE_SHUTDOWN_MS, DEFAULT_KINDS, DEFAULT_MAX, DEFAULT_SCALE, DEFAULT_TIMEOUT_MS, DEFAULT_WIDTH, closeBrowser, hasTrigger, renderMarkdownToImages, selectRenderableBlocks, stripBlocks };