wasm-html-to-image 0.1.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -0
  3. package/dist/browser-workers.d.ts +12 -0
  4. package/dist/browser-workers.js +26 -0
  5. package/dist/child-workers.d.ts +11 -0
  6. package/dist/child-workers.js +19 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +87 -0
  9. package/dist/core.d.ts +168 -0
  10. package/dist/core.js +824 -0
  11. package/dist/diagnostics.d.ts +93 -0
  12. package/dist/diagnostics.js +28 -0
  13. package/dist/edge-light.d.ts +18 -0
  14. package/dist/edge-light.js +70 -0
  15. package/dist/encode-args.d.ts +10 -0
  16. package/dist/encode-args.js +30 -0
  17. package/dist/html-to-image-single.js +0 -0
  18. package/dist/html-to-image-wasm.js +3 -0
  19. package/dist/html-to-image.js +2 -0
  20. package/dist/html-to-image.wasm +0 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +14 -0
  23. package/dist/input.d.ts +23 -0
  24. package/dist/input.js +90 -0
  25. package/dist/loader.d.ts +132 -0
  26. package/dist/loader.js +199 -0
  27. package/dist/preact.d.ts +10 -0
  28. package/dist/preact.js +13 -0
  29. package/dist/react.d.ts +9 -0
  30. package/dist/react.js +11 -0
  31. package/dist/single.d.ts +27 -0
  32. package/dist/single.js +22 -0
  33. package/dist/tailwind.d.ts +1 -0
  34. package/dist/tailwind.js +6 -0
  35. package/dist/wasm-payload.d.ts +2 -0
  36. package/dist/wasm-payload.js +39 -0
  37. package/dist/web-workers.js +4411 -0
  38. package/dist/worker-actions.d.ts +10 -0
  39. package/dist/worker-actions.js +13 -0
  40. package/dist/worker-lib-loader.d.ts +19 -0
  41. package/dist/worker-lib-loader.js +34 -0
  42. package/dist/workerd.d.ts +18 -0
  43. package/dist/workerd.js +65 -0
  44. package/dist/workers-dummy.d.ts +12 -0
  45. package/dist/workers-dummy.js +6 -0
  46. package/dist/workers-parent.js +602 -0
  47. package/dist/workers.d.ts +86 -0
  48. package/dist/workers.js +163 -0
  49. package/package.json +166 -0
package/dist/core.js ADDED
@@ -0,0 +1,824 @@
1
+ import { importNode } from "./loader.js";
2
+ import { buildSatoruOptions, FIT_INT, FORMAT_INT } from "./encode-args.js";
3
+ import { dataUrlToBytes, imageInputToBytes, isImageInput, toBytes, } from "./input.js";
4
+ import { DIAGNOSTIC_CODES, LogLevel, } from "./diagnostics.js";
5
+ // Backward compatibility: `isImageInput` now lives in `./input.js` but
6
+ // stays importable from here (and therefore from the package root).
7
+ export { isImageInput };
8
+ // Playground parity: diagnostics surface lives in `./diagnostics.js` but
9
+ // stays importable from here (and therefore from `./workers`).
10
+ export { DIAGNOSTIC_CODES, LogLevel };
11
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
12
+ const EMOJI_URL = "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2";
13
+ /**
14
+ * Upstream default font strategy (verbatim from satoru `DEFAULT_FONT_MAP`):
15
+ * generic families resolve to Google Fonts css2 URLs (fetched with a browser
16
+ * UA so woff2 is served); fetched at render time, nothing bundled.
17
+ */
18
+ export const DEFAULT_FONT_MAP = {
19
+ "sans-serif": "https://fonts.googleapis.com/css2?family=Noto+Sans+JP",
20
+ serif: "https://fonts.googleapis.com/css2?family=Noto+Serif+JP",
21
+ monospace: "https://fonts.googleapis.com/css2?family=M+PLUS+1+Code",
22
+ cursive: "https://fonts.googleapis.com/css2?family=Yuji+Syuku",
23
+ fantasy: "https://fonts.googleapis.com/css2?family=Reggae+One",
24
+ "Noto Color Emoji": EMOJI_URL,
25
+ emoji: EMOJI_URL,
26
+ "Noto Emoji": EMOJI_URL,
27
+ notocoloremoji: EMOJI_URL,
28
+ notoemoji: EMOJI_URL,
29
+ };
30
+ /** Process-wide fetched-bytes cache (upstream `resourceCache` parity). */
31
+ const resourceCache = new Map();
32
+ function requireBindingError(name) {
33
+ return new Error(`wasm-html-to-image: single-WASM binding "${name}" is required but not exposed by this build ` +
34
+ `(rebuild the unified module; single-WASM only).`);
35
+ }
36
+ function isNodeRuntime() {
37
+ const proc = globalThis
38
+ .process;
39
+ return typeof proc?.versions?.node === "string";
40
+ }
41
+ function fileUrlToFsPath(p) {
42
+ if (!p.startsWith("file://"))
43
+ return p;
44
+ let out = decodeURIComponent(p.slice("file://".length));
45
+ if (/^\/[A-Za-z]:\//.test(out))
46
+ out = out.slice(1);
47
+ return out;
48
+ }
49
+ /**
50
+ * Fetch external resource bytes. `data:` URLs resolve inline in C++
51
+ * (`request()`), so they are skipped here. Relative URLs resolve against
52
+ * `baseUrl` (http base via fetch, fs path via node:fs on Node).
53
+ */
54
+ async function fetchResourceBytes(url, baseUrl, userAgent, resolveResource) {
55
+ if (url.startsWith("data:"))
56
+ return null;
57
+ const fallback = () => fetchResourceBytesInner(url, baseUrl, userAgent);
58
+ if (resolveResource) {
59
+ try {
60
+ return await resolveResource(url, fallback);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ return fallback();
67
+ }
68
+ /** Built-in resolution behind the `resolveResource` hook (see above). */
69
+ async function fetchResourceBytesInner(url, baseUrl, userAgent) {
70
+ const headers = userAgent ? { "User-Agent": userAgent } : undefined;
71
+ const fetchBytes = async (target) => {
72
+ const cached = resourceCache.get(target);
73
+ if (cached)
74
+ return cached;
75
+ try {
76
+ const res = await fetch(target, headers ? { headers } : undefined);
77
+ if (!res.ok)
78
+ return null;
79
+ const bytes = toBytes(await res.arrayBuffer());
80
+ resourceCache.set(target, bytes);
81
+ return bytes;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ };
87
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) {
88
+ return fetchBytes(url);
89
+ }
90
+ if (baseUrl !== undefined) {
91
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(baseUrl)) {
92
+ try {
93
+ return await fetchBytes(new URL(url, baseUrl).href);
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ if (isNodeRuntime()) {
100
+ try {
101
+ const fs = await importNode("fs/promises");
102
+ const path = await importNode("path");
103
+ const joined = path.join(fileUrlToFsPath(baseUrl), url);
104
+ const cached = resourceCache.get(joined);
105
+ if (cached)
106
+ return cached;
107
+ // Keep an owning copy: fs Buffers may ride a shared pool.
108
+ const bytes = new Uint8Array(await fs.readFile(joined));
109
+ resourceCache.set(joined, bytes);
110
+ return bytes;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ if (isNodeRuntime()) {
119
+ try {
120
+ const cached = resourceCache.get(url);
121
+ if (cached)
122
+ return cached;
123
+ const fs = await importNode("fs/promises");
124
+ // Keep an owning copy: fs Buffers may ride a shared pool.
125
+ const bytes = new Uint8Array(await fs.readFile(url));
126
+ resourceCache.set(url, bytes);
127
+ return bytes;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+ /** Parse the `get_pending_resources` binary form (see loader.ts). */
136
+ function parsePendingResources(bin) {
137
+ if (!bin || bin.length < 4)
138
+ return [];
139
+ const view = new DataView(bin.buffer, bin.byteOffset, bin.byteLength);
140
+ const dec = new TextDecoder();
141
+ let off = 0;
142
+ const readStr = () => {
143
+ if (off + 4 > bin.byteLength)
144
+ return null;
145
+ const len = view.getUint32(off, true);
146
+ off += 4;
147
+ if (off + len > bin.byteLength)
148
+ return null;
149
+ const s = dec.decode(new Uint8Array(bin.buffer, bin.byteOffset + off, len));
150
+ off += len;
151
+ return s;
152
+ };
153
+ const count = view.getUint32(off, true);
154
+ off += 4;
155
+ const out = [];
156
+ for (let i = 0; i < count; i++) {
157
+ if (off + 2 > bin.byteLength)
158
+ break;
159
+ const typeInt = view.getUint8(off);
160
+ off += 2; // type + redraw_on_ready
161
+ const url = readStr();
162
+ const name = readStr();
163
+ if (readStr() === null || url === null || name === null)
164
+ break;
165
+ out.push({
166
+ type: typeInt === 2 ? "image" : typeInt === 3 ? "css" : "font",
167
+ url,
168
+ name,
169
+ });
170
+ }
171
+ return out;
172
+ }
173
+ /** Monotonic-ish clock for timings (satoru parity). */
174
+ function now() {
175
+ return typeof performance !== "undefined" &&
176
+ typeof performance.now === "function"
177
+ ? performance.now()
178
+ : Date.now();
179
+ }
180
+ /**
181
+ * Protocol/host allow-list check. Returns a block reason, or `null` when
182
+ * allowed. Relative URLs skip the check (satoru parity).
183
+ */
184
+ function checkResourceAllowed(url, limits) {
185
+ if (!limits.allowedProtocols &&
186
+ !limits.allowedHosts &&
187
+ !limits.blockedHosts) {
188
+ return null;
189
+ }
190
+ let parsed = null;
191
+ try {
192
+ parsed = new URL(url);
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ if (limits.allowedProtocols &&
198
+ !limits.allowedProtocols.includes(parsed.protocol)) {
199
+ return `Protocol ${parsed.protocol} is blocked`;
200
+ }
201
+ if (limits.allowedHosts && !limits.allowedHosts.includes(parsed.hostname)) {
202
+ return `Host ${parsed.hostname} is not in allowed list`;
203
+ }
204
+ if (limits.blockedHosts && limits.blockedHosts.includes(parsed.hostname)) {
205
+ return `Host ${parsed.hostname} is blocked`;
206
+ }
207
+ return null;
208
+ }
209
+ /** Limit-violation code for a block reason (satoru parity). */
210
+ function limitCodeForReason(reason) {
211
+ if (reason.startsWith("Protocol "))
212
+ return DIAGNOSTIC_CODES.LIMIT_PROTOCOL_BLOCKED;
213
+ return DIAGNOSTIC_CODES.LIMIT_HOST_BLOCKED;
214
+ }
215
+ /**
216
+ * Upstream-style discovery loop: collect pending URLs on the satoru
217
+ * instance, fetch them (file/http), and inject the bytes back, so the
218
+ * subsequent render on the SAME instance sees cached fonts/images.
219
+ * Missing bindings (old builds) skip the loop silently.
220
+ */
221
+ async function resolveHtmlResources(module, satoruInst, htmls, width, height, baseUrl, fontMap, userAgent, fallbackFonts, ctx) {
222
+ const collect = module.satoru_collect_resources;
223
+ const getPending = module.satoru_get_pending_resources;
224
+ const addRes = module.satoru_add_resource;
225
+ if (typeof collect !== "function" ||
226
+ typeof getPending !== "function" ||
227
+ typeof addRes !== "function")
228
+ return;
229
+ // Upstream default: generic families resolve via fontMap before discovery.
230
+ if (fontMap) {
231
+ const setFontMap = module.satoru_set_font_map;
232
+ if (typeof setFontMap === "function") {
233
+ (await setFontMap(satoruInst, { ...fontMap }));
234
+ ctx.emitLog(LogLevel.Debug, "fontMap applied");
235
+ }
236
+ }
237
+ // Upstream idiom: user-supplied fallback fonts go in before discovery.
238
+ if (fallbackFonts && fallbackFonts.length > 0) {
239
+ const loadFallback = module.satoru_load_fallback_font;
240
+ if (typeof loadFallback === "function") {
241
+ for (const entry of fallbackFonts) {
242
+ try {
243
+ const bytes = typeof entry === "string"
244
+ ? entry.startsWith("data:")
245
+ ? dataUrlToBytes(entry)
246
+ : await fetchResourceBytes(entry, baseUrl, userAgent, ctx.resolveResource)
247
+ : entry instanceof Uint8Array
248
+ ? entry
249
+ : new Uint8Array(entry);
250
+ if (!bytes || bytes.length === 0)
251
+ continue;
252
+ (await loadFallback(satoruInst, bytes));
253
+ ctx.diag?.fonts.push({
254
+ family: "(fallback)",
255
+ status: "loaded",
256
+ source: typeof entry === "string" ? entry : "(bytes)",
257
+ });
258
+ }
259
+ catch {
260
+ // Per-font failure is non-fatal; discovery proceeds regardless.
261
+ ctx.diag?.warnings.push({
262
+ code: DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED,
263
+ message: "Failed to load fallback font entry",
264
+ });
265
+ }
266
+ }
267
+ }
268
+ }
269
+ // Named font preloads (`fonts` option), before discovery (satoru parity).
270
+ if (ctx.fonts && ctx.fonts.length > 0) {
271
+ const loadFont = module.satoru_load_font;
272
+ if (typeof loadFont === "function") {
273
+ for (const f of ctx.fonts) {
274
+ try {
275
+ (await loadFont(satoruInst, f.name, f.data));
276
+ ctx.diag?.fonts.push({
277
+ family: f.name,
278
+ status: "loaded",
279
+ source: "fonts option",
280
+ });
281
+ }
282
+ catch (e) {
283
+ ctx.diag?.warnings.push({
284
+ code: DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED,
285
+ message: e instanceof Error ? e.message : String(e),
286
+ source: f.name,
287
+ });
288
+ }
289
+ }
290
+ }
291
+ }
292
+ // Extra CSS pre-scan (`css` option), before discovery (satoru parity).
293
+ if (ctx.css) {
294
+ const scanCss = module.satoru_scan_css;
295
+ if (typeof scanCss === "function") {
296
+ try {
297
+ (await scanCss(satoruInst, ctx.css));
298
+ ctx.emitLog(LogLevel.Debug, "css pre-scanned");
299
+ }
300
+ catch (e) {
301
+ ctx.diag?.warnings.push({
302
+ code: DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED,
303
+ message: e instanceof Error ? e.message : String(e),
304
+ source: "(css option)",
305
+ });
306
+ }
307
+ }
308
+ }
309
+ // Collect-phase profiling (merged into timings when diagnostics on).
310
+ const setProfile = module.satoru_set_collect_profile_enabled;
311
+ const profileEnabled = ctx.diag !== null && typeof setProfile === "function";
312
+ if (profileEnabled) {
313
+ try {
314
+ (await setProfile(satoruInst, true));
315
+ }
316
+ catch {
317
+ // Non-fatal; timings simply miss the C++ breakdown.
318
+ }
319
+ }
320
+ const list = Array.isArray(htmls) ? htmls : [htmls];
321
+ for (let round = 0; round < 10; round++) {
322
+ ctx.checkTimeout();
323
+ let progressed = false;
324
+ for (const html of list) {
325
+ (await collect(satoruInst, html, width, height ?? 0, ctx.mediaTypeInt));
326
+ const bin = (await getPending(satoruInst));
327
+ const pending = parsePendingResources(bin instanceof Uint8Array ? bin : undefined);
328
+ if (pending.length === 0)
329
+ continue;
330
+ progressed = true;
331
+ await Promise.all(pending.map(async (r) => {
332
+ const diag = ctx.diag;
333
+ const entry = diag
334
+ ? {
335
+ type: r.type,
336
+ url: r.url,
337
+ name: r.name || undefined,
338
+ status: "pending",
339
+ }
340
+ : undefined;
341
+ if (diag && entry)
342
+ diag.resources.push(entry);
343
+ const settle = (status, bytes, reason) => {
344
+ if (!entry)
345
+ return;
346
+ entry.status = status;
347
+ if (bytes !== undefined)
348
+ entry.bytes = bytes;
349
+ if (reason !== undefined)
350
+ entry.reason = reason;
351
+ };
352
+ const skipWithError = (code, message) => {
353
+ settle("skipped", undefined, message);
354
+ diag?.errors.push({ code, message, source: r.url });
355
+ ctx.emitLog(LogLevel.Warning, `${message} (${r.url})`);
356
+ };
357
+ try {
358
+ if (r.url.startsWith("data:")) {
359
+ // Inline data: URLs resolve inside C++; nothing to fetch.
360
+ settle("skipped", undefined, "inline data URL (resolved in C++)");
361
+ return;
362
+ }
363
+ if (ctx.limits.maxResourceCount !== undefined &&
364
+ diag &&
365
+ diag.resourceCount >= ctx.limits.maxResourceCount) {
366
+ const message = `Maximum resource count (${ctx.limits.maxResourceCount}) exceeded`;
367
+ skipWithError(DIAGNOSTIC_CODES.LIMIT_RESOURCE_COUNT, message);
368
+ return;
369
+ }
370
+ const blocked = checkResourceAllowed(r.url, ctx.limits);
371
+ if (blocked) {
372
+ skipWithError(limitCodeForReason(blocked), blocked);
373
+ return;
374
+ }
375
+ const bytes = await fetchResourceBytes(r.url, baseUrl, userAgent, ctx.resolveResource);
376
+ if (!bytes) {
377
+ settle("failed");
378
+ const message = `Failed to fetch resource: ${r.url}`;
379
+ diag?.warnings.push({
380
+ code: DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED,
381
+ message,
382
+ source: r.url,
383
+ });
384
+ ctx.emitLog(LogLevel.Warning, message);
385
+ return;
386
+ }
387
+ if (ctx.limits.maxResourceBytes !== undefined &&
388
+ bytes.length > ctx.limits.maxResourceBytes) {
389
+ const message = `Resource size (${bytes.length} bytes) exceeds limit ` +
390
+ `(${ctx.limits.maxResourceBytes})`;
391
+ skipWithError(DIAGNOSTIC_CODES.LIMIT_RESOURCE_SIZE, message);
392
+ return;
393
+ }
394
+ if (ctx.limits.maxTotalResourceBytes !== undefined &&
395
+ diag &&
396
+ diag.totalResourceBytes + bytes.length >
397
+ ctx.limits.maxTotalResourceBytes) {
398
+ const message = `Total resource size exceeds limit (${ctx.limits.maxTotalResourceBytes})`;
399
+ skipWithError(DIAGNOSTIC_CODES.LIMIT_TOTAL_SIZE, message);
400
+ return;
401
+ }
402
+ if (diag) {
403
+ diag.resourceCount++;
404
+ diag.totalResourceBytes += bytes.length;
405
+ }
406
+ settle("loaded", bytes.length);
407
+ (await addRes(satoruInst, r.url, r.type === "image" ? 2 : r.type === "css" ? 3 : 1, bytes));
408
+ if (r.type === "font") {
409
+ diag?.fonts.push({
410
+ family: r.name || r.url,
411
+ status: "loaded",
412
+ source: r.url,
413
+ });
414
+ }
415
+ }
416
+ catch (e) {
417
+ // Per-resource failure is non-fatal; render proceeds regardless.
418
+ const message = e instanceof Error ? e.message : String(e);
419
+ settle("failed", undefined, message);
420
+ diag?.warnings.push({
421
+ code: DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED,
422
+ message,
423
+ source: r.url,
424
+ });
425
+ }
426
+ }));
427
+ }
428
+ if (!progressed)
429
+ break;
430
+ }
431
+ // Merge the C++ collect-phase breakdown into the JS timings.
432
+ if (profileEnabled && ctx.diag) {
433
+ const getProfile = module.satoru_get_collect_profile;
434
+ if (typeof getProfile === "function") {
435
+ try {
436
+ const parsed = JSON.parse((await getProfile(satoruInst)));
437
+ for (const [key, value] of Object.entries(parsed)) {
438
+ if (typeof value === "number") {
439
+ ctx.addTime(key, value);
440
+ }
441
+ }
442
+ }
443
+ catch {
444
+ // Non-fatal; timings simply miss the C++ breakdown.
445
+ }
446
+ }
447
+ }
448
+ }
449
+ export async function htmlToImage(module, options) {
450
+ const { format = "png", quality = 85, speed = 6, animation = false, width, height, crop, fit, value, url, baseUrl, fontMap, userAgent, fallbackFonts, logLevel = LogLevel.None, onLog, diagnostics = false, onDiagnostics, resolveResource, mediaType = "screen", textToPaths, css, fonts: preloadFonts, limits = {}, pdfTitle, pdfAuthor, pdfSubject, pdfKeywords, pdfCreator, pdfProducer, pdfMargin, pdfHeader, pdfFooter, } = options;
451
+ const fmtInt = FORMAT_INT[format];
452
+ const mediaTypeInt = mediaType === "print" ? 1 : 0;
453
+ /** JS-side log hook, gated by `logLevel` (satoru parity). */
454
+ const emitLog = (level, message) => {
455
+ if (onLog && logLevel !== LogLevel.None && level <= logLevel) {
456
+ try {
457
+ onLog(level, message);
458
+ }
459
+ catch {
460
+ // User hooks must not break rendering.
461
+ }
462
+ }
463
+ };
464
+ /** Build an Error with an Error-level log line (`throw logged(...)`). */
465
+ const logged = (message) => {
466
+ emitLog(LogLevel.Error, message);
467
+ return new Error(message);
468
+ };
469
+ const loggedBinding = (name) => {
470
+ const e = requireBindingError(name);
471
+ emitLog(LogLevel.Error, e.message);
472
+ return e;
473
+ };
474
+ const t0 = now();
475
+ const diag = diagnostics
476
+ ? {
477
+ resources: [],
478
+ fonts: [],
479
+ warnings: [],
480
+ errors: [],
481
+ totalResourceBytes: 0,
482
+ resourceCount: 0,
483
+ }
484
+ : null;
485
+ const timings = {};
486
+ const addTime = (name, ms) => {
487
+ if (diag)
488
+ timings[name] = (timings[name] ?? 0) + ms;
489
+ };
490
+ const checkTimeout = () => {
491
+ if (limits.timeoutMs !== undefined && now() - t0 >= limits.timeoutMs) {
492
+ const message = `Render timed out after ${limits.timeoutMs}ms`;
493
+ diag?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_TIMEOUT, message });
494
+ throw logged(message);
495
+ }
496
+ };
497
+ /** Deliver the diagnostics report on success (satoru parity). */
498
+ const deliverReport = () => {
499
+ if (!diag || !onDiagnostics)
500
+ return;
501
+ try {
502
+ onDiagnostics({
503
+ version: 1,
504
+ format: format,
505
+ width,
506
+ height,
507
+ mediaType,
508
+ timings,
509
+ resources: diag.resources,
510
+ fonts: diag.fonts,
511
+ warnings: diag.warnings,
512
+ errors: diag.errors,
513
+ });
514
+ }
515
+ catch {
516
+ // User hooks must not break rendering.
517
+ }
518
+ };
519
+ const resolveCtx = {
520
+ mediaTypeInt,
521
+ css,
522
+ fonts: preloadFonts,
523
+ limits,
524
+ resolveResource,
525
+ diag,
526
+ t0,
527
+ emitLog,
528
+ addTime,
529
+ checkTimeout,
530
+ };
531
+ if (logLevel !== LogLevel.None) {
532
+ try {
533
+ module.satoru_set_log_level(logLevel);
534
+ }
535
+ catch {
536
+ // Non-fatal; JS-side logging still works.
537
+ }
538
+ }
539
+ emitLog(LogLevel.Info, `render start: format=${format} width=${width}`);
540
+ // ---- Image input: skip rendering, straight to converter_* ----
541
+ if (isImageInput(value)) {
542
+ const loadImage = module.converter_load_image;
543
+ if (typeof loadImage !== "function")
544
+ throw loggedBinding("converter_load_image");
545
+ const bytes = imageInputToBytes(value);
546
+ const inst = module.converter_create_instance();
547
+ try {
548
+ if (!loadImage(inst, bytes)) {
549
+ throw logged("wasm-html-to-image: failed to load image input (unsupported or corrupt)");
550
+ }
551
+ const originalWidth = module.converter_get_original_width?.(inst) ?? 0;
552
+ const originalHeight = module.converter_get_original_height?.(inst) ?? 0;
553
+ const originalFormat = module.converter_get_original_format?.(inst) ?? "";
554
+ const originalAnimation = module.converter_is_original_animation?.(inst) ?? false;
555
+ if (format === "none") {
556
+ addTime("total", now() - t0);
557
+ emitLog(LogLevel.Info, "render done (image input, none/passthrough)");
558
+ deliverReport();
559
+ return {
560
+ data: bytes,
561
+ width: originalWidth,
562
+ height: originalHeight,
563
+ format: "none",
564
+ originalWidth,
565
+ originalHeight,
566
+ originalFormat,
567
+ originalAnimation,
568
+ animation: originalAnimation,
569
+ };
570
+ }
571
+ if (crop) {
572
+ module.converter_crop(inst, crop.x, crop.y, crop.width, crop.height);
573
+ }
574
+ if (width !== undefined || height !== undefined) {
575
+ module.converter_resize(inst, width ?? 0, height ?? 0, FIT_INT[fit ?? "contain"]);
576
+ }
577
+ const outWidth = module.converter_get_width?.(inst) ?? width ?? originalWidth;
578
+ const outHeight = module.converter_get_height?.(inst) ?? height ?? originalHeight;
579
+ const outAnimation = module.converter_is_animation?.(inst) ?? animation;
580
+ const outFormat = module.converter_get_format?.(inst) || format;
581
+ if (format === "svg") {
582
+ const svgBinding = module.converter_encode_svg;
583
+ if (typeof svgBinding !== "function")
584
+ throw loggedBinding("converter_encode_svg");
585
+ const encodeStart = now();
586
+ const svg = (await svgBinding(inst));
587
+ addTime("encode", now() - encodeStart);
588
+ if (!svg) {
589
+ throw logged("wasm-html-to-image: failed to encode image to svg");
590
+ }
591
+ addTime("total", now() - t0);
592
+ emitLog(LogLevel.Info, "render done (image input, svg)");
593
+ deliverReport();
594
+ return {
595
+ data: svg,
596
+ width: outWidth,
597
+ height: outHeight,
598
+ format: "svg",
599
+ originalWidth,
600
+ originalHeight,
601
+ originalFormat,
602
+ originalAnimation,
603
+ animation: false,
604
+ };
605
+ }
606
+ if (format === "pdf") {
607
+ const pdfBinding = module.converter_encode_pdf;
608
+ if (typeof pdfBinding !== "function")
609
+ throw loggedBinding("converter_encode_pdf");
610
+ const encodeStart = now();
611
+ const out = (await pdfBinding(inst));
612
+ addTime("encode", now() - encodeStart);
613
+ if (out == null) {
614
+ throw logged("wasm-html-to-image: failed to encode image to pdf");
615
+ }
616
+ addTime("total", now() - t0);
617
+ emitLog(LogLevel.Info, "render done (image input, pdf)");
618
+ deliverReport();
619
+ return {
620
+ data: new Uint8Array(out instanceof Uint8Array
621
+ ? out
622
+ : new Uint8Array(out)),
623
+ width: outWidth,
624
+ height: outHeight,
625
+ format: "pdf",
626
+ originalWidth,
627
+ originalHeight,
628
+ originalFormat,
629
+ originalAnimation,
630
+ animation: false,
631
+ };
632
+ }
633
+ const encodeBinding = module.converter_encode;
634
+ if (typeof encodeBinding !== "function")
635
+ throw loggedBinding("converter_encode");
636
+ const encodeStart = now();
637
+ const out = (await encodeBinding(inst, fmtInt, quality, speed, animation));
638
+ addTime("encode", now() - encodeStart);
639
+ if (out == null) {
640
+ throw logged("wasm-html-to-image: failed to encode image");
641
+ }
642
+ addTime("total", now() - t0);
643
+ emitLog(LogLevel.Info, `render done (image input, ${format})`);
644
+ deliverReport();
645
+ return {
646
+ data: new Uint8Array(out instanceof Uint8Array ? out : new Uint8Array(out)),
647
+ width: outWidth,
648
+ height: outHeight,
649
+ format: outFormat,
650
+ originalWidth,
651
+ originalHeight,
652
+ originalFormat,
653
+ originalAnimation,
654
+ animation: outAnimation,
655
+ };
656
+ }
657
+ finally {
658
+ module.converter_destroy_instance(inst);
659
+ }
660
+ }
661
+ // ---- HTML input: resolve resources on the instance, then render ----
662
+ // (unified `html_to_image` builds its own instance internally and cannot
663
+ // see pre-resolved fonts/images, so it is only a legacy fallback here).
664
+ if (format === "none") {
665
+ throw logged("wasm-html-to-image: 'none' format is only supported for image inputs");
666
+ }
667
+ let htmls;
668
+ if (value !== undefined) {
669
+ htmls = value;
670
+ }
671
+ else if (typeof url === "string") {
672
+ const headers = { "User-Agent": userAgent ?? DEFAULT_USER_AGENT };
673
+ const fetchStart = now();
674
+ if (resolveResource) {
675
+ const fallbackFetch = async () => {
676
+ const res = await fetch(url, { headers });
677
+ if (!res.ok)
678
+ return null;
679
+ return toBytes(await res.arrayBuffer());
680
+ };
681
+ const raw = await resolveResource(url, fallbackFetch);
682
+ if (!raw) {
683
+ throw logged(`wasm-html-to-image: failed to fetch HTML from URL: ${url}`);
684
+ }
685
+ htmls = new TextDecoder().decode(raw);
686
+ }
687
+ else {
688
+ const res = await fetch(url, { headers });
689
+ if (!res.ok) {
690
+ throw logged(`wasm-html-to-image: failed to fetch HTML from URL: ${url} (${res.status})`);
691
+ }
692
+ htmls = await res.text();
693
+ }
694
+ addTime("fetchHtml", now() - fetchStart);
695
+ }
696
+ else {
697
+ throw logged("wasm-html-to-image: either 'value' or 'url' must be provided.");
698
+ }
699
+ const satoruOpts = {
700
+ ...buildSatoruOptions(crop, fit),
701
+ svgTextToPaths: textToPaths ?? true,
702
+ mediaType: mediaTypeInt,
703
+ pdfTitle: pdfTitle ?? "",
704
+ pdfAuthor: pdfAuthor ?? "",
705
+ pdfSubject: pdfSubject ?? "",
706
+ pdfKeywords: pdfKeywords ?? "",
707
+ pdfCreator: pdfCreator ?? "",
708
+ pdfProducer: pdfProducer ?? "",
709
+ pdfMarginTop: pdfMargin?.top ?? 0,
710
+ pdfMarginRight: pdfMargin?.right ?? 0,
711
+ pdfMarginBottom: pdfMargin?.bottom ?? 0,
712
+ pdfMarginLeft: pdfMargin?.left ?? 0,
713
+ pdfHeader: pdfHeader ?? "",
714
+ pdfFooter: pdfFooter ?? "",
715
+ };
716
+ const renderBinding = module.satoru_render;
717
+ const encodeBinding = module.converter_encode;
718
+ if (typeof renderBinding === "function" &&
719
+ typeof encodeBinding === "function") {
720
+ const loadImage = module.converter_load_image;
721
+ if (typeof loadImage !== "function")
722
+ throw loggedBinding("converter_load_image");
723
+ const sInst = module.satoru_create_instance();
724
+ try {
725
+ const resolveStart = now();
726
+ await resolveHtmlResources(module, sInst, htmls, width, height, baseUrl, fontMap ?? DEFAULT_FONT_MAP, userAgent ?? DEFAULT_USER_AGENT, fallbackFonts, resolveCtx);
727
+ addTime("resolveResources", now() - resolveStart);
728
+ emitLog(LogLevel.Info, "resources resolved");
729
+ if (format === "svg" || format === "pdf") {
730
+ const renderStart = now();
731
+ const out = (await renderBinding(sInst, htmls, width, height ?? 0, fmtInt, satoruOpts));
732
+ addTime("render", now() - renderStart);
733
+ if (out == null) {
734
+ throw logged("wasm-html-to-image: satoru_render returned null");
735
+ }
736
+ const bytes = new Uint8Array(out instanceof Uint8Array ? out : new Uint8Array(out));
737
+ addTime("total", now() - t0);
738
+ emitLog(LogLevel.Info, `render done: format=${format}`);
739
+ deliverReport();
740
+ if (format === "svg") {
741
+ const svgStr = new TextDecoder().decode(bytes);
742
+ let outHeight = height ?? 0;
743
+ if (outHeight === 0) {
744
+ const matchH = svgStr.match(/height="(\d+(?:\.\d+)?)"/);
745
+ if (matchH) {
746
+ outHeight = Math.round(parseFloat(matchH[1]));
747
+ }
748
+ }
749
+ return {
750
+ data: svgStr,
751
+ width,
752
+ height: outHeight,
753
+ format: "svg",
754
+ };
755
+ }
756
+ return {
757
+ data: bytes,
758
+ width,
759
+ height: height ?? 0,
760
+ format: "pdf",
761
+ };
762
+ }
763
+ const renderStart = now();
764
+ const png = (await renderBinding(sInst, htmls, width, height ?? 0, FORMAT_INT.png, satoruOpts));
765
+ addTime("render", now() - renderStart);
766
+ if (png == null) {
767
+ throw logged("wasm-html-to-image: satoru_render returned null");
768
+ }
769
+ const cInst = module.converter_create_instance();
770
+ try {
771
+ if (!loadImage(cInst, png)) {
772
+ throw logged("wasm-html-to-image: failed to load render output");
773
+ }
774
+ const outWidth = module.converter_get_width?.(cInst) ?? width;
775
+ const outHeight = module.converter_get_height?.(cInst) ?? height ?? 0;
776
+ const outAnimation = module.converter_is_animation?.(cInst) ?? animation;
777
+ const outFormat = module.converter_get_format?.(cInst) || format;
778
+ const encodeStart = now();
779
+ const out = (await encodeBinding(cInst, fmtInt, quality, speed, animation));
780
+ addTime("encode", now() - encodeStart);
781
+ if (out == null) {
782
+ throw logged("wasm-html-to-image: failed to encode image");
783
+ }
784
+ addTime("total", now() - t0);
785
+ emitLog(LogLevel.Info, `render done: format=${format}`);
786
+ deliverReport();
787
+ return {
788
+ data: new Uint8Array(out instanceof Uint8Array
789
+ ? out
790
+ : new Uint8Array(out)),
791
+ width: outWidth,
792
+ height: outHeight,
793
+ format: outFormat,
794
+ animation: outAnimation,
795
+ };
796
+ }
797
+ finally {
798
+ module.converter_destroy_instance(cInst);
799
+ }
800
+ }
801
+ finally {
802
+ module.satoru_destroy_instance(sInst);
803
+ }
804
+ }
805
+ // Legacy fallback for builds without instance render bindings.
806
+ const unified = module.html_to_image;
807
+ if (typeof unified === "function") {
808
+ const out = (await unified(htmls, width, height ?? 0, fmtInt, satoruOpts, quality, speed, animation));
809
+ if (out == null) {
810
+ throw logged("wasm-html-to-image: html_to_image returned null");
811
+ }
812
+ const bytes = new Uint8Array(out instanceof Uint8Array ? out : new Uint8Array(out));
813
+ addTime("total", now() - t0);
814
+ emitLog(LogLevel.Info, `render done (legacy): format=${format}`);
815
+ deliverReport();
816
+ return {
817
+ data: format === "svg" ? new TextDecoder().decode(bytes) : bytes,
818
+ width,
819
+ height: height ?? 0,
820
+ format,
821
+ };
822
+ }
823
+ throw loggedBinding("satoru_render");
824
+ }