satoru-render 0.0.18 → 0.0.19

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
@@ -220,17 +220,56 @@ const png = await render({
220
220
 
221
221
  ---
222
222
 
223
+ ### 6. JSDOM Hydration (For Next.js / SPAs)
224
+
225
+ For complex client-side applications (like Next.js) that require full Javascript evaluation and DOM hydration before rendering, Satoru provides an optional `jsdom` helper.
226
+
227
+ _Note: `jsdom` must be installed separately in your project (`npm install jsdom`)._
228
+
229
+ ```typescript
230
+ import { render } from "satoru-render";
231
+ import { getHtml } from "satoru-render/jsdom";
232
+
233
+ // 1. Let JSDOM fetch the URL, execute scripts, and wait for network/hydration
234
+ const hydratedHtml = await getHtml({
235
+ src: "https://example.com/",
236
+ waitUntil: "networkidle", // Wait until Next.js finishes loading chunks
237
+ beforeParse: (window) => {
238
+ // Provide polyfills if the target site requires them
239
+ window.matchMedia = () => ({ matches: false, addListener: () => {} });
240
+ window.IntersectionObserver = class {
241
+ observe() {}
242
+ unobserve() {}
243
+ disconnect() {}
244
+ };
245
+ },
246
+ });
247
+
248
+ // 2. Render the fully constructed DOM in Satoru (at native speed)
249
+ const pngBytes = await render({
250
+ value: hydratedHtml,
251
+ baseUrl: "https://example.com/",
252
+ width: 1200,
253
+ format: "png",
254
+ });
255
+ ```
256
+
257
+ ---
258
+
223
259
  ## 💻 CLI Tool
224
260
 
225
261
  Convert files or URLs directly from your terminal.
226
262
 
227
263
  ```bash
228
- # Local HTML to PNG
264
+ # Local HTML to PNG (JSDOM hydration enabled by default)
229
265
  npx satoru-render input.html -o output.png
230
266
 
231
267
  # URL to PDF with specific width
232
268
  npx satoru-render https://example.com -o site.pdf -w 1280
233
269
 
270
+ # Convert without JSDOM hydration
271
+ npx satoru-render https://example.com --no-jsdom -o example.pdf
272
+
234
273
  # WebP conversion with verbose logs
235
274
  npx satoru-render input.html -f webp --verbose
236
275
  ```
@@ -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
- renderOptions.url = input;
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
- renderOptions.value = fs.readFileSync(input, "utf-8");
82
- renderOptions.baseUrl = path.dirname(path.resolve(input));
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;
@@ -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);
@@ -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>;
package/dist/jsdom.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Hydrates an HTML string or URL using JSDOM and returns the final rendered HTML.
3
+ * Note: Requires 'jsdom' to be installed as a peer dependency.
4
+ */
5
+ export async function getHtml(options) {
6
+ let jsdomModule;
7
+ try {
8
+ jsdomModule = await import("jsdom");
9
+ }
10
+ catch (e) {
11
+ throw new Error("[Satoru] 'jsdom' is required to use hydrateHtml. Please run: npm install jsdom");
12
+ }
13
+ const { JSDOM, VirtualConsole } = jsdomModule.default || jsdomModule;
14
+ const isUrl = /^https?:\/\//.test(options.src);
15
+ const finalBaseUrl = options.baseUrl || (isUrl ? options.src : "http://localhost/");
16
+ const virtualConsole = new VirtualConsole();
17
+ if (options.forwardConsole) {
18
+ virtualConsole.forwardTo(console);
19
+ }
20
+ const jsdomOptions = {
21
+ runScripts: "dangerously",
22
+ resources: "usable",
23
+ pretendToBeVisual: true,
24
+ url: finalBaseUrl,
25
+ virtualConsole,
26
+ beforeParse: (window) => {
27
+ // Provide basic polyfills often needed by modern frameworks
28
+ if (!window.fetch) {
29
+ window.fetch = async (url, opts) => {
30
+ const absoluteUrl = new URL(url, finalBaseUrl).href;
31
+ return await globalThis.fetch(absoluteUrl, opts);
32
+ };
33
+ }
34
+ window.matchMedia = window.matchMedia || (() => ({
35
+ matches: false,
36
+ addListener: () => { },
37
+ removeListener: () => { },
38
+ addEventListener: () => { },
39
+ removeEventListener: () => { },
40
+ dispatchEvent: () => false,
41
+ }));
42
+ window.IntersectionObserver = window.IntersectionObserver || class {
43
+ observe() { }
44
+ unobserve() { }
45
+ disconnect() { }
46
+ };
47
+ window.ResizeObserver = window.ResizeObserver || class {
48
+ observe() { }
49
+ unobserve() { }
50
+ disconnect() { }
51
+ };
52
+ if (options.beforeParse) {
53
+ options.beforeParse(window);
54
+ }
55
+ },
56
+ };
57
+ let dom;
58
+ if (isUrl) {
59
+ const { url, ...fromUrlOptions } = jsdomOptions;
60
+ dom = await JSDOM.fromURL(options.src, fromUrlOptions);
61
+ }
62
+ else {
63
+ dom = new JSDOM(options.src, jsdomOptions);
64
+ }
65
+ const timeoutMs = options.timeout || 10000;
66
+ const startTime = Date.now();
67
+ const checkWaitCondition = async () => {
68
+ if (Date.now() - startTime >= timeoutMs) {
69
+ return true; // Timeout reached
70
+ }
71
+ if (typeof options.waitUntil === "number") {
72
+ return Date.now() - startTime >= options.waitUntil;
73
+ }
74
+ if (options.waitUntil === "networkidle") {
75
+ // Wait at least 2 seconds for heavy sites like Zenn
76
+ return Date.now() - startTime >= 2000;
77
+ }
78
+ if (typeof options.waitUntil === "function") {
79
+ return await options.waitUntil(dom.window);
80
+ }
81
+ // Default: wait a short tick for basic sync scripts
82
+ return Date.now() - startTime >= 50;
83
+ };
84
+ // Polling loop
85
+ while (!(await checkWaitCondition())) {
86
+ await new Promise((resolve) => setTimeout(resolve, 50));
87
+ }
88
+ const removeScripts = options.removeScripts ?? true;
89
+ if (removeScripts) {
90
+ const scripts = dom.window.document.querySelectorAll("script");
91
+ scripts.forEach((s) => s.remove());
92
+ }
93
+ const finalHtml = dom.serialize();
94
+ // Cleanup to prevent memory leaks
95
+ if (dom.window) {
96
+ dom.window.close();
97
+ }
98
+ return finalHtml;
99
+ }
Binary file