satoru-render 0.0.19 → 0.0.21

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,17 +114,58 @@ 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
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;
134
+ },
135
+ });
136
+ ```
137
+
138
+ ### 2. Avoiding CORS Issues with Proxy
139
+
140
+ If you encounter CORS errors when fetching images or fonts from other domains, you can use a proxy service within the `resolveResource` callback.
141
+
142
+ ```typescript
143
+ const png = await render({
144
+ value: html,
145
+ width: 800,
146
+ format: "png",
147
+ resolveResource: async (resource, defaultResolver) => {
148
+ // Intercept external images to avoid CORS issues
149
+ if (resource.type === "image" && !resource.url.startsWith("data:")) {
150
+ const proxyUrl = `https://your-proxy-service.com/?url=${encodeURIComponent(resource.url)}`;
151
+ try {
152
+ const resp = await fetch(proxyUrl);
153
+ if (resp.ok) {
154
+ const buf = await resp.arrayBuffer();
155
+ return new Uint8Array(buf);
156
+ }
157
+ } catch (e) {
158
+ console.warn(`Failed to fetch via proxy: ${resource.url}`, e);
159
+ }
160
+ }
161
+
162
+ // Fallback to default resolver for other resources
122
163
  return defaultResolver(resource);
123
164
  },
124
165
  });
125
166
  ```
126
167
 
127
- ### 2. Multi-page PDF Generation
168
+ ### 3. Multi-page PDF Generation
128
169
 
129
170
  Generate complex documents by passing an array of HTML strings. Each element in the array becomes a new page.
130
171
 
@@ -254,6 +295,31 @@ const pngBytes = await render({
254
295
  });
255
296
  ```
256
297
 
298
+ ### 7. DOM Capture (html2canvas alternative)
299
+
300
+ Satoru can capture live DOM elements directly in the browser, preserving computed styles, pseudo-elements (`::before`/`::after`), canvas contents, and form states.
301
+
302
+ ```typescript
303
+ import { Satoru } from "satoru-render";
304
+ import createSatoruModule from "satoru-render/satoru.js";
305
+
306
+ const satoru = await Satoru.create(createSatoruModule);
307
+ const element = document.getElementById("target-element");
308
+
309
+ // 1. Capture to HTMLCanvasElement (Direct html2canvas replacement)
310
+ const canvas = await satoru.capture(element, {
311
+ format: "png",
312
+ });
313
+ document.body.appendChild(canvas);
314
+
315
+ // 2. Or render directly to binary (Uint8Array)
316
+ const png = await satoru.render({
317
+ value: element, // Accepts HTMLElement directly
318
+ width: 800,
319
+ format: "png",
320
+ });
321
+ ```
322
+
257
323
  ---
258
324
 
259
325
  ## 💻 CLI Tool
@@ -280,9 +346,9 @@ npx satoru-render input.html -f webp --verbose
280
346
 
281
347
  ### Render Options
282
348
 
283
- | Option | Type | Description |
284
- | :---------------- | :---------------------------------- | :------------------------------------------------------ |
285
- | `value` | `string \| string[]` | HTML string or array of strings (for multi-page PDF). |
349
+ | Option | Type | Description |
350
+ | :---------------- | :--------------------------------------- | :------------------------------------------------------ |
351
+ | `value` | `string \| string[] \| HTMLElement \| ...` | HTML string, array of strings, or DOM element(s). |
286
352
  | `url` | `string` | URL to fetch HTML from. |
287
353
  | `width` | `number` | **Required.** Output width in pixels. |
288
354
  | `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;
@@ -82,4 +82,9 @@ export declare abstract class SatoruBase {
82
82
  format?: "svg";
83
83
  }): Promise<string>;
84
84
  render(options: RenderOptions): Promise<string | Uint8Array>;
85
+ /**
86
+ * Combines multiple simple PDF files into one.
87
+ * This is a minimal implementation specialized for Skia-generated PDFs.
88
+ */
89
+ private mergeSimplePDFs;
85
90
  }
package/dist/core.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { LogLevel } from "./log-level.js";
2
2
  export const DEFAULT_FONT_MAP = {
3
- "sans-serif": "https://cdn.jsdelivr.net/npm/@fontsource/noto-sans-jp/files/noto-sans-jp-japanese-400-normal.woff2",
4
- serif: "https://cdn.jsdelivr.net/npm/@fontsource/noto-serif-jp/files/noto-serif-jp-japanese-400-normal.woff2",
5
- monospace: "https://cdn.jsdelivr.net/npm/@fontsource/m-plus-1-code/files/m-plus-1-code-japanese-400-normal.woff2",
6
- cursive: "https://cdn.jsdelivr.net/npm/@fontsource/yuji-syuku/files/yuji-syuku-japanese-400-normal.woff2",
7
- fantasy: "https://cdn.jsdelivr.net/npm/@fontsource/reggae-one/files/reggae-one-japanese-400-normal.woff2",
3
+ "sans-serif": "https://fonts.googleapis.com/css2?family=Noto+Sans+JP",
4
+ serif: "https://fonts.googleapis.com/css2?family=Noto+Serif+JP",
5
+ monospace: "https://fonts.googleapis.com/css2?family=M+PLUS+1+Code",
6
+ cursive: "https://fonts.googleapis.com/css2?family=Yuji+Syuku",
7
+ fantasy: "https://fonts.googleapis.com/css2?family=Reggae+One",
8
8
  emoji: "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2",
9
9
  "Noto Color Emoji": "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2",
10
10
  };
@@ -161,8 +161,23 @@ export class SatoruBase {
161
161
  }
162
162
  }
163
163
  async render(options) {
164
+ let { format = "svg", value, url, baseUrl } = options;
165
+ if (format === "pdf" && Array.isArray(value) && value.length > 1) {
166
+ const pagePdfs = [];
167
+ const SatoruClass = this.constructor;
168
+ for (const html of value) {
169
+ const engine = await SatoruClass.create();
170
+ const pagePdf = await engine.render({
171
+ ...options,
172
+ value: html,
173
+ format: "pdf",
174
+ });
175
+ pagePdfs.push(pagePdf);
176
+ }
177
+ return this.mergeSimplePDFs(pagePdfs);
178
+ }
164
179
  const mod = await this.getModule();
165
- let { value, url, width, height = 0, format = "svg", fonts, images, css, baseUrl, logLevel, onLog, } = options;
180
+ const { width, height = 0, fonts, images, css, logLevel, onLog, } = options;
166
181
  if (!options.userAgent) {
167
182
  options.userAgent =
168
183
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
@@ -240,7 +255,9 @@ export class SatoruBase {
240
255
  const uint8 = data instanceof Uint8Array
241
256
  ? data
242
257
  : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
243
- if (r.type === "image" && typeof createImageBitmap !== "undefined" && typeof OffscreenCanvas !== "undefined") {
258
+ if (r.type === "image" &&
259
+ typeof createImageBitmap !== "undefined" &&
260
+ typeof OffscreenCanvas !== "undefined") {
244
261
  try {
245
262
  const blob = new Blob([uint8.buffer]);
246
263
  const bitmap = await createImageBitmap(blob);
@@ -308,4 +325,97 @@ export class SatoruBase {
308
325
  this.currentFontMap = prevFontMap;
309
326
  }
310
327
  }
328
+ /**
329
+ * Combines multiple simple PDF files into one.
330
+ * This is a minimal implementation specialized for Skia-generated PDFs.
331
+ */
332
+ mergeSimplePDFs(pdfs) {
333
+ if (pdfs.length === 0)
334
+ return new Uint8Array();
335
+ if (pdfs.length === 1)
336
+ return pdfs[0];
337
+ const decoder = new TextDecoder();
338
+ const encoder = new TextEncoder();
339
+ let maxObjNum = 0;
340
+ const allObjects = [];
341
+ const pageIds = [];
342
+ for (const pdf of pdfs) {
343
+ const offset = maxObjNum;
344
+ const content = decoder.decode(pdf);
345
+ // Find all objects
346
+ const objRegex = /(\d+)\s+0\s+obj([\s\S]*?)endobj/g;
347
+ let match;
348
+ let currentPdfMax = 0;
349
+ while ((match = objRegex.exec(content)) !== null) {
350
+ const oldId = parseInt(match[1], 10);
351
+ const objBody = match[2];
352
+ const newId = oldId + offset;
353
+ if (oldId > currentPdfMax)
354
+ currentPdfMax = oldId;
355
+ // Shift references within the object body: "N 0 R" -> "(N+offset) 0 R"
356
+ const shiftedBody = objBody.replace(/(\d+)\s+0\s+R/g, (_, id) => {
357
+ return `${parseInt(id, 10) + offset} 0 R`;
358
+ });
359
+ // Detect if this is a Page object
360
+ if (shiftedBody.includes("/Type /Page") &&
361
+ !shiftedBody.includes("/Type /Pages")) {
362
+ pageIds.push(newId);
363
+ }
364
+ // We skip Catalog and original Pages root, we'll create new ones
365
+ if (!shiftedBody.includes("/Type /Catalog") &&
366
+ !shiftedBody.includes("/Type /Pages")) {
367
+ allObjects.push({
368
+ id: newId,
369
+ content: encoder.encode(`${newId} 0 obj${shiftedBody}endobj\n`),
370
+ });
371
+ }
372
+ }
373
+ maxObjNum += currentPdfMax;
374
+ }
375
+ // Create new Catalog and Pages root
376
+ const catalogId = ++maxObjNum;
377
+ const pagesRootId = ++maxObjNum;
378
+ const newPagesRoot = encoder.encode(`${pagesRootId} 0 obj\n<< /Type /Pages /Kids [${pageIds
379
+ .map((id) => `${id} 0 R`)
380
+ .join(" ")}] /Count ${pageIds.length} >>\nendobj\n`);
381
+ const newCatalog = encoder.encode(`${catalogId} 0 obj\n<< /Type /Catalog /Pages ${pagesRootId} 0 R >>\nendobj\n`);
382
+ allObjects.push({ id: pagesRootId, content: newPagesRoot });
383
+ allObjects.push({ id: catalogId, content: newCatalog });
384
+ // Build the final PDF
385
+ const resultParts = [encoder.encode("%PDF-1.4\n")];
386
+ const xref = [];
387
+ let currentOffset = resultParts[0].length;
388
+ // Sort objects by ID for a clean xref table
389
+ allObjects.sort((a, b) => a.id - b.id);
390
+ for (const obj of allObjects) {
391
+ xref.push({ id: obj.id, offset: currentOffset });
392
+ resultParts.push(obj.content);
393
+ currentOffset += obj.content.length;
394
+ }
395
+ const startXref = currentOffset;
396
+ resultParts.push(encoder.encode("xref\n"));
397
+ resultParts.push(encoder.encode(`0 ${maxObjNum + 1}\n`));
398
+ resultParts.push(encoder.encode("0000000000 65535 f \n"));
399
+ const xrefMap = new Map(xref.map((x) => [x.id, x.offset]));
400
+ for (let i = 1; i <= maxObjNum; i++) {
401
+ const offset = xrefMap.get(i);
402
+ if (offset !== undefined) {
403
+ resultParts.push(encoder.encode(`${offset.toString().padStart(10, "0")} 00000 n \n`));
404
+ }
405
+ else {
406
+ resultParts.push(encoder.encode("0000000000 00001 f \n"));
407
+ }
408
+ }
409
+ resultParts.push(encoder.encode(`trailer\n<< /Size ${maxObjNum + 1} /Root ${catalogId} 0 R >>\n`));
410
+ resultParts.push(encoder.encode(`startxref\n${startXref}\n%%EOF`));
411
+ // Combine all parts
412
+ const totalLength = resultParts.reduce((acc, part) => acc + part.length, 0);
413
+ const merged = new Uint8Array(totalLength);
414
+ let pos = 0;
415
+ for (const part of resultParts) {
416
+ merged.set(part, pos);
417
+ pos += part.length;
418
+ }
419
+ return merged;
420
+ }
311
421
  }
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 (typeof HTMLCanvasElement !== "undefined" && 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 (typeof HTMLImageElement !== "undefined" && src instanceof HTMLImageElement) {
87
+ dest.setAttribute("src", absoluteUrl(src.src));
88
+ }
89
+ else if (typeof HTMLInputElement !== "undefined" && 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 (typeof HTMLTextAreaElement !== "undefined" && src instanceof HTMLTextAreaElement) {
99
+ dest.innerText = src.value;
100
+ }
101
+ else if (typeof HTMLSelectElement !== "undefined" && 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 (typeof HTMLElement !== "undefined" && 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")) {
Binary file
Binary file
package/dist/satoru.wasm CHANGED
Binary file