@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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/index.cjs +406 -0
- package/dist/index.d.cts +96 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +371 -0
- package/dist/page/entry.js +7936 -0
- package/package.json +80 -0
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 };
|