);
};
```
diff --git a/examples/cmyk/README.md b/examples/cmyk/README.md
index 5540e18c..1ae885e1 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,68 @@ 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.
+
+## `cmyk.overrideMap`
+
+This CMYK feature assumes you know every color that appears in the PDF and control it through CSS. In large documents that is not always the case. `cmyk.overrideMap` is a last resort for keeping the output fully CMYK.
+
+When building this document, some unknown grays appeared (for demonstration purposes; I actually know the endnote `
` produces them, and styling it with CSS would be the proper fix). `cmyk.overrideMap` can convert these directly to CMYK values.
+
+```
+WARN RGB color not mapped to CMYK: {"r":6039,"g":6039,"b":6039}
+WARN RGB color not mapped to CMYK: {"r":9333,"g":9333,"b":9333}
+```
+
+Like `replaceImage`, `cmyk.overrideMap` also accepts functions. `builtinCmykConversion()` and `builtinGrayConversion()` are provided for automatic conversion.
+
+These functions make it possible to produce a CMYK PDF without any explicit `device-cmyk()` declarations, but you should not do this. What print actually requires is a PDF with an output intent (which is why PDF/X-4 can accept RGB PDFs in the first place). If you are going to run automatic CMYK conversion, you would cause fewer problems by submitting the RGB PDF directly to the print shop. Chromium's PDFs do not carry an output intent, but treating them as sRGB is good enough. In any case, `cmyk.overrideMap` is a tool for assisting intentional CMYK workflows, not for converting everything automatically.
## 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.
-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.
+Similarly, `cmyk.warnUnreplacedImages` (default: `true`) logs warnings for any non-CMYK images remaining in the PDF after image replacement.
-`mapOutput` is primarily a debugging tool. It writes the internal color mapping table to a file.
+`cmyk.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 +85,12 @@ 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.39651 CMYK OK
+ 0.26049 0.00000 0.00000 4.64140 CMYK OK
+ 0.26049 0.00000 0.00000 4.58088 CMYK OK
+ 0.26049 0.00000 0.00000 3.75094 CMYK OK
+ 0.28051 0.00000 0.00000 2.70628 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..85f977d6 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,20 @@ export default defineConfig({
['#808080', { c: 0, m: 0, y: 0, k: 5000 }],
['#408080', { c: 5000, m: 0, y: 0, k: 5000 }],
],
+ overrideMap: [
+ [
+ { r: 6039, g: 6039, b: 6039 },
+ { c: 0, m: 0, y: 0, k: 10000 - 6039 },
+ ],
+ [
+ { r: 9333, g: 9333, b: 9333 },
+ { c: 0, m: 0, y: 0, k: 10000 - 9333 },
+ ],
+ ],
},
- 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..f25a415b 100644
--- a/src/config/resolve.ts
+++ b/src/config/resolve.ts
@@ -261,19 +261,33 @@ function resolveMapEntries(
});
}
+export type CmykConvertFunction = (
+ rgb: RGBValue,
+) => CMYKValue | Promise;
+
export interface CmykConfig {
warnUnmapped: boolean;
- overrideMap: CmykMapEntry[];
+ warnUnreplacedImages: boolean;
+ overrideMap: (CmykMapEntry | CmykConvertFunction)[];
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,7 +711,13 @@ export function resolveTaskConfig(
if (cmykOption && typeof cmykOption === 'object') {
return {
warnUnmapped: cmykOption.warnUnmapped ?? true,
- overrideMap: resolveMapEntries(cmykOption.overrideMap ?? []),
+ warnUnreplacedImages: cmykOption.warnUnreplacedImages ?? true,
+ overrideMap: (cmykOption.overrideMap ?? []).flatMap(
+ (item): (CmykMapEntry | CmykConvertFunction)[] =>
+ typeof item === 'function'
+ ? [item as CmykConvertFunction]
+ : resolveMapEntries([item]),
+ ),
reserveMap: resolveMapEntries(cmykOption.reserveMap ?? []),
mapOutput: cmykOption.mapOutput
? upath.resolve(context, cmykOption.mapOutput)
@@ -708,6 +728,7 @@ export function resolveTaskConfig(
if (options.cmyk || cmykOption === true) {
return {
warnUnmapped: true,
+ warnUnreplacedImages: true,
overrideMap: [],
reserveMap: [],
mapOutput: undefined,
@@ -729,23 +750,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..f3358330 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -270,11 +270,34 @@ const CmykConfigSchema = v.pipe(
v.partial(
v.object({
overrideMap: v.pipe(
- v.array(CmykMapEntrySchema),
+ v.array(
+ v.union([
+ CmykMapEntrySchema,
+ v.pipe(
+ v.function() as v.GenericSchema<
+ (rgb: { r: number; g: number; b: number }) =>
+ | { c: number; m: number; y: number; k: number }
+ | Promise<{
+ c: number;
+ m: number;
+ y: number;
+ k: number;
+ }>
+ >,
+ v.metadata({
+ typeString:
+ '((rgb: { r: number; g: number; b: number }) => { c: number; m: number; y: number; k: number } | Promise<{ c: number; m: number; y: number; k: number }>)',
+ }),
+ v.description(
+ 'Function that converts any unmapped RGB color to CMYK.',
+ ),
+ ),
+ ]),
+ ),
v.description($`
Custom RGB to CMYK color mapping.
- Each entry is a tuple of [rgb, {c, m, y, k}].
- RGB can be an object {r, g, b} with integers (0-10000) or a hex color string (e.g. "#ff0000").
+ Each entry is either a tuple of [rgb, {c, m, y, k}] or a function
+ that converts unmapped RGB colors to CMYK (used as fallback).
`),
),
reserveMap: v.pipe(
@@ -291,6 +314,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 +339,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 +361,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 +371,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..1f546968 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -18,7 +18,19 @@ export type {
VivliostyleConfigSchema,
VivliostylePackageMetadata,
} from './config/schema.js';
+export type {
+ CmykConvertFunction,
+ ImageContext,
+ ReplaceFunction,
+} from './config/resolve.js';
export type { TemplateVariable } from './create-template.js';
+export {
+ builtinCmykConversion,
+ builtinCmykReplacement,
+ builtinGrayConversion,
+ builtinGrayReplacement,
+ type ColorConversionOptions,
+} from './output/image.js';
export { createVitePlugin } from './vite-adapter.js';
/** @hidden */
export type PublicationManifest = _PublicationManifest;
diff --git a/src/output/cmyk.ts b/src/output/cmyk.ts
index e4a02828..8fb5b1cf 100644
--- a/src/output/cmyk.ts
+++ b/src/output/cmyk.ts
@@ -1,7 +1,10 @@
import type * as mupdfType from 'mupdf';
import type { CmykMap } from '../global-viewer.js';
import { importNodeModule } from '../node-modules.js';
-import { convertStreamColors } from './pdf-stream.js';
+import {
+ type InternalColorConverter,
+ convertStreamColors,
+} from './pdf-stream.js';
interface Destroyable {
destroy(): void;
@@ -15,47 +18,59 @@ function disposable(obj: T): T & Disposable {
});
}
-function processStream(
+export function mapToConverter(map: CmykMap): InternalColorConverter {
+ return (rgb) => {
+ const key = JSON.stringify([rgb.r, rgb.g, rgb.b]);
+ return map[key] ?? null;
+ };
+}
+
+async function processStream(
stream: mupdfType.PDFObject,
- colorMap: CmykMap,
+ converters: InternalColorConverter[],
warnUnmapped: boolean,
warnedColors: Set,
mupdf: typeof import('mupdf'),
-): void {
+): Promise {
const buffer = stream.readStream();
const content = buffer.asString();
- const converted = convertStreamColors(
+ const converted = await convertStreamColors(
content,
- colorMap,
+ converters,
warnUnmapped,
warnedColors,
);
stream.writeStream(new mupdf.Buffer(converted));
}
-function processFormXObjects(
+async function processFormXObjects(
resources: mupdfType.PDFObject,
- colorMap: CmykMap,
+ converters: InternalColorConverter[],
warnUnmapped: boolean,
warnedColors: Set,
mupdf: typeof import('mupdf'),
processed: Set,
-): void {
+): Promise {
const xobjects = resources.get('XObject');
if (!xobjects || !xobjects.isDictionary()) {
return;
}
+ // Collect entries first to use for...of with await
+ const entries: mupdfType.PDFObject[] = [];
xobjects.forEach((xobj) => {
+ entries.push(xobj);
+ });
+
+ for (const xobj of entries) {
if (!xobj || !xobj.isStream()) {
- return;
+ continue;
}
// Use original indirect reference for stream operations (see #735)
const objNum = xobj.asIndirect();
if (objNum && processed.has(objNum)) {
- // Avoid circular references
- return;
+ continue;
}
if (objNum) {
processed.add(objNum);
@@ -63,53 +78,63 @@ function processFormXObjects(
const subtype = xobj.get('Subtype');
if (!subtype || subtype.toString() !== '/Form') {
- return;
+ continue;
}
- processStream(xobj, colorMap, warnUnmapped, warnedColors, mupdf);
+ await processStream(xobj, converters, warnUnmapped, warnedColors, mupdf);
const nestedResources = xobj.get('Resources');
if (nestedResources && nestedResources.isDictionary()) {
- processFormXObjects(
+ await processFormXObjects(
nestedResources,
- colorMap,
+ converters,
warnUnmapped,
warnedColors,
mupdf,
processed,
);
}
- });
+ }
}
-function processContents(
+async function processContents(
contents: mupdfType.PDFObject,
- colorMap: CmykMap,
+ converters: InternalColorConverter[],
warnUnmapped: boolean,
warnedColors: Set,
mupdf: typeof import('mupdf'),
-): void {
+): Promise {
if (contents.isArray()) {
- // Multiple content streams
for (let i = 0; i < contents.length; i++) {
const streamObj = contents.get(i);
// Use original indirect reference for stream operations (see #735)
if (streamObj && streamObj.isStream()) {
- processStream(streamObj, colorMap, warnUnmapped, warnedColors, mupdf);
+ await processStream(
+ streamObj,
+ converters,
+ warnUnmapped,
+ warnedColors,
+ mupdf,
+ );
}
}
} else if (contents.isStream()) {
- // Single content stream
- processStream(contents, colorMap, warnUnmapped, warnedColors, mupdf);
+ await processStream(
+ contents,
+ converters,
+ warnUnmapped,
+ warnedColors,
+ mupdf,
+ );
}
}
export async function convertCmykColors({
pdf,
- colorMap,
+ converters,
warnUnmapped,
}: {
pdf: Uint8Array;
- colorMap: CmykMap;
+ converters: InternalColorConverter[];
warnUnmapped: boolean;
}): Promise {
const mupdf = await importNodeModule('mupdf');
@@ -125,19 +150,25 @@ export async function convertCmykColors({
const pageCount = doc.countPages();
for (let i = 0; i < pageCount; i++) {
- const page = doc.loadPage(i) as mupdfType.PDFPage;
+ using page = disposable(doc.loadPage(i) as mupdfType.PDFPage);
const pageObj = page.getObject().resolve();
const contents = pageObj.get('Contents');
if (contents) {
- processContents(contents, colorMap, warnUnmapped, warnedColors, mupdf);
+ await processContents(
+ contents,
+ converters,
+ warnUnmapped,
+ warnedColors,
+ mupdf,
+ );
}
const resources = pageObj.get('Resources');
if (resources && resources.isDictionary()) {
- processFormXObjects(
+ await processFormXObjects(
resources,
- colorMap,
+ converters,
warnUnmapped,
warnedColors,
mupdf,
@@ -165,14 +196,24 @@ export async function convertCmykColors({
continue;
}
if (n.isStream()) {
- processStream(n, colorMap, warnUnmapped, warnedColors, mupdf);
+ await processStream(n, converters, warnUnmapped, warnedColors, mupdf);
} else if (n.isDictionary()) {
// Multiple appearance states
+ const stateEntries: mupdfType.PDFObject[] = [];
n.forEach((val) => {
+ stateEntries.push(val);
+ });
+ for (const val of stateEntries) {
if (val?.isStream()) {
- processStream(val, colorMap, warnUnmapped, warnedColors, mupdf);
+ await processStream(
+ val,
+ converters,
+ warnUnmapped,
+ warnedColors,
+ mupdf,
+ );
}
- });
+ }
}
}
}
diff --git a/src/output/image.ts b/src/output/image.ts
index e9f3e7ad..c6e87db3 100644
--- a/src/output/image.ts
+++ b/src/output/image.ts
@@ -1,6 +1,13 @@
import fs from 'node:fs';
import type * as mupdfType from 'mupdf';
-import type { ReplaceImageConfig } from '../config/resolve.js';
+import type { CMYKValue } from '../global-viewer.js';
+import type {
+ CmykConvertFunction,
+ ImageContext,
+ RGBValue,
+ ReplaceFunction,
+ ReplaceImageConfig,
+} from '../config/resolve.js';
import { Logger } from '../logger.js';
import { importNodeModule } from '../node-modules.js';
@@ -16,13 +23,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 +55,400 @@ 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`));
+ try {
+ 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);
+ } finally {
+ 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);
+}
+
+/**
+ * Returns a CmykConvertFunction that converts RGB colors to CMYK.
+ * Internally creates a 1x1 RGB pixmap and delegates to builtinCmykReplacement
+ * for the actual conversion, reusing ICC profile support.
+ */
+export function builtinCmykConversion(
+ options: ColorConversionOptions = {},
+): CmykConvertFunction {
+ const replaceFn = builtinCmykReplacement(options);
+ return async (rgb: RGBValue): Promise => {
+ const mupdf = await importNodeModule('mupdf');
+ // Create a 1x1 RGB pixmap with the color
+ using pixmap = disposable(
+ new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, [0, 0, 1, 1], false),
+ );
+ const pixels = pixmap.getPixels();
+ pixels[0] = Math.round((rgb.r / 10000) * 255);
+ pixels[1] = Math.round((rgb.g / 10000) * 255);
+ pixels[2] = Math.round((rgb.b / 10000) * 255);
+
+ // Delegate to builtinCmykReplacement via ImageContext
+ const resultBytes = await replaceFn({ asPNG: () => pixmap.asPNG() });
+
+ // Read CMYK values from the result
+ using resultImg = disposable(new mupdf.Image(resultBytes));
+ using resultPixmap = disposable(resultImg.toPixmap());
+ const cmykPixels = resultPixmap.getPixels();
+ return {
+ c: Math.round((cmykPixels[0] / 255) * 10000),
+ m: Math.round((cmykPixels[1] / 255) * 10000),
+ y: Math.round((cmykPixels[2] / 255) * 10000),
+ k: Math.round((cmykPixels[3] / 255) * 10000),
+ };
+ };
+}
+
+/**
+ * Returns a CmykConvertFunction that converts RGB colors to grayscale (K only).
+ * Internally delegates to builtinGrayReplacement and maps the Gray value to K.
+ */
+export function builtinGrayConversion(
+ options: ColorConversionOptions = {},
+): CmykConvertFunction {
+ const replaceFn = builtinGrayReplacement(options);
+ return async (rgb: RGBValue): Promise => {
+ const mupdf = await importNodeModule('mupdf');
+ using pixmap = disposable(
+ new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, [0, 0, 1, 1], false),
+ );
+ const pixels = pixmap.getPixels();
+ pixels[0] = Math.round((rgb.r / 10000) * 255);
+ pixels[1] = Math.round((rgb.g / 10000) * 255);
+ pixels[2] = Math.round((rgb.b / 10000) * 255);
+
+ const resultBytes = await replaceFn({ asPNG: () => pixmap.asPNG() });
+
+ using resultImg = disposable(new mupdf.Image(resultBytes));
+ using resultPixmap = disposable(resultImg.toPixmap());
+ const grayPixels = resultPixmap.getPixels();
+ // Gray value → K channel (inverted: 255=white=K0, 0=black=K10000)
+ const k = Math.round(((255 - grayPixels[0]) / 255) * 10000);
+ return { c: 0, m: 0, y: 0, k };
+ };
+}
+
+/**
+ * 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 +456,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 +489,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 +523,83 @@ export async function replaceImages({
}
const mupdf = await importNodeModule('mupdf');
+ const disposables = new Set();
- // Load image pairs
- const imagePairs: ImagePair[] = [];
- for (const { source, replacement } of replaceImageConfig) {
- let srcImage: mupdfType.Image;
- let destImage: mupdfType.Image;
+ 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;
+ }
- 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;
- }
+ const { source, replacement } = item;
- 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}`,
+ 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;
+ }
+
+ 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 (preparedEntries.length === 0) {
+ return pdf;
+ }
- if (imagePairs.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 = await replaceImagesInDocument(
+ doc,
+ preparedEntries,
+ disposables,
+ );
+ Logger.debug(`Replaced ${stats.replaced} of ${stats.total} images`);
- const stats = replaceImagesInDocument(doc, imagePairs);
- 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..234f519f 100644
--- a/src/output/pdf-postprocess.ts
+++ b/src/output/pdf-postprocess.ts
@@ -14,8 +14,8 @@ import type { CmykMap, Meta, TOCItem } from '../global-viewer.js';
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 { convertCmykColors, mapToConverter } from './cmyk.js';
+import { findNonCmykImages, replaceImages } from './image.js';
export type SaveOption = Pick<
PdfOutput,
@@ -111,16 +111,26 @@ export class PostProcess {
let pdf = await this.document.save();
if (cmyk) {
- const mergedMap: CmykMap = { ...cmykMap };
- for (const [rgb, cmykValue] of cmyk.overrideMap) {
- const key = JSON.stringify([rgb.r, rgb.g, rgb.b]);
- mergedMap[key] = cmykValue;
+ // Build converter chain: static map first, then user functions as fallback
+ const converters: import('./pdf-stream.js').InternalColorConverter[] = [];
+ const overrideStaticMap: CmykMap = {};
+ for (const item of cmyk.overrideMap) {
+ if (typeof item === 'function') {
+ converters.push(item);
+ } else {
+ const [rgb, cmykValue] = item;
+ const key = JSON.stringify([rgb.r, rgb.g, rgb.b]);
+ overrideStaticMap[key] = cmykValue;
+ }
}
+ // Static map: override entries take priority over base map from core
+ const mergedMap: CmykMap = { ...cmykMap, ...overrideStaticMap };
+ converters.unshift(mapToConverter(mergedMap));
Logger.logInfo('Converting CMYK colors');
pdf = await convertCmykColors({
pdf,
- colorMap: mergedMap,
+ converters,
warnUnmapped: cmyk.warnUnmapped,
});
@@ -143,6 +153,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/src/output/pdf-stream.ts b/src/output/pdf-stream.ts
index 7fdf5a6d..0d81e528 100644
--- a/src/output/pdf-stream.ts
+++ b/src/output/pdf-stream.ts
@@ -1,6 +1,13 @@
-import type { CmykMap } from '../global-viewer.js';
+import type { CMYKValue } from '../global-viewer.js';
+import type { RGBValue } from '../config/resolve.js';
import { Logger } from '../logger.js';
+// Internal converter: null means no match (try next entry).
+// User-facing CmykConvertFunction never returns null (matches all colors).
+export type InternalColorConverter = (
+ rgb: RGBValue,
+) => CMYKValue | null | Promise;
+
/**
* `SRGBValue.MAX`
* @see https://github.com/vivliostyle/vivliostyle.js/blob/master/packages/core/src/vivliostyle/cmyk-store.ts
@@ -173,12 +180,12 @@ function formatRgbKeyForWarning(r: number, g: number, b: number): string {
/**
* Convert RGB color operators to CMYK in a content stream
*/
-export function convertStreamColors(
+export async function convertStreamColors(
content: string,
- colorMap: CmykMap,
+ converters: InternalColorConverter[],
warnUnmapped: boolean,
warnedColors: Set,
-): string {
+): Promise {
const result: string[] = [];
const pendingNumbers: { value: number; raw: string }[] = [];
@@ -203,8 +210,21 @@ export function convertStreamColors(
const r = pendingNumbers.pop()!;
flushPendingNumbers();
- const key = formatRgbKey(r.value, g.value, b.value);
- const cmyk = colorMap[key];
+ const rgb: RGBValue = {
+ r: Math.round(r.value * SRGB_MAX),
+ g: Math.round(g.value * SRGB_MAX),
+ b: Math.round(b.value * SRGB_MAX),
+ };
+
+ let cmyk: CMYKValue | null = null;
+ for (const fn of converters) {
+ try {
+ cmyk = await fn(rgb);
+ } catch {
+ continue;
+ }
+ if (cmyk !== null) break;
+ }
if (cmyk) {
const c = (cmyk.c / CMYK_MAX).toString();
diff --git a/tests/cmyk.test.ts b/tests/cmyk.test.ts
index 53023c49..adff45ad 100644
--- a/tests/cmyk.test.ts
+++ b/tests/cmyk.test.ts
@@ -3,7 +3,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { CmykMap } from '../src/global-viewer.js';
-import { convertCmykColors } from '../src/output/cmyk.js';
+import { convertCmykColors, mapToConverter } from '../src/output/cmyk.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesDir = path.join(__dirname, 'fixtures', 'cmyk');
@@ -86,7 +86,7 @@ describe('convertCmykColors', () => {
const destPdf = await convertCmykColors({
pdf: srcPdf,
- colorMap,
+ converters: [mapToConverter(colorMap)],
warnUnmapped: false,
});
@@ -110,7 +110,7 @@ describe('convertCmykColors', () => {
// This should not throw "object is not a stream" error
const destPdf = await convertCmykColors({
pdf: srcPdf,
- colorMap,
+ converters: [mapToConverter(colorMap)],
warnUnmapped: false,
});
@@ -123,7 +123,7 @@ describe('convertCmykColors', () => {
const destPdf = await convertCmykColors({
pdf: srcPdf,
- colorMap: {}, // no colors will be converted
+ converters: [], // no colors will be converted
warnUnmapped: false,
});
@@ -132,4 +132,45 @@ describe('convertCmykColors', () => {
const destHasRgb = destContents.some(containsRgbOperators);
expect(destHasRgb).toBe(true);
});
+
+ it('converts colors using a CmykConvertFunction fallback', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'text.pdf'));
+
+ // Verify source has RGB
+ const srcContents = await extractPdfContentStream(srcPdf);
+ expect(srcContents.some(containsRgbOperators)).toBe(true);
+
+ // Use a function that converts all colors to K100
+ const destPdf = await convertCmykColors({
+ pdf: srcPdf,
+ converters: [(rgb) => ({ c: 0, m: 0, y: 0, k: 10000 })],
+ warnUnmapped: false,
+ });
+
+ const destContents = await extractPdfContentStream(destPdf);
+ expect(destContents.some(containsCmykOperators)).toBe(true);
+ expect(destContents.some(containsRgbOperators)).toBe(false);
+ });
+
+ it('static map entries take priority over function fallback', async () => {
+ const srcPdf = fs.readFileSync(path.join(fixturesDir, 'text.pdf'));
+
+ // Static map for black, function for everything else
+ const colorMap: CmykMap = {
+ [JSON.stringify([0, 0, 0])]: { c: 0, m: 0, y: 0, k: 10000 },
+ };
+ const destPdf = await convertCmykColors({
+ pdf: srcPdf,
+ converters: [
+ mapToConverter(colorMap),
+ (rgb) => ({ c: 5000, m: 5000, y: 5000, k: 0 }),
+ ],
+ warnUnmapped: false,
+ });
+
+ const destContents = await extractPdfContentStream(destPdf);
+ // All RGB should be converted (no RGB operators remaining)
+ expect(destContents.some(containsRgbOperators)).toBe(false);
+ expect(destContents.some(containsCmykOperators)).toBe(true);
+ });
});
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..f1bdb9ee 100644
--- a/tests/image.test.ts
+++ b/tests/image.test.ts
@@ -1,8 +1,17 @@
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 {
+ builtinCmykConversion,
+ builtinCmykReplacement,
+ builtinGrayConversion,
+ 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 +28,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 +101,347 @@ 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('builtinCmykConversion', () => {
+ it('converts black to mostly K', async () => {
+ const fn = builtinCmykConversion();
+ const result = await fn({ r: 0, g: 0, b: 0 });
+ // DeviceCMYK conversion is implementation-specific; K should dominate
+ expect(result.k).toBeGreaterThan(5000);
+ });
+
+ it('converts white to near-zero CMYK', async () => {
+ const fn = builtinCmykConversion();
+ const result = await fn({ r: 10000, g: 10000, b: 10000 });
+ expect(result.c).toBeLessThan(500);
+ expect(result.m).toBeLessThan(500);
+ expect(result.y).toBeLessThan(500);
+ expect(result.k).toBeLessThan(500);
+ });
+});
+
+describe('builtinGrayConversion', () => {
+ it('converts black to high K', async () => {
+ const fn = builtinGrayConversion();
+ const result = await fn({ r: 0, g: 0, b: 0 });
+ expect(result.c).toBe(0);
+ expect(result.m).toBe(0);
+ expect(result.y).toBe(0);
+ expect(result.k).toBeGreaterThan(5000);
+ });
+
+ it('converts white to near-zero K', async () => {
+ const fn = builtinGrayConversion();
+ const result = await fn({ r: 10000, g: 10000, b: 10000 });
+ expect(result.c).toBe(0);
+ expect(result.m).toBe(0);
+ expect(result.y).toBe(0);
+ expect(result.k).toBeLessThan(500);
+ });
+});
+
+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();
+ });
});
diff --git a/tests/pdf-stream.test.ts b/tests/pdf-stream.test.ts
index 87b345f1..5f2314fb 100644
--- a/tests/pdf-stream.test.ts
+++ b/tests/pdf-stream.test.ts
@@ -1,136 +1,150 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
-import { convertStreamColors } from '../src/output/pdf-stream.js';
+import {
+ type InternalColorConverter,
+ convertStreamColors,
+} from '../src/output/pdf-stream.js';
+import { mapToConverter } from '../src/output/cmyk.js';
import type { CmykMap } from '../src/global-viewer.js';
/**
- * Helper to create a color map from RGB (0-10000 scale) to CMYK values
+ * Helper to create converters from RGB (0-10000 scale) to CMYK values
*/
-function createColorMap(
+function createConverters(
entries: [
number,
number,
number,
{ c: number; m: number; y: number; k: number },
][],
-): CmykMap {
+): InternalColorConverter[] {
const map: CmykMap = {};
for (const [r, g, b, cmyk] of entries) {
const key = JSON.stringify([r, g, b]);
map[key] = cmyk;
}
- return map;
+ return [mapToConverter(map)];
}
-describe('convertStreamColors', () => {
- describe('RGB to CMYK conversion', () => {
- describe('rg operator (non-stroking)', () => {
- it('converts mapped RGB color to CMYK', () => {
- const colorMap = createColorMap([
+describe('convertStreamColors', async () => {
+ describe('RGB to CMYK conversion', async () => {
+ describe('rg operator (non-stroking)', async () => {
+ it('converts mapped RGB color to CMYK', async () => {
+ const converters = createConverters([
[0, 0, 0, { c: 0, m: 0, y: 0, k: 10000 }],
]);
- const result = convertStreamColors(
+ const result = await convertStreamColors(
'0 0 0 rg',
- colorMap,
+ converters,
false,
new Set(),
);
expect(result).toBe('0 0 0 1 k');
});
- it('converts 50% gray correctly', () => {
- const colorMap = createColorMap([
+ it('converts 50% gray correctly', async () => {
+ const converters = createConverters([
[5000, 5000, 5000, { c: 0, m: 0, y: 0, k: 5000 }],
]);
- const result = convertStreamColors(
+ const result = await convertStreamColors(
'0.5 0.5 0.5 rg',
- colorMap,
+ converters,
false,
new Set(),
);
expect(result).toBe('0 0 0 0.5 k');
});
- it('preserves unmapped RGB colors', () => {
- const result = convertStreamColors(
+ it('preserves unmapped RGB colors', async () => {
+ const result = await convertStreamColors(
'0.1 0.2 0.3 rg',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('0.1 0.2 0.3 rg');
});
- it('handles fractional CMYK values', () => {
- const colorMap = createColorMap([
+ it('handles fractional CMYK values', async () => {
+ const converters = createConverters([
[5000, 3000, 2000, { c: 1234, m: 5678, y: 9012, k: 3456 }],
]);
- const result = convertStreamColors(
+ const result = await convertStreamColors(
'0.5 0.3 0.2 rg',
- colorMap,
+ converters,
false,
new Set(),
);
expect(result).toBe('0.1234 0.5678 0.9012 0.3456 k');
});
- it('handles insufficient arguments gracefully', () => {
- const result = convertStreamColors('0.5 0.5 rg', {}, false, new Set());
+ it('handles insufficient arguments gracefully', async () => {
+ const result = await convertStreamColors(
+ '0.5 0.5 rg',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('0.5 0.5 rg');
});
});
- describe('RG operator (stroking)', () => {
- it('converts mapped RGB stroking color to CMYK', () => {
- const colorMap = createColorMap([
+ describe('RG operator (stroking)', async () => {
+ it('converts mapped RGB stroking color to CMYK', async () => {
+ const converters = createConverters([
[10000, 0, 0, { c: 0, m: 10000, y: 10000, k: 0 }],
]);
- const result = convertStreamColors(
+ const result = await convertStreamColors(
'1 0 0 RG',
- colorMap,
+ converters,
false,
new Set(),
);
expect(result).toBe('0 1 1 0 K');
});
- it('preserves unmapped RGB stroking colors', () => {
- const result = convertStreamColors(
+ it('preserves unmapped RGB stroking colors', async () => {
+ const result = await convertStreamColors(
'0.9 0.8 0.7 RG',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('0.9 0.8 0.7 RG');
});
- it('handles insufficient arguments gracefully', () => {
- const result = convertStreamColors('0.5 RG', {}, false, new Set());
+ it('handles insufficient arguments gracefully', async () => {
+ const result = await convertStreamColors(
+ '0.5 RG',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('0.5 RG');
});
});
- describe('mixed operators', () => {
- it('converts both rg and RG in same stream', () => {
- const colorMap = createColorMap([
+ describe('mixed operators', async () => {
+ it('converts both rg and RG in same stream', async () => {
+ const converters = createConverters([
[0, 0, 0, { c: 0, m: 0, y: 0, k: 10000 }],
]);
- const result = convertStreamColors(
+ const result = await convertStreamColors(
'0 0 0 rg 0 0 0 RG',
- colorMap,
+ converters,
false,
new Set(),
);
expect(result).toBe('0 0 0 1 k 0 0 0 1 K');
});
- it('handles multiple color changes', () => {
- const colorMap = createColorMap([
+ it('handles multiple color changes', async () => {
+ const converters = createConverters([
[0, 0, 0, { c: 0, m: 0, y: 0, k: 10000 }],
[10000, 10000, 10000, { c: 0, m: 0, y: 0, k: 0 }],
]);
- const result = convertStreamColors(
+ const result = await convertStreamColors(
'0 0 0 rg 1 1 1 rg',
- colorMap,
+ converters,
false,
new Set(),
);
@@ -139,54 +153,64 @@ describe('convertStreamColors', () => {
});
});
- describe('content preservation', () => {
- describe('existing CMYK and gray colors', () => {
- it('preserves k operator', () => {
- const result = convertStreamColors('0 0 0 1 k', {}, false, new Set());
+ describe('content preservation', async () => {
+ describe('existing CMYK and gray colors', async () => {
+ it('preserves k operator', async () => {
+ const result = await convertStreamColors(
+ '0 0 0 1 k',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('0 0 0 1 k');
});
- it('preserves K operator', () => {
- const result = convertStreamColors('1 0 0 0 K', {}, false, new Set());
+ it('preserves K operator', async () => {
+ const result = await convertStreamColors(
+ '1 0 0 0 K',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('1 0 0 0 K');
});
- it('preserves g operator', () => {
- const result = convertStreamColors('0.5 g', {}, false, new Set());
+ it('preserves g operator', async () => {
+ const result = await convertStreamColors('0.5 g', [], false, new Set());
expect(result).toBe('0.5 g');
});
- it('preserves G operator', () => {
- const result = convertStreamColors('0.5 G', {}, false, new Set());
+ it('preserves G operator', async () => {
+ const result = await convertStreamColors('0.5 G', [], false, new Set());
expect(result).toBe('0.5 G');
});
});
- describe('PDF operators', () => {
- it('preserves text operators', () => {
- const result = convertStreamColors(
+ describe('PDF operators', async () => {
+ it('preserves text operators', async () => {
+ const result = await convertStreamColors(
'BT /F1 12 Tf ET',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('BT /F1 12 Tf ET');
});
- it('preserves path operators', () => {
- const result = convertStreamColors(
+ it('preserves path operators', async () => {
+ const result = await convertStreamColors(
'100 200 m 300 400 l S',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('100 200 m 300 400 l S');
});
- it('preserves graphics state operators', () => {
- const result = convertStreamColors(
+ it('preserves graphics state operators', async () => {
+ const result = await convertStreamColors(
'q 1 0 0 1 50 50 cm Q',
- {},
+ [],
false,
new Set(),
);
@@ -194,126 +218,131 @@ describe('convertStreamColors', () => {
});
});
- describe('PDF syntax elements', () => {
- it('preserves string literals', () => {
- const result = convertStreamColors(
+ describe('PDF syntax elements', async () => {
+ it('preserves string literals', async () => {
+ const result = await convertStreamColors(
'(Hello World) Tj',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('(Hello World) Tj');
});
- it('preserves nested parentheses in strings', () => {
- const result = convertStreamColors(
+ it('preserves nested parentheses in strings', async () => {
+ const result = await convertStreamColors(
'(test (nested) string) Tj',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('(test (nested) string) Tj');
});
- it('preserves escaped characters in strings', () => {
- const result = convertStreamColors(
+ it('preserves escaped characters in strings', async () => {
+ const result = await convertStreamColors(
'(line1\\nline2) Tj',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('(line1\\nline2) Tj');
});
- it('preserves escaped parentheses in strings', () => {
- const result = convertStreamColors(
+ it('preserves escaped parentheses in strings', async () => {
+ const result = await convertStreamColors(
'(test\\(escaped\\)parens) Tj',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('(test\\(escaped\\)parens) Tj');
});
- it('preserves empty strings', () => {
- const result = convertStreamColors('() Tj', {}, false, new Set());
+ it('preserves empty strings', async () => {
+ const result = await convertStreamColors('() Tj', [], false, new Set());
expect(result).toBe('() Tj');
});
- it('preserves hex strings', () => {
- const result = convertStreamColors(
+ it('preserves hex strings', async () => {
+ const result = await convertStreamColors(
'<48454C4C4F> Tj',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('<48454C4C4F> Tj');
});
- it('preserves hex strings with spaces', () => {
- const result = convertStreamColors(
+ it('preserves hex strings with spaces', async () => {
+ const result = await convertStreamColors(
'<48 65 6C 6C 6F> Tj',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('<48 65 6C 6C 6F> Tj');
});
- it('preserves empty hex strings', () => {
- const result = convertStreamColors('<> Tj', {}, false, new Set());
+ it('preserves empty hex strings', async () => {
+ const result = await convertStreamColors('<> Tj', [], false, new Set());
expect(result).toBe('<> Tj');
});
- it('preserves names', () => {
- const result = convertStreamColors(
+ it('preserves names', async () => {
+ const result = await convertStreamColors(
'/DeviceCMYK cs',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('/DeviceCMYK cs');
});
- it('preserves names with special characters', () => {
- const result = convertStreamColors(
+ it('preserves names with special characters', async () => {
+ const result = await convertStreamColors(
'/sRGB-IEC61966-2.1 cs',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('/sRGB-IEC61966-2.1 cs');
});
- it('preserves inline dictionaries', () => {
- const result = convertStreamColors(
+ it('preserves inline dictionaries', async () => {
+ const result = await convertStreamColors(
'/Span << /MCID 0 >> BDC',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('/Span << /MCID 0 >> BDC');
});
- it('distinguishes hex strings from dictionary markers', () => {
- const result = convertStreamColors(
+ it('distinguishes hex strings from dictionary markers', async () => {
+ const result = await convertStreamColors(
'<< /Key >>',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('<< /Key >>');
});
- it('preserves arrays', () => {
- const result = convertStreamColors('[1 2 3] TJ', {}, false, new Set());
+ it('preserves arrays', async () => {
+ const result = await convertStreamColors(
+ '[1 2 3] TJ',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('[ 1 2 3 ] TJ');
});
- it('preserves comments', () => {
- const result = convertStreamColors(
+ it('preserves comments', async () => {
+ const result = await convertStreamColors(
'% comment\n0.5 g',
- {},
+ [],
false,
new Set(),
);
@@ -321,124 +350,169 @@ describe('convertStreamColors', () => {
});
});
- describe('number formats', () => {
- it('handles integers', () => {
- const result = convertStreamColors('42 g', {}, false, new Set());
+ describe('number formats', async () => {
+ it('handles integers', async () => {
+ const result = await convertStreamColors('42 g', [], false, new Set());
expect(result).toBe('42 g');
});
- it('handles floating point numbers', () => {
- const result = convertStreamColors('3.14159 g', {}, false, new Set());
+ it('handles floating point numbers', async () => {
+ const result = await convertStreamColors(
+ '3.14159 g',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('3.14159 g');
});
- it('handles negative numbers', () => {
- const result = convertStreamColors('-123 0 m', {}, false, new Set());
+ it('handles negative numbers', async () => {
+ const result = await convertStreamColors(
+ '-123 0 m',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('-123 0 m');
});
- it('handles positive numbers with explicit sign', () => {
- const result = convertStreamColors('+456 0 m', {}, false, new Set());
+ it('handles positive numbers with explicit sign', async () => {
+ const result = await convertStreamColors(
+ '+456 0 m',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('+456 0 m');
});
- it('handles numbers starting with decimal point', () => {
- const result = convertStreamColors('.5 g', {}, false, new Set());
+ it('handles numbers starting with decimal point', async () => {
+ const result = await convertStreamColors('.5 g', [], false, new Set());
expect(result).toBe('.5 g');
});
- it('handles numbers ending with decimal point', () => {
- const result = convertStreamColors('5. g', {}, false, new Set());
+ it('handles numbers ending with decimal point', async () => {
+ const result = await convertStreamColors('5. g', [], false, new Set());
expect(result).toBe('5. g');
});
});
- describe('whitespace handling', () => {
- it('handles various whitespace characters', () => {
- const result = convertStreamColors(
+ describe('whitespace handling', async () => {
+ it('handles various whitespace characters', async () => {
+ const result = await convertStreamColors(
'1\t2\n3\r4 re',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('1 2 3 4 re');
});
- it('handles empty input', () => {
- const result = convertStreamColors('', {}, false, new Set());
+ it('handles empty input', async () => {
+ const result = await convertStreamColors('', [], false, new Set());
expect(result).toBe('');
});
- it('handles whitespace-only input', () => {
- const result = convertStreamColors(' \t\n ', {}, false, new Set());
+ it('handles whitespace-only input', async () => {
+ const result = await convertStreamColors(
+ ' \t\n ',
+ [],
+ false,
+ new Set(),
+ );
expect(result).toBe('');
});
});
});
- describe('complex content streams', () => {
- it('converts colors within text block', () => {
- const colorMap = createColorMap([
+ describe('complex content streams', async () => {
+ it('converts colors within text block', async () => {
+ const converters = createConverters([
[0, 0, 0, { c: 0, m: 0, y: 0, k: 10000 }],
]);
const input = 'BT 0 0 0 rg /F1 12 Tf (Hello) Tj ET';
- const result = convertStreamColors(input, colorMap, false, new Set());
+ const result = await convertStreamColors(
+ input,
+ converters,
+ false,
+ new Set(),
+ );
expect(result).toBe('BT 0 0 0 1 k /F1 12 Tf (Hello) Tj ET');
});
- it('converts colors within graphics state', () => {
- const colorMap = createColorMap([
+ it('converts colors within graphics state', async () => {
+ const converters = createConverters([
[10000, 0, 0, { c: 0, m: 10000, y: 10000, k: 0 }],
]);
const input = 'q 1 0 0 RG 100 100 200 200 re S Q';
- const result = convertStreamColors(input, colorMap, false, new Set());
+ const result = await convertStreamColors(
+ input,
+ converters,
+ false,
+ new Set(),
+ );
expect(result).toBe('q 0 1 1 0 K 100 100 200 200 re S Q');
});
- it('handles multiple color changes with other operators', () => {
- const colorMap = createColorMap([
+ it('handles multiple color changes with other operators', async () => {
+ const converters = createConverters([
[0, 0, 0, { c: 0, m: 0, y: 0, k: 10000 }],
[10000, 0, 0, { c: 0, m: 10000, y: 10000, k: 0 }],
]);
const input =
'0 0 0 rg 50 50 m 100 100 l S 1 0 0 rg 150 150 m 200 200 l S';
- const result = convertStreamColors(input, colorMap, false, new Set());
+ const result = await convertStreamColors(
+ input,
+ converters,
+ false,
+ new Set(),
+ );
expect(result).toBe(
'0 0 0 1 k 50 50 m 100 100 l S 0 1 1 0 k 150 150 m 200 200 l S',
);
});
- it('handles Vivliostyle-generated content with BDC markers', () => {
- const colorMap = createColorMap([
+ it('handles Vivliostyle-generated content with BDC markers', async () => {
+ const converters = createConverters([
[0, 0, 0, { c: 0, m: 0, y: 0, k: 10000 }],
]);
const input =
'/NonStruct << /MCID 0 >> BDC BT 0 0 0 rg /F4 127 Tf ET EMC';
- const result = convertStreamColors(input, colorMap, false, new Set());
+ const result = await convertStreamColors(
+ input,
+ converters,
+ false,
+ new Set(),
+ );
expect(result).toBe(
'/NonStruct << /MCID 0 >> BDC BT 0 0 0 1 k /F4 127 Tf ET EMC',
);
});
- it('handles crop marks with CMYK colors (no conversion needed)', () => {
+ it('handles crop marks with CMYK colors (no conversion needed)', async () => {
const input = 'q 1 0 0 1 K 0 49.133858 m 37.795277 49.133858 l S Q';
- const result = convertStreamColors(input, {}, false, new Set());
+ const result = await convertStreamColors(input, [], false, new Set());
expect(result).toBe(
'q 1 0 0 1 K 0 49.133858 m 37.795277 49.133858 l S Q',
);
});
- it('handles ExtGState references', () => {
- const colorMap = createColorMap([
+ it('handles ExtGState references', async () => {
+ const converters = createConverters([
[0, 0, 0, { c: 0, m: 0, y: 0, k: 10000 }],
]);
const input = '/G3 gs 0 0 0 rg';
- const result = convertStreamColors(input, colorMap, false, new Set());
+ const result = await convertStreamColors(
+ input,
+ converters,
+ false,
+ new Set(),
+ );
expect(result).toBe('/G3 gs 0 0 0 1 k');
});
});
- describe('warning for unmapped colors', () => {
+ describe('warning for unmapped colors', async () => {
let logWarnMock: ReturnType;
let originalLogWarn: typeof import('../src/logger.js').Logger.logWarn;
@@ -454,105 +528,125 @@ describe('convertStreamColors', () => {
Logger.logWarn = originalLogWarn;
});
- it('warns for unmapped rg colors when warnUnmapped is true', () => {
- convertStreamColors('0.1 0.2 0.3 rg', {}, true, new Set());
+ it('warns for unmapped rg colors when warnUnmapped is true', async () => {
+ await convertStreamColors('0.1 0.2 0.3 rg', [], true, new Set());
expect(logWarnMock).toHaveBeenCalledWith(
'RGB color not mapped to CMYK: {"r":1000,"g":2000,"b":3000}',
);
});
- it('warns for unmapped RG colors when warnUnmapped is true', () => {
- convertStreamColors('0.1 0.2 0.3 RG', {}, true, new Set());
+ it('warns for unmapped RG colors when warnUnmapped is true', async () => {
+ await convertStreamColors('0.1 0.2 0.3 RG', [], true, new Set());
expect(logWarnMock).toHaveBeenCalledWith(
'RGB color not mapped to CMYK: {"r":1000,"g":2000,"b":3000}',
);
});
- it('does not warn when warnUnmapped is false', () => {
- convertStreamColors('0.1 0.2 0.3 rg', {}, false, new Set());
+ it('does not warn when warnUnmapped is false', async () => {
+ await convertStreamColors('0.1 0.2 0.3 rg', [], false, new Set());
expect(logWarnMock).not.toHaveBeenCalled();
});
- it('warns only once for duplicate colors in same stream', () => {
- convertStreamColors('0.1 0.2 0.3 rg 0.1 0.2 0.3 rg', {}, true, new Set());
+ it('warns only once for duplicate colors in same stream', async () => {
+ await convertStreamColors(
+ '0.1 0.2 0.3 rg 0.1 0.2 0.3 rg',
+ [],
+ true,
+ new Set(),
+ );
expect(logWarnMock).toHaveBeenCalledTimes(1);
});
- it('warns separately for different colors', () => {
- convertStreamColors('0.1 0.2 0.3 rg 0.4 0.5 0.6 rg', {}, true, new Set());
+ it('warns separately for different colors', async () => {
+ await convertStreamColors(
+ '0.1 0.2 0.3 rg 0.4 0.5 0.6 rg',
+ [],
+ true,
+ new Set(),
+ );
expect(logWarnMock).toHaveBeenCalledTimes(2);
});
- it('tracks warned colors across multiple calls', () => {
+ it('tracks warned colors across multiple calls', async () => {
const warnedColors = new Set();
- convertStreamColors('0.1 0.2 0.3 rg', {}, true, warnedColors);
- convertStreamColors('0.1 0.2 0.3 rg', {}, true, warnedColors);
+ await convertStreamColors('0.1 0.2 0.3 rg', [], true, warnedColors);
+ await convertStreamColors('0.1 0.2 0.3 rg', [], true, warnedColors);
expect(logWarnMock).toHaveBeenCalledTimes(1);
});
- it('shares warned colors between rg and RG operators', () => {
- convertStreamColors('0.1 0.2 0.3 rg 0.1 0.2 0.3 RG', {}, true, new Set());
+ it('shares warned colors between rg and RG operators', async () => {
+ await convertStreamColors(
+ '0.1 0.2 0.3 rg 0.1 0.2 0.3 RG',
+ [],
+ true,
+ new Set(),
+ );
expect(logWarnMock).toHaveBeenCalledTimes(1);
});
});
- describe('inline images', () => {
- it('skips binary data between ID and EI', () => {
+ describe('inline images', async () => {
+ it('skips binary data between ID and EI', async () => {
// Binary data could contain byte sequences that look like "0.5 0.5 0.5 rg"
const input = 'BI /W 10 /H 10 ID binary0.5 0.5 0.5 rgdata EI';
- const result = convertStreamColors(input, {}, false, new Set());
+ const result = await convertStreamColors(input, [], false, new Set());
// The binary data should pass through unchanged
expect(result).toContain('ID');
expect(result).toContain('EI');
expect(result).not.toContain('k'); // Should NOT convert the fake rg in binary
});
- it('handles inline image followed by real color operator', () => {
- const colorMap = createColorMap([
+ it('handles inline image followed by real color operator', async () => {
+ const converters = createConverters([
[5000, 5000, 5000, { c: 0, m: 0, y: 0, k: 5000 }],
]);
const input = 'BI /W 1 /H 1 ID x EI 0.5 0.5 0.5 rg';
- const result = convertStreamColors(input, colorMap, false, new Set());
+ const result = await convertStreamColors(
+ input,
+ converters,
+ false,
+ new Set(),
+ );
expect(result).toContain('0 0 0 0.5 k');
});
- it('handles inline image with EI-like bytes in data', () => {
+ it('handles inline image with EI-like bytes in data', async () => {
// "EI" without proper whitespace context should not end the image
const input = 'BI /W 1 /H 1 ID xEIy EI';
- const result = convertStreamColors(input, {}, false, new Set());
+ const result = await convertStreamColors(input, [], false, new Set());
expect(result).toContain('EI');
});
});
- describe('edge cases', () => {
- it('handles negative color values (out of range)', () => {
- const result = convertStreamColors(
+ describe('edge cases', async () => {
+ it('handles negative color values (out of range)', async () => {
+ const result = await convertStreamColors(
'-0.1 0.2 0.3 rg',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('-0.1 0.2 0.3 rg');
});
- it('handles color values > 1 (out of range)', () => {
- const result = convertStreamColors(
+ it('handles color values > 1 (out of range)', async () => {
+ const result = await convertStreamColors(
'1.5 0.5 0.5 rg',
- {},
+ [],
false,
new Set(),
);
expect(result).toBe('1.5 0.5 0.5 rg');
});
- it('handles rounding at color map boundaries', () => {
+ it('handles rounding at color map boundaries', async () => {
// 0.12345 * 10000 = 1234.5 -> rounds to 1235
- const colorMap = createColorMap([
+ const converters = createConverters([
[1235, 6789, 10000, { c: 1000, m: 2000, y: 3000, k: 4000 }],
]);
- const result = convertStreamColors(
+ const result = await convertStreamColors(
'0.12345 0.6789 0.99999 rg',
- colorMap,
+ converters,
false,
new Set(),
);