satoru-render 0.0.19 → 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 +46 -10
- package/dist/core.d.ts +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +163 -0
- package/dist/web-workers.js +116 -0
- package/package.json +1 -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
|
```
|
|
@@ -254,6 +265,31 @@ const pngBytes = await render({
|
|
|
254
265
|
});
|
|
255
266
|
```
|
|
256
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
|
+
|
|
257
293
|
---
|
|
258
294
|
|
|
259
295
|
## 💻 CLI Tool
|
|
@@ -280,9 +316,9 @@ npx satoru-render input.html -f webp --verbose
|
|
|
280
316
|
|
|
281
317
|
### Render Options
|
|
282
318
|
|
|
283
|
-
| Option | Type
|
|
284
|
-
| :---------------- |
|
|
285
|
-
| `value` | `string \| string[]
|
|
319
|
+
| Option | Type | Description |
|
|
320
|
+
| :---------------- | :--------------------------------------- | :------------------------------------------------------ |
|
|
321
|
+
| `value` | `string \| string[] \| HTMLElement \| ...` | HTML string, array of strings, or DOM element(s). |
|
|
286
322
|
| `url` | `string` | URL to fetch HTML from. |
|
|
287
323
|
| `width` | `number` | **Required.** Output width in pixels. |
|
|
288
324
|
| `height` | `number` | Output height. Default: `0` (auto-calculate). |
|
package/dist/core.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface RequiredResource {
|
|
|
28
28
|
}
|
|
29
29
|
export type ResourceResolver = (resource: RequiredResource, defaultResolver: (resource: RequiredResource) => Promise<Uint8Array | null>) => Promise<Uint8Array | ArrayBufferView | null>;
|
|
30
30
|
export interface RenderOptions {
|
|
31
|
-
value?: string | string[];
|
|
31
|
+
value?: string | string[] | any | any[];
|
|
32
32
|
url?: string;
|
|
33
33
|
width: number;
|
|
34
34
|
height?: number;
|
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/web-workers.js
CHANGED
|
@@ -404,6 +404,122 @@ var Satoru$1 = class Satoru$1 extends SatoruBase {
|
|
|
404
404
|
static async create(createSatoruModuleFunc) {
|
|
405
405
|
return new Satoru$1(createSatoruModuleFunc);
|
|
406
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* Captures an HTMLElement and returns an HTMLCanvasElement.
|
|
409
|
+
* Similar to html2canvas.
|
|
410
|
+
*/
|
|
411
|
+
async capture(element, options = {}) {
|
|
412
|
+
const rect = element.getBoundingClientRect();
|
|
413
|
+
const width = options.width ?? Math.ceil(rect.width);
|
|
414
|
+
const height = options.height ?? Math.ceil(rect.height);
|
|
415
|
+
const serializedHtml = this.serializeElement(element);
|
|
416
|
+
const pngData = await this.render({
|
|
417
|
+
...options,
|
|
418
|
+
value: serializedHtml,
|
|
419
|
+
width,
|
|
420
|
+
height,
|
|
421
|
+
format: "png"
|
|
422
|
+
});
|
|
423
|
+
return this.pngToCanvas(pngData, width, height);
|
|
424
|
+
}
|
|
425
|
+
serializeElement(element) {
|
|
426
|
+
const clone = element.cloneNode(true);
|
|
427
|
+
const absoluteUrl = (url) => {
|
|
428
|
+
if (!url || url.startsWith("data:") || url.startsWith("blob:")) return url;
|
|
429
|
+
const a = document.createElement("a");
|
|
430
|
+
a.href = url;
|
|
431
|
+
return a.href;
|
|
432
|
+
};
|
|
433
|
+
const applyStyles = (src, dest) => {
|
|
434
|
+
const computed = window.getComputedStyle(src);
|
|
435
|
+
const styleArr = [];
|
|
436
|
+
for (let i = 0; i < computed.length; i++) {
|
|
437
|
+
const prop = computed[i];
|
|
438
|
+
let value = computed.getPropertyValue(prop);
|
|
439
|
+
if (value.includes("url(")) value = value.replace(/url\(['"]?([^'"]+)['"]?\)/g, (match, url) => {
|
|
440
|
+
return `url("${absoluteUrl(url)}")`;
|
|
441
|
+
});
|
|
442
|
+
styleArr.push(`${prop}:${value}`);
|
|
443
|
+
}
|
|
444
|
+
dest.setAttribute("style", styleArr.join(";"));
|
|
445
|
+
const handlePseudo = (pseudoType) => {
|
|
446
|
+
const style = window.getComputedStyle(src, pseudoType);
|
|
447
|
+
const content = style.getPropertyValue("content");
|
|
448
|
+
if (!content || content === "none" || content === "normal") return;
|
|
449
|
+
const pseudoEl = document.createElement("span");
|
|
450
|
+
const pseudoStyles = [];
|
|
451
|
+
for (let i = 0; i < style.length; i++) {
|
|
452
|
+
const prop = style[i];
|
|
453
|
+
pseudoStyles.push(`${prop}:${style.getPropertyValue(prop)}`);
|
|
454
|
+
}
|
|
455
|
+
pseudoEl.setAttribute("style", pseudoStyles.join(";"));
|
|
456
|
+
if (content.startsWith("\"") && content.endsWith("\"")) pseudoEl.innerText = content.slice(1, -1);
|
|
457
|
+
if (pseudoType === "::before") dest.prepend(pseudoEl);
|
|
458
|
+
else dest.append(pseudoEl);
|
|
459
|
+
};
|
|
460
|
+
handlePseudo("::before");
|
|
461
|
+
handlePseudo("::after");
|
|
462
|
+
if (src instanceof HTMLCanvasElement) {
|
|
463
|
+
const img = document.createElement("img");
|
|
464
|
+
img.src = src.toDataURL();
|
|
465
|
+
img.setAttribute("style", dest.getAttribute("style") || "");
|
|
466
|
+
dest.replaceWith(img);
|
|
467
|
+
} else if (src instanceof HTMLImageElement) dest.setAttribute("src", absoluteUrl(src.src));
|
|
468
|
+
else if (src instanceof HTMLInputElement) if (src.type === "checkbox" || src.type === "radio") {
|
|
469
|
+
if (src.checked) dest.setAttribute("checked", "");
|
|
470
|
+
} else dest.setAttribute("value", src.value);
|
|
471
|
+
else if (src instanceof HTMLTextAreaElement) dest.innerText = src.value;
|
|
472
|
+
else if (src instanceof HTMLSelectElement) {
|
|
473
|
+
const index = src.selectedIndex;
|
|
474
|
+
const clonedSelect = dest;
|
|
475
|
+
if (clonedSelect.options[index]) clonedSelect.options[index].setAttribute("selected", "");
|
|
476
|
+
}
|
|
477
|
+
for (let i = 0; i < src.children.length; i++) {
|
|
478
|
+
const srcChild = src.children[i];
|
|
479
|
+
const destChild = dest.children[i];
|
|
480
|
+
if (srcChild && destChild) applyStyles(srcChild, destChild);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
applyStyles(element, clone);
|
|
484
|
+
return `<!DOCTYPE html><html><body style="margin:0;padding:0;overflow:hidden;background:transparent;">${clone.outerHTML}</body></html>`;
|
|
485
|
+
}
|
|
486
|
+
async pngToCanvas(data, width, height) {
|
|
487
|
+
const blob = new Blob([data], { type: "image/png" });
|
|
488
|
+
const url = URL.createObjectURL(blob);
|
|
489
|
+
try {
|
|
490
|
+
const img = new Image();
|
|
491
|
+
img.crossOrigin = "anonymous";
|
|
492
|
+
await new Promise((resolve, reject) => {
|
|
493
|
+
img.onload = resolve;
|
|
494
|
+
img.onerror = reject;
|
|
495
|
+
img.src = url;
|
|
496
|
+
});
|
|
497
|
+
const canvas = document.createElement("canvas");
|
|
498
|
+
canvas.width = width;
|
|
499
|
+
canvas.height = height;
|
|
500
|
+
const ctx = canvas.getContext("2d");
|
|
501
|
+
if (ctx) ctx.drawImage(img, 0, 0);
|
|
502
|
+
return canvas;
|
|
503
|
+
} finally {
|
|
504
|
+
URL.revokeObjectURL(url);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async render(options) {
|
|
508
|
+
if (options.value) {
|
|
509
|
+
const values = Array.isArray(options.value) ? options.value : [options.value];
|
|
510
|
+
const processedValues = [];
|
|
511
|
+
let hasElement = false;
|
|
512
|
+
for (const val of values) if (val instanceof HTMLElement) {
|
|
513
|
+
processedValues.push(this.serializeElement(val));
|
|
514
|
+
hasElement = true;
|
|
515
|
+
} else processedValues.push(val);
|
|
516
|
+
if (hasElement) options = {
|
|
517
|
+
...options,
|
|
518
|
+
value: Array.isArray(options.value) ? processedValues : processedValues[0]
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return super.render(options);
|
|
522
|
+
}
|
|
407
523
|
static async defaultResourceResolver(resource, baseUrl, userAgent) {
|
|
408
524
|
try {
|
|
409
525
|
if (resource.url.startsWith("provider:google-fonts")) return resolveGoogleFonts(resource, userAgent);
|