@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Liang Li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @ai-gui/image
2
+
3
+ Render [AIGUI](../../README.md) markdown blocks — ECharts charts, Mermaid diagrams, KaTeX math, tables, cards, dashboards — to PNG, by running the real `@ai-gui/vanilla` renderer in a headless Chromium and screenshotting each block.
4
+
5
+ Use it where a channel carries pictures but not markup: WeChat, email, an image-only webhook.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @ai-gui/image playwright
11
+ pnpm exec playwright install chromium
12
+ ```
13
+
14
+ Playwright is an optional peer dependency. Without it every render throws `BrowserUnavailableError`, so a host can treat pictures as a bonus rather than a requirement.
15
+
16
+ On Linux the container also needs CJK faces, or Chinese renders as tofu in the delivered image:
17
+
18
+ ```sh
19
+ apt-get install -y fonts-noto-cjk
20
+ ```
21
+
22
+ Maths needs nothing: KaTeX's stylesheet and all twenty of its font faces are inlined into the page, so formulas typeset correctly with no network and no system fonts. Only CJK text depends on what the host has installed.
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ import { renderMarkdownToImages } from "@ai-gui/image"
28
+
29
+ const { text, images } = await renderMarkdownToImages(answer, { outDir: "/tmp/aigui" })
30
+ // text — the answer with every rendered block removed
31
+ // images — [{ kind: "chart", path: "/tmp/aigui/aigui-chart-0-83421-0.png", width, height }]
32
+ ```
33
+
34
+ Blocks that fail to render are left in `text` as their original source, so a broken diagram costs a picture, never the answer.
35
+
36
+ ## Exports
37
+
38
+ - `renderMarkdownToImages(markdown, options)` — the whole job.
39
+ - `selectRenderableBlocks(markdown, options)` / `stripBlocks(markdown, selections)` — the pure parts, if you want to drive rendering yourself.
40
+ - `hasTrigger(markdown)` — a cheap pre-filter for hot paths.
41
+ - `closeBrowser()` — shut the resident Chromium down now instead of waiting for the idle timer.
42
+ - `BrowserUnavailableError` — thrown when Playwright is not installed.
43
+ - Types — `RenderOptions`, `RenderResult`, `RenderedImage`, `BlockSelection`, `RenderableKind`, `SelectOptions` — plus the `DEFAULT_KINDS` / `DEFAULT_WIDTH` / `DEFAULT_SCALE` / `DEFAULT_MAX` / `DEFAULT_TIMEOUT_MS` / `DEFAULT_IDLE_SHUTDOWN_MS` constants backing the defaults below.
44
+
45
+ ## Options
46
+
47
+ - `outDir` — where PNGs are written (required).
48
+ - `kinds` — which families to draw. Default: all six.
49
+ - `theme` — `"light"` (default) or `"dark"`.
50
+ - `width` — viewport width in CSS pixels. Default 720.
51
+ - `scale` — device pixels per CSS pixel. Default 2, which is what a phone screen wants.
52
+ - `max` — cap on pictures per call. Default 6; the rest stay as text.
53
+ - `timeoutMs` — per-block budget. Default 10000.
54
+ - `idleShutdownMs` — how long the browser stays resident with nothing to do. Default 300000.
55
+
56
+ ## Cost when there is nothing to draw
57
+
58
+ `renderMarkdownToImages` parses before it launches anything, and returns the source untouched if no block qualifies. The browser is lazy and shuts itself down after five idle minutes, so a process that renders one chart an hour does not hold a Chromium open in between.
59
+
60
+ ## Testing
61
+
62
+ The screenshot tests need a real browser and are opt-in:
63
+
64
+ ```sh
65
+ pnpm exec playwright install chromium
66
+ AIGUI_IMAGE_E2E=1 pnpm exec vitest run --project image render.e2e
67
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,406 @@
1
+ "use strict";
2
+ //#region rolldown:runtime
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+
24
+ //#endregion
25
+ const __ai_gui_core = __toESM(require("@ai-gui/core"));
26
+ const __ai_gui_plugin_chart = __toESM(require("@ai-gui/plugin-chart"));
27
+ const __ai_gui_plugin_dashboard = __toESM(require("@ai-gui/plugin-dashboard"));
28
+ const __ai_gui_plugin_katex = __toESM(require("@ai-gui/plugin-katex"));
29
+ const __ai_gui_plugin_mermaid = __toESM(require("@ai-gui/plugin-mermaid"));
30
+ const node_fs_promises = __toESM(require("node:fs/promises"));
31
+ const node_module = __toESM(require("node:module"));
32
+ const node_path = __toESM(require("node:path"));
33
+ const node_fs = __toESM(require("node:fs"));
34
+ const __ai_gui_plugin_katex_inline_css = __toESM(require("@ai-gui/plugin-katex/inline-css"));
35
+
36
+ //#region src/types.ts
37
+ const DEFAULT_KINDS = [
38
+ "chart",
39
+ "mermaid",
40
+ "dashboard",
41
+ "card",
42
+ "math",
43
+ "table"
44
+ ];
45
+ const DEFAULT_WIDTH = 720;
46
+ const DEFAULT_SCALE = 2;
47
+ const DEFAULT_MAX = 6;
48
+ const DEFAULT_TIMEOUT_MS = 1e4;
49
+ const DEFAULT_IDLE_SHUTDOWN_MS = 3e5;
50
+
51
+ //#endregion
52
+ //#region src/plugins.ts
53
+ /**
54
+ * The plugins an image render understands.
55
+ *
56
+ * `interactive: false` is not a preference. It makes plugin-chart return an SSR SVG in the same
57
+ * tick rather than mounting a live ECharts instance with animations to wait out.
58
+ *
59
+ * The chart is sized to the page rather than left at the plugin's 600x400 default, which would
60
+ * otherwise sit in a 720px column with a band of dead space beside it.
61
+ */
62
+ function imagePlugins(width = DEFAULT_WIDTH) {
63
+ const inner = Math.max(200, width - 32);
64
+ return [
65
+ (0, __ai_gui_plugin_chart.chart)({
66
+ interactive: false,
67
+ width: inner,
68
+ height: Math.round(inner * .625)
69
+ }),
70
+ (0, __ai_gui_plugin_mermaid.mermaid)(),
71
+ (0, __ai_gui_plugin_katex.katex)({ css: "" }),
72
+ (0, __ai_gui_plugin_dashboard.dashboard)()
73
+ ];
74
+ }
75
+
76
+ //#endregion
77
+ //#region src/blocks.ts
78
+ /**
79
+ * A cheap "might there be a picture in here?" test.
80
+ *
81
+ * The point is to spend nothing on the overwhelming majority of replies, which are prose. It is
82
+ * intentionally loose — a sentence that happens to start with a pipe costs one markdown parse,
83
+ * and the parse is what actually decides. Nothing launches a browser on the strength of this.
84
+ *
85
+ * Loose in the right direction, though. A false positive costs a parse; a false negative silently
86
+ * drops a picture the reader asked for. Two shapes earned their own branch for that reason:
87
+ * models write tables without leading pipes at least as often as with them, and they escalate a
88
+ * fence to four backticks whenever the payload contains three.
89
+ */
90
+ const TRIGGER = /^ {0,3}(?:(?:`{3,}|~{3,})[ \t]*(?:chart|mermaid|dashboard|card:)|\$\$|\||:?-+:?[ \t]*\|)/m;
91
+ function hasTrigger(markdown) {
92
+ return TRIGGER.test(markdown);
93
+ }
94
+ /**
95
+ * Which picture, if any, a parsed node represents.
96
+ *
97
+ * Charts, diagrams and dashboards announce themselves through the node type, because their
98
+ * plugins register node renderers. Math and tables do not: KaTeX extends markdown-it rather than
99
+ * registering a renderer, and tables are plain markdown-it, so both arrive as generic `html`
100
+ * nodes carrying already-rendered markup. They have to be recognised by what is in that markup.
101
+ */
102
+ function classify(node) {
103
+ if (node.type === "chart" || node.type === "mermaid" || node.type === "dashboard") return node.complete ? node.type : void 0;
104
+ if (node.type === "card") return node.card?.complete && node.card.valid ? "card" : void 0;
105
+ if (node.type !== "html") return void 0;
106
+ const html = node.content ?? "";
107
+ if (/class="[^"]*\bkatex-display\b/.test(html)) return "math";
108
+ if (/<table[\s>]/.test(html)) return "table";
109
+ return void 0;
110
+ }
111
+ function selectRenderableBlocks(markdown, options = {}) {
112
+ const kinds = new Set(options.kinds ?? DEFAULT_KINDS);
113
+ const max = options.max ?? DEFAULT_MAX;
114
+ const parse = (0, __ai_gui_core.createParserWithMetadata)({
115
+ plugins: imagePlugins(),
116
+ registry: options.registry
117
+ });
118
+ const { nodes, blocks } = parse(markdown);
119
+ const selections = [];
120
+ for (const block of blocks) {
121
+ if (selections.length >= max) break;
122
+ for (let i = block.nodeStart; i < block.nodeEnd; i++) {
123
+ const kind = classify(nodes[i]);
124
+ if (!kind || !kinds.has(kind)) continue;
125
+ selections.push({
126
+ kind,
127
+ start: block.start,
128
+ end: block.end
129
+ });
130
+ break;
131
+ }
132
+ }
133
+ return selections;
134
+ }
135
+ /**
136
+ * Cut the rendered blocks out of the text.
137
+ *
138
+ * Back to front, because slicing from the front shifts every offset behind it and silently
139
+ * corrupts the second cut onwards. Runs of blank lines left by the cuts collapse to one, so a
140
+ * message that was mostly pictures does not arrive as a column of empty lines. The collapse has
141
+ * to know about `\r\n` — matching bare `\n` leaves a stray blank line in every CRLF message.
142
+ */
143
+ function stripBlocks(markdown, selections) {
144
+ let out = markdown;
145
+ for (const selection of [...selections].sort((a, b) => b.start - a.start)) out = out.slice(0, selection.start) + out.slice(selection.end);
146
+ return out.replace(/(?:\r?\n){3,}/g, "\n\n").trim();
147
+ }
148
+
149
+ //#endregion
150
+ //#region src/browser.ts
151
+ /** Playwright is an optional peer. Its absence is a configuration fact, not a bug. */
152
+ var BrowserUnavailableError = class extends Error {
153
+ constructor(cause) {
154
+ super("Playwright is not installed. Run `pnpm add playwright` and `pnpm exec playwright install chromium`.");
155
+ this.name = "BrowserUnavailableError";
156
+ this.cause = cause;
157
+ }
158
+ };
159
+ const defaultLauncher = async () => {
160
+ const playwright = await import("playwright");
161
+ return await playwright.chromium.launch({ args: ["--font-render-hinting=none"] });
162
+ };
163
+ let browser;
164
+ let launching;
165
+ let leases = 0;
166
+ let idleTimer;
167
+ function cancelIdle() {
168
+ if (idleTimer === void 0) return;
169
+ clearTimeout(idleTimer);
170
+ idleTimer = void 0;
171
+ }
172
+ /**
173
+ * Close the browser once nobody has wanted one for a while.
174
+ *
175
+ * A gateway can go hours between charts, and a resident Chromium is a few hundred megabytes of
176
+ * nothing. Launching costs about a second, which is affordable on the first chart of a burst and
177
+ * free on the rest.
178
+ */
179
+ function scheduleIdleShutdown(idleShutdownMs) {
180
+ cancelIdle();
181
+ idleTimer = setTimeout(() => {
182
+ if (leases > 0) return;
183
+ closeBrowser();
184
+ }, idleShutdownMs);
185
+ idleTimer.unref?.();
186
+ }
187
+ async function acquirePage(options = {}) {
188
+ const launcher = options.launcher ?? defaultLauncher;
189
+ const idleShutdownMs = options.idleShutdownMs ?? DEFAULT_IDLE_SHUTDOWN_MS;
190
+ cancelIdle();
191
+ if (!browser) try {
192
+ launching ??= launcher();
193
+ browser = await launching;
194
+ } catch (error) {
195
+ throw new BrowserUnavailableError(error);
196
+ } finally {
197
+ launching = void 0;
198
+ }
199
+ let page;
200
+ try {
201
+ page = await browser.newPage({ deviceScaleFactor: options.deviceScaleFactor ?? DEFAULT_SCALE });
202
+ } catch (error) {
203
+ browser = void 0;
204
+ throw error;
205
+ }
206
+ leases++;
207
+ let released = false;
208
+ return {
209
+ page,
210
+ async release() {
211
+ if (released) return;
212
+ released = true;
213
+ leases--;
214
+ await page.close().catch(() => {});
215
+ if (leases === 0) scheduleIdleShutdown(idleShutdownMs);
216
+ }
217
+ };
218
+ }
219
+ async function closeBrowser() {
220
+ cancelIdle();
221
+ const current = browser;
222
+ browser = void 0;
223
+ launching = void 0;
224
+ await current?.close().catch(() => {});
225
+ }
226
+
227
+ //#endregion
228
+ //#region src/page/fonts.ts
229
+ const PLACEHOLDER = "AIGUI_KATEX_FONTS/";
230
+ /**
231
+ * KaTeX's stylesheet with its fonts inlined as data URIs.
232
+ *
233
+ * Two problems get solved together. The plugin's default `css` is an `@import` of a bare npm
234
+ * specifier, which resolves to nothing inside `page.setContent` — without the real stylesheet a
235
+ * formula renders as flat text, so `\frac{a}{b}` arrives as "ba". And `katexInlineCss({ fontBase })`
236
+ * alone is not enough either: Chromium refuses `file://` subresources from an `about:blank`
237
+ * document, so all twenty faces fail and the maths falls back to a serif. That fallback is
238
+ * legible, but it has no blackboard bold or script faces — `\mathbb{R}` degrades to a bold R.
239
+ *
240
+ * Data URIs need no origin and no network, so the fonts simply work. 296 kB of woff2 becomes
241
+ * roughly 368 kB of CSS, read once and kept for the life of the process.
242
+ */
243
+ let cached;
244
+ function katexCss() {
245
+ if (cached !== void 0) return cached;
246
+ const require_$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
247
+ const fontDir = (0, node_path.join)((0, node_path.dirname)(require_$1.resolve("katex/package.json")), "dist", "fonts");
248
+ const inlined = new Map();
249
+ for (const file of (0, node_fs.readdirSync)(fontDir)) {
250
+ if (!file.endsWith(".woff2")) continue;
251
+ inlined.set(file, `data:font/woff2;base64,${(0, node_fs.readFileSync)((0, node_path.join)(fontDir, file)).toString("base64")}`);
252
+ }
253
+ let css = (0, __ai_gui_plugin_katex_inline_css.katexInlineCss)({ fontBase: PLACEHOLDER });
254
+ css = css.replace(new RegExp(`url\\(${PLACEHOLDER}([^)]+?)\\.woff2\\)`, "g"), (whole, name) => {
255
+ const uri = inlined.get(`${name}.woff2`);
256
+ return uri ? `url(${uri})` : whole;
257
+ });
258
+ css = css.replace(new RegExp(`,\\s*url\\(${PLACEHOLDER}[^)]+?\\.(?:woff|ttf)\\)\\s*format\\("(?:woff|truetype)"\\)`, "g"), "");
259
+ cached = css;
260
+ return cached;
261
+ }
262
+
263
+ //#endregion
264
+ //#region src/page/html.ts
265
+ const THEMES = {
266
+ light: {
267
+ bg: "#ffffff",
268
+ fg: "#1a1a1a"
269
+ },
270
+ dark: {
271
+ bg: "#161616",
272
+ fg: "#e8e8e8"
273
+ }
274
+ };
275
+ /**
276
+ * The document a block is drawn into.
277
+ *
278
+ * Animation is disabled globally. ECharts is already static here, but Mermaid and the dashboard
279
+ * plugin animate on entry, and an animating element is a coin flip between a finished picture and
280
+ * a half-faded one. The font stack names CJK families explicitly: a screenshot has no fallback
281
+ * chain to fall back to at read time, so a missing face is permanent tofu in the delivered image.
282
+ */
283
+ function pageHtml(options = {}) {
284
+ const theme = THEMES[options.theme ?? "light"];
285
+ const pluginCss = (0, __ai_gui_core.collectPluginStyles)(imagePlugins(options.width)).map((style) => style.css).join("\n");
286
+ return `<!doctype html>
287
+ <html><head><meta charset="utf-8"><style>
288
+ *,*::before,*::after{animation:none!important;transition:none!important}
289
+ html,body{margin:0;padding:0;background:${theme.bg};color:${theme.fg}}
290
+ 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}
291
+ #root{display:inline-block;padding:16px;box-sizing:border-box;max-width:${options.width ?? 720}px}
292
+ ${__ai_gui_core.baseCss}
293
+ ${katexCss()}
294
+ ${pluginCss}
295
+ </style></head><body><div id="root"></div></body></html>`;
296
+ }
297
+
298
+ //#endregion
299
+ //#region src/render.ts
300
+ const require_ = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
301
+ /** The built browser bundle that ships alongside this module. */
302
+ function pageBundlePath() {
303
+ return (0, node_path.join)(require_.resolve("@ai-gui/image/package.json"), "..", "dist", "page", "entry.js");
304
+ }
305
+ function withTimeout(promise, ms, what) {
306
+ return new Promise((resolve, reject) => {
307
+ const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
308
+ promise.then((value) => {
309
+ clearTimeout(timer);
310
+ resolve(value);
311
+ }, (error) => {
312
+ clearTimeout(timer);
313
+ reject(error);
314
+ });
315
+ });
316
+ }
317
+ /**
318
+ * Draw every renderable block in `markdown` and return the leftover text plus the pictures.
319
+ *
320
+ * A block that fails is left alone: its source stays in the text, so a reader gets the raw fence
321
+ * rather than a silently missing answer. Only blocks that actually produced a file are stripped.
322
+ */
323
+ async function renderMarkdownToImages(markdown, options) {
324
+ const selections = selectRenderableBlocks(markdown, {
325
+ kinds: options.kinds,
326
+ registry: options.registry,
327
+ max: options.max
328
+ });
329
+ if (selections.length === 0) return {
330
+ text: markdown,
331
+ images: []
332
+ };
333
+ await (0, node_fs_promises.mkdir)(options.outDir, { recursive: true });
334
+ const acquire = options.acquire ?? ((opts) => acquirePage(opts));
335
+ const width = options.width ?? DEFAULT_WIDTH;
336
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
337
+ const lease = await acquire({
338
+ idleShutdownMs: options.idleShutdownMs,
339
+ deviceScaleFactor: options.scale ?? DEFAULT_SCALE
340
+ });
341
+ const page = lease.page;
342
+ const images = [];
343
+ const rendered = [];
344
+ try {
345
+ await page.setViewportSize({
346
+ width,
347
+ height: 800
348
+ });
349
+ await page.setContent(pageHtml({
350
+ theme: options.theme,
351
+ width
352
+ }));
353
+ await page.addScriptTag({ path: pageBundlePath() });
354
+ for (const [index, selection] of selections.entries()) {
355
+ const source = markdown.slice(selection.start, selection.end);
356
+ const path = (0, node_path.join)(options.outDir, `aigui-${selection.kind}-${index}-${process.pid}-${images.length}.png`);
357
+ try {
358
+ const size = await withTimeout(
359
+ // A real function, not a string. Playwright evaluates a string as an *expression*: it
360
+ // would produce the function object and never call it, so every render silently
361
+ // returned undefined. Verified in a live browser — the fake page in the unit tests
362
+ // cannot distinguish the two, which is exactly why it went unnoticed.
363
+ page.evaluate((arg) => window.__aiguiRenderBlock(arg.source, {
364
+ width: arg.width,
365
+ theme: arg.theme
366
+ }), {
367
+ source,
368
+ width,
369
+ theme: options.theme
370
+ }),
371
+ timeoutMs,
372
+ `rendering ${selection.kind}`
373
+ );
374
+ if (size.failed) throw new Error(`${selection.kind} failed to draw`);
375
+ await withTimeout(page.locator("#root").screenshot({ path }), timeoutMs, `screenshotting ${selection.kind}`);
376
+ images.push({
377
+ kind: selection.kind,
378
+ path,
379
+ width: size.width,
380
+ height: size.height
381
+ });
382
+ rendered.push(selection);
383
+ } catch {}
384
+ }
385
+ } finally {
386
+ await lease.release();
387
+ }
388
+ return {
389
+ text: stripBlocks(markdown, rendered),
390
+ images
391
+ };
392
+ }
393
+
394
+ //#endregion
395
+ exports.BrowserUnavailableError = BrowserUnavailableError
396
+ exports.DEFAULT_IDLE_SHUTDOWN_MS = DEFAULT_IDLE_SHUTDOWN_MS
397
+ exports.DEFAULT_KINDS = DEFAULT_KINDS
398
+ exports.DEFAULT_MAX = DEFAULT_MAX
399
+ exports.DEFAULT_SCALE = DEFAULT_SCALE
400
+ exports.DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_MS
401
+ exports.DEFAULT_WIDTH = DEFAULT_WIDTH
402
+ exports.closeBrowser = closeBrowser
403
+ exports.hasTrigger = hasTrigger
404
+ exports.renderMarkdownToImages = renderMarkdownToImages
405
+ exports.selectRenderableBlocks = selectRenderableBlocks
406
+ exports.stripBlocks = stripBlocks
@@ -0,0 +1,96 @@
1
+ import { CardRegistry } from "@ai-gui/core";
2
+
3
+ //#region src/types.d.ts
4
+ /** A block family that can be turned into a picture. */
5
+ /** A block family that can be turned into a picture. */
6
+ type RenderableKind = "chart" | "mermaid" | "dashboard" | "card" | "math" | "table";
7
+ /** One block chosen for rendering, with the source range it occupies. */
8
+ interface BlockSelection {
9
+ kind: RenderableKind;
10
+ /** Character offset of the block's first character in the source. */
11
+ start: number;
12
+ /** Character offset one past the block's last character. */
13
+ end: number;
14
+ }
15
+ interface RenderedImage {
16
+ kind: RenderableKind;
17
+ /** Absolute path of the written PNG. */
18
+ path: string;
19
+ width: number;
20
+ height: number;
21
+ }
22
+ interface RenderOptions {
23
+ /** Directory the PNGs are written into. Created if missing. */
24
+ outDir: string;
25
+ kinds?: RenderableKind[];
26
+ theme?: "light" | "dark";
27
+ /** Viewport width in CSS pixels. */
28
+ width?: number;
29
+ /** Device pixels per CSS pixel. */
30
+ scale?: number;
31
+ /** Cap on how many blocks are rendered. Extra blocks stay as text. */
32
+ max?: number;
33
+ timeoutMs?: number;
34
+ idleShutdownMs?: number;
35
+ }
36
+ interface RenderResult {
37
+ /** The source with every successfully rendered block removed. */
38
+ text: string;
39
+ images: RenderedImage[];
40
+ }
41
+ declare const DEFAULT_KINDS: RenderableKind[];
42
+ declare const DEFAULT_WIDTH = 720;
43
+ declare const DEFAULT_SCALE = 2;
44
+ declare const DEFAULT_MAX = 6;
45
+ declare const DEFAULT_TIMEOUT_MS = 10000;
46
+ declare const DEFAULT_IDLE_SHUTDOWN_MS = 300000; //#endregion
47
+ //#region src/blocks.d.ts
48
+ declare function hasTrigger(markdown: string): boolean;
49
+ interface SelectOptions {
50
+ kinds?: RenderableKind[];
51
+ registry?: CardRegistry;
52
+ max?: number;
53
+ }
54
+ declare function selectRenderableBlocks(markdown: string, options?: SelectOptions): BlockSelection[];
55
+ /**
56
+ * Cut the rendered blocks out of the text.
57
+ *
58
+ * Back to front, because slicing from the front shifts every offset behind it and silently
59
+ * corrupts the second cut onwards. Runs of blank lines left by the cuts collapse to one, so a
60
+ * message that was mostly pictures does not arrive as a column of empty lines. The collapse has
61
+ * to know about `\r\n` — matching bare `\n` leaves a stray blank line in every CRLF message.
62
+ */
63
+ declare function stripBlocks(markdown: string, selections: BlockSelection[]): string;
64
+
65
+ //#endregion
66
+ //#region src/browser.d.ts
67
+ /** Playwright is an optional peer. Its absence is a configuration fact, not a bug. */
68
+ declare class BrowserUnavailableError extends Error {
69
+ constructor(cause: unknown);
70
+ }
71
+ declare function closeBrowser(): Promise<void>;
72
+
73
+ //#endregion
74
+ //#region src/render.d.ts
75
+ /** Drops all module state. Tests only. */
76
+ interface InternalRenderOptions extends RenderOptions {
77
+ registry?: CardRegistry;
78
+ /** Injected in tests. Defaults to the module's own lazy Chromium. */
79
+ acquire?: (options: {
80
+ idleShutdownMs?: number;
81
+ deviceScaleFactor?: number;
82
+ }) => Promise<{
83
+ page: unknown;
84
+ release: () => Promise<void>;
85
+ }>;
86
+ }
87
+ /**
88
+ * Draw every renderable block in `markdown` and return the leftover text plus the pictures.
89
+ *
90
+ * A block that fails is left alone: its source stays in the text, so a reader gets the raw fence
91
+ * rather than a silently missing answer. Only blocks that actually produced a file are stripped.
92
+ */
93
+ declare function renderMarkdownToImages(markdown: string, options: InternalRenderOptions): Promise<RenderResult>;
94
+
95
+ //#endregion
96
+ export { BlockSelection, BrowserUnavailableError, DEFAULT_IDLE_SHUTDOWN_MS, DEFAULT_KINDS, DEFAULT_MAX, DEFAULT_SCALE, DEFAULT_TIMEOUT_MS, DEFAULT_WIDTH, InternalRenderOptions, RenderOptions, RenderResult, RenderableKind, RenderedImage, SelectOptions, closeBrowser, hasTrigger, renderMarkdownToImages, selectRenderableBlocks, stripBlocks };
@@ -0,0 +1,96 @@
1
+ import { CardRegistry } from "@ai-gui/core";
2
+
3
+ //#region src/types.d.ts
4
+ /** A block family that can be turned into a picture. */
5
+ /** A block family that can be turned into a picture. */
6
+ type RenderableKind = "chart" | "mermaid" | "dashboard" | "card" | "math" | "table";
7
+ /** One block chosen for rendering, with the source range it occupies. */
8
+ interface BlockSelection {
9
+ kind: RenderableKind;
10
+ /** Character offset of the block's first character in the source. */
11
+ start: number;
12
+ /** Character offset one past the block's last character. */
13
+ end: number;
14
+ }
15
+ interface RenderedImage {
16
+ kind: RenderableKind;
17
+ /** Absolute path of the written PNG. */
18
+ path: string;
19
+ width: number;
20
+ height: number;
21
+ }
22
+ interface RenderOptions {
23
+ /** Directory the PNGs are written into. Created if missing. */
24
+ outDir: string;
25
+ kinds?: RenderableKind[];
26
+ theme?: "light" | "dark";
27
+ /** Viewport width in CSS pixels. */
28
+ width?: number;
29
+ /** Device pixels per CSS pixel. */
30
+ scale?: number;
31
+ /** Cap on how many blocks are rendered. Extra blocks stay as text. */
32
+ max?: number;
33
+ timeoutMs?: number;
34
+ idleShutdownMs?: number;
35
+ }
36
+ interface RenderResult {
37
+ /** The source with every successfully rendered block removed. */
38
+ text: string;
39
+ images: RenderedImage[];
40
+ }
41
+ declare const DEFAULT_KINDS: RenderableKind[];
42
+ declare const DEFAULT_WIDTH = 720;
43
+ declare const DEFAULT_SCALE = 2;
44
+ declare const DEFAULT_MAX = 6;
45
+ declare const DEFAULT_TIMEOUT_MS = 10000;
46
+ declare const DEFAULT_IDLE_SHUTDOWN_MS = 300000; //#endregion
47
+ //#region src/blocks.d.ts
48
+ declare function hasTrigger(markdown: string): boolean;
49
+ interface SelectOptions {
50
+ kinds?: RenderableKind[];
51
+ registry?: CardRegistry;
52
+ max?: number;
53
+ }
54
+ declare function selectRenderableBlocks(markdown: string, options?: SelectOptions): BlockSelection[];
55
+ /**
56
+ * Cut the rendered blocks out of the text.
57
+ *
58
+ * Back to front, because slicing from the front shifts every offset behind it and silently
59
+ * corrupts the second cut onwards. Runs of blank lines left by the cuts collapse to one, so a
60
+ * message that was mostly pictures does not arrive as a column of empty lines. The collapse has
61
+ * to know about `\r\n` — matching bare `\n` leaves a stray blank line in every CRLF message.
62
+ */
63
+ declare function stripBlocks(markdown: string, selections: BlockSelection[]): string;
64
+
65
+ //#endregion
66
+ //#region src/browser.d.ts
67
+ /** Playwright is an optional peer. Its absence is a configuration fact, not a bug. */
68
+ declare class BrowserUnavailableError extends Error {
69
+ constructor(cause: unknown);
70
+ }
71
+ declare function closeBrowser(): Promise<void>;
72
+
73
+ //#endregion
74
+ //#region src/render.d.ts
75
+ /** Drops all module state. Tests only. */
76
+ interface InternalRenderOptions extends RenderOptions {
77
+ registry?: CardRegistry;
78
+ /** Injected in tests. Defaults to the module's own lazy Chromium. */
79
+ acquire?: (options: {
80
+ idleShutdownMs?: number;
81
+ deviceScaleFactor?: number;
82
+ }) => Promise<{
83
+ page: unknown;
84
+ release: () => Promise<void>;
85
+ }>;
86
+ }
87
+ /**
88
+ * Draw every renderable block in `markdown` and return the leftover text plus the pictures.
89
+ *
90
+ * A block that fails is left alone: its source stays in the text, so a reader gets the raw fence
91
+ * rather than a silently missing answer. Only blocks that actually produced a file are stripped.
92
+ */
93
+ declare function renderMarkdownToImages(markdown: string, options: InternalRenderOptions): Promise<RenderResult>;
94
+
95
+ //#endregion
96
+ export { BlockSelection, BrowserUnavailableError, DEFAULT_IDLE_SHUTDOWN_MS, DEFAULT_KINDS, DEFAULT_MAX, DEFAULT_SCALE, DEFAULT_TIMEOUT_MS, DEFAULT_WIDTH, InternalRenderOptions, RenderOptions, RenderResult, RenderableKind, RenderedImage, SelectOptions, closeBrowser, hasTrigger, renderMarkdownToImages, selectRenderableBlocks, stripBlocks };