wasm-html-to-image 0.1.3 → 0.1.5

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.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Resource layer split from `./core.ts` (behavior unchanged).
3
+ *
4
+ * Owns: fetched-bytes cache, `fetchResourceBytes`, pending-resources
5
+ * parsing, allow-list checks, and the `resolveHtmlResources` discovery loop.
6
+ * Orchestration context types (`DiagState`, `ResolveContext`) live here so
7
+ * both the loop and the orchestration/pipeline layer share them.
8
+ */
9
+ import { importNode } from "./loader.js";
10
+ import { dataUrlToBytes, toBytes } from "./input.js";
11
+ import { DIAGNOSTIC_CODES, LogLevel, errorToDiagnostic, } from "./diagnostics.js";
12
+ /** Process-wide fetched-bytes cache (upstream `resourceCache` parity). */
13
+ const resourceCache = new Map();
14
+ function isNodeRuntime() {
15
+ const proc = globalThis
16
+ .process;
17
+ return typeof proc?.versions?.node === "string";
18
+ }
19
+ function fileUrlToFsPath(p) {
20
+ if (!p.startsWith("file://"))
21
+ return p;
22
+ let out = decodeURIComponent(p.slice("file://".length));
23
+ if (/^\/[A-Za-z]:\//.test(out))
24
+ out = out.slice(1);
25
+ return out;
26
+ }
27
+ /**
28
+ * Fetch external resource bytes. `data:` URLs resolve inline in C++
29
+ * (`request()`), so they are skipped here. Relative URLs resolve against
30
+ * `baseUrl` (http base via fetch, fs path via node:fs on Node).
31
+ */
32
+ export async function fetchResourceBytes(url, baseUrl, userAgent, resolveResource) {
33
+ if (url.startsWith("data:"))
34
+ return null;
35
+ const fallback = () => fetchResourceBytesInner(url, baseUrl, userAgent);
36
+ if (resolveResource) {
37
+ try {
38
+ return await resolveResource(url, fallback);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ return fallback();
45
+ }
46
+ /** Built-in resolution behind the `resolveResource` hook (see above). */
47
+ async function fetchResourceBytesInner(url, baseUrl, userAgent) {
48
+ const headers = userAgent ? { "User-Agent": userAgent } : undefined;
49
+ const fetchBytes = async (target) => {
50
+ const cached = resourceCache.get(target);
51
+ if (cached)
52
+ return cached;
53
+ try {
54
+ const res = await fetch(target, headers ? { headers } : undefined);
55
+ if (!res.ok)
56
+ return null;
57
+ const bytes = toBytes(await res.arrayBuffer());
58
+ resourceCache.set(target, bytes);
59
+ return bytes;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ };
65
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) {
66
+ return fetchBytes(url);
67
+ }
68
+ if (baseUrl !== undefined) {
69
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(baseUrl)) {
70
+ try {
71
+ return await fetchBytes(new URL(url, baseUrl).href);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ if (isNodeRuntime()) {
78
+ try {
79
+ const fs = await importNode("fs/promises");
80
+ const path = await importNode("path");
81
+ const joined = path.join(fileUrlToFsPath(baseUrl), url);
82
+ const cached = resourceCache.get(joined);
83
+ if (cached)
84
+ return cached;
85
+ // Keep an owning copy: fs Buffers may ride a shared pool.
86
+ const bytes = new Uint8Array(await fs.readFile(joined));
87
+ resourceCache.set(joined, bytes);
88
+ return bytes;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ if (isNodeRuntime()) {
97
+ try {
98
+ const cached = resourceCache.get(url);
99
+ if (cached)
100
+ return cached;
101
+ const fs = await importNode("fs/promises");
102
+ // Keep an owning copy: fs Buffers may ride a shared pool.
103
+ const bytes = new Uint8Array(await fs.readFile(url));
104
+ resourceCache.set(url, bytes);
105
+ return bytes;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+ /** Parse the `get_pending_resources` binary form (see loader.ts). */
114
+ export function parsePendingResources(bin) {
115
+ if (!bin || bin.length < 4)
116
+ return [];
117
+ const view = new DataView(bin.buffer, bin.byteOffset, bin.byteLength);
118
+ const dec = new TextDecoder();
119
+ let off = 0;
120
+ const readStr = () => {
121
+ if (off + 4 > bin.byteLength)
122
+ return null;
123
+ const len = view.getUint32(off, true);
124
+ off += 4;
125
+ if (off + len > bin.byteLength)
126
+ return null;
127
+ const s = dec.decode(new Uint8Array(bin.buffer, bin.byteOffset + off, len));
128
+ off += len;
129
+ return s;
130
+ };
131
+ const count = view.getUint32(off, true);
132
+ off += 4;
133
+ const out = [];
134
+ for (let i = 0; i < count; i++) {
135
+ if (off + 2 > bin.byteLength)
136
+ break;
137
+ const typeInt = view.getUint8(off);
138
+ off += 2; // type + redraw_on_ready
139
+ const url = readStr();
140
+ const name = readStr();
141
+ if (readStr() === null || url === null || name === null)
142
+ break;
143
+ out.push({
144
+ type: typeInt === 2 ? "image" : typeInt === 3 ? "css" : "font",
145
+ url,
146
+ name,
147
+ });
148
+ }
149
+ return out;
150
+ }
151
+ /** Monotonic-ish clock for timings (satoru parity). */
152
+ export function now() {
153
+ return typeof performance !== "undefined" &&
154
+ typeof performance.now === "function"
155
+ ? performance.now()
156
+ : Date.now();
157
+ }
158
+ /**
159
+ * Protocol/host allow-list check. Returns a block reason, or `null` when
160
+ * allowed. Relative URLs skip the check (satoru parity).
161
+ */
162
+ export function checkResourceAllowed(url, limits) {
163
+ if (!limits.allowedProtocols &&
164
+ !limits.allowedHosts &&
165
+ !limits.blockedHosts) {
166
+ return null;
167
+ }
168
+ let parsed = null;
169
+ try {
170
+ parsed = new URL(url);
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ if (limits.allowedProtocols &&
176
+ !limits.allowedProtocols.includes(parsed.protocol)) {
177
+ return `Protocol ${parsed.protocol} is blocked`;
178
+ }
179
+ if (limits.allowedHosts && !limits.allowedHosts.includes(parsed.hostname)) {
180
+ return `Host ${parsed.hostname} is not in allowed list`;
181
+ }
182
+ if (limits.blockedHosts && limits.blockedHosts.includes(parsed.hostname)) {
183
+ return `Host ${parsed.hostname} is blocked`;
184
+ }
185
+ return null;
186
+ }
187
+ /** Limit-violation code for a block reason (satoru parity). */
188
+ export function limitCodeForReason(reason) {
189
+ if (reason.startsWith("Protocol "))
190
+ return DIAGNOSTIC_CODES.LIMIT_PROTOCOL_BLOCKED;
191
+ return DIAGNOSTIC_CODES.LIMIT_HOST_BLOCKED;
192
+ }
193
+ /**
194
+ * Upstream-style discovery loop: collect pending URLs on the satoru
195
+ * instance, fetch them (file/http), and inject the bytes back, so the
196
+ * subsequent render on the SAME instance sees cached fonts/images.
197
+ * Missing bindings (old builds) skip the loop silently.
198
+ */
199
+ export async function resolveHtmlResources(module, satoruInst, htmls, width, height, baseUrl, fontMap, userAgent, fallbackFonts, ctx) {
200
+ const collect = module.satoru_collect_resources;
201
+ const getPending = module.satoru_get_pending_resources;
202
+ const addRes = module.satoru_add_resource;
203
+ if (typeof collect !== "function" ||
204
+ typeof getPending !== "function" ||
205
+ typeof addRes !== "function")
206
+ return;
207
+ // Upstream default: generic families resolve via fontMap before discovery.
208
+ if (fontMap) {
209
+ const setFontMap = module.satoru_set_font_map;
210
+ if (typeof setFontMap === "function") {
211
+ (await setFontMap(satoruInst, { ...fontMap }));
212
+ ctx.emitLog(LogLevel.Debug, "fontMap applied");
213
+ }
214
+ }
215
+ // Upstream idiom: user-supplied fallback fonts go in before discovery.
216
+ if (fallbackFonts && fallbackFonts.length > 0) {
217
+ const loadFallback = module.satoru_load_fallback_font;
218
+ if (typeof loadFallback === "function") {
219
+ for (const entry of fallbackFonts) {
220
+ try {
221
+ const bytes = typeof entry === "string"
222
+ ? entry.startsWith("data:")
223
+ ? dataUrlToBytes(entry)
224
+ : await fetchResourceBytes(entry, baseUrl, userAgent, ctx.resolveResource)
225
+ : entry instanceof Uint8Array
226
+ ? entry
227
+ : new Uint8Array(entry);
228
+ if (!bytes || bytes.length === 0)
229
+ continue;
230
+ (await loadFallback(satoruInst, bytes));
231
+ ctx.diag?.fonts.push({
232
+ family: "(fallback)",
233
+ status: "loaded",
234
+ source: typeof entry === "string" ? entry : "(bytes)",
235
+ });
236
+ }
237
+ catch {
238
+ // Per-font failure is non-fatal; discovery proceeds regardless.
239
+ ctx.diag?.warnings.push({
240
+ code: DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED,
241
+ message: "Failed to load fallback font entry",
242
+ });
243
+ }
244
+ }
245
+ }
246
+ }
247
+ // Named font preloads (`fonts` option), before discovery (satoru parity).
248
+ if (ctx.fonts && ctx.fonts.length > 0) {
249
+ const loadFont = module.satoru_load_font;
250
+ if (typeof loadFont === "function") {
251
+ for (const f of ctx.fonts) {
252
+ try {
253
+ (await loadFont(satoruInst, f.name, f.data));
254
+ ctx.diag?.fonts.push({
255
+ family: f.name,
256
+ status: "loaded",
257
+ source: "fonts option",
258
+ });
259
+ }
260
+ catch (e) {
261
+ ctx.diag?.warnings.push(errorToDiagnostic(e, DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED, f.name));
262
+ }
263
+ }
264
+ }
265
+ }
266
+ // Extra CSS pre-scan (`css` option), before discovery (satoru parity).
267
+ if (ctx.css) {
268
+ const scanCss = module.satoru_scan_css;
269
+ if (typeof scanCss === "function") {
270
+ try {
271
+ (await scanCss(satoruInst, ctx.css));
272
+ ctx.emitLog(LogLevel.Debug, "css pre-scanned");
273
+ }
274
+ catch (e) {
275
+ ctx.diag?.warnings.push(errorToDiagnostic(e, DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED, "(css option)"));
276
+ }
277
+ }
278
+ }
279
+ // Collect-phase profiling (merged into timings when diagnostics on).
280
+ const setProfile = module.satoru_set_collect_profile_enabled;
281
+ const profileEnabled = ctx.diag !== null && typeof setProfile === "function";
282
+ if (profileEnabled) {
283
+ try {
284
+ (await setProfile(satoruInst, true));
285
+ }
286
+ catch {
287
+ // Non-fatal; timings simply miss the C++ breakdown.
288
+ }
289
+ }
290
+ const list = Array.isArray(htmls) ? htmls : [htmls];
291
+ for (let round = 0; round < 10; round++) {
292
+ ctx.checkTimeout();
293
+ let progressed = false;
294
+ for (const html of list) {
295
+ (await collect(satoruInst, html, width, height ?? 0, ctx.mediaTypeInt));
296
+ const bin = (await getPending(satoruInst));
297
+ const pending = parsePendingResources(bin instanceof Uint8Array ? bin : undefined);
298
+ if (pending.length === 0)
299
+ continue;
300
+ progressed = true;
301
+ await Promise.all(pending.map(async (r) => {
302
+ const diag = ctx.diag;
303
+ const entry = diag
304
+ ? {
305
+ type: r.type,
306
+ url: r.url,
307
+ name: r.name || undefined,
308
+ status: "pending",
309
+ }
310
+ : undefined;
311
+ if (diag && entry)
312
+ diag.resources.push(entry);
313
+ const settle = (status, bytes, reason) => {
314
+ if (!entry)
315
+ return;
316
+ entry.status = status;
317
+ if (bytes !== undefined)
318
+ entry.bytes = bytes;
319
+ if (reason !== undefined)
320
+ entry.reason = reason;
321
+ };
322
+ const skipWithError = (code, message) => {
323
+ settle("skipped", undefined, message);
324
+ diag?.errors.push({ code, message, source: r.url });
325
+ ctx.emitLog(LogLevel.Warning, `${message} (${r.url})`);
326
+ };
327
+ try {
328
+ if (r.url.startsWith("data:")) {
329
+ // Inline data: URLs resolve inside C++; nothing to fetch.
330
+ settle("skipped", undefined, "inline data URL (resolved in C++)");
331
+ return;
332
+ }
333
+ if (ctx.limits.maxResourceCount !== undefined &&
334
+ diag &&
335
+ diag.resourceCount >= ctx.limits.maxResourceCount) {
336
+ const message = `Maximum resource count (${ctx.limits.maxResourceCount}) exceeded`;
337
+ skipWithError(DIAGNOSTIC_CODES.LIMIT_RESOURCE_COUNT, message);
338
+ return;
339
+ }
340
+ const blocked = checkResourceAllowed(r.url, ctx.limits);
341
+ if (blocked) {
342
+ skipWithError(limitCodeForReason(blocked), blocked);
343
+ return;
344
+ }
345
+ const bytes = await fetchResourceBytes(r.url, baseUrl, userAgent, ctx.resolveResource);
346
+ if (!bytes) {
347
+ settle("failed");
348
+ const message = `Failed to fetch resource: ${r.url}`;
349
+ diag?.warnings.push({
350
+ code: DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED,
351
+ message,
352
+ source: r.url,
353
+ });
354
+ ctx.emitLog(LogLevel.Warning, message);
355
+ return;
356
+ }
357
+ if (ctx.limits.maxResourceBytes !== undefined &&
358
+ bytes.length > ctx.limits.maxResourceBytes) {
359
+ const message = `Resource size (${bytes.length} bytes) exceeds limit ` +
360
+ `(${ctx.limits.maxResourceBytes})`;
361
+ skipWithError(DIAGNOSTIC_CODES.LIMIT_RESOURCE_SIZE, message);
362
+ return;
363
+ }
364
+ if (ctx.limits.maxTotalResourceBytes !== undefined &&
365
+ diag &&
366
+ diag.totalResourceBytes + bytes.length >
367
+ ctx.limits.maxTotalResourceBytes) {
368
+ const message = `Total resource size exceeds limit (${ctx.limits.maxTotalResourceBytes})`;
369
+ skipWithError(DIAGNOSTIC_CODES.LIMIT_TOTAL_SIZE, message);
370
+ return;
371
+ }
372
+ if (diag) {
373
+ diag.resourceCount++;
374
+ diag.totalResourceBytes += bytes.length;
375
+ }
376
+ settle("loaded", bytes.length);
377
+ (await addRes(satoruInst, r.url, r.type === "image" ? 2 : r.type === "css" ? 3 : 1, bytes));
378
+ if (r.type === "font") {
379
+ diag?.fonts.push({
380
+ family: r.name || r.url,
381
+ status: "loaded",
382
+ source: r.url,
383
+ });
384
+ }
385
+ }
386
+ catch (e) {
387
+ // Per-resource failure is non-fatal; render proceeds regardless.
388
+ const d = errorToDiagnostic(e, DIAGNOSTIC_CODES.RESOURCE_FETCH_FAILED, r.url);
389
+ settle("failed", undefined, d.message);
390
+ diag?.warnings.push(d);
391
+ }
392
+ }));
393
+ }
394
+ if (!progressed)
395
+ break;
396
+ }
397
+ // Merge the C++ collect-phase breakdown into the JS timings.
398
+ if (profileEnabled && ctx.diag) {
399
+ const getProfile = module.satoru_get_collect_profile;
400
+ if (typeof getProfile === "function") {
401
+ try {
402
+ const parsed = JSON.parse((await getProfile(satoruInst)));
403
+ for (const [key, value] of Object.entries(parsed)) {
404
+ if (typeof value === "number") {
405
+ ctx.addTime(key, value);
406
+ }
407
+ }
408
+ }
409
+ catch {
410
+ // Non-fatal; timings simply miss the C++ breakdown.
411
+ }
412
+ }
413
+ }
414
+ }
package/dist/core.d.ts CHANGED
@@ -4,6 +4,7 @@ import { DIAGNOSTIC_CODES, LogLevel, type RenderDiagnostics, type RenderLimits,
4
4
  export { isImageInput };
5
5
  export { DIAGNOSTIC_CODES, LogLevel };
6
6
  export type { DiagnosticMessage, FontDiagnostic, RenderDiagnostics, RenderLimits, ResolveResourceHook, ResourceDiagnostic, } from "./diagnostics.js";
7
+ export type { WasmBindingName } from "./core-orchestrate.js";
7
8
  /**
8
9
  * Final output formats. Single-WASM internal routing:
9
10
  * - HTML input: resources are resolved on the instance first
@@ -15,7 +16,7 @@ export type { DiagnosticMessage, FontDiagnostic, RenderDiagnostics, RenderLimits
15
16
  * (`converter_load_image` -> optional crop/resize -> `converter_encode`,
16
17
  * or `converter_encode_svg` / `converter_encode_pdf`).
17
18
  */
18
- export type OutputFormat = "svg" | "png" | "pdf" | "jpeg" | "webp" | "avif" | "raw" | "thumbhash" | "none";
19
+ export type OutputFormat = "svg" | "png" | "pdf" | "jpeg" | "webp" | "avif" | "jxl" | "raw" | "thumbhash" | "none";
19
20
  /** Result of rendering an HTML document or converting an image. */
20
21
  export interface RenderResult<T = Uint8Array | string> {
21
22
  /** Output binary or text (string for svg, Uint8Array for all others). */
@@ -154,13 +155,16 @@ export interface HtmlToImageOptions extends RenderOptions {
154
155
  * UA so woff2 is served); fetched at render time, nothing bundled.
155
156
  */
156
157
  export declare const DEFAULT_FONT_MAP: Record<string, string>;
157
- /** Binding names reported by {@link requireBindingError}. */
158
- export type WasmBindingName = "satoru_render" | "converter_encode" | "converter_encode_svg" | "converter_encode_pdf" | "converter_load_image" | "html_to_image";
159
158
  /**
160
159
  * Render HTML — or convert an image — through the single unified WASM
161
160
  * module. Image input skips the render stage and goes straight to
162
161
  * `converter_encode`. Returns bytes (`Uint8Array`); only `svg` resolves
163
162
  * to `string`.
163
+ *
164
+ * Thin orchestration: context comes from `./core-orchestrate.ts`, resource
165
+ * discovery from `./core-resources.ts`, satoru options from
166
+ * `./encode-args.ts` (canonical builder). Branch selection uses
167
+ * `isImageInput` (`./input.ts`).
164
168
  */
165
169
  export declare function htmlToImage(module: HtmlToImageModule, options: HtmlToImageOptions & {
166
170
  format: "svg";