@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/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
|
package/dist/index.d.cts
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|