);
};
```
diff --git a/examples/cmyk/README.md b/examples/cmyk/README.md
index 5540e18c..47e15dbc 100644
--- a/examples/cmyk/README.md
+++ b/examples/cmyk/README.md
@@ -1,8 +1,10 @@
# CMYK
-You can output CMYK color PDFs using the `device-cmyk()` CSS function. To enable CMYK support, set `pdfPostprocess.cmyk` to a truthy value (either `true` or a configuration object) in vivliostyle.config.js.
+You can output CMYK color PDFs using the `device-cmyk()` CSS function. To enable CMYK support, set `pdfPostprocess.cmyk` to a truthy value (either `true` or a configuration object) in vivliostyle.config.js. Under the hood, this feature works by predicting the PDF operators that Chromium produces and replacing them in a post-processing step. Text, borders, background colors, and SVG vector elements are the typical targets of this conversion.
-Under the hood, this feature works by predicting the PDF operators that Chromium produces and replacing them in a post-processing step. Text, borders, background colors, and SVG vector elements are the typical targets of this conversion. Several additional features are provided to help produce fully CMYK PDFs.
+K100 K80 K60 K40 K20 C100 C80 C60 C40 C20 link Noto Sans JPによるType 3に変換された日本語テキスト
+
+Several additional features are provided to help produce fully CMYK PDFs.
## `cmyk.reserveMap`
@@ -10,27 +12,57 @@ SVG vector elements can be converted to CMYK, but `device-cmyk()` values within
[^svg-cmyk]: Technically, SVG can use any color expression that CSS allows, so SVG does support CMYK insofar as `device-cmyk()` exists. In practice, however, it is unlikely that CMYK SVGs will become common given the limited adoption of `device-cmyk()`.
-shapes.svg is an SVG file designed to use C50, K50, and C50+K50 — colors not used elsewhere in the CSS. The chosen RGB placeholders are `#80ffff` for C50, `#808080` for K50, and `#408080` for C50+K50. These particular values are arbitrary; any values that don't collide with other colors in the document will work. They are then registered in `cmyk.reserveMap` as shown in the example config, enabling CMYK colors for vector elements inside SVG.
+shapes.svg is an SVG file designed to use C50, K50, and C50+K50, colors not used elsewhere in the CSS. The chosen RGB placeholders are `#80ffff` for C50, `#808080` for K50, and `#408080` for C50+K50. These particular values are arbitrary; any values that don't collide with other colors in the document will work. They are then registered in `cmyk.reserveMap` as shown in the example config, enabling CMYK colors for vector elements inside SVG.
+
+
## `replaceImage`
Raster images are not covered by the color conversion described above. `replaceImage` lets you substitute raster images with CMYK-ready versions. Since this feature is not specific to CMYK, it is placed outside the `cmyk` configuration.
-Images used in Vivliostyle must be displayable by a web browser. You reference an RGB image in your manuscript and specify the replacement via `replaceImage`, so that the final PDF output contains the CMYK image. The `source` field accepts a regular expression, so managing files by prefix/suffix or separate directories is recommended. Note that JPEG is a raster format that supports CMYK and can be displayed in web browsers[^tiff], but when a CMYK JPEG is included in a web page, Chromium internally converts it to RGB before embedding it in the PDF. This conversion is unpredictable, making CMYK JPEGs unsuitable for this purpose.
+Images used in Vivliostyle must be displayable by a web browser. You reference an RGB image in your manuscript and specify the replacement via `replaceImage`, so that the final PDF output contains the CMYK image. The `source` field accepts a regular expression, so managing files by prefix/suffix or separate directories is recommended. Note that JPEG is a raster format that supports CMYK and can be displayed in web browsers[^tiff], but when a CMYK JPEG is included in a web page, Chromium internally converts it to RGB before embedding it in the PDF. The resulting color values are unpredictable, making CMYK JPEGs unsuitable for this purpose.
[^tiff]: TIFF also supports CMYK, but it can only be displayed in Safari. Since Vivliostyle's CMYK feature depends on Chromium-specific behavior, TIFF is excluded here.
-Replacement works when the original image stream is preserved, such as when only resizing is applied as in the example, but there are cases where replacement does not work when complex operations like filters are applied to the image. Additionally, replacement does not work if the image contains semi-transparent pixels. Only fully opaque RGB images (not RGBA) are supported.
+ck_cmyk.tiff is a cyan-and-key-plate gradient. Its RGB conversion is saved as ck_rgb.png, which the manuscript references. At PDF output time, ck_rgb.png is replaced with the contents of ck_cmyk.tiff.
+
+
+
+The `replaceImage` array is processed front to back; the first entry that matches a given image wins. File-based replacement works by comparing pixel data, so it requires the original image stream to be preserved in the PDF. Simple resizing keeps the stream intact, but complex operations like filters or `object-view-box` cropping may cause the browser to rasterize the image, producing different pixel data. Images loaded from URLs also cannot be matched because no local source file exists. Additionally, images with semi-transparent pixels are not supported; only fully opaque RGB images (not RGBA) can be matched.
+
+### `ReplaceFunction` and `builtinCmykReplacement` / `builtinGrayReplacement`
+
+For images that cannot be matched by pixel comparison, `replaceImage` also accepts a `ReplaceFunction`, a function that receives an image context and returns replacement image bytes. A bare `ReplaceFunction` placed in the array matches all RGB images (equivalent to `source: *`), so it should come last as a fallback after file-based entries.
+
+The manuscript includes two cases where file-based matching fails. The first is an image loaded from a URL, where no local file exists to compare against:
+
+
+
+The second is ck_rgb.png, cropped with `object-view-box: xywh(1px 0 100px 100px)`. The browser rasterizes the cropped region into new pixel data that no longer matches the original file. Whether rasterization occurs is an opaque browser implementation detail; for instance, changing `x` to `50px` happens to preserve the original stream in the current version of Chromium.
+
+{style="object-view-box: xywh(1px 0 100px 100px)"}
+
+In both cases, the `builtinGrayReplacement` fallback at the end of the `replaceImage` array converts the image to grayscale. See `vivliostyle.config.js` in this example for the full configuration.
+
+A `ReplaceFunction` can also be used as the `replacement` in a `{ source, replacement }` entry. This is useful for images like screenshots where CMYK accuracy is not critical: preparing a separate CMYK file for each one is unnecessary busywork that also creates a second copy to keep in sync, when an automatic conversion would suffice.
+
+`builtinCmykReplacement()` and `builtinGrayReplacement()` return `ReplaceFunction` implementations that convert RGB images to CMYK or grayscale. Both accept an optional `ColorConversionOptions` object with `outputProfile` (as `Uint8Array` of ICC profile data) for profile-based conversion. When an output profile is provided, a separate mupdf WASM instance is used internally to isolate ICC state.
+
+A `ReplaceFunction` only receives RGB images; non-RGB images are skipped.
## Other options
By design, this feature cannot produce PDFs that freely mix RGB and CMYK colors (more precisely, it can produce a PDF with unconverted RGB values left in place, but it cannot guarantee that arbitrary RGB and CMYK values will coexist correctly). Since stray RGB elements are usually undesirable in a CMYK workflow, `cmyk.warnUnmapped` (default: `true`) logs warnings for any RGB colors in the PDF that have not been mapped to CMYK.
+Similarly, `cmyk.warnUnreplacedImages` (default: `true`) logs warnings for any non-CMYK images remaining in the PDF after image replacement.
+
+Building this example produces unmapped color warnings from the `
` element in the footnote section (`#2b2b2b`, `#9a9a9a`, `#eeeeee`). The correct fix would be to style `hr` with `device-cmyk()`, but here we use `cmyk.overrideMap` to demonstrate how to forcibly replace unmapped RGB colors with CMYK values and keep the output fully CMYK.
+
This CMYK feature assumes that you are aware of and in control of every colored element in your document. For complex documents, that may not always be the case. `cmyk.overrideMap` is a last resort for silencing `cmyk.warnUnmapped` warnings: it forcibly replaces any remaining RGB values in the PDF with the specified CMYK values.
`mapOutput` is primarily a debugging tool. It writes the internal color mapping table to a file.
-```
+```shellsession
$ npm run build && gs -dQUIET -dBATCH -dNOPAUSE -sOutputFile=- -sDEVICE=ink_cov output.pdf
INFO Start building
@@ -42,6 +74,11 @@ INFO Converting CMYK colors
INFO Replacing images
SUCCESS Finished building output.pdf
📙 Built successfully!
- 0.34325 0.00000 0.00000 0.76783 CMYK OK
-10.23707 0.00000 0.00000 10.22007 CMYK OK
+ 0.34246 0.00000 0.00000 4.31122 CMYK OK
+ 2.52962 0.00000 0.00000 6.10935 CMYK OK
+ 9.80958 0.00000 0.00000 12.63330 CMYK OK
+ 0.26049 0.00000 0.00000 11.36965 CMYK OK
+ 0.26049 0.00000 0.00000 4.06668 CMYK OK
+ 0.26049 0.00000 0.00000 4.91960 CMYK OK
+ 0.28051 0.00000 0.00000 3.23283 CMYK OK
```
diff --git a/examples/cmyk/Noto_Sans_JP/NotoSansJP-VariableFont_wght.ttf b/examples/cmyk/css/Noto_Sans_JP/NotoSansJP-VariableFont_wght.ttf
similarity index 100%
rename from examples/cmyk/Noto_Sans_JP/NotoSansJP-VariableFont_wght.ttf
rename to examples/cmyk/css/Noto_Sans_JP/NotoSansJP-VariableFont_wght.ttf
diff --git a/examples/cmyk/Noto_Sans_JP/OFL.txt b/examples/cmyk/css/Noto_Sans_JP/OFL.txt
similarity index 100%
rename from examples/cmyk/Noto_Sans_JP/OFL.txt
rename to examples/cmyk/css/Noto_Sans_JP/OFL.txt
diff --git a/examples/cmyk/Noto_Sans_JP/README.txt b/examples/cmyk/css/Noto_Sans_JP/README.txt
similarity index 100%
rename from examples/cmyk/Noto_Sans_JP/README.txt
rename to examples/cmyk/css/Noto_Sans_JP/README.txt
diff --git a/examples/cmyk/css/package.json b/examples/cmyk/css/package.json
new file mode 100644
index 00000000..13b03b06
--- /dev/null
+++ b/examples/cmyk/css/package.json
@@ -0,0 +1,9 @@
+{
+ "//": "see: https://github.com/vivliostyle/vivliostyle-cli/issues/517#issuecomment-2381590030",
+ "name": "css",
+ "vivliostyle": {
+ "theme": {
+ "style": "style.css"
+ }
+ }
+}
diff --git a/examples/cmyk/css/style.css b/examples/cmyk/css/style.css
new file mode 100644
index 00000000..daf7d6d3
--- /dev/null
+++ b/examples/cmyk/css/style.css
@@ -0,0 +1,85 @@
+@import url('https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@300..700&display=swap');
+
+@font-face {
+ font-family: 'Noto Sans JP';
+ font-style: normal;
+ font-weight: 400;
+ src: url('Noto_Sans_JP/NotoSansJP-VariableFont_wght.ttf');
+}
+
+@page {
+ size: A5;
+ marks: crop cross;
+ bleed: 3mm;
+ /* see https://github.com/vivliostyle/vivliostyle.js/pull/1505 */
+ crop-marks-line-color: device-cmyk(1 0 0 1);
+}
+
+figure {
+ margin: 0;
+}
+
+img {
+ max-width: 100%;
+}
+
+.k100 {
+ color: device-cmyk(0 0 0 1);
+}
+
+.k80 {
+ color: device-cmyk(0 0 0 0.8);
+}
+
+.k60 {
+ color: device-cmyk(0 0 0 0.6);
+}
+
+.k40 {
+ color: device-cmyk(0 0 0 0.4);
+}
+
+.k20 {
+ color: device-cmyk(0 0 0 0.2);
+}
+
+.c100 {
+ color: device-cmyk(1 0 0 0);
+}
+
+.c80 {
+ color: device-cmyk(0.8 0 0 0);
+}
+
+.c60 {
+ color: device-cmyk(0.6 0 0 0);
+}
+
+.c40 {
+ color: device-cmyk(0.4 0 0 0);
+}
+
+.c20 {
+ color: device-cmyk(0.2 0 0 0);
+}
+
+a {
+ color: device-cmyk(1 0 0 0);
+}
+
+/* Intentionally left unstyled to demonstrate warnUnmapped
+hr {
+ border: none;
+ border-top: 1px solid device-cmyk(0 0 0 0.5);
+} */
+
+pre code {
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+code {
+ font:
+ 80% monospace,
+ 'Noto Emoji';
+}
diff --git a/examples/cmyk/manuscript.html b/examples/cmyk/manuscript.html
deleted file mode 100644
index 0356f81d..00000000
--- a/examples/cmyk/manuscript.html
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
- CMYK
-
-
-
-
- K100
- K80
- K60
- K40
- K20
- C100
- C80
- C60
- C40
- C20
- link
-
- Noto Sans JPのテキストはType 3フォントに変換されます。
-
-
-
-
-
diff --git a/examples/cmyk/vivliostyle.config.js b/examples/cmyk/vivliostyle.config.js
index 55d1d224..945e31ad 100644
--- a/examples/cmyk/vivliostyle.config.js
+++ b/examples/cmyk/vivliostyle.config.js
@@ -1,8 +1,9 @@
// @ts-check
-import { defineConfig } from '@vivliostyle/cli';
+import { builtinGrayReplacement, defineConfig } from '@vivliostyle/cli';
export default defineConfig({
- entry: ['manuscript.html'],
+ theme: './css',
+ entry: ['README.md'],
pdfPostprocess: {
cmyk: {
reserveMap: [
@@ -10,7 +11,15 @@ export default defineConfig({
['#808080', { c: 0, m: 0, y: 0, k: 5000 }],
['#408080', { c: 5000, m: 0, y: 0, k: 5000 }],
],
+ overrideMap: [
+ ['#2b2b2b', { c: 0, m: 0, y: 0, k: 8300 }],
+ ['#9a9a9a', { c: 0, m: 0, y: 0, k: 4000 }],
+ ['#eeeeee', { c: 0, m: 0, y: 0, k: 700 }],
+ ],
},
- replaceImage: [{ source: /^(.*)_rgb\.png$/, replacement: '$1_cmyk.tiff' }],
+ replaceImage: [
+ { source: /^(.*)_rgb\.png$/, replacement: '$1_cmyk.tiff' },
+ builtinGrayReplacement(),
+ ],
},
});
diff --git a/src/config/resolve.ts b/src/config/resolve.ts
index c096cc7d..14467efb 100644
--- a/src/config/resolve.ts
+++ b/src/config/resolve.ts
@@ -263,17 +263,27 @@ function resolveMapEntries(
export interface CmykConfig {
warnUnmapped: boolean;
+ warnUnreplacedImages: boolean;
overrideMap: CmykMapEntry[];
reserveMap: CmykMapEntry[];
mapOutput: string | undefined;
}
+export interface ImageContext {
+ asPNG(): Uint8Array;
+}
+
+export type ReplaceFunction = (
+ image: ImageContext,
+) => Uint8Array | Promise;
+
export interface ReplaceImageEntry {
source: string;
- replacement: string;
+ replacement: string | ReplaceFunction;
}
-export type ReplaceImageConfig = ReplaceImageEntry[];
+export type ReplaceImageConfigItem = ReplaceImageEntry | ReplaceFunction;
+export type ReplaceImageConfig = ReplaceImageConfigItem[];
export interface PdfOutput {
format: 'pdf';
@@ -697,6 +707,7 @@ export function resolveTaskConfig(
if (cmykOption && typeof cmykOption === 'object') {
return {
warnUnmapped: cmykOption.warnUnmapped ?? true,
+ warnUnreplacedImages: cmykOption.warnUnreplacedImages ?? true,
overrideMap: resolveMapEntries(cmykOption.overrideMap ?? []),
reserveMap: resolveMapEntries(cmykOption.reserveMap ?? []),
mapOutput: cmykOption.mapOutput
@@ -708,6 +719,7 @@ export function resolveTaskConfig(
if (options.cmyk || cmykOption === true) {
return {
warnUnmapped: true,
+ warnUnreplacedImages: true,
overrideMap: [],
reserveMap: [],
mapOutput: undefined,
@@ -729,23 +741,46 @@ export function resolveTaskConfig(
cwd: entryContextDir,
onlyFiles: true,
});
- return replaceImageOption.flatMap(({ source, replacement }) => {
- if (source instanceof RegExp) {
- return allFiles
- .filter((file) => source.test(file))
- .map((file) => ({
- source: upath.resolve(entryContextDir, file),
- replacement: upath.resolve(
- entryContextDir,
- file.replace(source, replacement),
- ),
- }));
- }
- return {
- source: upath.resolve(entryContextDir, source),
- replacement: upath.resolve(entryContextDir, replacement),
- };
- });
+ return replaceImageOption.flatMap(
+ (item): ReplaceImageConfigItem | ReplaceImageConfigItem[] => {
+ // Bare function: pass through as-is
+ if (typeof item === 'function') {
+ return item as ReplaceFunction;
+ }
+ const { source, replacement } = item;
+ const isFnReplacement = typeof replacement === 'function';
+
+ if (source instanceof RegExp) {
+ if (isFnReplacement) {
+ // RegExp source + function replacement
+ return allFiles
+ .filter((file) => source.test(file))
+ .map((file) => ({
+ source: upath.resolve(entryContextDir, file),
+ replacement,
+ }));
+ }
+ // RegExp source + string replacement (existing)
+ return allFiles
+ .filter((file) => source.test(file))
+ .map((file) => ({
+ source: upath.resolve(entryContextDir, file),
+ replacement: upath.resolve(
+ entryContextDir,
+ file.replace(source, replacement),
+ ),
+ }));
+ }
+
+ // String source + string or function replacement
+ return {
+ source: upath.resolve(entryContextDir, source),
+ replacement: isFnReplacement
+ ? replacement
+ : upath.resolve(entryContextDir, replacement),
+ };
+ },
+ );
};
// Resolve preflight with priority:
diff --git a/src/config/schema.ts b/src/config/schema.ts
index d3bd89d2..2a908b2c 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -291,6 +291,12 @@ const CmykConfigSchema = v.pipe(
Warn when RGB colors not mapped to CMYK are encountered. (default: true)
`),
),
+ warnUnreplacedImages: v.pipe(
+ v.boolean(),
+ v.description($`
+ Warn when non-CMYK-compatible images remain in the PDF after image replacement. (default: true)
+ `),
+ ),
mapOutput: v.pipe(
ValidString,
v.description($`
@@ -310,6 +316,19 @@ const CmykSchema = v.pipe(
`),
);
+const ReplaceFunctionSchema = v.pipe(
+ v.function() as v.GenericSchema<
+ (image: { asPNG(): Uint8Array }) => Uint8Array | Promise
+ >,
+ v.metadata({
+ typeString:
+ '((image: { asPNG(): Uint8Array }) => Uint8Array | Promise)',
+ }),
+ v.description(
+ 'Function that receives an image context and returns replacement image bytes.',
+ ),
+);
+
const ReplaceImageEntrySchema = v.pipe(
v.object({
source: v.pipe(
@@ -319,9 +338,9 @@ const ReplaceImageEntrySchema = v.pipe(
),
),
replacement: v.pipe(
- ValidString,
+ v.union([ValidString, ReplaceFunctionSchema]),
v.description(
- 'Path to the replacement image file. When source is a RegExp, supports $1, $2, etc. for captured groups.',
+ 'Path to the replacement image file, a function that processes the image, or when source is a RegExp with a string replacement, supports $1, $2, etc.',
),
),
}),
@@ -329,11 +348,11 @@ const ReplaceImageEntrySchema = v.pipe(
);
const ReplaceImageSchema = v.pipe(
- v.array(ReplaceImageEntrySchema),
+ v.array(v.union([ReplaceImageEntrySchema, ReplaceFunctionSchema])),
v.description($`
Replace images in the output PDF.
- Each entry specifies a source image path and its replacement image path.
- Useful for replacing RGB images with CMYK versions.
+ Each entry can be an object with source/replacement paths, an object with a source path
+ and a replacement function, or a bare function that processes all RGB images.
`),
);
diff --git a/src/index.ts b/src/index.ts
index 6bd85b6c..2f50d7f8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -18,7 +18,13 @@ export type {
VivliostyleConfigSchema,
VivliostylePackageMetadata,
} from './config/schema.js';
+export type { ImageContext, ReplaceFunction } from './config/resolve.js';
export type { TemplateVariable } from './create-template.js';
+export {
+ builtinCmykReplacement,
+ builtinGrayReplacement,
+ type ColorConversionOptions,
+} from './output/image.js';
export { createVitePlugin } from './vite-adapter.js';
/** @hidden */
export type PublicationManifest = _PublicationManifest;
diff --git a/src/output/image.ts b/src/output/image.ts
index e9f3e7ad..e1b07e5f 100644
--- a/src/output/image.ts
+++ b/src/output/image.ts
@@ -1,6 +1,10 @@
import fs from 'node:fs';
import type * as mupdfType from 'mupdf';
-import type { ReplaceImageConfig } from '../config/resolve.js';
+import type {
+ ImageContext,
+ ReplaceFunction,
+ ReplaceImageConfig,
+} from '../config/resolve.js';
import { Logger } from '../logger.js';
import { importNodeModule } from '../node-modules.js';
@@ -16,13 +20,15 @@ function disposable(obj: T): T & Disposable {
});
}
+type DisposableImage = mupdfType.Image & Disposable;
+
function imagesEqual(a: mupdfType.Image, b: mupdfType.Image): boolean {
if (a.getWidth() !== b.getWidth() || a.getHeight() !== b.getHeight()) {
return false;
}
- const pixmapA = a.toPixmap();
- const pixmapB = b.toPixmap();
+ using pixmapA = disposable(a.toPixmap());
+ using pixmapB = disposable(b.toPixmap());
const typeA = pixmapA.getColorSpace();
const typeB = pixmapB.getColorSpace();
@@ -46,11 +52,332 @@ function imagesEqual(a: mupdfType.Image, b: mupdfType.Image): boolean {
);
}
-interface ImagePair {
- srcImage: mupdfType.Image;
- destImage: mupdfType.Image;
- sourcePath: string;
- replacementPath: string;
+function createImageContext(pdfImage: mupdfType.Image): ImageContext {
+ return {
+ asPNG() {
+ using pixmap = disposable(pdfImage.toPixmap());
+ return pixmap.asPNG();
+ },
+ };
+}
+
+function isRgbImage(pdfImage: mupdfType.Image): boolean {
+ using pixmap = disposable(pdfImage.toPixmap());
+ const cs = pixmap.getColorSpace();
+ return cs?.isRGB() ?? false;
+}
+
+function convertImageColorSpace(
+ image: mupdfType.Image,
+ colorSpace: mupdfType.ColorSpace,
+ mupdf: typeof import('mupdf'),
+): DisposableImage {
+ using pixmap = disposable(image.toPixmap());
+ using converted = disposable(pixmap.convertToColorSpace(colorSpace));
+ return disposable(new mupdf.Image(converted));
+}
+
+// Cached ICC-enabled WASM instance (lazily initialized).
+// A separate instance is required because enableICC() mutates global WASM state
+// and cannot be safely reverted on the shared instance.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let iccWasmInstance: Promise | null = null;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function getIccWasmInstance(): Promise {
+ if (!iccWasmInstance) {
+ iccWasmInstance = (async () => {
+ const wasmUrl = new URL('./mupdf-wasm.js', import.meta.resolve('mupdf'))
+ .href;
+ const factory = (await import(/* @vite-ignore */ wasmUrl)).default;
+ const lib = await factory();
+ lib._wasm_init_context();
+ lib._wasm_enable_icc();
+ return lib;
+ })();
+ }
+ return iccWasmInstance;
+}
+
+async function convertWithICC(
+ pngBytes: Uint8Array,
+ type: 'CMYK' | 'Gray',
+ outputProfile?: Uint8Array,
+): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const lib: any = await getIccWasmInstance();
+
+ // Wrap a low-level WASM pointer with a drop function as Disposable
+ function wasmDisposable(
+ ptr: number,
+ drop: (ptr: number) => void,
+ ): Disposable & { ptr: number } {
+ return {
+ ptr,
+ [Symbol.dispose]() {
+ drop(ptr);
+ },
+ };
+ }
+
+ function toHeap(data: Uint8Array): number {
+ const ptr: number = lib._wasm_malloc(data.length);
+ lib.HEAPU8.set(data, ptr);
+ return ptr;
+ }
+
+ using imgBuf = wasmDisposable(
+ lib._wasm_new_buffer_from_data(toHeap(pngBytes), pngBytes.length),
+ (p: number) => lib._wasm_drop_buffer(p),
+ );
+ using img = wasmDisposable(
+ lib._wasm_new_image_from_buffer(imgBuf.ptr),
+ (p: number) => lib._wasm_drop_image(p),
+ );
+ using pixmap = wasmDisposable(
+ lib._wasm_get_pixmap_from_image(img.ptr),
+ (p: number) => lib._wasm_drop_pixmap(p),
+ );
+
+ let targetCsDisposable: Disposable | undefined;
+ let targetCsPtr: number;
+ if (outputProfile) {
+ const namePtr = toHeap(new TextEncoder().encode(`custom-${type}\0`));
+ using profileBuf = wasmDisposable(
+ lib._wasm_new_buffer_from_data(
+ toHeap(outputProfile),
+ outputProfile.length,
+ ),
+ (p: number) => lib._wasm_drop_buffer(p),
+ );
+ targetCsPtr = lib._wasm_new_icc_colorspace(namePtr, profileBuf.ptr);
+ lib._wasm_free(namePtr);
+ targetCsDisposable = {
+ [Symbol.dispose]() {
+ lib._wasm_drop_colorspace(targetCsPtr);
+ },
+ };
+ } else {
+ targetCsPtr =
+ type === 'CMYK' ? lib._wasm_device_cmyk() : lib._wasm_device_gray();
+ }
+ using _cs = targetCsDisposable;
+
+ using converted = wasmDisposable(
+ lib._wasm_convert_pixmap(pixmap.ptr, targetCsPtr, 0),
+ (p: number) => lib._wasm_drop_pixmap(p),
+ );
+ using pamBuf = wasmDisposable(
+ lib._wasm_new_buffer_from_pixmap_as_pam(converted.ptr),
+ (p: number) => lib._wasm_drop_buffer(p),
+ );
+ const pamDataPtr: number = lib._wasm_buffer_get_data(pamBuf.ptr);
+ const pamLen: number = lib._wasm_buffer_get_len(pamBuf.ptr);
+ return new Uint8Array(lib.HEAPU8.buffer, pamDataPtr, pamLen).slice();
+}
+
+const BUILTIN_TYPE = Symbol('builtinType');
+
+function getBuiltinType(fn: ReplaceFunction): 'CMYK' | 'Gray' | undefined {
+ if (BUILTIN_TYPE in fn) {
+ const value = (fn as Record)[BUILTIN_TYPE];
+ if (value === 'CMYK' || value === 'Gray') return value;
+ }
+ return undefined;
+}
+
+export interface ColorConversionOptions {
+ outputProfile?: Uint8Array;
+}
+
+function createBuiltinReplacement(
+ type: 'CMYK' | 'Gray',
+ options: ColorConversionOptions = {},
+): ReplaceFunction {
+ const { outputProfile } = options;
+ const useICC = !!outputProfile;
+ const fn: ReplaceFunction = async (image) => {
+ if (useICC) {
+ return convertWithICC(image.asPNG(), type, outputProfile);
+ }
+ const mupdf = await importNodeModule('mupdf');
+ using img = disposable(new mupdf.Image(image.asPNG()));
+ using result = convertImageColorSpace(
+ img,
+ type === 'CMYK'
+ ? mupdf.ColorSpace.DeviceCMYK
+ : mupdf.ColorSpace.DeviceGray,
+ mupdf,
+ );
+ using pixmap = disposable(result.toPixmap());
+ return pixmap.asPAM();
+ };
+ // Tag for fast-path detection (only when not using ICC;
+ // ICC conversions use a separate WASM instance via the general path)
+ if (!useICC) {
+ Object.defineProperty(fn, BUILTIN_TYPE, { value: type });
+ }
+ return fn;
+}
+
+/**
+ * Returns a ReplaceFunction that converts RGB images to CMYK.
+ * When called without arguments, uses mupdf's DeviceCMYK color space.
+ * An output ICC profile can be provided for profile-based conversion.
+ */
+export function builtinCmykReplacement(
+ options: ColorConversionOptions = {},
+): ReplaceFunction {
+ return createBuiltinReplacement('CMYK', options);
+}
+
+/**
+ * Returns a ReplaceFunction that converts RGB images to grayscale.
+ * When called without arguments, uses mupdf's DeviceGray color space.
+ * An output ICC profile can be provided for profile-based conversion.
+ */
+export function builtinGrayReplacement(
+ options: ColorConversionOptions = {},
+): ReplaceFunction {
+ return createBuiltinReplacement('Gray', options);
+}
+
+/**
+ * Scan PDF for images with non-CMYK-compatible color spaces and log warnings.
+ */
+export async function findNonCmykImages(pdf: Uint8Array): Promise {
+ const mupdf = await importNodeModule('mupdf');
+ using doc = disposable(
+ mupdf.PDFDocument.openDocument(
+ pdf,
+ 'application/pdf',
+ ) as import('mupdf').PDFDocument,
+ );
+
+ const warned = new Set();
+ const pageCount = doc.countPages();
+
+ for (let i = 0; i < pageCount; i++) {
+ using page = disposable(doc.loadPage(i));
+ const pageObj = page.getObject().resolve();
+ const res = pageObj.get('Resources');
+ if (!res?.isDictionary()) continue;
+ const xobjects = res.get('XObject');
+ if (!xobjects?.isDictionary()) continue;
+
+ xobjects.forEach((value) => {
+ const resolved = value.resolve();
+ if (resolved.get('Subtype')?.toString() !== '/Image') return;
+
+ using img = disposable(doc.loadImage(value));
+ using pixmap = disposable(img.toPixmap());
+ const cs = pixmap.getColorSpace();
+ if (cs && !cs.isCMYK() && !cs.isGray()) {
+ const warnKey = `${img.getWidth()}x${img.getHeight()} on page ${i + 1}`;
+ if (!warned.has(warnKey)) {
+ warned.add(warnKey);
+ Logger.logWarn(`Non-CMYK image remaining in PDF: ${warnKey}`);
+ }
+ }
+ });
+ }
+}
+
+function applyReplaceFunction(
+ fn: ReplaceFunction,
+ pdfImage: mupdfType.Image,
+ mupdf: typeof import('mupdf'),
+): Promise | DisposableImage {
+ const builtinType = getBuiltinType(fn);
+ if (builtinType) {
+ // Fast path for non-ICC builtin replacements: direct pixmap conversion
+ return convertImageColorSpace(
+ pdfImage,
+ builtinType === 'CMYK'
+ ? mupdf.ColorSpace.DeviceCMYK
+ : mupdf.ColorSpace.DeviceGray,
+ mupdf,
+ );
+ }
+ // General path: bytes in, bytes out (includes ICC builtins)
+ return (async () => {
+ const resultBytes = await fn(createImageContext(pdfImage));
+ return disposable(new mupdf.Image(resultBytes));
+ })();
+}
+
+// A prepared entry is a function that attempts to match and replace a PDF image.
+// Returns the replacement DisposableImage on match, or null to try the next entry.
+type PreparedEntry = (
+ pdfImage: mupdfType.Image,
+ pageIndex: number,
+ key: string | number,
+) => Promise;
+
+function prepareFileEntry(
+ srcImage: mupdfType.Image,
+ destImage: DisposableImage,
+ sourcePath: string,
+ replacementPath: string,
+): PreparedEntry {
+ return async (pdfImage, pageIndex, key) => {
+ if (!imagesEqual(pdfImage, srcImage)) return null;
+ Logger.debug(
+ ` Page ${pageIndex + 1}, ref "${key}": ${sourcePath} -> ${replacementPath}`,
+ );
+ return destImage;
+ };
+}
+
+function prepareFnWithSourceEntry(
+ srcImage: mupdfType.Image,
+ sourcePath: string,
+ fn: ReplaceFunction,
+ mupdf: typeof import('mupdf'),
+): PreparedEntry {
+ // Chromium converts all images to RGB in its PDF output
+ // (even grayscale PNGs are embedded as RGB).
+ // Only pass RGB images to ReplaceFunction.
+ return async (pdfImage, pageIndex, key) => {
+ if (!isRgbImage(pdfImage)) return null;
+ if (!imagesEqual(pdfImage, srcImage)) return null;
+ try {
+ const newImage = await applyReplaceFunction(fn, pdfImage, mupdf);
+ Logger.debug(
+ ` Page ${pageIndex + 1}, ref "${key}": ${sourcePath} -> [function]`,
+ );
+ return newImage;
+ } catch (error) {
+ Logger.logWarn(
+ `Failed to apply replacement function for ${sourcePath} on page ${pageIndex + 1}: ${error}`,
+ );
+ return null;
+ }
+ };
+}
+
+function prepareBareFnEntry(
+ fn: ReplaceFunction,
+ mupdf: typeof import('mupdf'),
+): PreparedEntry {
+ // Chromium converts all images to RGB in its PDF output
+ // (even grayscale PNGs are embedded as RGB).
+ // Only pass RGB images to ReplaceFunction.
+ return async (pdfImage, pageIndex, key) => {
+ if (!isRgbImage(pdfImage)) return null;
+ try {
+ const newImage = await applyReplaceFunction(fn, pdfImage, mupdf);
+ Logger.debug(
+ ` Page ${pageIndex + 1}, ref "${key}": [all RGB] -> [function]`,
+ );
+ return newImage;
+ } catch (error) {
+ Logger.logWarn(
+ `Failed to apply replacement function on page ${pageIndex + 1}: ${error}`,
+ );
+ return null;
+ }
+ };
}
interface ReplaceStats {
@@ -58,17 +385,18 @@ interface ReplaceStats {
total: number;
}
-function replaceImagesInDocument(
+async function replaceImagesInDocument(
doc: mupdfType.PDFDocument,
- imagePairs: ImagePair[],
-): ReplaceStats {
+ preparedEntries: PreparedEntry[],
+ disposables: Set,
+): Promise {
let replaced = 0;
let total = 0;
const pageCount = doc.countPages();
for (let i = 0; i < pageCount; i++) {
- const page = doc.loadPage(i);
+ using page = disposable(doc.loadPage(i));
const pageObj = page.getObject().resolve();
const res = pageObj.get('Resources');
@@ -90,18 +418,15 @@ function replaceImagesInDocument(
if (subtype && subtype.toString() === '/Image') {
total++;
- // Extract image from PDF
- const pdfImage = doc.loadImage(value);
-
- // Find matching source image
- for (const pair of imagePairs) {
- if (imagesEqual(pdfImage, pair.srcImage)) {
- const newImageRef = doc.addImage(pair.destImage);
+ const pdfImage = disposable(doc.loadImage(value));
+ disposables.add(pdfImage);
+ for (const entry of preparedEntries) {
+ const result = await entry(pdfImage, i, key);
+ if (result) {
+ disposables.add(result);
+ const newImageRef = doc.addImage(result);
xobjects.put(key, newImageRef);
replaced++;
- Logger.debug(
- ` Page ${i + 1}, ref "${key}": ${pair.sourcePath} -> ${pair.replacementPath}`,
- );
break;
}
}
@@ -127,60 +452,83 @@ export async function replaceImages({
}
const mupdf = await importNodeModule('mupdf');
+ const disposables = new Set();
+
+ try {
+ // Prepare entries: each config item becomes a match-and-replace function
+ const preparedEntries: PreparedEntry[] = [];
+ for (const item of replaceImageConfig) {
+ if (typeof item === 'function') {
+ preparedEntries.push(prepareBareFnEntry(item, mupdf));
+ continue;
+ }
- // Load image pairs
- const imagePairs: ImagePair[] = [];
- for (const { source, replacement } of replaceImageConfig) {
- let srcImage: mupdfType.Image;
- let destImage: mupdfType.Image;
+ const { source, replacement } = item;
- try {
- const srcBuffer = fs.readFileSync(source);
- srcImage = new mupdf.Image(srcBuffer);
- Logger.debug(
- `Loaded source image: ${source} (${srcImage.getWidth()}x${srcImage.getHeight()})`,
- );
- } catch (error) {
- Logger.logWarn(`Failed to load source image: ${source}: ${error}`);
- continue;
- }
+ let srcImage: DisposableImage;
+ try {
+ const srcBuffer = fs.readFileSync(source);
+ srcImage = disposable(new mupdf.Image(srcBuffer));
+ disposables.add(srcImage);
+ Logger.debug(
+ `Loaded source image: ${source} (${srcImage.getWidth()}x${srcImage.getHeight()})`,
+ );
+ } catch (error) {
+ Logger.logWarn(`Failed to load source image: ${source}: ${error}`);
+ continue;
+ }
- try {
- const destBuffer = fs.readFileSync(replacement);
- destImage = new mupdf.Image(destBuffer);
- Logger.debug(
- `Loaded replacement image: ${replacement} (${destImage.getWidth()}x${destImage.getHeight()})`,
- );
- } catch (error) {
- Logger.logWarn(
- `Failed to load replacement image: ${replacement}: ${error}`,
+ if (typeof replacement === 'function') {
+ preparedEntries.push(
+ prepareFnWithSourceEntry(srcImage, source, replacement, mupdf),
+ );
+ continue;
+ }
+
+ let destImage: DisposableImage;
+ try {
+ const destBuffer = fs.readFileSync(replacement);
+ destImage = disposable(new mupdf.Image(destBuffer));
+ disposables.add(destImage);
+ Logger.debug(
+ `Loaded replacement image: ${replacement} (${destImage.getWidth()}x${destImage.getHeight()})`,
+ );
+ } catch (error) {
+ Logger.logWarn(
+ `Failed to load replacement image: ${replacement}: ${error}`,
+ );
+ continue;
+ }
+
+ preparedEntries.push(
+ prepareFileEntry(srcImage, destImage, source, replacement),
);
- continue;
}
- imagePairs.push({
- srcImage,
- destImage,
- sourcePath: source,
- replacementPath: replacement,
- });
- }
-
- if (imagePairs.length === 0) {
- return pdf;
- }
+ if (preparedEntries.length === 0) {
+ return pdf;
+ }
- using doc = disposable(
- mupdf.PDFDocument.openDocument(
- pdf,
- 'application/pdf',
- ) as mupdfType.PDFDocument,
- );
+ using doc = disposable(
+ mupdf.PDFDocument.openDocument(
+ pdf,
+ 'application/pdf',
+ ) as mupdfType.PDFDocument,
+ );
- const stats = replaceImagesInDocument(doc, imagePairs);
- Logger.debug(`Replaced ${stats.replaced} of ${stats.total} images`);
+ const stats = await replaceImagesInDocument(
+ doc,
+ preparedEntries,
+ disposables,
+ );
+ Logger.debug(`Replaced ${stats.replaced} of ${stats.total} images`);
- using outputBuffer = disposable(doc.saveToBuffer('compress'));
- // Create a copy to ensure the data remains valid after the buffer is destroyed
- return new Uint8Array(outputBuffer.asUint8Array());
+ using outputBuffer = disposable(doc.saveToBuffer('compress'));
+ // Create a copy to ensure the data remains valid after the buffer is destroyed
+ return new Uint8Array(outputBuffer.asUint8Array());
+ } finally {
+ for (const d of disposables) {
+ d[Symbol.dispose]();
+ }
+ }
}
diff --git a/src/output/pdf-postprocess.ts b/src/output/pdf-postprocess.ts
index a74648ed..d7fd2d96 100644
--- a/src/output/pdf-postprocess.ts
+++ b/src/output/pdf-postprocess.ts
@@ -15,7 +15,7 @@ import { Logger } from '../logger.js';
import { importNodeModule } from '../node-modules.js';
import { coreVersion, isInContainer } from '../util.js';
import { convertCmykColors } from './cmyk.js';
-import { replaceImages } from './image.js';
+import { findNonCmykImages, replaceImages } from './image.js';
export type SaveOption = Pick<
PdfOutput,
@@ -143,6 +143,10 @@ export class PostProcess {
});
}
+ if (cmyk && cmyk.warnUnreplacedImages) {
+ await findNonCmykImages(pdf);
+ }
+
if (preflight) {
const input = upath.join(os.tmpdir(), `vivliostyle-cli-${uuid()}.pdf`);
await fs.promises.writeFile(input, pdf);
diff --git a/tests/fixtures/cmyk/ICC-PROFILES-LICENSE b/tests/fixtures/cmyk/ICC-PROFILES-LICENSE
new file mode 100644
index 00000000..aa9a3656
--- /dev/null
+++ b/tests/fixtures/cmyk/ICC-PROFILES-LICENSE
@@ -0,0 +1,11 @@
+The ICC profile(s) in this directory are obtained from the GhostPDL
+project (ghostpdl-10.07.0) and are licensed under the GNU Affero General
+Public License (AGPL) version 3 or later.
+
+Release: https://github.com/ArtifexSoftware/ghostpdl/releases/tag/ghostpdl-10.07.0
+Source: https://github.com/ArtifexSoftware/ghostpdl/blob/ghostpdl-10.07.0/iccprofiles
+License: https://github.com/ArtifexSoftware/ghostpdl/blob/ghostpdl-10.07.0/LICENSE
+
+Files:
+ default_cmyk.icc - Default CMYK ICC profile from GhostPDL
+ default_gray.icc - Default Gray ICC profile from GhostPDL
diff --git a/tests/fixtures/cmyk/default_cmyk.icc b/tests/fixtures/cmyk/default_cmyk.icc
new file mode 100644
index 00000000..fce61bf2
Binary files /dev/null and b/tests/fixtures/cmyk/default_cmyk.icc differ
diff --git a/tests/fixtures/cmyk/default_gray.icc b/tests/fixtures/cmyk/default_gray.icc
new file mode 100644
index 00000000..e9cd5b12
Binary files /dev/null and b/tests/fixtures/cmyk/default_gray.icc differ
diff --git a/tests/image.test.ts b/tests/image.test.ts
index 70138714..42f96a0a 100644
--- a/tests/image.test.ts
+++ b/tests/image.test.ts
@@ -1,8 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { describe, expect, it } from 'vitest';
-import { replaceImages } from '../src/output/image.js';
+import { describe, expect, it, vi } from 'vitest';
+import type { ImageContext } from '../src/config/resolve.js';
+import {
+ builtinCmykReplacement,
+ builtinGrayReplacement,
+ findNonCmykImages,
+ replaceImages,
+} from '../src/output/image.js';
+import { Logger } from '../src/logger.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesDir = path.join(__dirname, 'fixtures', 'cmyk');
@@ -19,44 +26,41 @@ async function getImageColorSpace(
'application/pdf',
) as import('mupdf').PDFDocument;
- const page = doc.loadPage(0) as import('mupdf').PDFPage;
- const pageObj = page.getObject().resolve();
- const res = pageObj.get('Resources');
+ try {
+ const page = doc.loadPage(0) as import('mupdf').PDFPage;
+ try {
+ const pageObj = page.getObject().resolve();
+ const res = pageObj.get('Resources');
+ if (!res?.isDictionary()) return 'Unknown';
- if (!res?.isDictionary()) {
- doc.destroy();
- return 'Unknown';
- }
+ const xobjects = res.get('XObject');
+ if (!xobjects?.isDictionary()) return 'Unknown';
- const xobjects = res.get('XObject');
- if (!xobjects?.isDictionary()) {
- doc.destroy();
- return 'Unknown';
- }
+ let colorSpace: 'RGB' | 'CMYK' | 'Gray' | 'Unknown' = 'Unknown';
- let colorSpace: 'RGB' | 'CMYK' | 'Gray' | 'Unknown' = 'Unknown';
+ xobjects.forEach((value) => {
+ const resolved = value.resolve();
+ if (resolved.get('Subtype')?.toString() !== '/Image') return;
- xobjects.forEach((value) => {
- const resolved = value.resolve();
- const subtype = resolved.get('Subtype');
+ const pdfImage = doc.loadImage(value);
+ const pixmap = pdfImage.toPixmap();
+ const cs = pixmap.getColorSpace();
- if (subtype?.toString() === '/Image') {
- const pdfImage = doc.loadImage(value);
- const pixmap = pdfImage.toPixmap();
- const cs = pixmap.getColorSpace();
+ if (cs?.isRGB()) colorSpace = 'RGB';
+ else if (cs?.isCMYK()) colorSpace = 'CMYK';
+ else if (cs?.isGray()) colorSpace = 'Gray';
- if (cs?.isRGB()) {
- colorSpace = 'RGB';
- } else if (cs?.isCMYK()) {
- colorSpace = 'CMYK';
- } else if (cs?.isGray()) {
- colorSpace = 'Gray';
- }
- }
- });
+ pixmap.destroy();
+ pdfImage.destroy();
+ });
- doc.destroy();
- return colorSpace;
+ return colorSpace;
+ } finally {
+ page.destroy();
+ }
+ } finally {
+ doc.destroy();
+ }
}
describe('replaceImages', () => {
@@ -95,4 +99,309 @@ describe('replaceImages', () => {
// Should return the same PDF
expect(destPdf).toEqual(srcPdf);
});
+
+ it('replaces RGB image using a bare ReplaceFunction', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+
+ const srcColorSpace = await getImageColorSpace(srcPdf);
+ expect(srcColorSpace).toBe('RGB');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ async (image: ImageContext) => {
+ const mupdf = await import('mupdf');
+ const img = new mupdf.Image(image.asPNG());
+ const pixmap = img.toPixmap();
+ const cmykPixmap = pixmap.convertToColorSpace(
+ mupdf.ColorSpace.DeviceCMYK,
+ );
+ return cmykPixmap.asPAM();
+ },
+ ],
+ });
+
+ const destColorSpace = await getImageColorSpace(destPdf);
+ expect(destColorSpace).toBe('CMYK');
+ });
+
+ it('replaces image using file-to-function entry', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const srcImagePath = path.join(fixturesDir, 'ck_rgb.png');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ {
+ source: srcImagePath,
+ replacement: async (image: ImageContext) => {
+ const mupdf = await import('mupdf');
+ const img = new mupdf.Image(image.asPNG());
+ const pixmap = img.toPixmap();
+ const cmykPixmap = pixmap.convertToColorSpace(
+ mupdf.ColorSpace.DeviceCMYK,
+ );
+ return cmykPixmap.asPAM();
+ },
+ },
+ ],
+ });
+
+ const destColorSpace = await getImageColorSpace(destPdf);
+ expect(destColorSpace).toBe('CMYK');
+ });
+
+ it('file entry takes precedence over bare function (first match wins)', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const srcImagePath = path.join(fixturesDir, 'ck_rgb.png');
+ const destImagePath = path.join(fixturesDir, 'ck_cmyk.tiff');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ { source: srcImagePath, replacement: destImagePath },
+ builtinGrayReplacement(),
+ ],
+ });
+
+ // File entry matched first producing CMYK, not Gray from the fallback
+ const destColorSpace = await getImageColorSpace(destPdf);
+ expect(destColorSpace).toBe('CMYK');
+ });
+
+ it('skips entries with nonexistent source file', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const spy = vi.spyOn(Logger, 'logWarn');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ { source: '/nonexistent/source.png', replacement: 'any.tiff' },
+ ],
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to load source image'),
+ );
+ expect(destPdf).toEqual(srcPdf);
+ spy.mockRestore();
+ });
+
+ it('skips entries with nonexistent replacement file', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const srcImagePath = path.join(fixturesDir, 'ck_rgb.png');
+ const spy = vi.spyOn(Logger, 'logWarn');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ { source: srcImagePath, replacement: '/nonexistent/dest.tiff' },
+ ],
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to load replacement image'),
+ );
+ expect(destPdf).toEqual(srcPdf);
+ spy.mockRestore();
+ });
+
+ it('catches and warns on ReplaceFunction errors', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const spy = vi.spyOn(Logger, 'logWarn');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ () => {
+ throw new Error('test error');
+ },
+ ],
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to apply replacement function'),
+ );
+ // PDF should still be returned (image left unchanged)
+ expect(destPdf).toBeInstanceOf(Uint8Array);
+ spy.mockRestore();
+ });
+
+ it('catches and warns on file-to-function ReplaceFunction errors', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const srcImagePath = path.join(fixturesDir, 'ck_rgb.png');
+ const spy = vi.spyOn(Logger, 'logWarn');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ {
+ source: srcImagePath,
+ replacement: () => {
+ throw new Error('test error');
+ },
+ },
+ ],
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.stringContaining('Failed to apply replacement function'),
+ );
+ expect(destPdf).toBeInstanceOf(Uint8Array);
+ spy.mockRestore();
+ });
+
+ it('builtinCmykReplacement converts RGB image to CMYK', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+
+ const srcColorSpace = await getImageColorSpace(srcPdf);
+ expect(srcColorSpace).toBe('RGB');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [builtinCmykReplacement()],
+ });
+
+ const destColorSpace = await getImageColorSpace(destPdf);
+ expect(destColorSpace).toBe('CMYK');
+ });
+
+ // ICC profile-based conversion internally calls mupdf.enableICC(), which
+ // mutates global WASM state. If that state leaks, subsequent non-ICC
+ // conversions produce different results. This test runs a no-profile
+ // conversion before and after a profile-based one and asserts the outputs
+ // are identical, catching any state pollution.
+ it('builtinCmykReplacement with ICC profiles does not pollute global state', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const cmykProfile = fs.readFileSync(
+ path.join(fixturesDir, 'default_cmyk.icc'),
+ );
+
+ // 1. Convert without profile
+ const pdf1 = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [builtinCmykReplacement()],
+ });
+
+ // 2. Convert with a real ICC profile (uses separate WASM instance)
+ const pdf2 = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ builtinCmykReplacement({ outputProfile: cmykProfile }),
+ ],
+ });
+
+ // ICC profile should produce different output than DeviceCMYK
+ expect(Buffer.compare(Buffer.from(pdf1), Buffer.from(pdf2))).not.toBe(0);
+
+ // 3. Convert without profile again
+ const pdf3 = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [builtinCmykReplacement()],
+ });
+
+ // 1 and 3 must be identical — ICC state must not leak
+ expect(Buffer.compare(Buffer.from(pdf1), Buffer.from(pdf3))).toBe(0);
+ });
+
+ it('builtinGrayReplacement converts RGB image to Gray', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+
+ const srcColorSpace = await getImageColorSpace(srcPdf);
+ expect(srcColorSpace).toBe('RGB');
+
+ const destPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [builtinGrayReplacement()],
+ });
+
+ const destColorSpace = await getImageColorSpace(destPdf);
+ expect(destColorSpace).toBe('Gray');
+ });
+
+ // Same ICC state isolation test as CMYK, but for Gray
+ it('builtinGrayReplacement with ICC profiles does not pollute global state', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const grayProfile = fs.readFileSync(
+ path.join(fixturesDir, 'default_gray.icc'),
+ );
+
+ const pdf1 = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [builtinGrayReplacement()],
+ });
+
+ const pdf2 = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [
+ builtinGrayReplacement({ outputProfile: grayProfile }),
+ ],
+ });
+
+ expect(Buffer.compare(Buffer.from(pdf1), Buffer.from(pdf2))).not.toBe(0);
+
+ const pdf3 = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [builtinGrayReplacement()],
+ });
+
+ expect(Buffer.compare(Buffer.from(pdf1), Buffer.from(pdf3))).toBe(0);
+ });
+});
+
+describe('findNonCmykImages', () => {
+ it('warns about RGB images in PDF', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+ const spy = vi.spyOn(Logger, 'logWarn');
+
+ await findNonCmykImages(srcPdf);
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /Non-CMYK image remaining in PDF: \d+x\d+ on page \d+/,
+ ),
+ );
+ spy.mockRestore();
+ });
+
+ it('does not warn when all images are CMYK', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+
+ // Replace RGB image with CMYK first
+ const cmykPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [builtinCmykReplacement()],
+ });
+
+ const spy = vi.spyOn(Logger, 'logWarn');
+ await findNonCmykImages(cmykPdf);
+
+ expect(spy).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it('does not warn when all images are Gray', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf'));
+
+ const grayPdf = await replaceImages({
+ pdf: srcPdf,
+ replaceImageConfig: [builtinGrayReplacement()],
+ });
+
+ const spy = vi.spyOn(Logger, 'logWarn');
+ await findNonCmykImages(grayPdf);
+
+ expect(spy).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it('does not warn for PDF with no images', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'text.pdf'));
+
+ const spy = vi.spyOn(Logger, 'logWarn');
+ await findNonCmykImages(srcPdf);
+
+ expect(spy).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
});