@zhin.js/html-renderer 3.0.14 → 3.0.16

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/src/jsx.ts ADDED
@@ -0,0 +1,50 @@
1
+ const SELF_CLOSING = new Set(['img', 'br', 'hr', 'input', 'meta', 'link']);
2
+
3
+ function escape(value: string): string {
4
+ return value
5
+ .replace(/&/g, '&')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;');
9
+ }
10
+
11
+ export function serializeJsxToHtml(element: unknown): string {
12
+ if (typeof element === 'string') return escape(element);
13
+ if (typeof element === 'number') return String(element);
14
+ if (element == null || typeof element === 'boolean') return '';
15
+ if (Array.isArray(element)) return element.map(serializeJsxToHtml).join('');
16
+
17
+ if (typeof element === 'object' && 'type' in element) {
18
+ const { type, props = {} } = element as { type: string; props?: Record<string, unknown> };
19
+ const { children, style, dangerouslySetInnerHTML, ...rest } = props as {
20
+ children?: unknown;
21
+ style?: Record<string, unknown>;
22
+ dangerouslySetInnerHTML?: { __html?: string };
23
+ };
24
+
25
+ let styleText = '';
26
+ if (style && typeof style === 'object') {
27
+ styleText = Object.entries(style)
28
+ .map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
29
+ .join('; ');
30
+ }
31
+
32
+ const attrs = Object.entries(rest)
33
+ .filter(([key, value]) => key !== 'dangerouslySetInnerHTML' && value != null && value !== false)
34
+ .map(([key, value]) => `${key}="${escape(String(value))}"`)
35
+ .join(' ')
36
+
37
+ const styleAttr = styleText ? ` style="${escape(styleText)}"` : '';
38
+ const attrText = attrs ? ` ${attrs}` : '';
39
+
40
+ if (dangerouslySetInnerHTML?.__html) {
41
+ return `<${type}${attrText}${styleAttr}>${dangerouslySetInnerHTML.__html}</${type}>`;
42
+ }
43
+
44
+ const inner = serializeJsxToHtml(children);
45
+ if (SELF_CLOSING.has(type) && !inner) return `<${type}${attrText}${styleAttr} />`;
46
+ return `<${type}${attrText}${styleAttr}>${inner}</${type}>`;
47
+ }
48
+
49
+ return '';
50
+ }
package/src/renderer.ts CHANGED
@@ -1,344 +1,194 @@
1
- import { htmlToSvg, getAllBuiltinFonts, h, e, type HtmlComponent } from '@zhin.js/satori';
1
+ import type { CacheMode, Clip, ScreenshotOptions, CaptureStats } from '@shotkit/shotium';
2
2
 
3
- import { Resvg } from '@resvg/resvg-js';
3
+ import { resolveHtmlRendererConfig } from './config.js';
4
+ import { createEngine } from './engine.js';
5
+ import { buildFontFaces, isFullDocument, withDocumentFile, wrapDocument } from './html.js';
6
+ import { readImageSize } from './image.js';
7
+ import { serializeJsxToHtml } from './jsx.js';
4
8
  import type {
5
9
  FontConfig,
10
+ HtmlComponent,
6
11
  HtmlRendererConfig,
7
12
  HtmlRendererLogger,
8
13
  HtmlRendererService,
9
- RenderOptions,
10
14
  RenderResult,
15
+ RenderOptions,
11
16
  } from './types.js';
12
17
 
13
- const DEFAULT_CONFIG: Required<Omit<HtmlRendererConfig, 'aiTextAsImage'>> = {
14
- defaultWidth: 800,
15
- defaultFonts: [],
16
- defaultBackgroundColor: '#ffffff',
17
- };
18
-
19
- /** 外部资源(twemoji CDN)拉取超时 */
20
- const EMOJI_FETCH_TIMEOUT_MS = 10_000;
21
-
22
- /** 渲染并发上限,防止渲染任务堆积拖垮进程 */
23
18
  const MAX_CONCURRENT_RENDERS = 2;
24
19
 
25
- const fontCache: Map<string, FontConfig> = new Map();
26
- let defaultFontLoaded = false;
27
-
28
- /** fontCache 键:name + weight + style(缺 style 会让 italic 覆盖 normal) */
29
- function fontCacheKey(
30
- name: string,
31
- weight?: FontConfig['weight'],
32
- style?: FontConfig['style'],
33
- ): string {
34
- return `${name}-${weight ?? 400}-${style ?? 'normal'}`;
35
- }
36
-
37
- function toFontConfig(f: {
38
- name: string;
39
- data: ArrayBuffer | Buffer;
40
- weight?: FontConfig['weight'];
41
- style?: FontConfig['style'];
42
- }): FontConfig {
43
- return { name: f.name, data: f.data, weight: f.weight, style: f.style };
44
- }
45
-
46
- function uniqueFontsForRender(list: FontConfig[]): FontConfig[] {
47
- const m = new Map<string, FontConfig>();
48
- for (const f of list) {
49
- const k = `${f.name}\0${f.weight ?? 400}\0${f.style ?? 'normal'}`;
50
- m.set(k, f);
51
- }
52
- return [...m.values()];
53
- }
54
-
55
- function mergeFontLists(...lists: FontConfig[][]): FontConfig[] {
56
- const flat: FontConfig[] = [];
57
- for (const list of lists) flat.push(...list);
58
- return uniqueFontsForRender(flat);
59
- }
60
-
61
- function ensureBuiltinFontsCached(logger?: HtmlRendererLogger): void {
62
- if (defaultFontLoaded) return;
63
-
64
- try {
65
- const builtinFonts = getAllBuiltinFonts();
66
- if (builtinFonts.length > 0) {
67
- for (const font of builtinFonts) {
68
- const fc = toFontConfig(font);
69
- fontCache.set(fontCacheKey(font.name, font.weight, font.style), fc);
70
- logger?.debug?.(`Builtin font: ${font.name} (${Math.round(font.data.byteLength / 1024)}KB)`);
71
- }
72
- fontCache.set('default', toFontConfig(builtinFonts[0]));
73
- defaultFontLoaded = true;
74
- return;
75
- }
76
- } catch (e) {
77
- logger?.warn?.('html-renderer: builtin fonts failed', e);
78
- }
79
- defaultFontLoaded = true;
80
- logger?.warn?.('html-renderer: no builtin fonts available');
81
- }
82
-
83
- function emojiToTwemojiUrl(emoji: string): string {
84
- const codePoints: string[] = [];
85
- for (const char of emoji) {
86
- const cp = char.codePointAt(0);
87
- if (cp && cp !== 0xfe0f) codePoints.push(cp.toString(16));
88
- }
89
- return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoints.join('-')}.svg`;
90
- }
91
-
92
- async function loadEmojiImage(emoji: string, logger?: HtmlRendererLogger): Promise<string | null> {
93
- try {
94
- const url = emojiToTwemojiUrl(emoji);
95
- const response = await fetch(url, { signal: AbortSignal.timeout(EMOJI_FETCH_TIMEOUT_MS) });
96
- if (!response.ok) {
97
- logger?.debug?.(`Failed to load emoji ${emoji}: ${response.status}`);
98
- return null;
99
- }
100
- const svg = await response.text();
101
- return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
102
- } catch (error) {
103
- logger?.debug?.(`Failed to load emoji ${emoji}:`, error);
104
- return null;
105
- }
106
- }
107
-
108
- const EMOJI_CACHE_MAX = 200;
109
- /** 失败(null)结果的负缓存 TTL,避免每次渲染都重试失败的 emoji */
110
- const EMOJI_NEGATIVE_TTL_MS = 60_000;
111
-
112
- interface EmojiCacheEntry {
113
- value: string | null;
114
- ts: number;
115
- }
116
-
117
- /** LRU:Map 迭代顺序即插入顺序,命中时移到末尾,超容量时淘汰最旧项 */
118
- const emojiCache: Map<string, EmojiCacheEntry> = new Map();
119
-
120
- function getCachedEmoji(segment: string): { hit: boolean; value: string | null } {
121
- const entry = emojiCache.get(segment);
122
- if (!entry) return { hit: false, value: null };
123
- if (entry.value === null && Date.now() - entry.ts > EMOJI_NEGATIVE_TTL_MS) {
124
- emojiCache.delete(segment);
125
- return { hit: false, value: null };
126
- }
127
- // LRU touch:重新插入到末尾
128
- emojiCache.delete(segment);
129
- emojiCache.set(segment, entry);
130
- return { hit: true, value: entry.value };
131
- }
132
-
133
- function setCachedEmoji(segment: string, value: string | null): void {
134
- emojiCache.delete(segment);
135
- emojiCache.set(segment, { value, ts: Date.now() });
136
- while (emojiCache.size > EMOJI_CACHE_MAX) {
137
- const oldest = emojiCache.keys().next().value;
138
- if (oldest === undefined) break;
139
- emojiCache.delete(oldest);
140
- }
141
- }
142
-
143
- async function loadAdditionalAsset(
144
- languageCode: string,
145
- segment: string,
146
- logger?: HtmlRendererLogger,
147
- ): Promise<string | null> {
148
- if (languageCode === 'emoji') {
149
- const cached = getCachedEmoji(segment);
150
- if (cached.hit) return cached.value;
151
- const result = await loadEmojiImage(segment, logger);
152
- // 失败也缓存(短 TTL 负缓存),防止同一 emoji 反复打爆 CDN
153
- setCachedEmoji(segment, result);
154
- return result;
155
- }
156
- return null;
157
- }
158
-
159
- function wrapHtmlFragment(html: string, backgroundColor: string): string {
160
- if (html.includes('<!DOCTYPE') || html.includes('<html')) return html;
161
- 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>`;
162
- }
163
-
164
- async function renderHtmlToSvg(
165
- html: string,
166
- width: number,
167
- height: number | undefined,
168
- fonts: FontConfig[],
169
- backgroundColor: string | undefined,
170
- logger?: HtmlRendererLogger,
171
- enableEmoji: boolean = true,
172
- ): Promise<{ svg: string; width: number; height: number }> {
173
- ensureBuiltinFontsCached(logger);
174
- const finalFonts = mergeFontLists(getAllBuiltinFonts().map(toFontConfig), fonts);
175
- if (finalFonts.length === 0) {
176
- logger?.warn?.('html-renderer: no fonts; non-ascii text may fail');
177
- }
178
-
179
- const svg = await htmlToSvg(wrapHtmlFragment(html, backgroundColor ?? '#ffffff'), {
180
- width,
181
- ...(height != null && { height }),
182
- fonts: finalFonts.map((f) => ({
183
- name: f.name,
184
- data: f.data,
185
- weight: f.weight,
186
- style: f.style,
187
- })),
188
- loadAdditionalAsset: async (code, seg) =>
189
- enableEmoji ? ((await loadAdditionalAsset(code, seg, logger)) ?? '') : '',
190
- });
191
-
192
- const wm = svg.match(/width="(\d+)"/);
193
- const hm = svg.match(/height="(\d+)"/);
194
- return {
195
- svg,
196
- width: wm ? parseInt(wm[1], 10) : width,
197
- height: hm ? parseInt(hm[1], 10) : height || width,
198
- };
199
- }
200
-
201
- function svgToPng(svg: string, scale: number = 1): Buffer {
202
- const resvg = new Resvg(svg, {
203
- fitTo:
204
- scale !== 1
205
- ? {
206
- mode: 'zoom',
207
- value: scale,
208
- }
209
- : undefined,
210
- });
211
- const pngData = resvg.render();
212
- return Buffer.from(pngData.asPng());
213
- }
214
-
215
- export function serializeJsxToHtml(element: unknown): string {
216
- if (typeof element === 'string') return e(element);
217
- if (typeof element === 'number') return String(element);
218
- // boolean / null / undefined 一律渲染为空串
219
- if (element == null || typeof element === 'boolean') return '';
220
- if (Array.isArray(element)) {
221
- return element.map(serializeJsxToHtml).join('');
222
- }
223
- if (typeof element === 'object' && element !== null && 'type' in element) {
224
- const { type, props = {} } = element as { type: string; props?: Record<string, unknown> };
225
- const { children, style, dangerouslySetInnerHTML, ...restProps } = props as {
226
- children?: unknown;
227
- style?: Record<string, unknown>;
228
- dangerouslySetInnerHTML?: { __html?: string };
229
- };
230
-
231
- let styleStr = '';
232
- if (style && typeof style === 'object') {
233
- styleStr = Object.entries(style)
234
- .map(([key, value]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value}`)
235
- .join('; ');
236
- }
237
-
238
- const attrs = Object.entries(restProps)
239
- .filter(([key, value]) => key !== 'dangerouslySetInnerHTML' && value != null && value !== false)
240
- .map(([key, value]) => `${key}="${e(String(value))}"`)
241
- .join(' ');
242
-
243
- const styleAttr = styleStr ? ` style="${e(styleStr)}"` : '';
244
- const attrStr = attrs ? ` ${attrs}` : '';
245
-
246
- // Raw HTML 只走显式通道 dangerouslySetInnerHTML
247
- if (dangerouslySetInnerHTML?.__html) {
248
- return `<${type}${attrStr}${styleAttr}>${dangerouslySetInnerHTML.__html}</${type}>`;
249
- }
250
-
251
- const childrenHtml = serializeJsxToHtml(children);
252
- const selfClosingTags = ['img', 'br', 'hr', 'input', 'meta', 'link'];
253
- if (selfClosingTags.includes(type) && !childrenHtml) {
254
- return `<${type}${attrStr}${styleAttr} />`;
255
- }
256
-
257
- return `<${type}${attrStr}${styleAttr}>${childrenHtml}</${type}>`;
258
- }
259
-
260
- return '';
20
+ const MIME = {
21
+ png: 'image/png',
22
+ jpeg: 'image/jpeg',
23
+ webp: 'image/webp',
24
+ } as const;
25
+
26
+ function fontKey(font: FontConfig): string {
27
+ return `${font.name}\0${font.weight ?? 400}\0${font.style ?? 'normal'}`;
28
+ }
29
+
30
+ interface ShotiumRenderOptions {
31
+ width?: number;
32
+ height?: number;
33
+ type?: 'png' | 'jpeg' | 'webp';
34
+ quality?: number;
35
+ backgroundColor?: string;
36
+ fonts?: readonly FontConfig[];
37
+ fontFamily?: string;
38
+ scale?: number;
39
+ selector?: string;
40
+ fullPage?: boolean;
41
+ omitBackground?: boolean;
42
+ clip?: Clip;
43
+ timeout?: number;
44
+ waitUntil?: 'load' | 'networkidle';
45
+ headers?: Record<string, string>;
46
+ cache?: CacheMode;
47
+ allowFileAccess?: boolean;
48
+ }
49
+
50
+ interface ShotiumResult {
51
+ data: Buffer;
52
+ width: number;
53
+ height: number;
54
+ mimeType: string;
55
+ stats: CaptureStats;
261
56
  }
262
57
 
263
58
  export function createHtmlRenderer(
264
59
  config: HtmlRendererConfig = {},
265
60
  logger?: HtmlRendererLogger,
266
61
  ): HtmlRendererService {
267
- const mergedConfig = { ...DEFAULT_CONFIG, ...config };
268
-
269
- function cacheDefaultFonts(): void {
270
- for (const font of mergedConfig.defaultFonts) {
271
- fontCache.set(fontCacheKey(font.name, font.weight, font.style), font);
272
- }
273
- }
274
-
275
- ensureBuiltinFontsCached(logger);
276
- cacheDefaultFonts();
277
-
278
- // 渲染并发闸:最多 MAX_CONCURRENT_RENDERS 个并发,其余排队
62
+ const mergedConfig = resolveHtmlRendererConfig(config);
63
+ const engine = createEngine(mergedConfig.shotium, logger);
64
+ const registeredFonts = new Map<string, FontConfig>();
65
+ const warned = new Set<string>();
279
66
  let activeRenders = 0;
280
67
  const renderQueue: Array<() => void> = [];
281
68
 
282
- async function acquireRenderSlot(): Promise<void> {
69
+ const warnOnce = (key: string, ...message: unknown[]): void => {
70
+ if (warned.has(key)) return;
71
+ warned.add(key);
72
+ logger?.warn?.(...message);
73
+ };
74
+
75
+ const getFonts = (extraFonts: readonly FontConfig[] = []): FontConfig[] => {
76
+ const fonts = new Map<string, FontConfig>();
77
+ for (const font of mergedConfig.defaultFonts) fonts.set(fontKey(font), font);
78
+ for (const font of registeredFonts.values()) fonts.set(fontKey(font), font);
79
+ for (const font of extraFonts) fonts.set(fontKey(font), font);
80
+ return [...fonts.values()];
81
+ };
82
+
83
+ const acquireRenderSlot = async (): Promise<void> => {
283
84
  if (activeRenders >= MAX_CONCURRENT_RENDERS) {
284
85
  await new Promise<void>((resolve) => renderQueue.push(resolve));
285
86
  }
286
87
  activeRenders++;
287
- }
88
+ };
288
89
 
289
- function releaseRenderSlot(): void {
90
+ const releaseRenderSlot = (): void => {
290
91
  activeRenders--;
291
- const next = renderQueue.shift();
292
- if (next) next();
293
- }
92
+ renderQueue.shift()?.();
93
+ };
94
+
95
+ const capture = async (
96
+ file: string,
97
+ options: ShotiumRenderOptions,
98
+ defaults: { type: 'png' | 'jpeg' | 'webp'; fullPage: boolean; selector?: string },
99
+ ): Promise<ShotiumResult> => {
100
+ const type = options.type ?? defaults.type;
101
+ const shot: ScreenshotOptions = {
102
+ file,
103
+ type,
104
+ viewport: {
105
+ width: Math.round(options.width ?? mergedConfig.shotium.viewport.width),
106
+ height: Math.round(options.height ?? mergedConfig.shotium.viewport.height),
107
+ },
108
+ allowFileAccess: options.allowFileAccess ?? mergedConfig.shotium.allowFileAccess,
109
+ pageGotoParams: {
110
+ waitUntil: options.waitUntil ?? mergedConfig.shotium.waitUntil,
111
+ timeout: options.timeout ?? mergedConfig.shotium.timeout,
112
+ },
113
+ };
114
+
115
+ if (type !== 'png') shot.quality = options.quality ?? mergedConfig.shotium.quality;
116
+ const selector = options.selector ?? defaults.selector;
117
+ if (options.fullPage ?? defaults.fullPage) shot.fullPage = true;
118
+ else if (selector) shot.selector = selector;
119
+ if (options.omitBackground && type !== 'jpeg') shot.omitBackground = true;
120
+ if (options.clip && !shot.fullPage && !shot.selector) shot.clip = options.clip;
121
+ const scale = options.scale ?? mergedConfig.shotium.scale;
122
+ if (scale !== 1) shot.scale = scale;
123
+ if (options.headers && Object.keys(options.headers).length > 0) shot.headers = options.headers;
124
+ if (options.cache) shot.cache = options.cache;
125
+
126
+ const result = await engine.screenshot(shot);
127
+ const image = result.image;
128
+ if (!image) throw new Error('[shotium] engine returned no image');
129
+
130
+ const size = readImageSize(image);
131
+ if (mergedConfig.shotium.logStats) {
132
+ logger?.info?.(
133
+ `[shotium] render ${size ? `${size.width}x${size.height}` : '?'} ${(image.length / 1024).toFixed(1)}KB `
134
+ + `(engine ${result.stats.timing.total.toFixed(1)}ms, requests ${result.stats.requests}, cache ${result.stats.fromCache})`,
135
+ );
136
+ }
137
+
138
+ return {
139
+ data: image,
140
+ width: size?.width ?? 0,
141
+ height: size?.height ?? 0,
142
+ mimeType: MIME[type],
143
+ stats: result.stats,
144
+ };
145
+ };
146
+
147
+ const renderWithShotium = async (
148
+ html: string,
149
+ options: RenderOptions = {},
150
+ ): Promise<RenderResult> => {
151
+ const fonts = getFonts(options.fonts ?? []);
152
+ const wrapped = !isFullDocument(html);
153
+ const document = wrapDocument(html, {
154
+ width: options.width ?? mergedConfig.shotium.viewport.width,
155
+ ...(options.height != null ? { height: options.height } : {}),
156
+ backgroundColor: options.backgroundColor ?? mergedConfig.shotium.backgroundColor,
157
+ fontFamily: mergedConfig.shotium.fontFamily,
158
+ fontFaces: buildFontFaces(fonts),
159
+ });
160
+ const defaults = wrapped
161
+ ? { type: 'png' as const, fullPage: false, selector: 'body' }
162
+ : { type: 'png' as const, fullPage: true };
163
+
164
+ const result = await withDocumentFile(document, (file) =>
165
+ capture(file, {
166
+ width: options.width,
167
+ height: options.height,
168
+ backgroundColor: options.backgroundColor,
169
+ scale: options.scale,
170
+ }, defaults));
171
+
172
+ return {
173
+ data: result.data,
174
+ format: 'png',
175
+ width: result.width,
176
+ height: result.height,
177
+ mimeType: result.mimeType,
178
+ };
179
+ };
294
180
 
295
181
  return {
296
182
  async render(html: string, options: RenderOptions = {}): Promise<RenderResult> {
297
- const {
298
- width = mergedConfig.defaultWidth,
299
- height,
300
- format = 'png',
301
- backgroundColor = mergedConfig.defaultBackgroundColor,
302
- fonts = [],
303
- enableEmoji = true,
304
- scale = 1,
305
- } = options;
306
-
307
183
  await acquireRenderSlot();
308
184
  try {
309
- ensureBuiltinFontsCached(logger);
310
- const allFonts = uniqueFontsForRender([...fontCache.values(), ...fonts]);
311
-
312
- const { svg, width: actualWidth, height: actualHeight } = await renderHtmlToSvg(
313
- html,
314
- width,
315
- height,
316
- allFonts,
317
- backgroundColor,
318
- logger,
319
- enableEmoji,
320
- );
321
-
322
- if (format === 'svg') {
323
- return {
324
- data: svg,
325
- format: 'svg',
326
- width: actualWidth,
327
- height: actualHeight,
328
- mimeType: 'image/svg+xml',
329
- };
185
+ if ((options.format ?? 'png') === 'svg') {
186
+ warnOnce('svg', 'html-renderer: shotium does not support svg output; returning png instead');
330
187
  }
331
-
332
- const png = svgToPng(svg, scale);
333
- return {
334
- data: png,
335
- format: 'png',
336
- width: Math.round(actualWidth * scale),
337
- height: Math.round(actualHeight * scale),
338
- mimeType: 'image/png',
339
- };
188
+ return await renderWithShotium(html, options);
340
189
  } finally {
341
190
  releaseRenderSlot();
191
+ engine.release();
342
192
  }
343
193
  },
344
194
 
@@ -351,24 +201,23 @@ export function createHtmlRenderer(
351
201
  props: P,
352
202
  options: RenderOptions = {},
353
203
  ): Promise<RenderResult> {
354
- return this.render(h(component, props), options);
204
+ return this.render(serializeJsxToHtml(component(props)), options);
355
205
  },
356
206
 
357
207
  registerFont(font: FontConfig): void {
358
- fontCache.set(fontCacheKey(font.name, font.weight, font.style), font);
208
+ registeredFonts.set(fontKey(font), font);
359
209
  logger?.debug?.(`Font registered: ${font.name}`);
360
210
  },
361
211
 
362
212
  getFonts(): FontConfig[] {
363
- return Array.from(fontCache.values());
213
+ return getFonts();
364
214
  },
365
215
 
366
216
  clearFonts(): void {
367
- fontCache.clear();
368
- defaultFontLoaded = false;
369
- // clear 后重新合并 defaultFonts,避免用户配置的默认字体永久丢失
370
- cacheDefaultFonts();
217
+ registeredFonts.clear();
371
218
  logger?.debug?.('Font cache cleared');
372
219
  },
373
220
  };
374
221
  }
222
+
223
+ export { serializeJsxToHtml } from './jsx.js';
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { HtmlComponent } from '@zhin.js/satori';
2
-
3
1
  export type OutputFormat = 'svg' | 'png';
2
+ export type RasterFormat = 'png' | 'jpeg' | 'webp';
3
+ export type WaitUntil = 'load' | 'networkidle';
4
+ export type HtmlComponent<P> = (props: P) => unknown;
4
5
 
5
6
  export interface FontConfig {
6
7
  name: string;
@@ -43,11 +44,35 @@ export interface HtmlRendererAiTextAsImageConfig {
43
44
  fileName?: string;
44
45
  }
45
46
 
47
+ export interface HtmlRendererViewport {
48
+ width?: number;
49
+ height?: number;
50
+ }
51
+
46
52
  export interface HtmlRendererConfig {
47
53
  defaultWidth?: number;
54
+ width?: number;
48
55
  defaultFonts?: FontConfig[];
49
56
  defaultBackgroundColor?: string;
57
+ backgroundColor?: string;
50
58
  aiTextAsImage?: boolean | HtmlRendererAiTextAsImageConfig;
59
+ viewport?: HtmlRendererViewport;
60
+ scale?: number;
61
+ type?: RasterFormat;
62
+ quality?: number;
63
+ timeout?: number;
64
+ waitUntil?: WaitUntil;
65
+ fontFamily?: string;
66
+ maxImageHeight?: number;
67
+ sliceCompression?: number;
68
+ allowFileAccess?: boolean;
69
+ takeOverHtmlSegments?: boolean;
70
+ cacheDir?: string;
71
+ cacheMaxBytes?: number;
72
+ userAgent?: string;
73
+ idleTimeoutMs?: number;
74
+ logStats?: boolean;
75
+ htmlRenderer?: HtmlRendererConfig | Record<string, unknown>;
51
76
  }
52
77
 
53
78
  export interface HtmlRendererService {