@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/README.md +16 -18
- package/lib/config.d.ts +33 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +90 -0
- package/lib/config.js.map +1 -0
- package/lib/engine.d.ts +12 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +97 -0
- package/lib/engine.js.map +1 -0
- package/lib/html.d.ts +14 -0
- package/lib/html.d.ts.map +1 -0
- package/lib/html.js +94 -0
- package/lib/html.js.map +1 -0
- package/lib/image.d.ts +14 -0
- package/lib/image.d.ts.map +1 -0
- package/lib/image.js +236 -0
- package/lib/image.js.map +1 -0
- package/lib/index.d.ts +3 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/jsx.d.ts +2 -0
- package/lib/jsx.d.ts.map +1 -0
- package/lib/jsx.js +43 -0
- package/lib/jsx.js.map +1 -0
- package/lib/renderer.d.ts +1 -1
- package/lib/renderer.d.ts.map +1 -1
- package/lib/renderer.js +123 -243
- package/lib/renderer.js.map +1 -1
- package/lib/types.d.ts +26 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +6 -7
- package/src/config.ts +173 -0
- package/src/engine.ts +121 -0
- package/src/html.ts +113 -0
- package/src/image.ts +251 -0
- package/src/index.ts +5 -2
- package/src/jsx.ts +50 -0
- package/src/renderer.ts +163 -314
- package/src/types.ts +27 -2
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, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"');
|
|
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 {
|
|
1
|
+
import type { CacheMode, Clip, ScreenshotOptions, CaptureStats } from '@shotkit/shotium';
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
style
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 =
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
+
const releaseRenderSlot = (): void => {
|
|
290
91
|
activeRenders--;
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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(
|
|
204
|
+
return this.render(serializeJsxToHtml(component(props)), options);
|
|
355
205
|
},
|
|
356
206
|
|
|
357
207
|
registerFont(font: FontConfig): void {
|
|
358
|
-
|
|
208
|
+
registeredFonts.set(fontKey(font), font);
|
|
359
209
|
logger?.debug?.(`Font registered: ${font.name}`);
|
|
360
210
|
},
|
|
361
211
|
|
|
362
212
|
getFonts(): FontConfig[] {
|
|
363
|
-
return
|
|
213
|
+
return getFonts();
|
|
364
214
|
},
|
|
365
215
|
|
|
366
216
|
clearFonts(): void {
|
|
367
|
-
|
|
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 {
|