wasm-html-to-image 1.0.7 → 1.0.9

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
@@ -121,7 +121,9 @@ npx wasm-html-to-image loader.html -o loader.gif -f gif --animation 2000
121
121
  - **Image Buffer / Data URL**: `PNG`, `JPEG`, `WebP`, `GIF`, `AVIF`, `JXL`, `BMP`.
122
122
 
123
123
  ### Outputs (`format`)
124
- `png`, `jpeg`, `webp`, `avif`, `jxl`, `apng`, `gif`, `svg`, `pdf`, `thumbhash`, `raw` (uncompressed RGBA).
124
+ `png`, `jpeg`, `webp`, `avif`, `jxl`, `apng`, `gif`, `tiff`, `svg`, `pdf`, `thumbhash`, `raw` (uncompressed RGBA).
125
+
126
+ **Print colors:** `format: "tiff"` keeps RGBA; add `colorSpace: "cmyk"` (CLI: `--color-space cmyk`) to get a CMYK TIFF, or a PDF whose colors and images are DeviceCMYK. The conversion is the plain device formula (no ICC profile), so convert with your print shop's profile if you need exact press colors.
125
127
 
126
128
  Every call returns a `RenderResult` object:
127
129
  ```typescript
@@ -8,6 +8,8 @@ export interface CliFlags {
8
8
  quality: number;
9
9
  /** Explicit `--base-url`; absent means "let the source decide". */
10
10
  baseUrl?: string;
11
+ /** `--color-space <srgb|cmyk>`; absent means the default (srgb). */
12
+ colorSpace?: "srgb" | "cmyk";
11
13
  /** `--animation <duration-ms>`; absent means "no keyframe animation". */
12
14
  animation?: number;
13
15
  /** `--animation-fps <n>`; defaults to 15 when `--animation` is set. */
@@ -85,6 +85,7 @@ export async function buildRenderOptions(input, flags) {
85
85
  quality: flags.quality,
86
86
  ...source,
87
87
  ...(flags.baseUrl !== undefined ? { baseUrl: flags.baseUrl } : {}),
88
+ ...(flags.colorSpace !== undefined ? { colorSpace: flags.colorSpace } : {}),
88
89
  ...(animation ? { animation } : {}),
89
90
  };
90
91
  }
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ const OUTPUT_FORMATS = [
12
12
  "webp",
13
13
  "apng",
14
14
  "gif",
15
+ "tiff",
15
16
  "avif",
16
17
  "jxl",
17
18
  "raw",
@@ -32,6 +33,7 @@ program
32
33
  .option("-f, --format <format>", `output format (${OUTPUT_FORMATS.join(", ")})`, "png")
33
34
  .option("-q, --quality <number>", "encode quality 0-100 (encode stage only)", (v) => parseFloat(v), 85)
34
35
  .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)")
36
+ .option("--color-space <srgb|cmyk>", "output color space of tiff and pdf (default srgb; cmyk rewrites colors for print, with no ICC profile)", (v) => v.toLowerCase())
35
37
  .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
38
  .option("--animation-fps <n>", "animation frame rate (default 15, capped at ~75 frames total; requires --animation)", (v) => parseInt(v, 10))
37
39
  .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))
@@ -42,6 +44,12 @@ program
42
44
  console.error(`Error: unsupported format "${options.format}". Choose from: ${OUTPUT_FORMATS.join(", ")}`);
43
45
  process.exit(1);
44
46
  }
47
+ if (options.colorSpace !== undefined &&
48
+ options.colorSpace !== "srgb" &&
49
+ options.colorSpace !== "cmyk") {
50
+ console.error(`Error: unsupported color space "${options.colorSpace}". Choose from: srgb, cmyk`);
51
+ process.exit(1);
52
+ }
45
53
  const isUrl = isUrlInput(input);
46
54
  let outputPath = options.output;
47
55
  if (!outputPath) {
@@ -61,6 +69,7 @@ program
61
69
  format,
62
70
  quality: options.quality,
63
71
  baseUrl: options.baseUrl,
72
+ colorSpace: options.colorSpace,
64
73
  animation: options.animation,
65
74
  animationFps: options.animationFps,
66
75
  animationLoop: options.animationLoop,
@@ -5,7 +5,7 @@ import type { HtmlToImageOptions, OutputFormat, RenderResult } from "./core.js";
5
5
  declare 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";
6
6
  export { DEFAULT_USER_AGENT };
7
7
  /** Binding names reported by {@link requireBindingError}. */
8
- export type WasmBindingName = "satoru_render" | "converter_encode" | "converter_encode_svg" | "converter_encode_pdf" | "converter_load_image" | "html_to_image";
8
+ export type WasmBindingName = "satoru_render" | "converter_encode" | "converter_encode_svg" | "converter_encode_pdf" | "converter_encode_pdf_cmyk" | "converter_load_image" | "html_to_image";
9
9
  export declare function requireBindingError(name: WasmBindingName): Error;
10
10
  /** Mutable orchestration state for one `htmlToImage` call. */
11
11
  export interface Orchestration {
@@ -61,6 +61,8 @@ export interface Orchestration {
61
61
  pdfHeader: string | undefined;
62
62
  pdfFooter: string | undefined;
63
63
  fmtInt: number;
64
+ /** Output color space of `tiff` / `pdf`. */
65
+ colorSpace: "srgb" | "cmyk";
64
66
  mediaTypeInt: number;
65
67
  t0: number;
66
68
  diag: DiagState | null;
@@ -11,7 +11,7 @@
11
11
  * satoru option objects come from `./encode-args.ts` (canonical builder).
12
12
  */
13
13
  import { pdfFirstPageSize, svgRootHeight } from "./output-size.js";
14
- import { FIT_INT, FORMAT_INT } from "./encode-args.js";
14
+ import { FIT_INT, FORMAT_INT, TIFF_CMYK_INT } from "./encode-args.js";
15
15
  import { imageBytesFromUnknown, toBytes, } from "./input.js";
16
16
  import { DIAGNOSTIC_CODES, LogLevel, } from "./diagnostics.js";
17
17
  import { isNodeRuntime, now, resolveHtmlResources, } from "./core-resources.js";
@@ -46,7 +46,10 @@ export function createOrchestration(module, options) {
46
46
  // Loop count of the produced animated WebP (0 = infinite = legacy default).
47
47
  const animationLoop = Math.max(0, Math.trunc(animationFrames?.loop ?? 0));
48
48
  const speed = speedOption ?? defaultSpeed(format);
49
- const fmtInt = FORMAT_INT[format];
49
+ const colorSpace = options.colorSpace === "cmyk" ? "cmyk" : "srgb";
50
+ const fmtInt = format === "tiff" && colorSpace === "cmyk"
51
+ ? TIFF_CMYK_INT
52
+ : FORMAT_INT[format];
50
53
  const mediaTypeInt = mediaType === "print" ? 1 : 0;
51
54
  /** JS-side log hook, gated by `logLevel` (satoru parity). */
52
55
  const emitLog = (level, message) => {
@@ -170,6 +173,7 @@ export function createOrchestration(module, options) {
170
173
  pdfHeader,
171
174
  pdfFooter,
172
175
  fmtInt,
176
+ colorSpace,
173
177
  mediaTypeInt,
174
178
  t0,
175
179
  diag,
@@ -244,7 +248,7 @@ export async function resolveHtmlSource(orch) {
244
248
  * `imageBytesFromUnknown` (`unknown` in, no `as` casts).
245
249
  */
246
250
  export async function runImageInput(module, value, orch) {
247
- const { format, quality, speed, animation, animationLoop, width, height, crop, fit, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, } = orch;
251
+ const { format, quality, speed, animation, animationLoop, width, height, crop, fit, fmtInt, colorSpace, t0, emitLog, logged, loggedBinding, addTime, deliverReport, } = orch;
248
252
  // Narrowed here: caller selected this branch via `isImageInput(value)`.
249
253
  const loadImage = module.converter_load_image;
250
254
  if (typeof loadImage !== "function")
@@ -311,9 +315,10 @@ export async function runImageInput(module, value, orch) {
311
315
  };
312
316
  }
313
317
  if (format === "pdf") {
314
- const pdfBinding = module.converter_encode_pdf;
318
+ const pdfBindingName = colorSpace === "cmyk" ? "converter_encode_pdf_cmyk" : "converter_encode_pdf";
319
+ const pdfBinding = module[pdfBindingName];
315
320
  if (typeof pdfBinding !== "function")
316
- throw loggedBinding("converter_encode_pdf");
321
+ throw loggedBinding(pdfBindingName);
317
322
  const encodeStart = now();
318
323
  const out = (await pdfBinding(inst));
319
324
  addTime("encode", now() - encodeStart);
@@ -69,6 +69,14 @@ export declare function limitCodeForReason(reason: string): string;
69
69
  * together, so this bounds the connection burst for many-page documents.
70
70
  */
71
71
  export declare const FETCH_CONCURRENCY = 16;
72
+ /**
73
+ * Resolve the relative `url(...)` and `@import` references of a fetched stylesheet against
74
+ * the stylesheet's own URL. The engine resolves them against the page, which is wrong as soon
75
+ * as the sheet lives in another directory (`url(../fonts/a.woff)` in `/static/css/a.css`).
76
+ * Absolute, protocol-relative, `data:`, `#` and root-relative references are left alone, and a
77
+ * root-relative sheet URL keeps its root-relative form.
78
+ */
79
+ export declare function absolutizeCssUrls(css: string, cssUrl: string): string;
72
80
  /** Run `fn` over `items` with at most `limit` calls in flight (`fn` must not reject). */
73
81
  export declare function mapWithLimit<T>(items: readonly T[], limit: number, fn: (item: T) => Promise<void>): Promise<void>;
74
82
  /**
@@ -270,6 +270,50 @@ export function limitCodeForReason(reason) {
270
270
  * together, so this bounds the connection burst for many-page documents.
271
271
  */
272
272
  export const FETCH_CONCURRENCY = 16;
273
+ /**
274
+ * Resolve the relative `url(...)` and `@import` references of a fetched stylesheet against
275
+ * the stylesheet's own URL. The engine resolves them against the page, which is wrong as soon
276
+ * as the sheet lives in another directory (`url(../fonts/a.woff)` in `/static/css/a.css`).
277
+ * Absolute, protocol-relative, `data:`, `#` and root-relative references are left alone, and a
278
+ * root-relative sheet URL keeps its root-relative form.
279
+ */
280
+ export function absolutizeCssUrls(css, cssUrl) {
281
+ if (!css.includes("url(") && !css.includes("@import"))
282
+ return css;
283
+ const dummy = "http://css-base.invalid/";
284
+ let base;
285
+ try {
286
+ base = new URL(cssUrl, dummy);
287
+ }
288
+ catch {
289
+ return css;
290
+ }
291
+ const keepRootRelative = cssUrl.startsWith("/");
292
+ const absoluteCssUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(cssUrl);
293
+ const resolve = (ref) => {
294
+ const t = ref.trim();
295
+ if (t === "" ||
296
+ t.startsWith("#") ||
297
+ t.startsWith("/") ||
298
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(t))
299
+ return ref;
300
+ try {
301
+ const u = new URL(t, base);
302
+ if (absoluteCssUrl)
303
+ return u.href;
304
+ if (u.origin !== base.origin)
305
+ return u.href;
306
+ const path = u.pathname + u.search + u.hash;
307
+ return keepRootRelative ? path : path.slice(1);
308
+ }
309
+ catch {
310
+ return ref;
311
+ }
312
+ };
313
+ return css
314
+ .replace(/url\(\s*(['"]?)([^'")]*)\1\s*\)/g, (_m, q, ref) => `url(${q}${resolve(ref)}${q})`)
315
+ .replace(/@import\s+(['"])([^'"]+)\1/g, (_m, q, ref) => `@import ${q}${resolve(ref)}${q}`);
316
+ }
273
317
  /** Run `fn` over `items` with at most `limit` calls in flight (`fn` must not reject). */
274
318
  export async function mapWithLimit(items, limit, fn) {
275
319
  let next = 0;
@@ -392,6 +436,10 @@ export async function resolveHtmlResources(module, satoruInst, htmls, width, hei
392
436
  const pending = parsePendingResources(bin instanceof Uint8Array ? bin : undefined);
393
437
  if (pending.length === 0)
394
438
  break;
439
+ // Stylesheets cascade by document order, not by fetch completion order: a small
440
+ // later sheet must not land before (and so lose to) a large earlier one. They are
441
+ // buffered here and injected in discovery order once the whole round has settled.
442
+ const loadedCss = [];
395
443
  await mapWithLimit(pending, FETCH_CONCURRENCY, async (r) => {
396
444
  const diag = ctx.diag;
397
445
  const entry = diag
@@ -470,7 +518,11 @@ export async function resolveHtmlResources(module, satoruInst, htmls, width, hei
470
518
  }
471
519
  ctx.usage.totalResourceBytes += bytes.length;
472
520
  settle("loaded", bytes.length);
473
- (await addRes(satoruInst, r.url, r.type === "image" ? 2 : r.type === "css" ? 3 : 1, bytes));
521
+ if (r.type === "css") {
522
+ loadedCss.push({ index: pending.indexOf(r), url: r.url, bytes });
523
+ return;
524
+ }
525
+ (await addRes(satoruInst, r.url, r.type === "image" ? 2 : 1, bytes));
474
526
  if (r.type === "font") {
475
527
  diag?.fonts.push({
476
528
  family: r.name || r.url,
@@ -486,6 +538,13 @@ export async function resolveHtmlResources(module, satoruInst, htmls, width, hei
486
538
  diag?.warnings.push(d);
487
539
  }
488
540
  });
541
+ loadedCss.sort((a, b) => a.index - b.index);
542
+ for (const css of loadedCss) {
543
+ const text = new TextDecoder().decode(css.bytes);
544
+ const fixed = absolutizeCssUrls(text, css.url);
545
+ const bytes = fixed === text ? css.bytes : new TextEncoder().encode(fixed);
546
+ (await addRes(satoruInst, css.url, 3, bytes));
547
+ }
489
548
  }
490
549
  // Merge the C++ collect-phase breakdown into the JS timings.
491
550
  if (profileEnabled && ctx.diag) {
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" | "apng" | "gif" | "avif" | "jxl" | "raw" | "thumbhash" | "none";
19
+ export type OutputFormat = "svg" | "png" | "pdf" | "jpeg" | "webp" | "apng" | "gif" | "tiff" | "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). */
@@ -146,6 +146,17 @@ export interface HtmlToImageOptions extends RenderOptions {
146
146
  }[];
147
147
  /** Safety/performance limits, enforced in JS around resource resolution. */
148
148
  limits?: RenderLimits;
149
+ /**
150
+ * Output color space of `tiff` and `pdf` (default `"srgb"`).
151
+ *
152
+ * - `"srgb"`: RGB (TIFF keeps the alpha channel).
153
+ * - `"cmyk"`: CMYK for print. TIFF is written as a separated image with transparency
154
+ * flattened onto white; PDF has every fill / stroke color and image rewritten to
155
+ * DeviceCMYK. The conversion is the plain device formula (K = 1 - max(R, G, B), pure
156
+ * black stays K only); it applies no ICC profile, so saturated screen colors are not
157
+ * gamut-mapped. Other formats ignore it.
158
+ */
159
+ colorSpace?: "srgb" | "cmyk";
149
160
  /** PDF Title metadata. */
150
161
  pdfTitle?: string;
151
162
  /** PDF Author metadata. */
package/dist/core.js CHANGED
@@ -46,6 +46,7 @@ export async function htmlToImage(module, options) {
46
46
  fit: orch.fit,
47
47
  textToPaths: orch.textToPaths,
48
48
  mediaTypeInt: orch.mediaTypeInt,
49
+ colorSpace: orch.colorSpace,
49
50
  pdfTitle: orch.pdfTitle,
50
51
  pdfAuthor: orch.pdfAuthor,
51
52
  pdfSubject: orch.pdfSubject,
@@ -5,6 +5,8 @@
5
5
  import type { HtmlToImageOptions, OutputFormat } from "./core.js";
6
6
  /** `RenderFormat` enum values (mirrors `bridge_types.h`). */
7
7
  export declare const FORMAT_INT: Record<OutputFormat, number>;
8
+ /** `RenderFormat::TIFFCmyk`: the CMYK variant of `tiff` (the color space rides in the format int). */
9
+ export declare const TIFF_CMYK_INT = 12;
8
10
  export declare const FIT_INT: Record<NonNullable<HtmlToImageOptions["fit"]>, number>;
9
11
  /** Map `crop`/`fit` to satoru render-option fields (`parse_satoru_options`). */
10
12
  export declare function buildSatoruOptions(crop: HtmlToImageOptions["crop"], fit: HtmlToImageOptions["fit"]): Record<string, unknown>;
@@ -14,6 +16,7 @@ export interface FullSatoruOptionsInput {
14
16
  fit: HtmlToImageOptions["fit"];
15
17
  textToPaths?: boolean;
16
18
  mediaTypeInt: number;
19
+ colorSpace?: "srgb" | "cmyk";
17
20
  pdfTitle?: string;
18
21
  pdfAuthor?: string;
19
22
  pdfSubject?: string;
@@ -9,10 +9,13 @@ export const FORMAT_INT = {
9
9
  jxl: 8,
10
10
  apng: 9,
11
11
  gif: 10,
12
+ tiff: 11,
12
13
  raw: 6,
13
14
  thumbhash: 7,
14
15
  none: -1,
15
16
  };
17
+ /** `RenderFormat::TIFFCmyk`: the CMYK variant of `tiff` (the color space rides in the format int). */
18
+ export const TIFF_CMYK_INT = 12;
16
19
  export const FIT_INT = {
17
20
  contain: 0,
18
21
  cover: 1,
@@ -41,6 +44,7 @@ export function buildFullSatoruOptions(input) {
41
44
  ...buildSatoruOptions(input.crop, input.fit),
42
45
  svgTextToPaths: textToPaths ?? true,
43
46
  mediaType: mediaTypeInt,
47
+ ...(input.colorSpace === "cmyk" ? { colorSpace: 1 } : {}),
44
48
  pdfTitle: pdfTitle ?? "",
45
49
  pdfAuthor: pdfAuthor ?? "",
46
50
  pdfSubject: pdfSubject ?? "",