@zhin.js/html-renderer 0.0.1

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) 2025 凉菜
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,45 @@
1
+ # @zhin.js/html-renderer
2
+
3
+ HTML → PNG/SVG,供 Zhin.js 出站富媒体段(`segment.html` / `segment.markdown`)与可选 `aiTextAsImage` 使用。
4
+
5
+ 基于 `@zhin.js/satori` + `@resvg/resvg-js`,**不**再作为插件加载。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ pnpm add @zhin.js/html-renderer
11
+ ```
12
+
13
+ 未安装时,Adapter policy 为 `html:'image'` / `markdown:'image'` 的出站会自动 **降级为 text** 并打一次 warning。
14
+
15
+ ## 配置(zhin.config.yml)
16
+
17
+ ```yaml
18
+ htmlRenderer:
19
+ defaultWidth: 1080
20
+ defaultBackgroundColor: "#ffffff"
21
+ aiTextAsImage: false # true 时纯文本出站转 PNG(before.sendMessage)
22
+ ```
23
+
24
+ ## API
25
+
26
+ ```typescript
27
+ import { createHtmlRenderer } from '@zhin.js/html-renderer';
28
+
29
+ const renderer = createHtmlRenderer({ defaultWidth: 540 });
30
+ const png = await renderer.render('<div>Hello</div>', { format: 'png' });
31
+ ```
32
+
33
+ Core 出站链通过动态 import 自动调用,业务代码通常只需 `segment.html({ html: '...' })`。
34
+
35
+ ## IM 组件与 Context(安装包后 zhin 启动自动注册)
36
+
37
+ 无需再写 plugins 列表,安装 `@zhin.js/html-renderer` 后启动时自动:
38
+
39
+ | 能力 | 说明 |
40
+ |------|------|
41
+ | **`RenderImage` 组件** | 模板/命令中 `<RenderImage html="..." width="540"/>`,异步渲染为 `image` 段 |
42
+ | **`html-renderer` Context** | `useContext('html-renderer', (r) => …)` 或 `inject('html-renderer')` |
43
+ | **Agent 工具** | `html_render`、`html_card`(需已装 `@zhin.js/agent` 且 tool 服务可用) |
44
+
45
+ 自定义组件仍用 `defineComponent` + `addComponent`;需要转图时在组件内 `inject('html-renderer')` 或返回 `segment.html({ html })` 走出站 policy。
@@ -0,0 +1,13 @@
1
+ import type { SendContent, SendOptions } from '@zhin.js/core';
2
+ import type { HtmlRendererConfig, HtmlRendererLogger, HtmlRendererService } from './types.js';
3
+ export declare function extractPlainTextForImage(content: SendContent, skipIfRich: boolean): string | null;
4
+ export declare function registerAiTextAsImageOutput(params: {
5
+ root: {
6
+ on: (ev: 'before.sendMessage', fn: (o: SendOptions) => unknown) => void;
7
+ off?: (ev: 'before.sendMessage', fn: (o: SendOptions) => unknown) => void;
8
+ };
9
+ logger: HtmlRendererLogger;
10
+ fullConfig: HtmlRendererConfig;
11
+ getRenderer: () => HtmlRendererService;
12
+ }): (() => void) | undefined;
13
+ //# sourceMappingURL=ai-text-as-image.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ai-text-as-image.d.ts","sourceRoot":"","sources":["../src/ai-text-as-image.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE9D,OAAO,KAAK,EAEV,kBAAkB,EAClB,kBAAkB,EAClB,mBAAmB,EACpB,MAAM,YAAY,CAAC;AAgCpB,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,WAAW,EACpB,UAAU,EAAE,OAAO,GAClB,MAAM,GAAG,IAAI,CA0Bf;AAQD,wBAAgB,2BAA2B,CAAC,MAAM,EAAE;IAClD,IAAI,EAAE;QACJ,EAAE,EAAE,CAAC,EAAE,EAAE,oBAAoB,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,OAAO,KAAK,IAAI,CAAC;QACxE,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,oBAAoB,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,OAAO,KAAK,IAAI,CAAC;KAC3E,CAAC;IACF,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,kBAAkB,CAAC;IAC/B,WAAW,EAAE,MAAM,mBAAmB,CAAC;CACxC,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CA6D3B"}
@@ -0,0 +1,115 @@
1
+ import { segment } from '@zhin.js/core';
2
+ const RICH_STRING = /<\s*(image|video|audio|file|forward|xml|json|face|at)\b/i;
3
+ function isAiImageFeatureOn(raw) {
4
+ if (raw === true)
5
+ return true;
6
+ if (raw == null || raw === false)
7
+ return false;
8
+ if (typeof raw === 'object' && raw.enabled === false)
9
+ return false;
10
+ return true;
11
+ }
12
+ function mergeAiConfig(base, raw) {
13
+ const obj = raw === true || raw === false || raw == null ? {} : raw;
14
+ return {
15
+ onlyAdapters: obj.onlyAdapters,
16
+ minLength: obj.minLength ?? 1,
17
+ maxLength: obj.maxLength ?? 12_000,
18
+ skipIfRich: obj.skipIfRich !== false,
19
+ width: obj.width ?? base.defaultWidth ?? 720,
20
+ height: obj.height,
21
+ backgroundColor: obj.backgroundColor ?? base.defaultBackgroundColor ?? '#ffffff',
22
+ fontSize: obj.fontSize ?? 16,
23
+ color: obj.color ?? '#1a1a1a',
24
+ padding: obj.padding ?? 20,
25
+ scale: obj.scale ?? 2,
26
+ fileName: obj.fileName ?? 'message.png',
27
+ };
28
+ }
29
+ export function extractPlainTextForImage(content, skipIfRich) {
30
+ const parts = (Array.isArray(content) ? content : [content]);
31
+ if (parts.length === 0)
32
+ return null;
33
+ const texts = [];
34
+ for (const p of parts) {
35
+ if (typeof p === 'string') {
36
+ if (skipIfRich && (RICH_STRING.test(p) || /\bCQ:/i.test(p)))
37
+ return null;
38
+ texts.push(p);
39
+ continue;
40
+ }
41
+ if (p == null || typeof p !== 'object')
42
+ return null;
43
+ const t = p.type;
44
+ if (typeof t !== 'string')
45
+ return null;
46
+ if (t !== 'text')
47
+ return null;
48
+ const raw = p.data?.text;
49
+ if (raw != null && typeof raw !== 'string')
50
+ return null;
51
+ texts.push(raw ?? '');
52
+ }
53
+ return texts.join('');
54
+ }
55
+ function buildPlainTextHtml(text, opts) {
56
+ const pad = opts.padding ?? 20;
57
+ const escaped = segment.escape(text);
58
+ return `<div style="display:flex;flex-direction:column;width:100%;box-sizing:border-box;padding:${pad}px;font-family:Noto Sans SC,sans-serif;font-size:${opts.fontSize}px;color:${opts.color};line-height:1.65;white-space:pre-wrap;word-break:break-word;">${escaped}</div>`;
59
+ }
60
+ export function registerAiTextAsImageOutput(params) {
61
+ const { root, logger, fullConfig, getRenderer } = params;
62
+ if (!isAiImageFeatureOn(fullConfig.aiTextAsImage)) {
63
+ logger.debug?.('html-renderer: aiTextAsImage 未启用');
64
+ return;
65
+ }
66
+ const aiOpts = mergeAiConfig(fullConfig, fullConfig.aiTextAsImage);
67
+ const handler = async (options) => {
68
+ try {
69
+ const { context, content } = options;
70
+ if (content == null)
71
+ return options;
72
+ if (aiOpts.onlyAdapters?.length && !aiOpts.onlyAdapters.includes(context)) {
73
+ return options;
74
+ }
75
+ const plain = extractPlainTextForImage(content, aiOpts.skipIfRich !== false);
76
+ if (plain == null)
77
+ return options;
78
+ const trimmed = plain.trim();
79
+ if (trimmed.length < (aiOpts.minLength ?? 1))
80
+ return options;
81
+ const maxLen = aiOpts.maxLength ?? 0;
82
+ if (maxLen > 0 && plain.length > maxLen) {
83
+ logger.debug?.(`html-renderer: 文本过长 (${plain.length}>${maxLen}),跳过转图`);
84
+ return options;
85
+ }
86
+ const html = buildPlainTextHtml(plain, aiOpts);
87
+ const renderer = getRenderer();
88
+ const result = await renderer.render(html, {
89
+ width: aiOpts.width,
90
+ height: aiOpts.height,
91
+ format: 'png',
92
+ backgroundColor: aiOpts.backgroundColor,
93
+ scale: aiOpts.scale,
94
+ });
95
+ if (result.format !== 'png' || typeof result.data !== 'object')
96
+ return options;
97
+ const base64 = Buffer.from(result.data).toString('base64');
98
+ const dataUrl = `data:${result.mimeType};base64,${base64}`;
99
+ return {
100
+ ...options,
101
+ content: segment('image', { url: dataUrl, name: aiOpts.fileName }),
102
+ };
103
+ }
104
+ catch (e) {
105
+ logger.warn?.('html-renderer: aiTextAsImage failed', e);
106
+ return options;
107
+ }
108
+ };
109
+ root.on('before.sendMessage', handler);
110
+ logger.info?.('html-renderer: aiTextAsImage enabled');
111
+ return () => {
112
+ root.off?.('before.sendMessage', handler);
113
+ };
114
+ }
115
+ //# sourceMappingURL=ai-text-as-image.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ai-text-as-image.js","sourceRoot":"","sources":["../src/ai-text-as-image.ts"],"names":[],"mappings":"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"}
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createHtmlRenderer } from './renderer.js';
2
+ export { registerAiTextAsImageOutput, extractPlainTextForImage } from './ai-text-as-image.js';
3
+ export type { FontConfig, HtmlRendererAiTextAsImageConfig, HtmlRendererConfig, HtmlRendererLogger, HtmlRendererService, OutputFormat, RenderOptions, RenderResult, } from './types.js';
4
+ /** 动态 import 时使用的包名(与 package.json name 一致) */
5
+ export declare const HTML_RENDERER_PACKAGE = "@zhin.js/html-renderer";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,2BAA2B,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAC9F,YAAY,EACV,UAAU,EACV,+BAA+B,EAC/B,kBAAkB,EAClB,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,EACZ,aAAa,EACb,YAAY,GACb,MAAM,YAAY,CAAC;AAEpB,+CAA+C;AAC/C,eAAO,MAAM,qBAAqB,2BAA2B,CAAC"}
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createHtmlRenderer } from './renderer.js';
2
+ export { registerAiTextAsImageOutput, extractPlainTextForImage } from './ai-text-as-image.js';
3
+ /** 动态 import 时使用的包名(与 package.json name 一致) */
4
+ export const HTML_RENDERER_PACKAGE = '@zhin.js/html-renderer';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,2BAA2B,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAY9F,+CAA+C;AAC/C,MAAM,CAAC,MAAM,qBAAqB,GAAG,wBAAwB,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { HtmlRendererConfig, HtmlRendererLogger, HtmlRendererService } from './types.js';
2
+ export declare function createHtmlRenderer(config?: HtmlRendererConfig, logger?: HtmlRendererLogger): HtmlRendererService;
3
+ //# sourceMappingURL=renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAEV,kBAAkB,EAClB,kBAAkB,EAClB,mBAAmB,EAGpB,MAAM,YAAY,CAAC;AA0MpB,wBAAgB,kBAAkB,CAChC,MAAM,GAAE,kBAAuB,EAC/B,MAAM,CAAC,EAAE,kBAAkB,GAC1B,mBAAmB,CA6ErB"}
@@ -0,0 +1,216 @@
1
+ import { htmlToSvg, getAllBuiltinFonts, h } from '@zhin.js/satori';
2
+ import { Resvg } from '@resvg/resvg-js';
3
+ const DEFAULT_CONFIG = {
4
+ defaultWidth: 800,
5
+ defaultFonts: [],
6
+ defaultBackgroundColor: '#ffffff',
7
+ cacheFonts: true,
8
+ fontUrls: [],
9
+ };
10
+ const fontCache = new Map();
11
+ let defaultFontLoaded = false;
12
+ function toFontConfig(f) {
13
+ return { name: f.name, data: f.data, weight: f.weight, style: f.style };
14
+ }
15
+ function uniqueFontsForRender(list) {
16
+ const m = new Map();
17
+ for (const f of list) {
18
+ const k = `${f.name}\0${f.weight ?? 400}\0${f.style ?? 'normal'}`;
19
+ m.set(k, f);
20
+ }
21
+ return [...m.values()];
22
+ }
23
+ function mergeFontLists(...lists) {
24
+ const flat = [];
25
+ for (const list of lists)
26
+ flat.push(...list);
27
+ return uniqueFontsForRender(flat);
28
+ }
29
+ function ensureBuiltinFontsCached(logger) {
30
+ if (defaultFontLoaded)
31
+ return;
32
+ try {
33
+ const builtinFonts = getAllBuiltinFonts();
34
+ if (builtinFonts.length > 0) {
35
+ for (const font of builtinFonts) {
36
+ const fc = toFontConfig(font);
37
+ fontCache.set(`${font.name}-${font.weight}`, fc);
38
+ logger?.debug?.(`Builtin font: ${font.name} (${Math.round(font.data.byteLength / 1024)}KB)`);
39
+ }
40
+ fontCache.set('default', toFontConfig(builtinFonts[0]));
41
+ defaultFontLoaded = true;
42
+ return;
43
+ }
44
+ }
45
+ catch (e) {
46
+ logger?.warn?.('html-renderer: builtin fonts failed', e);
47
+ }
48
+ defaultFontLoaded = true;
49
+ logger?.warn?.('html-renderer: no builtin fonts available');
50
+ }
51
+ function emojiToTwemojiUrl(emoji) {
52
+ const codePoints = [];
53
+ for (const char of emoji) {
54
+ const cp = char.codePointAt(0);
55
+ if (cp && cp !== 0xfe0f)
56
+ codePoints.push(cp.toString(16));
57
+ }
58
+ return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoints.join('-')}.svg`;
59
+ }
60
+ async function loadEmojiImage(emoji, logger) {
61
+ try {
62
+ const url = emojiToTwemojiUrl(emoji);
63
+ const response = await fetch(url);
64
+ if (!response.ok) {
65
+ logger?.debug?.(`Failed to load emoji ${emoji}: ${response.status}`);
66
+ return null;
67
+ }
68
+ const svg = await response.text();
69
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
70
+ }
71
+ catch (error) {
72
+ logger?.debug?.(`Failed to load emoji ${emoji}:`, error);
73
+ return null;
74
+ }
75
+ }
76
+ const emojiCache = new Map();
77
+ async function loadAdditionalAsset(languageCode, segment, logger) {
78
+ if (languageCode === 'emoji') {
79
+ if (emojiCache.has(segment))
80
+ return emojiCache.get(segment);
81
+ const result = await loadEmojiImage(segment, logger);
82
+ if (result)
83
+ emojiCache.set(segment, result);
84
+ return result;
85
+ }
86
+ return null;
87
+ }
88
+ function wrapHtmlFragment(html, backgroundColor) {
89
+ if (html.includes('<!DOCTYPE') || html.includes('<html'))
90
+ return html;
91
+ return `<div style="display:flex;flex-direction:column;width:100%;height:100%;margin:0;padding:0;box-sizing:border-box;background-color:${backgroundColor};font-family:Noto Sans SC,sans-serif">${html}</div>`;
92
+ }
93
+ async function renderHtmlToSvg(html, width, height, fonts, backgroundColor, logger) {
94
+ ensureBuiltinFontsCached(logger);
95
+ const finalFonts = mergeFontLists(getAllBuiltinFonts().map(toFontConfig), fonts);
96
+ if (finalFonts.length === 0) {
97
+ logger?.warn?.('html-renderer: no fonts; non-ascii text may fail');
98
+ }
99
+ const svg = await htmlToSvg(wrapHtmlFragment(html, backgroundColor ?? '#ffffff'), {
100
+ width,
101
+ ...(height != null && { height }),
102
+ fonts: finalFonts.map((f) => ({
103
+ name: f.name,
104
+ data: f.data,
105
+ weight: f.weight,
106
+ style: f.style,
107
+ })),
108
+ loadAdditionalAsset: async (code, seg) => (await loadAdditionalAsset(code, seg, logger)) ?? '',
109
+ });
110
+ const wm = svg.match(/width="(\d+)"/);
111
+ const hm = svg.match(/height="(\d+)"/);
112
+ return {
113
+ svg,
114
+ width: wm ? parseInt(wm[1], 10) : width,
115
+ height: hm ? parseInt(hm[1], 10) : height || width,
116
+ };
117
+ }
118
+ function svgToPng(svg, scale = 1) {
119
+ const resvg = new Resvg(svg, {
120
+ fitTo: scale !== 1
121
+ ? {
122
+ mode: 'zoom',
123
+ value: scale,
124
+ }
125
+ : undefined,
126
+ });
127
+ const pngData = resvg.render();
128
+ return Buffer.from(pngData.asPng());
129
+ }
130
+ function serializeJsxToHtml(element) {
131
+ if (typeof element === 'string' || typeof element === 'number') {
132
+ return String(element);
133
+ }
134
+ if (element == null)
135
+ return '';
136
+ if (Array.isArray(element)) {
137
+ return element.map(serializeJsxToHtml).join('');
138
+ }
139
+ if (typeof element === 'object' && element !== null && 'type' in element) {
140
+ const { type, props = {} } = element;
141
+ const { children, style, dangerouslySetInnerHTML, ...restProps } = props;
142
+ let styleStr = '';
143
+ if (style && typeof style === 'object') {
144
+ styleStr = Object.entries(style)
145
+ .map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
146
+ .join('; ');
147
+ }
148
+ const attrs = Object.entries(restProps)
149
+ .filter(([key]) => key !== 'dangerouslySetInnerHTML')
150
+ .map(([key, value]) => `${key}="${value}"`)
151
+ .join(' ');
152
+ const styleAttr = styleStr ? ` style="${styleStr}"` : '';
153
+ const attrStr = attrs ? ` ${attrs}` : '';
154
+ if (dangerouslySetInnerHTML?.__html) {
155
+ return `<${type}${attrStr}${styleAttr}>${dangerouslySetInnerHTML.__html}</${type}>`;
156
+ }
157
+ const childrenHtml = children ? serializeJsxToHtml(children) : '';
158
+ const selfClosingTags = ['img', 'br', 'hr', 'input', 'meta', 'link'];
159
+ if (selfClosingTags.includes(type) && !childrenHtml) {
160
+ return `<${type}${attrStr}${styleAttr} />`;
161
+ }
162
+ return `<${type}${attrStr}${styleAttr}>${childrenHtml}</${type}>`;
163
+ }
164
+ return '';
165
+ }
166
+ export function createHtmlRenderer(config = {}, logger) {
167
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config };
168
+ ensureBuiltinFontsCached(logger);
169
+ for (const font of mergedConfig.defaultFonts) {
170
+ fontCache.set(`${font.name}-${font.weight || 400}`, font);
171
+ }
172
+ return {
173
+ async render(html, options = {}) {
174
+ const { width = mergedConfig.defaultWidth, height, format = 'png', backgroundColor = mergedConfig.defaultBackgroundColor, fonts = [], scale = 1, } = options;
175
+ ensureBuiltinFontsCached(logger);
176
+ const allFonts = uniqueFontsForRender([...fontCache.values(), ...fonts]);
177
+ const { svg, width: actualWidth, height: actualHeight } = await renderHtmlToSvg(html, width, height, allFonts, backgroundColor, logger);
178
+ if (format === 'svg') {
179
+ return {
180
+ data: svg,
181
+ format: 'svg',
182
+ width: actualWidth,
183
+ height: actualHeight,
184
+ mimeType: 'image/svg+xml',
185
+ };
186
+ }
187
+ const png = svgToPng(svg, scale);
188
+ return {
189
+ data: png,
190
+ format: 'png',
191
+ width: Math.round(actualWidth * scale),
192
+ height: Math.round(actualHeight * scale),
193
+ mimeType: 'image/png',
194
+ };
195
+ },
196
+ async renderJsx(element, options = {}) {
197
+ return this.render(serializeJsxToHtml(element), options);
198
+ },
199
+ async renderComponent(component, props, options = {}) {
200
+ return this.render(h(component, props), options);
201
+ },
202
+ registerFont(font) {
203
+ fontCache.set(`${font.name}-${font.weight || 400}`, font);
204
+ logger?.debug?.(`Font registered: ${font.name}`);
205
+ },
206
+ getFonts() {
207
+ return Array.from(fontCache.values());
208
+ },
209
+ clearFonts() {
210
+ fontCache.clear();
211
+ defaultFontLoaded = false;
212
+ logger?.debug?.('Font cache cleared');
213
+ },
214
+ };
215
+ }
216
+ //# sourceMappingURL=renderer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"renderer.js","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,CAAC,EAAE,MAAM,iBAAiB,CAAC;AAEnE,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAUxC,MAAM,cAAc,GAAwD;IAC1E,YAAY,EAAE,GAAG;IACjB,YAAY,EAAE,EAAE;IAChB,sBAAsB,EAAE,SAAS;IACjC,UAAU,EAAE,IAAI;IAChB,QAAQ,EAAE,EAAE;CACb,CAAC;AAEF,MAAM,SAAS,GAA4B,IAAI,GAAG,EAAE,CAAC;AACrD,IAAI,iBAAiB,GAAG,KAAK,CAAC;AAE9B,SAAS,YAAY,CAAC,CAKrB;IACC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;AAC1E,CAAC;AAED,SAAS,oBAAoB,CAAC,IAAkB;IAC9C,MAAM,CAAC,GAAG,IAAI,GAAG,EAAsB,CAAC;IACxC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,MAAM,CAAC,GAAG,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,KAAK,IAAI,QAAQ,EAAE,CAAC;QAClE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACd,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;AACzB,CAAC;AAED,SAAS,cAAc,CAAC,GAAG,KAAqB;IAC9C,MAAM,IAAI,GAAiB,EAAE,CAAC;IAC9B,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC;IAC7C,OAAO,oBAAoB,CAAC,IAAI,CAAC,CAAC;AACpC,CAAC;AAED,SAAS,wBAAwB,CAAC,MAA2B;IAC3D,IAAI,iBAAiB;QAAE,OAAO;IAE9B,IAAI,CAAC;QACH,MAAM,YAAY,GAAG,kBAAkB,EAAE,CAAC;QAC1C,IAAI,YAAY,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC5B,KAAK,MAAM,IAAI,IAAI,YAAY,EAAE,CAAC;gBAChC,MAAM,EAAE,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;gBAC9B,SAAS,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC;gBACjD,MAAM,EAAE,KAAK,EAAE,CAAC,iBAAiB,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;YAC/F,CAAC;YACD,SAAS,CAAC,GAAG,CAAC,SAAS,EAAE,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACxD,iBAAiB,GAAG,IAAI,CAAC;YACzB,OAAO;QACT,CAAC;IACH,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,MAAM,EAAE,IAAI,EAAE,CAAC,qCAAqC,EAAE,CAAC,CAAC,CAAC;IAC3D,CAAC;IACD,iBAAiB,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,IAAI,EAAE,CAAC,2CAA2C,CAAC,CAAC;AAC9D,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAa;IACtC,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,EAAE,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;QAC/B,IAAI,EAAE,IAAI,EAAE,KAAK,MAAM;YAAE,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,iEAAiE,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC;AACrG,CAAC;AAED,KAAK,UAAU,cAAc,CAAC,KAAa,EAAE,MAA2B;IACtE,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;QACrC,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,CAAC;QAClC,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,EAAE,KAAK,EAAE,CAAC,wBAAwB,KAAK,KAAK,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;YACrE,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QAClC,OAAO,6BAA6B,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;IAC5E,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,EAAE,KAAK,EAAE,CAAC,wBAAwB,KAAK,GAAG,EAAE,KAAK,CAAC,CAAC;QACzD,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,GAAwB,IAAI,GAAG,EAAE,CAAC;AAElD,KAAK,UAAU,mBAAmB,CAChC,YAAoB,EACpB,OAAe,EACf,MAA2B;IAE3B,IAAI,YAAY,KAAK,OAAO,EAAE,CAAC;QAC7B,IAAI,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,OAAO,UAAU,CAAC,GAAG,CAAC,OAAO,CAAE,CAAC;QAC7D,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACrD,IAAI,MAAM;YAAE,UAAU,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC5C,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAY,EAAE,eAAuB;IAC7D,IAAI,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IACtE,OAAO,mIAAmI,eAAe,yCAAyC,IAAI,QAAQ,CAAC;AACjN,CAAC;AAED,KAAK,UAAU,eAAe,CAC5B,IAAY,EACZ,KAAa,EACb,MAA0B,EAC1B,KAAmB,EACnB,eAAmC,EACnC,MAA2B;IAE3B,wBAAwB,CAAC,MAAM,CAAC,CAAC;IACjC,MAAM,UAAU,GAAG,cAAc,CAAC,kBAAkB,EAAE,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,KAAK,CAAC,CAAC;IACjF,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,MAAM,EAAE,IAAI,EAAE,CAAC,kDAAkD,CAAC,CAAC;IACrE,CAAC;IAED,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,gBAAgB,CAAC,IAAI,EAAE,eAAe,IAAI,SAAS,CAAC,EAAE;QAChF,KAAK;QACL,GAAG,CAAC,MAAM,IAAI,IAAI,IAAI,EAAE,MAAM,EAAE,CAAC;QACjC,KAAK,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC5B,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,KAAK,EAAE,CAAC,CAAC,KAAK;SACf,CAAC,CAAC;QACH,mBAAmB,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,EAAE,CACvC,CAAC,MAAM,mBAAmB,CAAC,IAAI,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,IAAI,EAAE;KACvD,CAAC,CAAC;IAEH,MAAM,EAAE,GAAG,GAAG,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;IACtC,MAAM,EAAE,GAAG,GAAG,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;IACvC,OAAO;QACL,GAAG;QACH,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK;QACvC,MAAM,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,IAAI,KAAK;KACnD,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,GAAW,EAAE,QAAgB,CAAC;IAC9C,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC,GAAG,EAAE;QAC3B,KAAK,EACH,KAAK,KAAK,CAAC;YACT,CAAC,CAAC;gBACE,IAAI,EAAE,MAAM;gBACZ,KAAK,EAAE,KAAK;aACb;YACH,CAAC,CAAC,SAAS;KAChB,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;IAC/B,OAAO,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;AACtC,CAAC;AAED,SAAS,kBAAkB,CAAC,OAAgB;IAC1C,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QAC/D,OAAO,MAAM,CAAC,OAAO,CAAC,CAAC;IACzB,CAAC;IACD,IAAI,OAAO,IAAI,IAAI;QAAE,OAAO,EAAE,CAAC;IAC/B,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC3B,OAAO,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAClD,CAAC;IACD,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,IAAI,MAAM,IAAI,OAAO,EAAE,CAAC;QACzE,MAAM,EAAE,IAAI,EAAE,KAAK,GAAG,EAAE,EAAE,GAAG,OAA4D,CAAC;QAC1F,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,uBAAuB,EAAE,GAAG,SAAS,EAAE,GAAG,KAIlE,CAAC;QAEF,IAAI,QAAQ,GAAG,EAAE,CAAC;QAClB,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YACvC,QAAQ,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;iBAC7B,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC,WAAW,EAAE,KAAK,KAAK,EAAE,CAAC;iBAClF,IAAI,CAAC,IAAI,CAAC,CAAC;QAChB,CAAC;QAED,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC;aACpC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,KAAK,yBAAyB,CAAC;aACpD,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,KAAK,KAAK,GAAG,CAAC;aAC1C,IAAI,CAAC,GAAG,CAAC,CAAC;QAEb,MAAM,SAAS,GAAG,QAAQ,CAAC,CAAC,CAAC,WAAW,QAAQ,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QACzD,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAEzC,IAAI,uBAAuB,EAAE,MAAM,EAAE,CAAC;YACpC,OAAO,IAAI,IAAI,GAAG,OAAO,GAAG,SAAS,IAAI,uBAAuB,CAAC,MAAM,KAAK,IAAI,GAAG,CAAC;QACtF,CAAC;QAED,MAAM,YAAY,GAAG,QAAQ,CAAC,CAAC,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAClE,MAAM,eAAe,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;QACrE,IAAI,eAAe,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YACpD,OAAO,IAAI,IAAI,GAAG,OAAO,GAAG,SAAS,KAAK,CAAC;QAC7C,CAAC;QAED,OAAO,IAAI,IAAI,GAAG,OAAO,GAAG,SAAS,IAAI,YAAY,KAAK,IAAI,GAAG,CAAC;IACpE,CAAC;IAED,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,kBAAkB,CAChC,SAA6B,EAAE,EAC/B,MAA2B;IAE3B,MAAM,YAAY,GAAG,EAAE,GAAG,cAAc,EAAE,GAAG,MAAM,EAAE,CAAC;IACtD,wBAAwB,CAAC,MAAM,CAAC,CAAC;IACjC,KAAK,MAAM,IAAI,IAAI,YAAY,CAAC,YAAY,EAAE,CAAC;QAC7C,SAAS,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,MAAM,IAAI,GAAG,EAAE,EAAE,IAAI,CAAC,CAAC;IAC5D,CAAC;IAED,OAAO;QACL,KAAK,CAAC,MAAM,CAAC,IAAY,EAAE,UAAyB,EAAE;YACpD,MAAM,EACJ,KAAK,GAAG,YAAY,CAAC,YAAY,EACjC,MAAM,EACN,MAAM,GAAG,KAAK,EACd,eAAe,GAAG,YAAY,CAAC,sBAAsB,EACrD,KAAK,GAAG,EAAE,EACV,KAAK,GAAG,CAAC,GACV,GAAG,OAAO,CAAC;YAEZ,wBAAwB,CAAC,MAAM,CAAC,CAAC;YACjC,MAAM,QAAQ,GAAG,oBAAoB,CAAC,CAAC,GAAG,SAAS,CAAC,MAAM,EAAE,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC;YAEzE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,GAAG,MAAM,eAAe,CAC7E,IAAI,EACJ,KAAK,EACL,MAAM,EACN,QAAQ,EACR,eAAe,EACf,MAAM,CACP,CAAC;YAEF,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;gBACrB,OAAO;oBACL,IAAI,EAAE,GAAG;oBACT,MAAM,EAAE,KAAK;oBACb,KAAK,EAAE,WAAW;oBAClB,MAAM,EAAE,YAAY;oBACpB,QAAQ,EAAE,eAAe;iBAC1B,CAAC;YACJ,CAAC;YAED,MAAM,GAAG,GAAG,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YACjC,OAAO;gBACL,IAAI,EAAE,GAAG;gBACT,MAAM,EAAE,KAAK;gBACb,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,KAAK,CAAC;gBACtC,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,GAAG,KAAK,CAAC;gBACxC,QAAQ,EAAE,WAAW;aACtB,CAAC;QACJ,CAAC;QAED,KAAK,CAAC,SAAS,CAAC,OAAgB,EAAE,UAAyB,EAAE;YAC3D,OAAO,IAAI,CAAC,MAAM,CAAC,kBAAkB,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC;QAC3D,CAAC;QAED,KAAK,CAAC,eAAe,CACnB,SAA2B,EAC3B,KAAQ,EACR,UAAyB,EAAE;YAE3B,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,OAAO,CAAC,CAAC;QACnD,CAAC;QAED,YAAY,CAAC,IAAgB;YAC3B,SAAS,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,MAAM,IAAI,GAAG,EAAE,EAAE,IAAI,CAAC,CAAC;YAC1D,MAAM,EAAE,KAAK,EAAE,CAAC,oBAAoB,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QACnD,CAAC;QAED,QAAQ;YACN,OAAO,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC;QACxC,CAAC;QAED,UAAU;YACR,SAAS,CAAC,KAAK,EAAE,CAAC;YAClB,iBAAiB,GAAG,KAAK,CAAC;YAC1B,MAAM,EAAE,KAAK,EAAE,CAAC,oBAAoB,CAAC,CAAC;QACxC,CAAC;KACF,CAAC;AACJ,CAAC"}
package/lib/types.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { HtmlComponent } from '@zhin.js/satori';
2
+ export type OutputFormat = 'svg' | 'png';
3
+ export interface FontConfig {
4
+ name: string;
5
+ data: ArrayBuffer | Buffer;
6
+ weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
7
+ style?: 'normal' | 'italic';
8
+ }
9
+ export interface RenderOptions {
10
+ width?: number;
11
+ height?: number;
12
+ format?: OutputFormat;
13
+ backgroundColor?: string;
14
+ fonts?: FontConfig[];
15
+ enableEmoji?: boolean;
16
+ scale?: number;
17
+ }
18
+ export interface RenderResult {
19
+ data: Buffer | string;
20
+ format: OutputFormat;
21
+ width: number;
22
+ height: number;
23
+ mimeType: string;
24
+ }
25
+ export interface HtmlRendererAiTextAsImageConfig {
26
+ enabled?: boolean;
27
+ onlyAdapters?: string[];
28
+ minLength?: number;
29
+ maxLength?: number;
30
+ skipIfRich?: boolean;
31
+ width?: number;
32
+ height?: number;
33
+ backgroundColor?: string;
34
+ fontSize?: number;
35
+ color?: string;
36
+ padding?: number;
37
+ scale?: number;
38
+ fileName?: string;
39
+ }
40
+ export interface HtmlRendererConfig {
41
+ defaultWidth?: number;
42
+ defaultFonts?: FontConfig[];
43
+ defaultBackgroundColor?: string;
44
+ cacheFonts?: boolean;
45
+ fontUrls?: string[];
46
+ aiTextAsImage?: boolean | HtmlRendererAiTextAsImageConfig;
47
+ }
48
+ export interface HtmlRendererService {
49
+ render(html: string, options?: RenderOptions): Promise<RenderResult>;
50
+ renderJsx(element: unknown, options?: RenderOptions): Promise<RenderResult>;
51
+ renderComponent<P>(component: HtmlComponent<P>, props: P, options?: RenderOptions): Promise<RenderResult>;
52
+ registerFont(font: FontConfig): void;
53
+ getFonts(): FontConfig[];
54
+ clearFonts(): void;
55
+ }
56
+ export interface HtmlRendererLogger {
57
+ debug?: (...args: unknown[]) => void;
58
+ warn?: (...args: unknown[]) => void;
59
+ info?: (...args: unknown[]) => void;
60
+ }
61
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/lib/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@zhin.js/html-renderer",
3
+ "version": "0.0.1",
4
+ "description": "HTML to PNG/SVG for Zhin.js (satori + resvg); optional peer of zhin.js",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "development": "./src/index.ts",
12
+ "import": "./lib/index.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "lib",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "zhin",
23
+ "zhin.js",
24
+ "html",
25
+ "image",
26
+ "satori",
27
+ "png"
28
+ ],
29
+ "author": {
30
+ "name": "lc-cn",
31
+ "email": "admin@liucl.cn",
32
+ "url": "https://github.com/lc-cn"
33
+ },
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@resvg/resvg-js": "^2.6.2",
37
+ "@zhin.js/satori": "0.2.14"
38
+ },
39
+ "peerDependencies": {
40
+ "@zhin.js/core": "1.3.1"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@zhin.js/core": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "typescript": "^6.0.3",
49
+ "@zhin.js/core": "1.3.1"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/zhinjs/zhin.git",
54
+ "directory": "packages/toolkit/html-renderer"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "registry": "https://registry.npmjs.org"
59
+ },
60
+ "scripts": {
61
+ "build": "tsc --build",
62
+ "clean": "rimraf lib"
63
+ }
64
+ }
@@ -0,0 +1,146 @@
1
+ import type { SendContent, SendOptions } from '@zhin.js/core';
2
+ import { segment } from '@zhin.js/core';
3
+ import type {
4
+ HtmlRendererAiTextAsImageConfig,
5
+ HtmlRendererConfig,
6
+ HtmlRendererLogger,
7
+ HtmlRendererService,
8
+ } from './types.js';
9
+
10
+ const RICH_STRING = /<\s*(image|video|audio|file|forward|xml|json|face|at)\b/i;
11
+
12
+ function isAiImageFeatureOn(raw: HtmlRendererConfig['aiTextAsImage']): boolean {
13
+ if (raw === true) return true;
14
+ if (raw == null || raw === false) return false;
15
+ if (typeof raw === 'object' && raw.enabled === false) return false;
16
+ return true;
17
+ }
18
+
19
+ function mergeAiConfig(
20
+ base: HtmlRendererConfig,
21
+ raw: HtmlRendererConfig['aiTextAsImage'],
22
+ ): HtmlRendererAiTextAsImageConfig {
23
+ const obj = raw === true || raw === false || raw == null ? {} : raw;
24
+ return {
25
+ onlyAdapters: obj.onlyAdapters,
26
+ minLength: obj.minLength ?? 1,
27
+ maxLength: obj.maxLength ?? 12_000,
28
+ skipIfRich: obj.skipIfRich !== false,
29
+ width: obj.width ?? base.defaultWidth ?? 720,
30
+ height: obj.height,
31
+ backgroundColor: obj.backgroundColor ?? base.defaultBackgroundColor ?? '#ffffff',
32
+ fontSize: obj.fontSize ?? 16,
33
+ color: obj.color ?? '#1a1a1a',
34
+ padding: obj.padding ?? 20,
35
+ scale: obj.scale ?? 2,
36
+ fileName: obj.fileName ?? 'message.png',
37
+ };
38
+ }
39
+
40
+ export function extractPlainTextForImage(
41
+ content: SendContent,
42
+ skipIfRich: boolean,
43
+ ): string | null {
44
+ const parts = (Array.isArray(content) ? content : [content]) as (
45
+ | string
46
+ | { type?: unknown; data?: Record<string, unknown> }
47
+ )[];
48
+
49
+ if (parts.length === 0) return null;
50
+
51
+ const texts: string[] = [];
52
+
53
+ for (const p of parts) {
54
+ if (typeof p === 'string') {
55
+ if (skipIfRich && (RICH_STRING.test(p) || /\bCQ:/i.test(p))) return null;
56
+ texts.push(p);
57
+ continue;
58
+ }
59
+ if (p == null || typeof p !== 'object') return null;
60
+ const t = p.type;
61
+ if (typeof t !== 'string') return null;
62
+ if (t !== 'text') return null;
63
+ const raw = p.data?.text;
64
+ if (raw != null && typeof raw !== 'string') return null;
65
+ texts.push(raw ?? '');
66
+ }
67
+
68
+ return texts.join('');
69
+ }
70
+
71
+ function buildPlainTextHtml(text: string, opts: HtmlRendererAiTextAsImageConfig): string {
72
+ const pad = opts.padding ?? 20;
73
+ const escaped = segment.escape(text);
74
+ return `<div style="display:flex;flex-direction:column;width:100%;box-sizing:border-box;padding:${pad}px;font-family:Noto Sans SC,sans-serif;font-size:${opts.fontSize}px;color:${opts.color};line-height:1.65;white-space:pre-wrap;word-break:break-word;">${escaped}</div>`;
75
+ }
76
+
77
+ export function registerAiTextAsImageOutput(params: {
78
+ root: {
79
+ on: (ev: 'before.sendMessage', fn: (o: SendOptions) => unknown) => void;
80
+ off?: (ev: 'before.sendMessage', fn: (o: SendOptions) => unknown) => void;
81
+ };
82
+ logger: HtmlRendererLogger;
83
+ fullConfig: HtmlRendererConfig;
84
+ getRenderer: () => HtmlRendererService;
85
+ }): (() => void) | undefined {
86
+ const { root, logger, fullConfig, getRenderer } = params;
87
+
88
+ if (!isAiImageFeatureOn(fullConfig.aiTextAsImage)) {
89
+ logger.debug?.('html-renderer: aiTextAsImage 未启用');
90
+ return;
91
+ }
92
+
93
+ const aiOpts = mergeAiConfig(fullConfig, fullConfig.aiTextAsImage);
94
+
95
+ const handler = async (options: SendOptions) => {
96
+ try {
97
+ const { context, content } = options;
98
+ if (content == null) return options;
99
+
100
+ if (aiOpts.onlyAdapters?.length && !aiOpts.onlyAdapters.includes(context)) {
101
+ return options;
102
+ }
103
+
104
+ const plain = extractPlainTextForImage(content, aiOpts.skipIfRich !== false);
105
+ if (plain == null) return options;
106
+
107
+ const trimmed = plain.trim();
108
+ if (trimmed.length < (aiOpts.minLength ?? 1)) return options;
109
+ const maxLen = aiOpts.maxLength ?? 0;
110
+ if (maxLen > 0 && plain.length > maxLen) {
111
+ logger.debug?.(`html-renderer: 文本过长 (${plain.length}>${maxLen}),跳过转图`);
112
+ return options;
113
+ }
114
+
115
+ const html = buildPlainTextHtml(plain, aiOpts);
116
+ const renderer = getRenderer();
117
+ const result = await renderer.render(html, {
118
+ width: aiOpts.width,
119
+ height: aiOpts.height,
120
+ format: 'png',
121
+ backgroundColor: aiOpts.backgroundColor,
122
+ scale: aiOpts.scale,
123
+ });
124
+
125
+ if (result.format !== 'png' || typeof result.data !== 'object') return options;
126
+
127
+ const base64 = Buffer.from(result.data as Buffer).toString('base64');
128
+ const dataUrl = `data:${result.mimeType};base64,${base64}`;
129
+
130
+ return {
131
+ ...options,
132
+ content: segment('image', { url: dataUrl, name: aiOpts.fileName }),
133
+ };
134
+ } catch (e) {
135
+ logger.warn?.('html-renderer: aiTextAsImage failed', e);
136
+ return options;
137
+ }
138
+ };
139
+
140
+ root.on('before.sendMessage', handler);
141
+ logger.info?.('html-renderer: aiTextAsImage enabled');
142
+
143
+ return () => {
144
+ root.off?.('before.sendMessage', handler);
145
+ };
146
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { createHtmlRenderer } from './renderer.js';
2
+ export { registerAiTextAsImageOutput, extractPlainTextForImage } from './ai-text-as-image.js';
3
+ export type {
4
+ FontConfig,
5
+ HtmlRendererAiTextAsImageConfig,
6
+ HtmlRendererConfig,
7
+ HtmlRendererLogger,
8
+ HtmlRendererService,
9
+ OutputFormat,
10
+ RenderOptions,
11
+ RenderResult,
12
+ } from './types.js';
13
+
14
+ /** 动态 import 时使用的包名(与 package.json name 一致) */
15
+ export const HTML_RENDERER_PACKAGE = '@zhin.js/html-renderer';
@@ -0,0 +1,293 @@
1
+ import { htmlToSvg, getAllBuiltinFonts, h } from '@zhin.js/satori';
2
+ import type { HtmlComponent } from '@zhin.js/satori';
3
+ import { Resvg } from '@resvg/resvg-js';
4
+ import type {
5
+ FontConfig,
6
+ HtmlRendererConfig,
7
+ HtmlRendererLogger,
8
+ HtmlRendererService,
9
+ RenderOptions,
10
+ RenderResult,
11
+ } from './types.js';
12
+
13
+ const DEFAULT_CONFIG: Required<Omit<HtmlRendererConfig, 'aiTextAsImage'>> = {
14
+ defaultWidth: 800,
15
+ defaultFonts: [],
16
+ defaultBackgroundColor: '#ffffff',
17
+ cacheFonts: true,
18
+ fontUrls: [],
19
+ };
20
+
21
+ const fontCache: Map<string, FontConfig> = new Map();
22
+ let defaultFontLoaded = false;
23
+
24
+ function toFontConfig(f: {
25
+ name: string;
26
+ data: ArrayBuffer | Buffer;
27
+ weight?: FontConfig['weight'];
28
+ style?: FontConfig['style'];
29
+ }): FontConfig {
30
+ return { name: f.name, data: f.data, weight: f.weight, style: f.style };
31
+ }
32
+
33
+ function uniqueFontsForRender(list: FontConfig[]): FontConfig[] {
34
+ const m = new Map<string, FontConfig>();
35
+ for (const f of list) {
36
+ const k = `${f.name}\0${f.weight ?? 400}\0${f.style ?? 'normal'}`;
37
+ m.set(k, f);
38
+ }
39
+ return [...m.values()];
40
+ }
41
+
42
+ function mergeFontLists(...lists: FontConfig[][]): FontConfig[] {
43
+ const flat: FontConfig[] = [];
44
+ for (const list of lists) flat.push(...list);
45
+ return uniqueFontsForRender(flat);
46
+ }
47
+
48
+ function ensureBuiltinFontsCached(logger?: HtmlRendererLogger): void {
49
+ if (defaultFontLoaded) return;
50
+
51
+ try {
52
+ const builtinFonts = getAllBuiltinFonts();
53
+ if (builtinFonts.length > 0) {
54
+ for (const font of builtinFonts) {
55
+ const fc = toFontConfig(font);
56
+ fontCache.set(`${font.name}-${font.weight}`, fc);
57
+ logger?.debug?.(`Builtin font: ${font.name} (${Math.round(font.data.byteLength / 1024)}KB)`);
58
+ }
59
+ fontCache.set('default', toFontConfig(builtinFonts[0]));
60
+ defaultFontLoaded = true;
61
+ return;
62
+ }
63
+ } catch (e) {
64
+ logger?.warn?.('html-renderer: builtin fonts failed', e);
65
+ }
66
+ defaultFontLoaded = true;
67
+ logger?.warn?.('html-renderer: no builtin fonts available');
68
+ }
69
+
70
+ function emojiToTwemojiUrl(emoji: string): string {
71
+ const codePoints: string[] = [];
72
+ for (const char of emoji) {
73
+ const cp = char.codePointAt(0);
74
+ if (cp && cp !== 0xfe0f) codePoints.push(cp.toString(16));
75
+ }
76
+ return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoints.join('-')}.svg`;
77
+ }
78
+
79
+ async function loadEmojiImage(emoji: string, logger?: HtmlRendererLogger): Promise<string | null> {
80
+ try {
81
+ const url = emojiToTwemojiUrl(emoji);
82
+ const response = await fetch(url);
83
+ if (!response.ok) {
84
+ logger?.debug?.(`Failed to load emoji ${emoji}: ${response.status}`);
85
+ return null;
86
+ }
87
+ const svg = await response.text();
88
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
89
+ } catch (error) {
90
+ logger?.debug?.(`Failed to load emoji ${emoji}:`, error);
91
+ return null;
92
+ }
93
+ }
94
+
95
+ const emojiCache: Map<string, string> = new Map();
96
+
97
+ async function loadAdditionalAsset(
98
+ languageCode: string,
99
+ segment: string,
100
+ logger?: HtmlRendererLogger,
101
+ ): Promise<string | null> {
102
+ if (languageCode === 'emoji') {
103
+ if (emojiCache.has(segment)) return emojiCache.get(segment)!;
104
+ const result = await loadEmojiImage(segment, logger);
105
+ if (result) emojiCache.set(segment, result);
106
+ return result;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function wrapHtmlFragment(html: string, backgroundColor: string): string {
112
+ if (html.includes('<!DOCTYPE') || html.includes('<html')) return html;
113
+ return `<div style="display:flex;flex-direction:column;width:100%;height:100%;margin:0;padding:0;box-sizing:border-box;background-color:${backgroundColor};font-family:Noto Sans SC,sans-serif">${html}</div>`;
114
+ }
115
+
116
+ async function renderHtmlToSvg(
117
+ html: string,
118
+ width: number,
119
+ height: number | undefined,
120
+ fonts: FontConfig[],
121
+ backgroundColor: string | undefined,
122
+ logger?: HtmlRendererLogger,
123
+ ): Promise<{ svg: string; width: number; height: number }> {
124
+ ensureBuiltinFontsCached(logger);
125
+ const finalFonts = mergeFontLists(getAllBuiltinFonts().map(toFontConfig), fonts);
126
+ if (finalFonts.length === 0) {
127
+ logger?.warn?.('html-renderer: no fonts; non-ascii text may fail');
128
+ }
129
+
130
+ const svg = await htmlToSvg(wrapHtmlFragment(html, backgroundColor ?? '#ffffff'), {
131
+ width,
132
+ ...(height != null && { height }),
133
+ fonts: finalFonts.map((f) => ({
134
+ name: f.name,
135
+ data: f.data,
136
+ weight: f.weight,
137
+ style: f.style,
138
+ })),
139
+ loadAdditionalAsset: async (code, seg) =>
140
+ (await loadAdditionalAsset(code, seg, logger)) ?? '',
141
+ });
142
+
143
+ const wm = svg.match(/width="(\d+)"/);
144
+ const hm = svg.match(/height="(\d+)"/);
145
+ return {
146
+ svg,
147
+ width: wm ? parseInt(wm[1], 10) : width,
148
+ height: hm ? parseInt(hm[1], 10) : height || width,
149
+ };
150
+ }
151
+
152
+ function svgToPng(svg: string, scale: number = 1): Buffer {
153
+ const resvg = new Resvg(svg, {
154
+ fitTo:
155
+ scale !== 1
156
+ ? {
157
+ mode: 'zoom',
158
+ value: scale,
159
+ }
160
+ : undefined,
161
+ });
162
+ const pngData = resvg.render();
163
+ return Buffer.from(pngData.asPng());
164
+ }
165
+
166
+ function serializeJsxToHtml(element: unknown): string {
167
+ if (typeof element === 'string' || typeof element === 'number') {
168
+ return String(element);
169
+ }
170
+ if (element == null) return '';
171
+ if (Array.isArray(element)) {
172
+ return element.map(serializeJsxToHtml).join('');
173
+ }
174
+ if (typeof element === 'object' && element !== null && 'type' in element) {
175
+ const { type, props = {} } = element as { type: string; props?: Record<string, unknown> };
176
+ const { children, style, dangerouslySetInnerHTML, ...restProps } = props as {
177
+ children?: unknown;
178
+ style?: Record<string, unknown>;
179
+ dangerouslySetInnerHTML?: { __html?: string };
180
+ };
181
+
182
+ let styleStr = '';
183
+ if (style && typeof style === 'object') {
184
+ styleStr = Object.entries(style)
185
+ .map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
186
+ .join('; ');
187
+ }
188
+
189
+ const attrs = Object.entries(restProps)
190
+ .filter(([key]) => key !== 'dangerouslySetInnerHTML')
191
+ .map(([key, value]) => `${key}="${value}"`)
192
+ .join(' ');
193
+
194
+ const styleAttr = styleStr ? ` style="${styleStr}"` : '';
195
+ const attrStr = attrs ? ` ${attrs}` : '';
196
+
197
+ if (dangerouslySetInnerHTML?.__html) {
198
+ return `<${type}${attrStr}${styleAttr}>${dangerouslySetInnerHTML.__html}</${type}>`;
199
+ }
200
+
201
+ const childrenHtml = children ? serializeJsxToHtml(children) : '';
202
+ const selfClosingTags = ['img', 'br', 'hr', 'input', 'meta', 'link'];
203
+ if (selfClosingTags.includes(type) && !childrenHtml) {
204
+ return `<${type}${attrStr}${styleAttr} />`;
205
+ }
206
+
207
+ return `<${type}${attrStr}${styleAttr}>${childrenHtml}</${type}>`;
208
+ }
209
+
210
+ return '';
211
+ }
212
+
213
+ export function createHtmlRenderer(
214
+ config: HtmlRendererConfig = {},
215
+ logger?: HtmlRendererLogger,
216
+ ): HtmlRendererService {
217
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config };
218
+ ensureBuiltinFontsCached(logger);
219
+ for (const font of mergedConfig.defaultFonts) {
220
+ fontCache.set(`${font.name}-${font.weight || 400}`, font);
221
+ }
222
+
223
+ return {
224
+ async render(html: string, options: RenderOptions = {}): Promise<RenderResult> {
225
+ const {
226
+ width = mergedConfig.defaultWidth,
227
+ height,
228
+ format = 'png',
229
+ backgroundColor = mergedConfig.defaultBackgroundColor,
230
+ fonts = [],
231
+ scale = 1,
232
+ } = options;
233
+
234
+ ensureBuiltinFontsCached(logger);
235
+ const allFonts = uniqueFontsForRender([...fontCache.values(), ...fonts]);
236
+
237
+ const { svg, width: actualWidth, height: actualHeight } = await renderHtmlToSvg(
238
+ html,
239
+ width,
240
+ height,
241
+ allFonts,
242
+ backgroundColor,
243
+ logger,
244
+ );
245
+
246
+ if (format === 'svg') {
247
+ return {
248
+ data: svg,
249
+ format: 'svg',
250
+ width: actualWidth,
251
+ height: actualHeight,
252
+ mimeType: 'image/svg+xml',
253
+ };
254
+ }
255
+
256
+ const png = svgToPng(svg, scale);
257
+ return {
258
+ data: png,
259
+ format: 'png',
260
+ width: Math.round(actualWidth * scale),
261
+ height: Math.round(actualHeight * scale),
262
+ mimeType: 'image/png',
263
+ };
264
+ },
265
+
266
+ async renderJsx(element: unknown, options: RenderOptions = {}): Promise<RenderResult> {
267
+ return this.render(serializeJsxToHtml(element), options);
268
+ },
269
+
270
+ async renderComponent<P>(
271
+ component: HtmlComponent<P>,
272
+ props: P,
273
+ options: RenderOptions = {},
274
+ ): Promise<RenderResult> {
275
+ return this.render(h(component, props), options);
276
+ },
277
+
278
+ registerFont(font: FontConfig): void {
279
+ fontCache.set(`${font.name}-${font.weight || 400}`, font);
280
+ logger?.debug?.(`Font registered: ${font.name}`);
281
+ },
282
+
283
+ getFonts(): FontConfig[] {
284
+ return Array.from(fontCache.values());
285
+ },
286
+
287
+ clearFonts(): void {
288
+ fontCache.clear();
289
+ defaultFontLoaded = false;
290
+ logger?.debug?.('Font cache cleared');
291
+ },
292
+ };
293
+ }
package/src/types.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { HtmlComponent } from '@zhin.js/satori';
2
+
3
+ export type OutputFormat = 'svg' | 'png';
4
+
5
+ export interface FontConfig {
6
+ name: string;
7
+ data: ArrayBuffer | Buffer;
8
+ weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
9
+ style?: 'normal' | 'italic';
10
+ }
11
+
12
+ export interface RenderOptions {
13
+ width?: number;
14
+ height?: number;
15
+ format?: OutputFormat;
16
+ backgroundColor?: string;
17
+ fonts?: FontConfig[];
18
+ enableEmoji?: boolean;
19
+ scale?: number;
20
+ }
21
+
22
+ export interface RenderResult {
23
+ data: Buffer | string;
24
+ format: OutputFormat;
25
+ width: number;
26
+ height: number;
27
+ mimeType: string;
28
+ }
29
+
30
+ export interface HtmlRendererAiTextAsImageConfig {
31
+ enabled?: boolean;
32
+ onlyAdapters?: string[];
33
+ minLength?: number;
34
+ maxLength?: number;
35
+ skipIfRich?: boolean;
36
+ width?: number;
37
+ height?: number;
38
+ backgroundColor?: string;
39
+ fontSize?: number;
40
+ color?: string;
41
+ padding?: number;
42
+ scale?: number;
43
+ fileName?: string;
44
+ }
45
+
46
+ export interface HtmlRendererConfig {
47
+ defaultWidth?: number;
48
+ defaultFonts?: FontConfig[];
49
+ defaultBackgroundColor?: string;
50
+ cacheFonts?: boolean;
51
+ fontUrls?: string[];
52
+ aiTextAsImage?: boolean | HtmlRendererAiTextAsImageConfig;
53
+ }
54
+
55
+ export interface HtmlRendererService {
56
+ render(html: string, options?: RenderOptions): Promise<RenderResult>;
57
+ renderJsx(element: unknown, options?: RenderOptions): Promise<RenderResult>;
58
+ renderComponent<P>(
59
+ component: HtmlComponent<P>,
60
+ props: P,
61
+ options?: RenderOptions,
62
+ ): Promise<RenderResult>;
63
+ registerFont(font: FontConfig): void;
64
+ getFonts(): FontConfig[];
65
+ clearFonts(): void;
66
+ }
67
+
68
+ export interface HtmlRendererLogger {
69
+ debug?: (...args: unknown[]) => void;
70
+ warn?: (...args: unknown[]) => void;
71
+ info?: (...args: unknown[]) => void;
72
+ }