@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/config.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FontConfig,
|
|
3
|
+
HtmlRendererAiTextAsImageConfig,
|
|
4
|
+
HtmlRendererConfig,
|
|
5
|
+
RasterFormat,
|
|
6
|
+
WaitUntil,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
|
|
9
|
+
export interface ShotiumConfig {
|
|
10
|
+
viewport: { width: number; height: number };
|
|
11
|
+
scale: number;
|
|
12
|
+
type: RasterFormat;
|
|
13
|
+
quality: number;
|
|
14
|
+
timeout: number;
|
|
15
|
+
waitUntil: WaitUntil;
|
|
16
|
+
backgroundColor: string;
|
|
17
|
+
fontFamily: string;
|
|
18
|
+
maxImageHeight: number;
|
|
19
|
+
sliceCompression: number;
|
|
20
|
+
allowFileAccess: boolean;
|
|
21
|
+
takeOverHtmlSegments: boolean;
|
|
22
|
+
cacheDir: string;
|
|
23
|
+
cacheMaxBytes: number;
|
|
24
|
+
userAgent: string;
|
|
25
|
+
idleTimeoutMs: number;
|
|
26
|
+
logStats: boolean;
|
|
27
|
+
mode: 'inprocess' | 'daemon';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedHtmlRendererConfig {
|
|
31
|
+
defaultWidth: number;
|
|
32
|
+
defaultFonts: FontConfig[];
|
|
33
|
+
defaultBackgroundColor: string;
|
|
34
|
+
aiTextAsImage?: boolean | HtmlRendererAiTextAsImageConfig;
|
|
35
|
+
shotium: ShotiumConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_CONFIG: ResolvedHtmlRendererConfig = {
|
|
39
|
+
defaultWidth: 800,
|
|
40
|
+
defaultFonts: [],
|
|
41
|
+
defaultBackgroundColor: '#ffffff',
|
|
42
|
+
aiTextAsImage: undefined,
|
|
43
|
+
shotium: {
|
|
44
|
+
mode: 'inprocess',
|
|
45
|
+
viewport: { width: 800, height: 600 },
|
|
46
|
+
scale: 1,
|
|
47
|
+
type: 'png',
|
|
48
|
+
quality: 90,
|
|
49
|
+
timeout: 30_000,
|
|
50
|
+
waitUntil: 'load',
|
|
51
|
+
backgroundColor: '#ffffff',
|
|
52
|
+
fontFamily:
|
|
53
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
|
54
|
+
maxImageHeight: 0,
|
|
55
|
+
sliceCompression: 3,
|
|
56
|
+
allowFileAccess: true,
|
|
57
|
+
takeOverHtmlSegments: true,
|
|
58
|
+
cacheDir: '',
|
|
59
|
+
cacheMaxBytes: 256 * 1024 * 1024,
|
|
60
|
+
userAgent: '',
|
|
61
|
+
idleTimeoutMs: 300_000,
|
|
62
|
+
logStats: false,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function clamp(value: number, min: number, max: number): number {
|
|
67
|
+
return Math.min(max, Math.max(min, value));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pickNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
71
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
72
|
+
? clamp(value, min, max)
|
|
73
|
+
: fallback;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pickString<T extends string>(
|
|
77
|
+
value: unknown,
|
|
78
|
+
fallback: T,
|
|
79
|
+
allowed?: readonly T[],
|
|
80
|
+
): T {
|
|
81
|
+
if (typeof value !== 'string') return fallback;
|
|
82
|
+
if (allowed && !allowed.includes(value as T)) return fallback;
|
|
83
|
+
return value as T;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pickBoolean(value: unknown, fallback: boolean): boolean {
|
|
87
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pickConfigRoot(raw: unknown): HtmlRendererConfig & Record<string, unknown> {
|
|
91
|
+
if (raw && typeof raw === 'object') {
|
|
92
|
+
const input = raw as HtmlRendererConfig & Record<string, unknown>;
|
|
93
|
+
if (input.htmlRenderer && typeof input.htmlRenderer === 'object') {
|
|
94
|
+
return input.htmlRenderer as HtmlRendererConfig & Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
return input;
|
|
97
|
+
}
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveHtmlRendererConfig(raw: unknown): ResolvedHtmlRendererConfig {
|
|
102
|
+
const input = pickConfigRoot(raw);
|
|
103
|
+
const viewport = (input.viewport ?? {}) as Record<string, unknown>;
|
|
104
|
+
const defaultWidth = pickNumber(
|
|
105
|
+
input.defaultWidth ?? input.width ?? viewport.width,
|
|
106
|
+
DEFAULT_CONFIG.defaultWidth,
|
|
107
|
+
1,
|
|
108
|
+
30_000,
|
|
109
|
+
);
|
|
110
|
+
const defaultBackgroundColor = pickString(
|
|
111
|
+
input.defaultBackgroundColor ?? input.backgroundColor,
|
|
112
|
+
DEFAULT_CONFIG.defaultBackgroundColor,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
defaultWidth,
|
|
117
|
+
defaultFonts: Array.isArray(input.defaultFonts) ? input.defaultFonts.filter(Boolean) : [],
|
|
118
|
+
defaultBackgroundColor,
|
|
119
|
+
aiTextAsImage: input.aiTextAsImage,
|
|
120
|
+
shotium: {
|
|
121
|
+
mode: pickString(input.mode, DEFAULT_CONFIG.shotium.mode, ['inprocess', 'daemon']),
|
|
122
|
+
viewport: {
|
|
123
|
+
width: pickNumber(viewport.width ?? input.width ?? input.defaultWidth, defaultWidth, 1, 30_000),
|
|
124
|
+
height: pickNumber(viewport.height, DEFAULT_CONFIG.shotium.viewport.height, 1, 30_000),
|
|
125
|
+
},
|
|
126
|
+
scale: pickNumber(input.scale, DEFAULT_CONFIG.shotium.scale, 0.01, 8),
|
|
127
|
+
type: pickString(input.type, DEFAULT_CONFIG.shotium.type, ['png', 'jpeg', 'webp']),
|
|
128
|
+
quality: pickNumber(input.quality, DEFAULT_CONFIG.shotium.quality, 1, 100),
|
|
129
|
+
timeout: pickNumber(input.timeout, DEFAULT_CONFIG.shotium.timeout, 1, 600_000),
|
|
130
|
+
waitUntil: pickString(input.waitUntil, DEFAULT_CONFIG.shotium.waitUntil, ['load', 'networkidle']),
|
|
131
|
+
backgroundColor: pickString(
|
|
132
|
+
input.backgroundColor ?? input.defaultBackgroundColor,
|
|
133
|
+
defaultBackgroundColor,
|
|
134
|
+
),
|
|
135
|
+
fontFamily: pickString(input.fontFamily, DEFAULT_CONFIG.shotium.fontFamily),
|
|
136
|
+
maxImageHeight: pickNumber(
|
|
137
|
+
input.maxImageHeight,
|
|
138
|
+
DEFAULT_CONFIG.shotium.maxImageHeight,
|
|
139
|
+
0,
|
|
140
|
+
100_000,
|
|
141
|
+
),
|
|
142
|
+
sliceCompression: pickNumber(
|
|
143
|
+
input.sliceCompression,
|
|
144
|
+
DEFAULT_CONFIG.shotium.sliceCompression,
|
|
145
|
+
0,
|
|
146
|
+
9,
|
|
147
|
+
),
|
|
148
|
+
allowFileAccess: pickBoolean(
|
|
149
|
+
input.allowFileAccess,
|
|
150
|
+
DEFAULT_CONFIG.shotium.allowFileAccess,
|
|
151
|
+
),
|
|
152
|
+
takeOverHtmlSegments: pickBoolean(
|
|
153
|
+
input.takeOverHtmlSegments,
|
|
154
|
+
DEFAULT_CONFIG.shotium.takeOverHtmlSegments,
|
|
155
|
+
),
|
|
156
|
+
cacheDir: pickString(input.cacheDir, DEFAULT_CONFIG.shotium.cacheDir),
|
|
157
|
+
cacheMaxBytes: pickNumber(
|
|
158
|
+
input.cacheMaxBytes,
|
|
159
|
+
DEFAULT_CONFIG.shotium.cacheMaxBytes,
|
|
160
|
+
0,
|
|
161
|
+
Number.MAX_SAFE_INTEGER,
|
|
162
|
+
),
|
|
163
|
+
userAgent: pickString(input.userAgent, DEFAULT_CONFIG.shotium.userAgent),
|
|
164
|
+
idleTimeoutMs: pickNumber(
|
|
165
|
+
input.idleTimeoutMs,
|
|
166
|
+
DEFAULT_CONFIG.shotium.idleTimeoutMs,
|
|
167
|
+
0,
|
|
168
|
+
86_400_000,
|
|
169
|
+
),
|
|
170
|
+
logStats: pickBoolean(input.logStats, DEFAULT_CONFIG.shotium.logStats),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
daemon,
|
|
3
|
+
releaseMemory,
|
|
4
|
+
screenshot,
|
|
5
|
+
start,
|
|
6
|
+
status,
|
|
7
|
+
} from '@shotkit/shotium';
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
DaemonClient,
|
|
11
|
+
ScreenshotOptions,
|
|
12
|
+
ScreenshotResult,
|
|
13
|
+
StartOptions,
|
|
14
|
+
} from '@shotkit/shotium';
|
|
15
|
+
import type { ShotiumConfig } from './config.js';
|
|
16
|
+
import type { HtmlRendererLogger } from './types.js';
|
|
17
|
+
|
|
18
|
+
interface Engine {
|
|
19
|
+
screenshot(options: ScreenshotOptions): Promise<ScreenshotResult>;
|
|
20
|
+
release(): void;
|
|
21
|
+
describe(): string;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toStartOptions(config: ShotiumConfig): StartOptions {
|
|
26
|
+
const options: StartOptions = { cacheMaxBytes: config.cacheMaxBytes };
|
|
27
|
+
const cacheDir = config.cacheDir.trim();
|
|
28
|
+
if (cacheDir === 'off') options.cacheDir = null;
|
|
29
|
+
else if (cacheDir) options.cacheDir = cacheDir;
|
|
30
|
+
if (config.userAgent) options.userAgent = config.userAgent;
|
|
31
|
+
return options;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createInprocessEngine(config: ShotiumConfig, logger?: HtmlRendererLogger): Engine {
|
|
35
|
+
const options = toStartOptions(config);
|
|
36
|
+
let started = false;
|
|
37
|
+
let mismatchWarned = false;
|
|
38
|
+
|
|
39
|
+
const ensureStarted = (): void => {
|
|
40
|
+
if (started && status().running) return;
|
|
41
|
+
try {
|
|
42
|
+
const result = start(options);
|
|
43
|
+
logger?.debug?.(
|
|
44
|
+
`[shotium] in-process engine ready cache=${result.cacheActive ? result.cacheDir : 'off'}`,
|
|
45
|
+
);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!status().running) throw error;
|
|
48
|
+
if (!mismatchWarned) {
|
|
49
|
+
mismatchWarned = true;
|
|
50
|
+
logger?.warn?.(
|
|
51
|
+
'[shotium] engine already started with different cacheDir/userAgent; reusing current process engine',
|
|
52
|
+
error,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
started = true;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
async screenshot(shot) {
|
|
61
|
+
ensureStarted();
|
|
62
|
+
return screenshot(shot);
|
|
63
|
+
},
|
|
64
|
+
release() {
|
|
65
|
+
if (status().running) releaseMemory();
|
|
66
|
+
},
|
|
67
|
+
describe: () => 'inprocess',
|
|
68
|
+
async close() {
|
|
69
|
+
if (status().running) releaseMemory({ releaseWorkingSet: true });
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createDaemonEngine(config: ShotiumConfig, logger?: HtmlRendererLogger): Engine {
|
|
75
|
+
const options = { ...toStartOptions(config), idleTimeoutMs: config.idleTimeoutMs };
|
|
76
|
+
let client: DaemonClient | null = null;
|
|
77
|
+
let pending: Promise<DaemonClient> | null = null;
|
|
78
|
+
|
|
79
|
+
const connect = async (): Promise<DaemonClient> => {
|
|
80
|
+
if (client) return client;
|
|
81
|
+
if (!pending) {
|
|
82
|
+
pending = daemon.connect(options)
|
|
83
|
+
.then((connected) => {
|
|
84
|
+
client = connected;
|
|
85
|
+
connected.once('close', () => {
|
|
86
|
+
client = null;
|
|
87
|
+
logger?.warn?.('[shotium] daemon connection closed; reconnecting on next render');
|
|
88
|
+
});
|
|
89
|
+
logger?.debug?.('[shotium] daemon engine connected');
|
|
90
|
+
return connected;
|
|
91
|
+
})
|
|
92
|
+
.finally(() => {
|
|
93
|
+
pending = null;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return pending;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
async screenshot(shot) {
|
|
101
|
+
try {
|
|
102
|
+
return await (await connect()).screenshot(shot);
|
|
103
|
+
} catch {
|
|
104
|
+
client = null;
|
|
105
|
+
return (await connect()).screenshot(shot);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
release() {},
|
|
109
|
+
describe: () => 'daemon',
|
|
110
|
+
async close() {
|
|
111
|
+
client?.close();
|
|
112
|
+
client = null;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createEngine(config: ShotiumConfig, logger?: HtmlRendererLogger): Engine {
|
|
118
|
+
return config.mode === 'daemon'
|
|
119
|
+
? createDaemonEngine(config, logger)
|
|
120
|
+
: createInprocessEngine(config, logger);
|
|
121
|
+
}
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import type { FontConfig } from './types.js';
|
|
8
|
+
|
|
9
|
+
interface WrapOptions {
|
|
10
|
+
width: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
backgroundColor: string;
|
|
13
|
+
fontFamily: string;
|
|
14
|
+
fontFaces?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TEMP_ROOT = path.join(os.tmpdir(), 'zhin-shotium');
|
|
18
|
+
const FONT_DIR = path.join(TEMP_ROOT, 'fonts');
|
|
19
|
+
|
|
20
|
+
function escapeCssValue(value: string): string {
|
|
21
|
+
return value.replace(/[<>{};]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fontExtension(data: Buffer): string {
|
|
25
|
+
const tag = data.subarray(0, 4).toString('ascii');
|
|
26
|
+
if (tag === 'wOFF') return 'woff';
|
|
27
|
+
if (tag === 'wOF2') return 'woff2';
|
|
28
|
+
if (tag === 'OTTO') return 'otf';
|
|
29
|
+
if (tag === 'ttcf') return 'ttc';
|
|
30
|
+
return 'ttf';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toBuffer(data: ArrayBuffer | Buffer): Buffer {
|
|
34
|
+
return Buffer.isBuffer(data) ? data : Buffer.from(new Uint8Array(data));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isFullDocument(html: string): boolean {
|
|
38
|
+
return /<!doctype\s+html|<html[\s>]/i.test(html);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function wrapDocument(html: string, options: WrapOptions): string {
|
|
42
|
+
const fontFaces = options.fontFaces ?? '';
|
|
43
|
+
|
|
44
|
+
if (isFullDocument(html)) {
|
|
45
|
+
if (!fontFaces) return html;
|
|
46
|
+
const style = `<style>${fontFaces}</style>`;
|
|
47
|
+
if (/<\/head>/i.test(html)) return html.replace(/<\/head>/i, `${style}</head>`);
|
|
48
|
+
if (/<body[^>]*>/i.test(html)) {
|
|
49
|
+
return html.replace(/<body[^>]*>/i, (match) => `${match}${style}`);
|
|
50
|
+
}
|
|
51
|
+
return `${style}${html}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const height = options.height ? `min-height:${Math.round(options.height)}px;` : '';
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
'<!DOCTYPE html>',
|
|
58
|
+
'<html><head><meta charset="utf-8">',
|
|
59
|
+
'<style>',
|
|
60
|
+
fontFaces,
|
|
61
|
+
'*,*::before,*::after{box-sizing:border-box}',
|
|
62
|
+
'html,body{margin:0;padding:0}',
|
|
63
|
+
`body{width:${Math.round(options.width)}px;${height}`,
|
|
64
|
+
`background:${escapeCssValue(options.backgroundColor)};`,
|
|
65
|
+
`font-family:${escapeCssValue(options.fontFamily)};`,
|
|
66
|
+
'-webkit-font-smoothing:antialiased}',
|
|
67
|
+
'</style></head><body>',
|
|
68
|
+
html,
|
|
69
|
+
'</body></html>',
|
|
70
|
+
].join('');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildFontFaces(fonts: readonly FontConfig[]): string {
|
|
74
|
+
if (fonts.length === 0) return '';
|
|
75
|
+
|
|
76
|
+
const rules: string[] = [];
|
|
77
|
+
for (const font of fonts) {
|
|
78
|
+
const data = toBuffer(font.data);
|
|
79
|
+
if (data.length === 0) continue;
|
|
80
|
+
|
|
81
|
+
const hash = crypto.createHash('sha1').update(data).digest('hex').slice(0, 16);
|
|
82
|
+
const file = path.join(FONT_DIR, `${hash}.${fontExtension(data)}`);
|
|
83
|
+
if (!fs.existsSync(file)) {
|
|
84
|
+
fs.mkdirSync(FONT_DIR, { recursive: true });
|
|
85
|
+
fs.writeFileSync(file, data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
rules.push(
|
|
89
|
+
'@font-face{'
|
|
90
|
+
+ `font-family:"${escapeCssValue(font.name).replace(/"/g, '')}";`
|
|
91
|
+
+ `font-weight:${font.weight ?? 400};`
|
|
92
|
+
+ `font-style:${font.style === 'italic' ? 'italic' : 'normal'};`
|
|
93
|
+
+ 'font-display:block;'
|
|
94
|
+
+ `src:url("${pathToFileURL(file).href}")}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return rules.join('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function withDocumentFile<T>(
|
|
101
|
+
document: string,
|
|
102
|
+
run: (file: string) => Promise<T>,
|
|
103
|
+
): Promise<T> {
|
|
104
|
+
fs.mkdirSync(TEMP_ROOT, { recursive: true });
|
|
105
|
+
const name = `${process.pid}-${crypto.randomBytes(8).toString('hex')}.html`;
|
|
106
|
+
const file = path.join(TEMP_ROOT, name);
|
|
107
|
+
fs.writeFileSync(file, document, 'utf8');
|
|
108
|
+
try {
|
|
109
|
+
return await run(file);
|
|
110
|
+
} finally {
|
|
111
|
+
fs.rm(file, { force: true }, () => {});
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import zlib from 'node:zlib';
|
|
2
|
+
|
|
3
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
4
|
+
|
|
5
|
+
const CHANNELS: Record<number, number> = {
|
|
6
|
+
0: 1,
|
|
7
|
+
2: 3,
|
|
8
|
+
4: 2,
|
|
9
|
+
6: 4,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const CRC_TABLE = (() => {
|
|
13
|
+
const table = new Int32Array(256);
|
|
14
|
+
for (let n = 0; n < 256; n++) {
|
|
15
|
+
let c = n;
|
|
16
|
+
for (let k = 0; k < 8; k++) {
|
|
17
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
18
|
+
}
|
|
19
|
+
table[n] = c;
|
|
20
|
+
}
|
|
21
|
+
return table;
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
function crc32(buf: Buffer): number {
|
|
25
|
+
let c = -1;
|
|
26
|
+
for (let i = 0; i < buf.length; i++) {
|
|
27
|
+
c = CRC_TABLE[(c ^ buf[i]!) & 0xff]! ^ (c >>> 8);
|
|
28
|
+
}
|
|
29
|
+
return (c ^ -1) >>> 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PngChunk {
|
|
33
|
+
type: string;
|
|
34
|
+
data: Buffer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readChunks(buf: Buffer): PngChunk[] | null {
|
|
38
|
+
if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
|
|
39
|
+
|
|
40
|
+
const chunks: PngChunk[] = [];
|
|
41
|
+
let offset = 8;
|
|
42
|
+
while (offset + 8 <= buf.length) {
|
|
43
|
+
const length = buf.readUInt32BE(offset);
|
|
44
|
+
const type = buf.toString('ascii', offset + 4, offset + 8);
|
|
45
|
+
const start = offset + 8;
|
|
46
|
+
const end = start + length;
|
|
47
|
+
if (end + 4 > buf.length) return null;
|
|
48
|
+
chunks.push({ type, data: buf.subarray(start, end) });
|
|
49
|
+
offset = end + 4;
|
|
50
|
+
if (type === 'IEND') break;
|
|
51
|
+
}
|
|
52
|
+
return chunks;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeChunk(type: string, data: Buffer): Buffer {
|
|
56
|
+
const length = Buffer.alloc(4);
|
|
57
|
+
length.writeUInt32BE(data.length, 0);
|
|
58
|
+
const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
59
|
+
const crc = Buffer.alloc(4);
|
|
60
|
+
crc.writeUInt32BE(crc32(typeAndData), 0);
|
|
61
|
+
return Buffer.concat([length, typeAndData, crc]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readImageSize(buf: Buffer): { width: number; height: number } | null {
|
|
65
|
+
const png = readPngInfo(buf);
|
|
66
|
+
if (png) return { width: png.width, height: png.height };
|
|
67
|
+
return readJpegSize(buf) ?? readWebpSize(buf);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PngInfo {
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
bitDepth: number;
|
|
74
|
+
colorType: number;
|
|
75
|
+
interlace: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readPngInfo(buf: Buffer): PngInfo | null {
|
|
79
|
+
if (buf.length < 33 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
|
|
80
|
+
if (buf.toString('ascii', 12, 16) !== 'IHDR') return null;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
width: buf.readUInt32BE(16),
|
|
84
|
+
height: buf.readUInt32BE(20),
|
|
85
|
+
bitDepth: buf[24]!,
|
|
86
|
+
colorType: buf[25]!,
|
|
87
|
+
interlace: buf[28]!,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readJpegSize(buf: Buffer): { width: number; height: number } | null {
|
|
92
|
+
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
|
93
|
+
let offset = 2;
|
|
94
|
+
while (offset + 9 < buf.length) {
|
|
95
|
+
if (buf[offset] !== 0xff) {
|
|
96
|
+
offset++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const marker = buf[offset + 1]!;
|
|
100
|
+
if (marker === 0xff) {
|
|
101
|
+
offset++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9) || marker === 0x01) {
|
|
105
|
+
offset += 2;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const length = buf.readUInt16BE(offset + 2);
|
|
109
|
+
const isFrameHeader = marker >= 0xc0 && marker <= 0xcf
|
|
110
|
+
&& marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
111
|
+
if (isFrameHeader) {
|
|
112
|
+
return {
|
|
113
|
+
height: buf.readUInt16BE(offset + 5),
|
|
114
|
+
width: buf.readUInt16BE(offset + 7),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
offset += 2 + length;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readWebpSize(buf: Buffer): { width: number; height: number } | null {
|
|
123
|
+
if (buf.length < 30) return null;
|
|
124
|
+
if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const chunk = buf.toString('ascii', 12, 16);
|
|
128
|
+
if (chunk === 'VP8X') {
|
|
129
|
+
return {
|
|
130
|
+
width: (buf.readUIntLE(24, 3) & 0xffffff) + 1,
|
|
131
|
+
height: (buf.readUIntLE(27, 3) & 0xffffff) + 1,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (chunk === 'VP8 ') {
|
|
135
|
+
return {
|
|
136
|
+
width: buf.readUInt16LE(26) & 0x3fff,
|
|
137
|
+
height: buf.readUInt16LE(28) & 0x3fff,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (chunk === 'VP8L') {
|
|
141
|
+
const bits = buf.readUInt32LE(21);
|
|
142
|
+
return {
|
|
143
|
+
width: (bits & 0x3fff) + 1,
|
|
144
|
+
height: ((bits >> 14) & 0x3fff) + 1,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function paeth(a: number, b: number, c: number): number {
|
|
151
|
+
const p = a + b - c;
|
|
152
|
+
const pa = Math.abs(p - a);
|
|
153
|
+
const pb = Math.abs(p - b);
|
|
154
|
+
const pc = Math.abs(p - c);
|
|
155
|
+
if (pa <= pb && pa <= pc) return a;
|
|
156
|
+
if (pb <= pc) return b;
|
|
157
|
+
return c;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function unfilter(filtered: Buffer, width: number, height: number, bpp: number): Buffer {
|
|
161
|
+
const stride = width * bpp;
|
|
162
|
+
const out = Buffer.alloc(stride * height);
|
|
163
|
+
|
|
164
|
+
for (let y = 0; y < height; y++) {
|
|
165
|
+
const filterType = filtered[y * (stride + 1)]!;
|
|
166
|
+
const src = y * (stride + 1) + 1;
|
|
167
|
+
const dst = y * stride;
|
|
168
|
+
const up = dst - stride;
|
|
169
|
+
|
|
170
|
+
for (let x = 0; x < stride; x++) {
|
|
171
|
+
const rawByte = filtered[src + x]!;
|
|
172
|
+
const a = x >= bpp ? out[dst + x - bpp]! : 0;
|
|
173
|
+
const b = y > 0 ? out[up + x]! : 0;
|
|
174
|
+
const c = x >= bpp && y > 0 ? out[up + x - bpp]! : 0;
|
|
175
|
+
|
|
176
|
+
let value: number;
|
|
177
|
+
switch (filterType) {
|
|
178
|
+
case 0: value = rawByte; break;
|
|
179
|
+
case 1: value = rawByte + a; break;
|
|
180
|
+
case 2: value = rawByte + b; break;
|
|
181
|
+
case 3: value = rawByte + ((a + b) >> 1); break;
|
|
182
|
+
case 4: value = rawByte + paeth(a, b, c); break;
|
|
183
|
+
default: value = rawByte;
|
|
184
|
+
}
|
|
185
|
+
out[dst + x] = value & 0xff;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function splitPng(buf: Buffer, sliceHeight: number, level = 3): Buffer[] | null {
|
|
193
|
+
const info = readPngInfo(buf);
|
|
194
|
+
if (!info) return null;
|
|
195
|
+
if (info.interlace !== 0 || info.bitDepth !== 8) return null;
|
|
196
|
+
|
|
197
|
+
const channels = CHANNELS[info.colorType];
|
|
198
|
+
if (!channels) return null;
|
|
199
|
+
|
|
200
|
+
const height = Math.max(1, Math.floor(sliceHeight));
|
|
201
|
+
if (height >= info.height) return [buf];
|
|
202
|
+
|
|
203
|
+
const chunks = readChunks(buf);
|
|
204
|
+
if (!chunks) return null;
|
|
205
|
+
|
|
206
|
+
const idat = chunks.filter((item) => item.type === 'IDAT').map((item) => item.data);
|
|
207
|
+
if (idat.length === 0) return null;
|
|
208
|
+
|
|
209
|
+
let inflated: Buffer;
|
|
210
|
+
try {
|
|
211
|
+
inflated = zlib.inflateSync(Buffer.concat(idat));
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const stride = info.width * channels;
|
|
217
|
+
if (inflated.length < (stride + 1) * info.height) return null;
|
|
218
|
+
|
|
219
|
+
const pixels = unfilter(inflated, info.width, info.height, channels);
|
|
220
|
+
const extras = chunks.filter((item) =>
|
|
221
|
+
['PLTE', 'tRNS', 'gAMA', 'sRGB', 'cHRM', 'iCCP', 'pHYs'].includes(item.type));
|
|
222
|
+
|
|
223
|
+
const list: Buffer[] = [];
|
|
224
|
+
for (let top = 0; top < info.height; top += height) {
|
|
225
|
+
const sliceRows = Math.min(height, info.height - top);
|
|
226
|
+
const body = Buffer.alloc((stride + 1) * sliceRows);
|
|
227
|
+
for (let y = 0; y < sliceRows; y++) {
|
|
228
|
+
body[y * (stride + 1)] = 0;
|
|
229
|
+
pixels.copy(body, y * (stride + 1) + 1, (top + y) * stride, (top + y + 1) * stride);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ihdr = Buffer.alloc(13);
|
|
233
|
+
ihdr.writeUInt32BE(info.width, 0);
|
|
234
|
+
ihdr.writeUInt32BE(sliceRows, 4);
|
|
235
|
+
ihdr[8] = info.bitDepth;
|
|
236
|
+
ihdr[9] = info.colorType;
|
|
237
|
+
ihdr[10] = 0;
|
|
238
|
+
ihdr[11] = 0;
|
|
239
|
+
ihdr[12] = 0;
|
|
240
|
+
|
|
241
|
+
list.push(Buffer.concat([
|
|
242
|
+
PNG_SIGNATURE,
|
|
243
|
+
writeChunk('IHDR', ihdr),
|
|
244
|
+
...extras.map((item) => writeChunk(item.type, item.data)),
|
|
245
|
+
writeChunk('IDAT', zlib.deflateSync(body, { level })),
|
|
246
|
+
writeChunk('IEND', Buffer.alloc(0)),
|
|
247
|
+
]));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return list;
|
|
251
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
export { createHtmlRenderer
|
|
1
|
+
export { createHtmlRenderer } from './renderer.js';
|
|
2
|
+
export { serializeJsxToHtml } from './jsx.js';
|
|
2
3
|
export { registerAiTextAsImageOutput, extractPlainTextForImage } from './ai-text-as-image.js';
|
|
3
4
|
export type {
|
|
4
5
|
FontConfig,
|
|
6
|
+
HtmlComponent,
|
|
5
7
|
HtmlRendererAiTextAsImageConfig,
|
|
6
8
|
HtmlRendererConfig,
|
|
7
9
|
HtmlRendererLogger,
|
|
@@ -9,7 +11,8 @@ export type {
|
|
|
9
11
|
OutputFormat,
|
|
10
12
|
RenderOptions,
|
|
11
13
|
RenderResult,
|
|
14
|
+
RasterFormat,
|
|
15
|
+
WaitUntil,
|
|
12
16
|
} from './types.js';
|
|
13
17
|
|
|
14
|
-
/** 动态 import 时使用的包名(与 package.json name 一致) */
|
|
15
18
|
export const HTML_RENDERER_PACKAGE = '@zhin.js/html-renderer';
|