wasm-html-to-image 1.0.2 → 1.0.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.
- package/README.md +6 -0
- package/dist/browser-workers.js +20 -7
- package/dist/cli-options.d.ts +32 -0
- package/dist/cli-options.js +35 -0
- package/dist/cli.js +8 -0
- package/dist/core-orchestrate.d.ts +15 -0
- package/dist/core-orchestrate.js +36 -8
- package/dist/core.d.ts +26 -3
- package/dist/encode-args.js +2 -0
- package/dist/html-to-image-wasm.js +3 -1
- package/dist/html-to-image.wasm +0 -0
- package/dist/input.d.ts +1 -1
- package/dist/input.js +11 -1
- package/dist/loader.d.ts +30 -2
- package/dist/loader.js +172 -11
- package/dist/tailwind.d.ts +38 -1
- package/dist/tailwind.js +187 -3
- package/dist/wasm-binary.d.ts +34 -0
- package/dist/wasm-binary.js +210 -0
- package/dist/wasm-payload.js +8 -0
- package/dist/web-workers.js +342 -23
- package/llms.txt +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -273,6 +273,12 @@ npx wasm-html-to-image https://example.com/news -o news.png --base-url https://e
|
|
|
273
273
|
|
|
274
274
|
# Convert and resize an image
|
|
275
275
|
npx wasm-html-to-image photo.jpg -o photo.avif -w 800 -f avif -q 80
|
|
276
|
+
|
|
277
|
+
# Sample an opacity / transform / color @keyframes animation into an animated WebP
|
|
278
|
+
npx wasm-html-to-image loader.html -o loader.webp -f webp --animation 2000 --animation-fps 20
|
|
279
|
+
|
|
280
|
+
# Play the animated WebP once instead of looping forever
|
|
281
|
+
npx wasm-html-to-image loader.html -o loader.webp -f webp --animation 2000 --animation-loop 1
|
|
276
282
|
```
|
|
277
283
|
|
|
278
284
|
---
|
package/dist/browser-workers.js
CHANGED
|
@@ -5,16 +5,29 @@ import { createRenderActions } from "./worker-actions.js";
|
|
|
5
5
|
// @ts-expect-error — dist/html-to-image.js is generated at build time
|
|
6
6
|
import createHtmlToImageModule from "../dist/html-to-image.js";
|
|
7
7
|
const { initWorker } = await loadWorkerLib();
|
|
8
|
-
let
|
|
9
|
-
/**
|
|
8
|
+
let modPromise;
|
|
9
|
+
/**
|
|
10
|
+
* Lazily load (and reuse) the module inside this worker.
|
|
11
|
+
*
|
|
12
|
+
* The pending promise is shared: parallel jobs arriving while the module
|
|
13
|
+
* is still loading wait for that single load instead of starting a second
|
|
14
|
+
* instantiation (a 12 MB binary plus its linear memory, each). A failed
|
|
15
|
+
* load clears the promise so the next job retries.
|
|
16
|
+
*/
|
|
10
17
|
const getModule = async () => {
|
|
11
|
-
if (!
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
if (!modPromise) {
|
|
19
|
+
modPromise = (async () => {
|
|
20
|
+
const wasmBinary = await loadCompressedWasmBinary();
|
|
21
|
+
return loadHtmlToImageModule({
|
|
22
|
+
factory: createHtmlToImageModule,
|
|
23
|
+
moduleArg: { wasmBinary },
|
|
24
|
+
});
|
|
25
|
+
})().catch((e) => {
|
|
26
|
+
modPromise = undefined;
|
|
27
|
+
throw e;
|
|
15
28
|
});
|
|
16
29
|
}
|
|
17
|
-
return
|
|
30
|
+
return modPromise;
|
|
18
31
|
};
|
|
19
32
|
/**
|
|
20
33
|
* Browser worker-side implementation (pre-bundled `dist/web-workers.js`).
|
package/dist/cli-options.d.ts
CHANGED
|
@@ -8,7 +8,39 @@ export interface CliFlags {
|
|
|
8
8
|
quality: number;
|
|
9
9
|
/** Explicit `--base-url`; absent means "let the source decide". */
|
|
10
10
|
baseUrl?: string;
|
|
11
|
+
/** `--animation <duration-ms>`; absent means "no keyframe animation". */
|
|
12
|
+
animation?: number;
|
|
13
|
+
/** `--animation-fps <n>`; defaults to 15 when `--animation` is set. */
|
|
14
|
+
animationFps?: number;
|
|
15
|
+
/**
|
|
16
|
+
* `--animation-loop <n>`; how many times the animated WebP repeats.
|
|
17
|
+
* Absent means infinite (libwebp loop_count 0 = the historical default).
|
|
18
|
+
*/
|
|
19
|
+
animationLoop?: number;
|
|
11
20
|
}
|
|
21
|
+
/** Default frame rate for `--animation` when `--animation-fps` is omitted. */
|
|
22
|
+
export declare const DEFAULT_ANIMATION_FPS = 15;
|
|
23
|
+
/**
|
|
24
|
+
* Animation flags resolved against the output format.
|
|
25
|
+
*
|
|
26
|
+
* Animated WebP is the only animated output the pipeline supports, so
|
|
27
|
+
* `--animation` is ignored (with a warning) for every other format. The
|
|
28
|
+
* boolean `animation: true` image-input path is intentionally not reachable
|
|
29
|
+
* from the CLI: there the object form requests `@keyframes` sampling, which
|
|
30
|
+
* is what `--animation` means.
|
|
31
|
+
*/
|
|
32
|
+
export interface AnimationResolution {
|
|
33
|
+
/** Resolved `render()` animation option, or absent when disabled/ignored. */
|
|
34
|
+
animation?: {
|
|
35
|
+
duration: number;
|
|
36
|
+
fps: number;
|
|
37
|
+
loop?: number;
|
|
38
|
+
};
|
|
39
|
+
/** Human-readable reason the flag was dropped; callers warn on stderr. */
|
|
40
|
+
warning?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Map `--animation` / `--animation-fps` / `--animation-loop` to the `render()` animation option. */
|
|
43
|
+
export declare function resolveAnimation(flags: CliFlags): AnimationResolution;
|
|
12
44
|
/**
|
|
13
45
|
* URL inputs are fetched, not read from disk. `data:` URLs stay values so
|
|
14
46
|
* image inputs keep flowing through the converter pipeline.
|
package/dist/cli-options.js
CHANGED
|
@@ -7,6 +7,37 @@
|
|
|
7
7
|
import fs from "node:fs/promises";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { isImageInput } from "./input.js";
|
|
10
|
+
/** Default frame rate for `--animation` when `--animation-fps` is omitted. */
|
|
11
|
+
export const DEFAULT_ANIMATION_FPS = 15;
|
|
12
|
+
/** Map `--animation` / `--animation-fps` / `--animation-loop` to the `render()` animation option. */
|
|
13
|
+
export function resolveAnimation(flags) {
|
|
14
|
+
if (flags.animation === undefined) {
|
|
15
|
+
if (flags.animationFps !== undefined) {
|
|
16
|
+
return { warning: "--animation-fps requires --animation; ignoring." };
|
|
17
|
+
}
|
|
18
|
+
if (flags.animationLoop !== undefined) {
|
|
19
|
+
return { warning: "--animation-loop requires --animation; ignoring." };
|
|
20
|
+
}
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
if (flags.format !== "webp" && flags.format !== "apng" && flags.format !== "gif") {
|
|
24
|
+
return {
|
|
25
|
+
warning: `--animation is only supported for webp, apng and gif output; ignoring (format=${flags.format}).`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// `loop` stays absent unless requested: the render() default (0) is the
|
|
29
|
+
// historical infinite loop, so the option object keeps its old shape.
|
|
30
|
+
const loop = flags.animationLoop === undefined
|
|
31
|
+
? undefined
|
|
32
|
+
: Math.max(0, Math.trunc(flags.animationLoop));
|
|
33
|
+
return {
|
|
34
|
+
animation: {
|
|
35
|
+
duration: flags.animation,
|
|
36
|
+
fps: flags.animationFps ?? DEFAULT_ANIMATION_FPS,
|
|
37
|
+
...(loop === undefined ? {} : { loop }),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
10
41
|
/**
|
|
11
42
|
* URL inputs are fetched, not read from disk. `data:` URLs stay values so
|
|
12
43
|
* image inputs keep flowing through the converter pipeline.
|
|
@@ -44,6 +75,9 @@ export async function buildRenderOptions(input, flags) {
|
|
|
44
75
|
const source = isUrlInput(input)
|
|
45
76
|
? { url: input }
|
|
46
77
|
: await readInput(input);
|
|
78
|
+
const { animation, warning } = resolveAnimation(flags);
|
|
79
|
+
if (warning)
|
|
80
|
+
console.warn(`wasm-html-to-image: ${warning}`);
|
|
47
81
|
return {
|
|
48
82
|
width: flags.width,
|
|
49
83
|
height: flags.height,
|
|
@@ -51,5 +85,6 @@ export async function buildRenderOptions(input, flags) {
|
|
|
51
85
|
quality: flags.quality,
|
|
52
86
|
...source,
|
|
53
87
|
...(flags.baseUrl !== undefined ? { baseUrl: flags.baseUrl } : {}),
|
|
88
|
+
...(animation ? { animation } : {}),
|
|
54
89
|
};
|
|
55
90
|
}
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,8 @@ const OUTPUT_FORMATS = [
|
|
|
10
10
|
"pdf",
|
|
11
11
|
"jpeg",
|
|
12
12
|
"webp",
|
|
13
|
+
"apng",
|
|
14
|
+
"gif",
|
|
13
15
|
"avif",
|
|
14
16
|
"jxl",
|
|
15
17
|
"raw",
|
|
@@ -30,6 +32,9 @@ program
|
|
|
30
32
|
.option("-f, --format <format>", `output format (${OUTPUT_FORMATS.join(", ")})`, "png")
|
|
31
33
|
.option("-q, --quality <number>", "encode quality 0-100 (encode stage only)", (v) => parseFloat(v), 85)
|
|
32
34
|
.option("--base-url <url>", "base URL for relative resources (URL input: defaults to the post-redirect document URL; file input: defaults to the file's directory)")
|
|
35
|
+
.option("--animation <duration-ms>", "sample the document's @keyframes opacity / transform / color animation into an animated WebP / APNG / GIF; one loop duration in ms (webp, apng and gif output only; the CSS animation-duration wins and this value is an upper bound)", (v) => parseInt(v, 10))
|
|
36
|
+
.option("--animation-fps <n>", "animation frame rate (default 15, capped at ~75 frames total; requires --animation)", (v) => parseInt(v, 10))
|
|
37
|
+
.option("--animation-loop <n>", "how many times the animated WebP / APNG / GIF repeats (0 = infinite, the default; 1 = play once; requires --animation)", (v) => parseInt(v, 10))
|
|
33
38
|
.action(async (input, options) => {
|
|
34
39
|
try {
|
|
35
40
|
const format = String(options.format).toLowerCase();
|
|
@@ -56,6 +61,9 @@ program
|
|
|
56
61
|
format,
|
|
57
62
|
quality: options.quality,
|
|
58
63
|
baseUrl: options.baseUrl,
|
|
64
|
+
animation: options.animation,
|
|
65
|
+
animationFps: options.animationFps,
|
|
66
|
+
animationLoop: options.animationLoop,
|
|
59
67
|
};
|
|
60
68
|
const result = await render(await buildRenderOptions(input, flags));
|
|
61
69
|
await fs.writeFile(outputPath, typeof result.data === "string"
|
|
@@ -13,6 +13,21 @@ export interface Orchestration {
|
|
|
13
13
|
quality: number;
|
|
14
14
|
speed: number;
|
|
15
15
|
animation: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* HTML animated-output request (`animation: { duration, fps, loop }`). When
|
|
18
|
+
* set, the HTML pipeline samples the document's `@keyframes` animation
|
|
19
|
+
* (opacity / transform / color) into converter frames before encoding.
|
|
20
|
+
*/
|
|
21
|
+
animationFrames: {
|
|
22
|
+
duration: number;
|
|
23
|
+
fps?: number;
|
|
24
|
+
loop?: number;
|
|
25
|
+
} | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Animated WebP loop count handed to the encode stage (libwebp
|
|
28
|
+
* `anim_params.loop_count`). `0` = infinite (the historical default).
|
|
29
|
+
*/
|
|
30
|
+
animationLoop: number;
|
|
16
31
|
width: number;
|
|
17
32
|
height: number | undefined;
|
|
18
33
|
crop: HtmlToImageOptions["crop"];
|
package/dist/core-orchestrate.js
CHANGED
|
@@ -34,7 +34,17 @@ export function defaultSpeed(format) {
|
|
|
34
34
|
}
|
|
35
35
|
/** Build per-call orchestration state (log gate, timings, diag, resolve ctx). */
|
|
36
36
|
export function createOrchestration(module, options) {
|
|
37
|
-
const { format = "png", quality = 85, speed: speedOption,
|
|
37
|
+
const { format = "png", quality = 85, speed: speedOption, 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;
|
|
38
|
+
// `animation` accepts a boolean (image input: keep frames) or, for HTML
|
|
39
|
+
// input, an object requesting keyframe sampling. The object form turns on
|
|
40
|
+
// the boolean flag for the encode stage and enables the frame path.
|
|
41
|
+
const rawAnimation = options.animation;
|
|
42
|
+
const animationFrames = rawAnimation !== null && typeof rawAnimation === "object"
|
|
43
|
+
? rawAnimation
|
|
44
|
+
: undefined;
|
|
45
|
+
const animation = animationFrames ? true : rawAnimation === true;
|
|
46
|
+
// Loop count of the produced animated WebP (0 = infinite = legacy default).
|
|
47
|
+
const animationLoop = Math.max(0, Math.trunc(animationFrames?.loop ?? 0));
|
|
38
48
|
const speed = speedOption ?? defaultSpeed(format);
|
|
39
49
|
const fmtInt = FORMAT_INT[format];
|
|
40
50
|
const mediaTypeInt = mediaType === "print" ? 1 : 0;
|
|
@@ -128,6 +138,8 @@ export function createOrchestration(module, options) {
|
|
|
128
138
|
quality,
|
|
129
139
|
speed,
|
|
130
140
|
animation,
|
|
141
|
+
animationFrames,
|
|
142
|
+
animationLoop,
|
|
131
143
|
width,
|
|
132
144
|
height,
|
|
133
145
|
crop,
|
|
@@ -228,7 +240,7 @@ export async function resolveHtmlSource(orch) {
|
|
|
228
240
|
* `imageBytesFromUnknown` (`unknown` in, no `as` casts).
|
|
229
241
|
*/
|
|
230
242
|
export async function runImageInput(module, value, orch) {
|
|
231
|
-
const { format, quality, speed, animation, width, height, crop, fit, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, } = orch;
|
|
243
|
+
const { format, quality, speed, animation, animationLoop, width, height, crop, fit, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, } = orch;
|
|
232
244
|
// Narrowed here: caller selected this branch via `isImageInput(value)`.
|
|
233
245
|
const loadImage = module.converter_load_image;
|
|
234
246
|
if (typeof loadImage !== "function")
|
|
@@ -323,7 +335,7 @@ export async function runImageInput(module, value, orch) {
|
|
|
323
335
|
if (typeof encodeBinding !== "function")
|
|
324
336
|
throw loggedBinding("converter_encode");
|
|
325
337
|
const encodeStart = now();
|
|
326
|
-
const out = (await encodeBinding(inst, fmtInt, quality, speed, animation));
|
|
338
|
+
const out = (await encodeBinding(inst, fmtInt, quality, speed, animation, animationLoop));
|
|
327
339
|
addTime("encode", now() - encodeStart);
|
|
328
340
|
if (out == null) {
|
|
329
341
|
throw logged("wasm-html-to-image: failed to encode image");
|
|
@@ -355,7 +367,7 @@ export async function runImageInput(module, value, orch) {
|
|
|
355
367
|
* pre-resolved resources, so it stays a fallback only).
|
|
356
368
|
*/
|
|
357
369
|
export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontMap, orch) {
|
|
358
|
-
const { format, quality, speed, animation, width, height, baseUrl, fontMap, userAgent, fallbackFonts, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, resolveCtx, } = orch;
|
|
370
|
+
const { format, quality, speed, animation, animationFrames, animationLoop, width, height, baseUrl, fontMap, userAgent, fallbackFonts, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, resolveCtx, } = orch;
|
|
359
371
|
const renderBinding = module.satoru_render;
|
|
360
372
|
const encodeBinding = module.converter_encode;
|
|
361
373
|
if (typeof renderBinding !== "function" ||
|
|
@@ -402,8 +414,22 @@ export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontM
|
|
|
402
414
|
const renderStart = now();
|
|
403
415
|
const cInst = module.converter_create_instance();
|
|
404
416
|
try {
|
|
417
|
+
// Animated HTML: sample the document's @keyframes animation (opacity /
|
|
418
|
+
// transform / color) straight into the converter's frames (additive).
|
|
419
|
+
// Falls back to the single-frame path when no frames were produced.
|
|
420
|
+
let renderedFrames = false;
|
|
421
|
+
const renderAnimationToConverter = module.satoru_render_animation_to_converter;
|
|
422
|
+
if (animationFrames &&
|
|
423
|
+
typeof renderAnimationToConverter === "function") {
|
|
424
|
+
const frames = (await renderAnimationToConverter(sInst, cInst, htmls, width, height ?? 0, satoruOpts, animationFrames.duration, animationFrames.fps ?? 15));
|
|
425
|
+
addTime("render", now() - renderStart);
|
|
426
|
+
renderedFrames = frames > 0;
|
|
427
|
+
}
|
|
405
428
|
const renderToConverter = module.satoru_render_to_converter;
|
|
406
|
-
if (
|
|
429
|
+
if (renderedFrames) {
|
|
430
|
+
// converter already holds the sampled frames.
|
|
431
|
+
}
|
|
432
|
+
else if (typeof renderToConverter === "function") {
|
|
407
433
|
// Bitmap goes straight into the converter: no PNG encode/decode.
|
|
408
434
|
const ok = await renderToConverter(sInst, cInst, htmls, width, height ?? 0, satoruOpts);
|
|
409
435
|
addTime("render", now() - renderStart);
|
|
@@ -423,14 +449,16 @@ export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontM
|
|
|
423
449
|
}
|
|
424
450
|
const outWidth = module.converter_get_width?.(cInst) ?? width;
|
|
425
451
|
const outHeight = module.converter_get_height?.(cInst) ?? height ?? 0;
|
|
426
|
-
const outAnimation = module.converter_is_animation?.(cInst) ?? animation;
|
|
427
|
-
const outFormat = module.converter_get_format?.(cInst) || format;
|
|
428
452
|
const encodeStart = now();
|
|
429
|
-
const out = (await encodeBinding(cInst, fmtInt, quality, speed, animation));
|
|
453
|
+
const out = (await encodeBinding(cInst, fmtInt, quality, speed, animation, animationLoop));
|
|
430
454
|
addTime("encode", now() - encodeStart);
|
|
431
455
|
if (out == null) {
|
|
432
456
|
throw logged("wasm-html-to-image: failed to encode image");
|
|
433
457
|
}
|
|
458
|
+
// Read the post-encode metadata: `is_animation`/`get_format` are only
|
|
459
|
+
// meaningful after `converter_encode` has run.
|
|
460
|
+
const outAnimation = module.converter_is_animation?.(cInst) ?? animation;
|
|
461
|
+
const outFormat = module.converter_get_format?.(cInst) || format;
|
|
434
462
|
addTime("total", now() - t0);
|
|
435
463
|
emitLog(LogLevel.Info, `render done: format=${format}`);
|
|
436
464
|
deliverReport();
|
package/dist/core.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type { WasmBindingName } from "./core-orchestrate.js";
|
|
|
16
16
|
* (`converter_load_image` -> optional crop/resize -> `converter_encode`,
|
|
17
17
|
* or `converter_encode_svg` / `converter_encode_pdf`).
|
|
18
18
|
*/
|
|
19
|
-
export type OutputFormat = "svg" | "png" | "pdf" | "jpeg" | "webp" | "avif" | "jxl" | "raw" | "thumbhash" | "none";
|
|
19
|
+
export type OutputFormat = "svg" | "png" | "pdf" | "jpeg" | "webp" | "apng" | "gif" | "avif" | "jxl" | "raw" | "thumbhash" | "none";
|
|
20
20
|
/** Result of rendering an HTML document or converting an image. */
|
|
21
21
|
export interface RenderResult<T = Uint8Array | string> {
|
|
22
22
|
/** Output binary or text (string for svg, Uint8Array for all others). */
|
|
@@ -65,8 +65,31 @@ export interface HtmlToImageOptions extends RenderOptions {
|
|
|
65
65
|
quality?: number;
|
|
66
66
|
/** Encode speed 0-10, mainly for AVIF (encode stage only; default 8 for AVIF, 6 otherwise). */
|
|
67
67
|
speed?: number;
|
|
68
|
-
/**
|
|
69
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Animation handling.
|
|
70
|
+
* - `true`: keep animation frames where supported (image input / encode stage).
|
|
71
|
+
* - `{ duration, fps, loop }` (HTML input only): sample the document's
|
|
72
|
+
* `@keyframes` animation into frames. `duration` is one loop length in ms
|
|
73
|
+
* (clamped to 5000), `fps` defaults to 15 (frame count capped at ~75).
|
|
74
|
+
* The CSS `animation-duration` (including the `animation` shorthand)
|
|
75
|
+
* is the length of one iteration; `animation-delay` /
|
|
76
|
+
* `animation-iteration-count` define the sampled timeline, capped by
|
|
77
|
+
* `duration`; `fps` is the upper frame rate. `loop` is how many times the
|
|
78
|
+
* animated WebP / APNG / GIF repeats: `0` (the default) means infinite, `1` means play
|
|
79
|
+
* once. `opacity`, `transform` (`translate` / `rotate` / `scale` only) and
|
|
80
|
+
* `color` / `background-color` keyframes are animated; their values may
|
|
81
|
+
* use `var()` (resolved against the element's custom properties).
|
|
82
|
+
* `animation-timing-function` (per keyframe or element-level:
|
|
83
|
+
* `linear` / `ease*` / `cubic-bezier()` / `steps()` / `step-*`),
|
|
84
|
+
* `animation-direction` and `animation-fill-mode` are honoured.
|
|
85
|
+
* Several animations on one element (`animation-name: a, b`) are out of
|
|
86
|
+
* scope.
|
|
87
|
+
*/
|
|
88
|
+
animation?: boolean | {
|
|
89
|
+
duration: number;
|
|
90
|
+
fps?: number;
|
|
91
|
+
loop?: number;
|
|
92
|
+
};
|
|
70
93
|
/** Crop rectangle (encode stage for image input, render options for HTML). */
|
|
71
94
|
crop?: {
|
|
72
95
|
x: number;
|