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 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
- // Custom intercept logic
118
- if (resource.url.startsWith("my-app://")) {
119
- return myAssetBuffer;
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
- // Fallback to default fetch/filesystem resolver
122
- return defaultResolver(resource);
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 | Description |
284
- | :---------------- | :---------------------------------- | :------------------------------------------------------ |
285
- | `value` | `string \| string[]` | HTML string or array of strings (for multi-page PDF). |
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")) {
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "satoru-render",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "description": "High-fidelity HTML/CSS to SVG/PNG/PDF converter running in WebAssembly",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",