satoru-render 0.0.18 → 0.0.20
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 +86 -11
- package/dist/bench.d.ts +1 -0
- package/dist/bench.js +70 -0
- package/dist/cli.js +55 -3
- package/dist/core.d.ts +5 -1
- package/dist/core.js +34 -2
- package/dist/index.d.ts +18 -1
- package/dist/index.js +163 -0
- package/dist/jsdom.d.ts +41 -0
- package/dist/jsdom.js +99 -0
- package/dist/satoru-single.js +0 -0
- package/dist/satoru.js +1 -1
- package/dist/satoru.wasm +0 -0
- package/dist/web-workers.js +178 -41
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -103,9 +103,9 @@ const png = await render({
|
|
|
103
103
|
|
|
104
104
|
## 🛠️ Advanced Usage
|
|
105
105
|
|
|
106
|
-
### 1. Dynamic Resource Resolution
|
|
106
|
+
### 1. Dynamic Resource Resolution & Caching
|
|
107
107
|
|
|
108
|
-
Satoru can automatically fetch missing fonts, images, or external CSS via a `resolveResource` callback.
|
|
108
|
+
Satoru can automatically fetch missing fonts, images, or external CSS via a `resolveResource` callback. You can also implement high-performance caching using the browser's `CacheStorage` API.
|
|
109
109
|
|
|
110
110
|
```typescript
|
|
111
111
|
const pdf = await render({
|
|
@@ -114,12 +114,23 @@ const pdf = await render({
|
|
|
114
114
|
format: "pdf",
|
|
115
115
|
baseUrl: "https://example.com/assets/",
|
|
116
116
|
resolveResource: async (resource, defaultResolver) => {
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
// 1. Open Cache storage
|
|
118
|
+
const cache = await caches.open("satoru-resource-cache");
|
|
119
|
+
const cachedResponse = await cache.match(resource.url);
|
|
120
|
+
|
|
121
|
+
// 2. Return cached data if available
|
|
122
|
+
if (cachedResponse) {
|
|
123
|
+
const buf = await cachedResponse.arrayBuffer();
|
|
124
|
+
return new Uint8Array(buf);
|
|
120
125
|
}
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
|
|
127
|
+
// 3. Fetch using default resolver and save to cache
|
|
128
|
+
const data = await defaultResolver(resource);
|
|
129
|
+
if (data?.length) {
|
|
130
|
+
await cache.put(resource.url, new Response(data));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return data;
|
|
123
134
|
},
|
|
124
135
|
});
|
|
125
136
|
```
|
|
@@ -220,17 +231,81 @@ const png = await render({
|
|
|
220
231
|
|
|
221
232
|
---
|
|
222
233
|
|
|
234
|
+
### 6. JSDOM Hydration (For Next.js / SPAs)
|
|
235
|
+
|
|
236
|
+
For complex client-side applications (like Next.js) that require full Javascript evaluation and DOM hydration before rendering, Satoru provides an optional `jsdom` helper.
|
|
237
|
+
|
|
238
|
+
_Note: `jsdom` must be installed separately in your project (`npm install jsdom`)._
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { render } from "satoru-render";
|
|
242
|
+
import { getHtml } from "satoru-render/jsdom";
|
|
243
|
+
|
|
244
|
+
// 1. Let JSDOM fetch the URL, execute scripts, and wait for network/hydration
|
|
245
|
+
const hydratedHtml = await getHtml({
|
|
246
|
+
src: "https://example.com/",
|
|
247
|
+
waitUntil: "networkidle", // Wait until Next.js finishes loading chunks
|
|
248
|
+
beforeParse: (window) => {
|
|
249
|
+
// Provide polyfills if the target site requires them
|
|
250
|
+
window.matchMedia = () => ({ matches: false, addListener: () => {} });
|
|
251
|
+
window.IntersectionObserver = class {
|
|
252
|
+
observe() {}
|
|
253
|
+
unobserve() {}
|
|
254
|
+
disconnect() {}
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// 2. Render the fully constructed DOM in Satoru (at native speed)
|
|
260
|
+
const pngBytes = await render({
|
|
261
|
+
value: hydratedHtml,
|
|
262
|
+
baseUrl: "https://example.com/",
|
|
263
|
+
width: 1200,
|
|
264
|
+
format: "png",
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### 7. DOM Capture (html2canvas alternative)
|
|
269
|
+
|
|
270
|
+
Satoru can capture live DOM elements directly in the browser, preserving computed styles, pseudo-elements (`::before`/`::after`), canvas contents, and form states.
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
import { Satoru } from "satoru-render";
|
|
274
|
+
import createSatoruModule from "satoru-render/satoru.js";
|
|
275
|
+
|
|
276
|
+
const satoru = await Satoru.create(createSatoruModule);
|
|
277
|
+
const element = document.getElementById("target-element");
|
|
278
|
+
|
|
279
|
+
// 1. Capture to HTMLCanvasElement (Direct html2canvas replacement)
|
|
280
|
+
const canvas = await satoru.capture(element, {
|
|
281
|
+
format: "png",
|
|
282
|
+
});
|
|
283
|
+
document.body.appendChild(canvas);
|
|
284
|
+
|
|
285
|
+
// 2. Or render directly to binary (Uint8Array)
|
|
286
|
+
const png = await satoru.render({
|
|
287
|
+
value: element, // Accepts HTMLElement directly
|
|
288
|
+
width: 800,
|
|
289
|
+
format: "png",
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
223
295
|
## 💻 CLI Tool
|
|
224
296
|
|
|
225
297
|
Convert files or URLs directly from your terminal.
|
|
226
298
|
|
|
227
299
|
```bash
|
|
228
|
-
# Local HTML to PNG
|
|
300
|
+
# Local HTML to PNG (JSDOM hydration enabled by default)
|
|
229
301
|
npx satoru-render input.html -o output.png
|
|
230
302
|
|
|
231
303
|
# URL to PDF with specific width
|
|
232
304
|
npx satoru-render https://example.com -o site.pdf -w 1280
|
|
233
305
|
|
|
306
|
+
# Convert without JSDOM hydration
|
|
307
|
+
npx satoru-render https://example.com --no-jsdom -o example.pdf
|
|
308
|
+
|
|
234
309
|
# WebP conversion with verbose logs
|
|
235
310
|
npx satoru-render input.html -f webp --verbose
|
|
236
311
|
```
|
|
@@ -241,9 +316,9 @@ npx satoru-render input.html -f webp --verbose
|
|
|
241
316
|
|
|
242
317
|
### Render Options
|
|
243
318
|
|
|
244
|
-
| Option | Type
|
|
245
|
-
| :---------------- |
|
|
246
|
-
| `value` | `string \| string[]
|
|
319
|
+
| Option | Type | Description |
|
|
320
|
+
| :---------------- | :--------------------------------------- | :------------------------------------------------------ |
|
|
321
|
+
| `value` | `string \| string[] \| HTMLElement \| ...` | HTML string, array of strings, or DOM element(s). |
|
|
247
322
|
| `url` | `string` | URL to fetch HTML from. |
|
|
248
323
|
| `width` | `number` | **Required.** Output width in pixels. |
|
|
249
324
|
| `height` | `number` | Output height. Default: `0` (auto-calculate). |
|
package/dist/bench.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/bench.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Bench } from "tinybench";
|
|
2
|
+
import { Satoru } from "./node.js";
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import createSatoruModule from "../dist/satoru-single.js";
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const assetsDir = path.join(__dirname, "../../../assets");
|
|
10
|
+
async function runBenchmark() {
|
|
11
|
+
const satoru = await Satoru.create(createSatoruModule);
|
|
12
|
+
const bench = new Bench({ time: 1000 });
|
|
13
|
+
const files = [
|
|
14
|
+
"06-flexbox.html",
|
|
15
|
+
"09-grid.html",
|
|
16
|
+
"11-complex-text.html",
|
|
17
|
+
"15-showcase.html",
|
|
18
|
+
];
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const filePath = path.join(assetsDir, file);
|
|
21
|
+
if (!fs.existsSync(filePath)) {
|
|
22
|
+
console.warn(`File not found: ${filePath}`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const html = fs.readFileSync(filePath, "utf-8");
|
|
26
|
+
// Full render
|
|
27
|
+
bench.add(`Full Render: ${file}`, async () => {
|
|
28
|
+
await satoru.render({
|
|
29
|
+
value: html,
|
|
30
|
+
width: 1200,
|
|
31
|
+
format: "svg",
|
|
32
|
+
baseUrl: `file://${assetsDir}/`,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
// Core layout and render (no resource discovery if we assume resources are loaded)
|
|
36
|
+
// To make it fair, we'll initialize once and layout/render multiple times
|
|
37
|
+
// However, tinybench runs the function multiple times, so we need to be careful about state.
|
|
38
|
+
// For core, we'll do init inside to avoid state pollution, but the focus is on layout/render.
|
|
39
|
+
bench.add(`Core Layout+Render (SVG): ${file}`, async () => {
|
|
40
|
+
const inst = await satoru.initDocument({
|
|
41
|
+
html,
|
|
42
|
+
width: 1200,
|
|
43
|
+
baseUrl: `file://${assetsDir}/`,
|
|
44
|
+
});
|
|
45
|
+
await satoru.layoutDocument(inst, 1200);
|
|
46
|
+
await satoru.renderFromState(inst, {
|
|
47
|
+
width: 1200,
|
|
48
|
+
format: "svg",
|
|
49
|
+
});
|
|
50
|
+
await satoru.destroyInstance(inst);
|
|
51
|
+
});
|
|
52
|
+
bench.add(`Core Layout+Render (PNG): ${file}`, async () => {
|
|
53
|
+
const inst = await satoru.initDocument({
|
|
54
|
+
html,
|
|
55
|
+
width: 1200,
|
|
56
|
+
baseUrl: `file://${assetsDir}/`,
|
|
57
|
+
});
|
|
58
|
+
await satoru.layoutDocument(inst, 1200);
|
|
59
|
+
await satoru.renderFromState(inst, {
|
|
60
|
+
width: 1200,
|
|
61
|
+
format: "png",
|
|
62
|
+
});
|
|
63
|
+
await satoru.destroyInstance(inst);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
console.log("Running benchmarks...");
|
|
67
|
+
await bench.run();
|
|
68
|
+
console.table(bench.table());
|
|
69
|
+
}
|
|
70
|
+
runBenchmark().catch(console.error);
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ async function main() {
|
|
|
7
7
|
const options = {
|
|
8
8
|
width: 800,
|
|
9
9
|
format: "png",
|
|
10
|
+
jsdom: true, // Default to true
|
|
10
11
|
};
|
|
11
12
|
let input;
|
|
12
13
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -23,6 +24,9 @@ async function main() {
|
|
|
23
24
|
else if (arg === "-o" || arg === "--output") {
|
|
24
25
|
options.output = args[++i];
|
|
25
26
|
}
|
|
27
|
+
else if (arg === "--no-jsdom") {
|
|
28
|
+
options.jsdom = false;
|
|
29
|
+
}
|
|
26
30
|
else if (arg === "--verbose") {
|
|
27
31
|
options.verbose = true;
|
|
28
32
|
}
|
|
@@ -70,16 +74,63 @@ async function main() {
|
|
|
70
74
|
console.error(`[Satoru] ${LogLevel[level]}: ${message}`);
|
|
71
75
|
};
|
|
72
76
|
}
|
|
77
|
+
let finalHtml;
|
|
78
|
+
let baseUrl;
|
|
73
79
|
if (isUrl) {
|
|
74
|
-
|
|
80
|
+
baseUrl = input;
|
|
81
|
+
if (options.jsdom) {
|
|
82
|
+
try {
|
|
83
|
+
const { getHtml } = await import("./jsdom.js");
|
|
84
|
+
if (options.verbose)
|
|
85
|
+
console.error(`[Satoru] Hydrating URL via JSDOM: ${input}`);
|
|
86
|
+
finalHtml = await getHtml({
|
|
87
|
+
src: input,
|
|
88
|
+
waitUntil: "networkidle",
|
|
89
|
+
forwardConsole: options.verbose,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (options.verbose)
|
|
94
|
+
console.error(`[Satoru] JSDOM hydration failed or not available, falling back to direct URL fetch:`, err);
|
|
95
|
+
renderOptions.url = input;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
renderOptions.url = input;
|
|
100
|
+
}
|
|
75
101
|
}
|
|
76
102
|
else {
|
|
77
103
|
if (!fs.existsSync(input)) {
|
|
78
104
|
console.error(`Error: File not found: ${input}`);
|
|
79
105
|
process.exit(1);
|
|
80
106
|
}
|
|
81
|
-
|
|
82
|
-
|
|
107
|
+
const rawContent = fs.readFileSync(input, "utf-8");
|
|
108
|
+
baseUrl = path.dirname(path.resolve(input));
|
|
109
|
+
if (options.jsdom) {
|
|
110
|
+
try {
|
|
111
|
+
const { getHtml } = await import("./jsdom.js");
|
|
112
|
+
if (options.verbose)
|
|
113
|
+
console.error(`[Satoru] Hydrating file via JSDOM: ${input}`);
|
|
114
|
+
finalHtml = await getHtml({
|
|
115
|
+
src: rawContent,
|
|
116
|
+
baseUrl: `file://${baseUrl}/`,
|
|
117
|
+
waitUntil: "networkidle",
|
|
118
|
+
forwardConsole: options.verbose,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (options.verbose)
|
|
123
|
+
console.error(`[Satoru] JSDOM hydration failed or not available, falling back to raw file content:`, err);
|
|
124
|
+
finalHtml = rawContent;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
finalHtml = rawContent;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (finalHtml !== undefined) {
|
|
132
|
+
renderOptions.value = finalHtml;
|
|
133
|
+
renderOptions.baseUrl = baseUrl;
|
|
83
134
|
}
|
|
84
135
|
try {
|
|
85
136
|
const result = await satoru.render(renderOptions);
|
|
@@ -100,6 +151,7 @@ Options:
|
|
|
100
151
|
-w, --width <number> Viewport width (default: 800)
|
|
101
152
|
-h, --height <number> Viewport height (default: 0, auto-calculate)
|
|
102
153
|
-f, --format <format> Output format: svg, png, webp, pdf
|
|
154
|
+
--no-jsdom Disable JSDOM hydration (enabled by default)
|
|
103
155
|
--verbose Enable detailed logging
|
|
104
156
|
--help Show this help message
|
|
105
157
|
`);
|
package/dist/core.d.ts
CHANGED
|
@@ -7,7 +7,9 @@ export interface SatoruModule {
|
|
|
7
7
|
add_resource: (inst: any, url: string, type: number, data: Uint8Array) => void;
|
|
8
8
|
scan_css: (inst: any, css: string) => void;
|
|
9
9
|
load_font: (inst: any, name: string, data: Uint8Array) => void;
|
|
10
|
+
load_fallback_font: (inst: any, data: Uint8Array) => void;
|
|
10
11
|
load_image: (inst: any, name: string, url: string, width: number, height: number) => void;
|
|
12
|
+
load_image_pixels: (inst: any, name: string, width: number, height: number, pixels: Uint8Array, data_url: string) => void;
|
|
11
13
|
set_font_map: (inst: any, fontMap: Record<string, string>) => void;
|
|
12
14
|
set_log_level: (level: number) => void;
|
|
13
15
|
init_document: (inst: any, html: string, width: number, height: number) => void;
|
|
@@ -26,7 +28,7 @@ export interface RequiredResource {
|
|
|
26
28
|
}
|
|
27
29
|
export type ResourceResolver = (resource: RequiredResource, defaultResolver: (resource: RequiredResource) => Promise<Uint8Array | null>) => Promise<Uint8Array | ArrayBufferView | null>;
|
|
28
30
|
export interface RenderOptions {
|
|
29
|
-
value?: string | string[];
|
|
31
|
+
value?: string | string[] | any | any[];
|
|
30
32
|
url?: string;
|
|
31
33
|
width: number;
|
|
32
34
|
height?: number;
|
|
@@ -37,6 +39,7 @@ export interface RenderOptions {
|
|
|
37
39
|
name: string;
|
|
38
40
|
data: Uint8Array;
|
|
39
41
|
}[];
|
|
42
|
+
fallbackFonts?: Uint8Array[];
|
|
40
43
|
images?: {
|
|
41
44
|
name: string;
|
|
42
45
|
url: string;
|
|
@@ -69,6 +72,7 @@ export declare abstract class SatoruBase {
|
|
|
69
72
|
textToPaths?: boolean;
|
|
70
73
|
}): Promise<string | Uint8Array>;
|
|
71
74
|
destroyInstance(inst: any): Promise<void>;
|
|
75
|
+
loadFallbackFont(data: Uint8Array): Promise<void>;
|
|
72
76
|
protected abstract resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
|
|
73
77
|
protected abstract fetchHtml(url: string, userAgent?: string): Promise<string>;
|
|
74
78
|
render(options: RenderOptions & {
|
package/dist/core.js
CHANGED
|
@@ -150,6 +150,16 @@ export class SatoruBase {
|
|
|
150
150
|
const mod = await this.getModule();
|
|
151
151
|
mod.destroy_instance(inst);
|
|
152
152
|
}
|
|
153
|
+
async loadFallbackFont(data) {
|
|
154
|
+
const mod = await this.getModule();
|
|
155
|
+
const inst = mod.create_instance();
|
|
156
|
+
try {
|
|
157
|
+
mod.load_fallback_font(inst, data);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
mod.destroy_instance(inst);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
153
163
|
async render(options) {
|
|
154
164
|
const mod = await this.getModule();
|
|
155
165
|
let { value, url, width, height = 0, format = "svg", fonts, images, css, baseUrl, logLevel, onLog, } = options;
|
|
@@ -181,6 +191,11 @@ export class SatoruBase {
|
|
|
181
191
|
mod.load_font(instancePtr, f.name, f.data);
|
|
182
192
|
}
|
|
183
193
|
}
|
|
194
|
+
if (options.fallbackFonts) {
|
|
195
|
+
for (const data of options.fallbackFonts) {
|
|
196
|
+
mod.load_fallback_font(instancePtr, data);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
184
199
|
if (images) {
|
|
185
200
|
for (const img of images) {
|
|
186
201
|
mod.load_image(instancePtr, img.name, img.url, img.width ?? 0, img.height ?? 0);
|
|
@@ -225,6 +240,23 @@ export class SatoruBase {
|
|
|
225
240
|
const uint8 = data instanceof Uint8Array
|
|
226
241
|
? data
|
|
227
242
|
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
243
|
+
if (r.type === "image" && typeof createImageBitmap !== "undefined" && typeof OffscreenCanvas !== "undefined") {
|
|
244
|
+
try {
|
|
245
|
+
const blob = new Blob([uint8.buffer]);
|
|
246
|
+
const bitmap = await createImageBitmap(blob);
|
|
247
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
248
|
+
const ctx = canvas.getContext("2d");
|
|
249
|
+
if (ctx) {
|
|
250
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
251
|
+
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
252
|
+
mod.load_image_pixels(instancePtr, r.url, bitmap.width, bitmap.height, new Uint8Array(imageData.data.buffer), r.url);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
// fallback to passing raw binary if decoding fails
|
|
258
|
+
}
|
|
259
|
+
}
|
|
228
260
|
let typeInt = 1;
|
|
229
261
|
if (r.type === "image")
|
|
230
262
|
typeInt = 2;
|
|
@@ -247,7 +279,7 @@ export class SatoruBase {
|
|
|
247
279
|
});
|
|
248
280
|
resolvedUrls.forEach((url) => {
|
|
249
281
|
const escapedUrl = url.replace(/[.*+?^${}()|[\]]/g, "\\$&");
|
|
250
|
-
const linkRegex = new RegExp(`<link[^>]*href\\s*=\\s*(["'])${escapedUrl}\\1[^>]*>`, "gi");
|
|
282
|
+
const linkRegex = new RegExp(`<link[^>]*href\\s*=\\s*(["']?)${escapedUrl}\\1[^>]*>`, "gi");
|
|
251
283
|
processedHtml = processedHtml.replace(linkRegex, "");
|
|
252
284
|
});
|
|
253
285
|
processedHtmls.push(processedHtml);
|
|
@@ -267,7 +299,7 @@ export class SatoruBase {
|
|
|
267
299
|
if (format === "svg") {
|
|
268
300
|
return new TextDecoder().decode(result);
|
|
269
301
|
}
|
|
270
|
-
return new Uint8Array(result);
|
|
302
|
+
return new Uint8Array(result.slice());
|
|
271
303
|
}
|
|
272
304
|
finally {
|
|
273
305
|
mod.destroy_instance(instancePtr);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
-
import { SatoruBase, RequiredResource } from "./core.js";
|
|
1
|
+
import { SatoruBase, RequiredResource, RenderOptions } from "./core.js";
|
|
2
2
|
export * from "./core.js";
|
|
3
3
|
export * from "./log-level.js";
|
|
4
4
|
export type { SatoruWorker } from "./child-workers.js";
|
|
5
5
|
export declare class Satoru extends SatoruBase {
|
|
6
6
|
static create(createSatoruModuleFunc: any): Promise<Satoru>;
|
|
7
|
+
/**
|
|
8
|
+
* Captures an HTMLElement and returns an HTMLCanvasElement.
|
|
9
|
+
* Similar to html2canvas.
|
|
10
|
+
*/
|
|
11
|
+
capture(element: HTMLElement, options?: Partial<Omit<RenderOptions, "value" | "url">>): Promise<HTMLCanvasElement>;
|
|
12
|
+
private serializeElement;
|
|
13
|
+
private pngToCanvas;
|
|
14
|
+
/**
|
|
15
|
+
* Overrides render to support HTMLElement as value.
|
|
16
|
+
*/
|
|
17
|
+
render(options: RenderOptions & {
|
|
18
|
+
format: "png" | "webp" | "pdf";
|
|
19
|
+
}): Promise<Uint8Array>;
|
|
20
|
+
render(options: RenderOptions & {
|
|
21
|
+
format?: "svg";
|
|
22
|
+
}): Promise<string>;
|
|
23
|
+
render(options: RenderOptions): Promise<string | Uint8Array>;
|
|
7
24
|
static defaultResourceResolver(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
|
|
8
25
|
protected resolveDefaultResource(resource: RequiredResource, baseUrl?: string, userAgent?: string): Promise<Uint8Array | null>;
|
|
9
26
|
protected fetchHtml(url: string, userAgent?: string): Promise<string>;
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,169 @@ export class Satoru extends SatoruBase {
|
|
|
5
5
|
static async create(createSatoruModuleFunc) {
|
|
6
6
|
return new Satoru(createSatoruModuleFunc);
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Captures an HTMLElement and returns an HTMLCanvasElement.
|
|
10
|
+
* Similar to html2canvas.
|
|
11
|
+
*/
|
|
12
|
+
async capture(element, options = {}) {
|
|
13
|
+
const rect = element.getBoundingClientRect();
|
|
14
|
+
const width = options.width ?? Math.ceil(rect.width);
|
|
15
|
+
const height = options.height ?? Math.ceil(rect.height);
|
|
16
|
+
// 1. Serialize DOM with computed styles
|
|
17
|
+
const serializedHtml = this.serializeElement(element);
|
|
18
|
+
// 2. Render to PNG
|
|
19
|
+
const pngData = await this.render({
|
|
20
|
+
...options,
|
|
21
|
+
value: serializedHtml,
|
|
22
|
+
width,
|
|
23
|
+
height,
|
|
24
|
+
format: "png",
|
|
25
|
+
});
|
|
26
|
+
// 3. Convert PNG to Canvas
|
|
27
|
+
return this.pngToCanvas(pngData, width, height);
|
|
28
|
+
}
|
|
29
|
+
serializeElement(element) {
|
|
30
|
+
const clone = element.cloneNode(true);
|
|
31
|
+
const absoluteUrl = (url) => {
|
|
32
|
+
if (!url || url.startsWith("data:") || url.startsWith("blob:"))
|
|
33
|
+
return url;
|
|
34
|
+
const a = document.createElement("a");
|
|
35
|
+
a.href = url;
|
|
36
|
+
return a.href;
|
|
37
|
+
};
|
|
38
|
+
const applyStyles = (src, dest) => {
|
|
39
|
+
const computed = window.getComputedStyle(src);
|
|
40
|
+
const styleArr = [];
|
|
41
|
+
for (let i = 0; i < computed.length; i++) {
|
|
42
|
+
const prop = computed[i];
|
|
43
|
+
let value = computed.getPropertyValue(prop);
|
|
44
|
+
// Convert relative URLs in styles (e.g., background-image)
|
|
45
|
+
if (value.includes("url(")) {
|
|
46
|
+
value = value.replace(/url\(['"]?([^'"]+)['"]?\)/g, (match, url) => {
|
|
47
|
+
return `url("${absoluteUrl(url)}")`;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
styleArr.push(`${prop}:${value}`);
|
|
51
|
+
}
|
|
52
|
+
dest.setAttribute("style", styleArr.join(";"));
|
|
53
|
+
// Handle pseudo-elements
|
|
54
|
+
const handlePseudo = (pseudoType) => {
|
|
55
|
+
const style = window.getComputedStyle(src, pseudoType);
|
|
56
|
+
const content = style.getPropertyValue("content");
|
|
57
|
+
if (!content || content === "none" || content === "normal")
|
|
58
|
+
return;
|
|
59
|
+
const pseudoEl = document.createElement("span");
|
|
60
|
+
const pseudoStyles = [];
|
|
61
|
+
for (let i = 0; i < style.length; i++) {
|
|
62
|
+
const prop = style[i];
|
|
63
|
+
pseudoStyles.push(`${prop}:${style.getPropertyValue(prop)}`);
|
|
64
|
+
}
|
|
65
|
+
pseudoEl.setAttribute("style", pseudoStyles.join(";"));
|
|
66
|
+
// Remove quotes from content for the innerText if it's just a string
|
|
67
|
+
if (content.startsWith('"') && content.endsWith('"')) {
|
|
68
|
+
pseudoEl.innerText = content.slice(1, -1);
|
|
69
|
+
}
|
|
70
|
+
if (pseudoType === "::before") {
|
|
71
|
+
dest.prepend(pseudoEl);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
dest.append(pseudoEl);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
handlePseudo("::before");
|
|
78
|
+
handlePseudo("::after");
|
|
79
|
+
// Special handling for specific elements
|
|
80
|
+
if (src instanceof HTMLCanvasElement) {
|
|
81
|
+
const img = document.createElement("img");
|
|
82
|
+
img.src = src.toDataURL();
|
|
83
|
+
img.setAttribute("style", dest.getAttribute("style") || "");
|
|
84
|
+
dest.replaceWith(img);
|
|
85
|
+
}
|
|
86
|
+
else if (src instanceof HTMLImageElement) {
|
|
87
|
+
dest.setAttribute("src", absoluteUrl(src.src));
|
|
88
|
+
}
|
|
89
|
+
else if (src instanceof HTMLInputElement) {
|
|
90
|
+
if (src.type === "checkbox" || src.type === "radio") {
|
|
91
|
+
if (src.checked)
|
|
92
|
+
dest.setAttribute("checked", "");
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
dest.setAttribute("value", src.value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (src instanceof HTMLTextAreaElement) {
|
|
99
|
+
dest.innerText = src.value;
|
|
100
|
+
}
|
|
101
|
+
else if (src instanceof HTMLSelectElement) {
|
|
102
|
+
// Find the selected option and mark it
|
|
103
|
+
const index = src.selectedIndex;
|
|
104
|
+
const clonedSelect = dest;
|
|
105
|
+
if (clonedSelect.options[index]) {
|
|
106
|
+
clonedSelect.options[index].setAttribute("selected", "");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (let i = 0; i < src.children.length; i++) {
|
|
110
|
+
const srcChild = src.children[i];
|
|
111
|
+
const destChild = dest.children[i];
|
|
112
|
+
if (srcChild && destChild) {
|
|
113
|
+
applyStyles(srcChild, destChild);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
applyStyles(element, clone);
|
|
118
|
+
return `<!DOCTYPE html><html><body style="margin:0;padding:0;overflow:hidden;background:transparent;">${clone.outerHTML}</body></html>`;
|
|
119
|
+
}
|
|
120
|
+
async pngToCanvas(data, width, height) {
|
|
121
|
+
const blob = new Blob([data], { type: "image/png" });
|
|
122
|
+
const url = URL.createObjectURL(blob);
|
|
123
|
+
try {
|
|
124
|
+
const img = new Image();
|
|
125
|
+
img.crossOrigin = "anonymous";
|
|
126
|
+
await new Promise((resolve, reject) => {
|
|
127
|
+
img.onload = resolve;
|
|
128
|
+
img.onerror = reject;
|
|
129
|
+
img.src = url;
|
|
130
|
+
});
|
|
131
|
+
const canvas = document.createElement("canvas");
|
|
132
|
+
canvas.width = width;
|
|
133
|
+
canvas.height = height;
|
|
134
|
+
const ctx = canvas.getContext("2d");
|
|
135
|
+
if (ctx) {
|
|
136
|
+
ctx.drawImage(img, 0, 0);
|
|
137
|
+
}
|
|
138
|
+
return canvas;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
URL.revokeObjectURL(url);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async render(options) {
|
|
145
|
+
if (options.value) {
|
|
146
|
+
const values = Array.isArray(options.value)
|
|
147
|
+
? options.value
|
|
148
|
+
: [options.value];
|
|
149
|
+
const processedValues = [];
|
|
150
|
+
let hasElement = false;
|
|
151
|
+
for (const val of values) {
|
|
152
|
+
if (val instanceof HTMLElement) {
|
|
153
|
+
processedValues.push(this.serializeElement(val));
|
|
154
|
+
hasElement = true;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
processedValues.push(val);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (hasElement) {
|
|
161
|
+
options = {
|
|
162
|
+
...options,
|
|
163
|
+
value: Array.isArray(options.value)
|
|
164
|
+
? processedValues
|
|
165
|
+
: processedValues[0],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return super.render(options);
|
|
170
|
+
}
|
|
8
171
|
static async defaultResourceResolver(resource, baseUrl, userAgent) {
|
|
9
172
|
try {
|
|
10
173
|
if (resource.url.startsWith("provider:google-fonts")) {
|
package/dist/jsdom.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface HydrateOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Target URL or raw HTML string to render.
|
|
4
|
+
*/
|
|
5
|
+
src: string;
|
|
6
|
+
/**
|
|
7
|
+
* Base URL for resolving relative paths and fetching resources.
|
|
8
|
+
* Recommended when passing raw HTML.
|
|
9
|
+
*/
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Wait condition for hydration to complete.
|
|
13
|
+
* - number: Wait for specified milliseconds
|
|
14
|
+
* - function: Polling function that receives the window object and returns true when done
|
|
15
|
+
* - "networkidle": Waits until network requests settle (basic implementation)
|
|
16
|
+
*/
|
|
17
|
+
waitUntil?: number | ((window: any) => boolean | Promise<boolean>) | "networkidle";
|
|
18
|
+
/**
|
|
19
|
+
* Maximum time to wait in milliseconds. Default: 10000ms.
|
|
20
|
+
*/
|
|
21
|
+
timeout?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Callback to mock or inject missing browser APIs (e.g. matchMedia, IntersectionObserver)
|
|
24
|
+
* before the HTML is parsed and scripts are executed.
|
|
25
|
+
*/
|
|
26
|
+
beforeParse?: (window: any) => void | Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* If true, forwards console.log/error from JSDOM to the Node.js console.
|
|
29
|
+
*/
|
|
30
|
+
forwardConsole?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* If true, removes all <script> tags from the final HTML before returning.
|
|
33
|
+
* Default: true.
|
|
34
|
+
*/
|
|
35
|
+
removeScripts?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Hydrates an HTML string or URL using JSDOM and returns the final rendered HTML.
|
|
39
|
+
* Note: Requires 'jsdom' to be installed as a peer dependency.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getHtml(options: HydrateOptions): Promise<string>;
|