From 142ba1c51f1573e415c921bfc67ef14198e72363 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 12:29:14 +0900 Subject: [PATCH 01/26] feat: add ReplaceFunction types, schema, and resolve logic for replaceImage --- src/config/resolve.ts | 70 +++++++++++++++++++++++++++++++------------ src/config/schema.ts | 23 ++++++++++---- src/output/image.ts | 7 +++-- 3 files changed, 74 insertions(+), 26 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index c096cc7d..a499f8e7 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -268,12 +268,21 @@ export interface CmykConfig { 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'; @@ -729,23 +738,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..403c33e6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -310,6 +310,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 +332,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 +342,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/output/image.ts b/src/output/image.ts index e9f3e7ad..b9a6cba9 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -128,9 +128,12 @@ export async function replaceImages({ const mupdf = await importNodeModule('mupdf'); - // Load image pairs + // Load image pairs (function entries are handled in a later phase) const imagePairs: ImagePair[] = []; - for (const { source, replacement } of replaceImageConfig) { + for (const item of replaceImageConfig) { + if (typeof item === 'function') continue; + if (typeof item.replacement === 'function') continue; + const { source, replacement } = item; let srcImage: mupdfType.Image; let destImage: mupdfType.Image; From 9c20ef25be629c4437944e4b23da3ae10ec90103 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 12:37:25 +0900 Subject: [PATCH 02/26] feat: implement ReplaceFunction execution logic in replaceImages --- src/output/image.ts | 126 +++++++++++++++++++++++++++++++++++++------- tests/image.test.ts | 75 ++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 20 deletions(-) diff --git a/src/output/image.ts b/src/output/image.ts index b9a6cba9..fc9d83c3 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'; @@ -46,22 +50,44 @@ function imagesEqual(a: mupdfType.Image, b: mupdfType.Image): boolean { ); } -interface ImagePair { +function createImageContext(pdfImage: mupdfType.Image): ImageContext { + return { + asPNG() { + return pdfImage.toPixmap().asPNG(); + }, + }; +} + +// Prepared entry types for the replacement pipeline +type PreparedFileEntry = { + type: 'file'; srcImage: mupdfType.Image; destImage: mupdfType.Image; sourcePath: string; replacementPath: string; -} +}; +type PreparedFnEntry = { + type: 'fn-with-source'; + srcImage: mupdfType.Image; + sourcePath: string; + fn: ReplaceFunction; +}; +type PreparedBareFn = { + type: 'bare-fn'; + fn: ReplaceFunction; +}; +type PreparedEntry = PreparedFileEntry | PreparedFnEntry | PreparedBareFn; interface ReplaceStats { replaced: number; total: number; } -function replaceImagesInDocument( +async function replaceImagesInDocument( doc: mupdfType.PDFDocument, - imagePairs: ImagePair[], -): ReplaceStats { + preparedEntries: PreparedEntry[], + mupdf: typeof import('mupdf'), +): Promise { let replaced = 0; let total = 0; @@ -90,17 +116,49 @@ 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); + for (const entry of preparedEntries) { + if (entry.type === 'file') { + if (imagesEqual(pdfImage, entry.srcImage)) { + const newImageRef = doc.addImage(entry.destImage); + xobjects.put(key, newImageRef); + replaced++; + Logger.debug( + ` Page ${i + 1}, ref "${key}": ${entry.sourcePath} -> ${entry.replacementPath}`, + ); + break; + } + } else if (entry.type === 'fn-with-source') { + // Chromium converts all images to RGB in its PDF output + // (even grayscale PNGs are embedded as RGB). + // Only pass RGB images to ReplaceFunction. + const cs = pdfImage.toPixmap().getColorSpace(); + if (!cs?.isRGB()) continue; + if (imagesEqual(pdfImage, entry.srcImage)) { + const resultBytes = await entry.fn(createImageContext(pdfImage)); + const newImage = new mupdf.Image(resultBytes); + const newImageRef = doc.addImage(newImage); + xobjects.put(key, newImageRef); + replaced++; + Logger.debug( + ` Page ${i + 1}, ref "${key}": ${entry.sourcePath} -> [function]`, + ); + break; + } + } else { + // bare-fn: matches all RGB images + // Chromium converts all images to RGB in its PDF output + // (even grayscale PNGs are embedded as RGB). + // Only pass RGB images to ReplaceFunction. + const cs = pdfImage.toPixmap().getColorSpace(); + if (!cs?.isRGB()) continue; + const resultBytes = await entry.fn(createImageContext(pdfImage)); + const newImage = new mupdf.Image(resultBytes); + const newImageRef = doc.addImage(newImage); xobjects.put(key, newImageRef); replaced++; Logger.debug( - ` Page ${i + 1}, ref "${key}": ${pair.sourcePath} -> ${pair.replacementPath}`, + ` Page ${i + 1}, ref "${key}": [all RGB] -> [function]`, ); break; } @@ -128,12 +186,39 @@ export async function replaceImages({ const mupdf = await importNodeModule('mupdf'); - // Load image pairs (function entries are handled in a later phase) - const imagePairs: ImagePair[] = []; + // Prepare entries: load source/dest images for file-based entries + const preparedEntries: PreparedEntry[] = []; for (const item of replaceImageConfig) { - if (typeof item === 'function') continue; - if (typeof item.replacement === 'function') continue; + if (typeof item === 'function') { + preparedEntries.push({ type: 'bare-fn', fn: item }); + continue; + } + const { source, replacement } = item; + + if (typeof replacement === 'function') { + // File source + function replacement + let srcImage: mupdfType.Image; + 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; + } + preparedEntries.push({ + type: 'fn-with-source', + srcImage, + sourcePath: source, + fn: replacement, + }); + continue; + } + + // File source + file replacement (existing behavior) let srcImage: mupdfType.Image; let destImage: mupdfType.Image; @@ -161,7 +246,8 @@ export async function replaceImages({ continue; } - imagePairs.push({ + preparedEntries.push({ + type: 'file', srcImage, destImage, sourcePath: source, @@ -169,7 +255,7 @@ export async function replaceImages({ }); } - if (imagePairs.length === 0) { + if (preparedEntries.length === 0) { return pdf; } @@ -180,7 +266,7 @@ export async function replaceImages({ ) as mupdfType.PDFDocument, ); - const stats = replaceImagesInDocument(doc, imagePairs); + const stats = await replaceImagesInDocument(doc, preparedEntries, mupdf); Logger.debug(`Replaced ${stats.replaced} of ${stats.total} images`); using outputBuffer = disposable(doc.saveToBuffer('compress')); diff --git a/tests/image.test.ts b/tests/image.test.ts index 70138714..36ee94dd 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import type { ImageContext } from '../src/config/resolve.js'; import { replaceImages } from '../src/output/image.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -95,4 +96,78 @@ 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'); + + let functionCalled = false; + const destPdf = await replaceImages({ + pdf: srcPdf, + replaceImageConfig: [ + { source: srcImagePath, replacement: destImagePath }, + (_image: ImageContext) => { + functionCalled = true; + return new Uint8Array(); + }, + ], + }); + + // File entry matched first, so the function should not have been called + expect(functionCalled).toBe(false); + const destColorSpace = await getImageColorSpace(destPdf); + expect(destColorSpace).toBe('CMYK'); + }); }); From 2b75901d39cce43e7b36644d6d59d93f9a7ab5b4 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 12:42:59 +0900 Subject: [PATCH 03/26] refactor: unify PreparedEntry types into single function abstraction --- src/output/image.ts | 176 ++++++++++++++++++++------------------------ 1 file changed, 81 insertions(+), 95 deletions(-) diff --git a/src/output/image.ts b/src/output/image.ts index fc9d83c3..27f54321 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -58,25 +58,70 @@ function createImageContext(pdfImage: mupdfType.Image): ImageContext { }; } -// Prepared entry types for the replacement pipeline -type PreparedFileEntry = { - type: 'file'; - srcImage: mupdfType.Image; - destImage: mupdfType.Image; - sourcePath: string; - replacementPath: string; -}; -type PreparedFnEntry = { - type: 'fn-with-source'; - srcImage: mupdfType.Image; - sourcePath: string; - fn: ReplaceFunction; -}; -type PreparedBareFn = { - type: 'bare-fn'; - fn: ReplaceFunction; -}; -type PreparedEntry = PreparedFileEntry | PreparedFnEntry | PreparedBareFn; +function isRgbImage(pdfImage: mupdfType.Image): boolean { + const cs = pdfImage.toPixmap().getColorSpace(); + return cs?.isRGB() ?? false; +} + +// A prepared entry is a function that attempts to match and replace a PDF image. +// Returns the replacement Image 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: mupdfType.Image, + 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; + const resultBytes = await fn(createImageContext(pdfImage)); + Logger.debug( + ` Page ${pageIndex + 1}, ref "${key}": ${sourcePath} -> [function]`, + ); + return new mupdf.Image(resultBytes); + }; +} + +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; + const resultBytes = await fn(createImageContext(pdfImage)); + Logger.debug( + ` Page ${pageIndex + 1}, ref "${key}": [all RGB] -> [function]`, + ); + return new mupdf.Image(resultBytes); + }; +} interface ReplaceStats { replaced: number; @@ -86,7 +131,6 @@ interface ReplaceStats { async function replaceImagesInDocument( doc: mupdfType.PDFDocument, preparedEntries: PreparedEntry[], - mupdf: typeof import('mupdf'), ): Promise { let replaced = 0; let total = 0; @@ -118,48 +162,11 @@ async function replaceImagesInDocument( const pdfImage = doc.loadImage(value); for (const entry of preparedEntries) { - if (entry.type === 'file') { - if (imagesEqual(pdfImage, entry.srcImage)) { - const newImageRef = doc.addImage(entry.destImage); - xobjects.put(key, newImageRef); - replaced++; - Logger.debug( - ` Page ${i + 1}, ref "${key}": ${entry.sourcePath} -> ${entry.replacementPath}`, - ); - break; - } - } else if (entry.type === 'fn-with-source') { - // Chromium converts all images to RGB in its PDF output - // (even grayscale PNGs are embedded as RGB). - // Only pass RGB images to ReplaceFunction. - const cs = pdfImage.toPixmap().getColorSpace(); - if (!cs?.isRGB()) continue; - if (imagesEqual(pdfImage, entry.srcImage)) { - const resultBytes = await entry.fn(createImageContext(pdfImage)); - const newImage = new mupdf.Image(resultBytes); - const newImageRef = doc.addImage(newImage); - xobjects.put(key, newImageRef); - replaced++; - Logger.debug( - ` Page ${i + 1}, ref "${key}": ${entry.sourcePath} -> [function]`, - ); - break; - } - } else { - // bare-fn: matches all RGB images - // Chromium converts all images to RGB in its PDF output - // (even grayscale PNGs are embedded as RGB). - // Only pass RGB images to ReplaceFunction. - const cs = pdfImage.toPixmap().getColorSpace(); - if (!cs?.isRGB()) continue; - const resultBytes = await entry.fn(createImageContext(pdfImage)); - const newImage = new mupdf.Image(resultBytes); - const newImageRef = doc.addImage(newImage); + const result = await entry(pdfImage, i, key); + if (result) { + const newImageRef = doc.addImage(result); xobjects.put(key, newImageRef); replaced++; - Logger.debug( - ` Page ${i + 1}, ref "${key}": [all RGB] -> [function]`, - ); break; } } @@ -186,42 +193,17 @@ export async function replaceImages({ const mupdf = await importNodeModule('mupdf'); - // Prepare entries: load source/dest images for file-based entries + // 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({ type: 'bare-fn', fn: item }); + preparedEntries.push(prepareBareFnEntry(item, mupdf)); continue; } const { source, replacement } = item; - if (typeof replacement === 'function') { - // File source + function replacement - let srcImage: mupdfType.Image; - 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; - } - preparedEntries.push({ - type: 'fn-with-source', - srcImage, - sourcePath: source, - fn: replacement, - }); - continue; - } - - // File source + file replacement (existing behavior) let srcImage: mupdfType.Image; - let destImage: mupdfType.Image; - try { const srcBuffer = fs.readFileSync(source); srcImage = new mupdf.Image(srcBuffer); @@ -233,6 +215,14 @@ export async function replaceImages({ continue; } + if (typeof replacement === 'function') { + preparedEntries.push( + prepareFnWithSourceEntry(srcImage, source, replacement, mupdf), + ); + continue; + } + + let destImage: mupdfType.Image; try { const destBuffer = fs.readFileSync(replacement); destImage = new mupdf.Image(destBuffer); @@ -246,13 +236,9 @@ export async function replaceImages({ continue; } - preparedEntries.push({ - type: 'file', - srcImage, - destImage, - sourcePath: source, - replacementPath: replacement, - }); + preparedEntries.push( + prepareFileEntry(srcImage, destImage, source, replacement), + ); } if (preparedEntries.length === 0) { @@ -266,7 +252,7 @@ export async function replaceImages({ ) as mupdfType.PDFDocument, ); - const stats = await replaceImagesInDocument(doc, preparedEntries, mupdf); + const stats = await replaceImagesInDocument(doc, preparedEntries); Logger.debug(`Replaced ${stats.replaced} of ${stats.total} images`); using outputBuffer = disposable(doc.saveToBuffer('compress')); From 341841c106c12114983fbc9407c7d2de3c402da5 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 12:45:36 +0900 Subject: [PATCH 04/26] fix: properly destroy mupdf Image objects to prevent memory leaks --- src/output/image.ts | 137 ++++++++++++++++++++++++++------------------ 1 file changed, 82 insertions(+), 55 deletions(-) diff --git a/src/output/image.ts b/src/output/image.ts index 27f54321..de037401 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -91,6 +91,7 @@ function prepareFnWithSourceEntry( sourcePath: string, fn: ReplaceFunction, mupdf: typeof import('mupdf'), + imagesToDestroy: mupdfType.Image[], ): PreparedEntry { // Chromium converts all images to RGB in its PDF output // (even grayscale PNGs are embedded as RGB). @@ -99,16 +100,19 @@ function prepareFnWithSourceEntry( if (!isRgbImage(pdfImage)) return null; if (!imagesEqual(pdfImage, srcImage)) return null; const resultBytes = await fn(createImageContext(pdfImage)); + const newImage = new mupdf.Image(resultBytes); + imagesToDestroy.push(newImage); Logger.debug( ` Page ${pageIndex + 1}, ref "${key}": ${sourcePath} -> [function]`, ); - return new mupdf.Image(resultBytes); + return newImage; }; } function prepareBareFnEntry( fn: ReplaceFunction, mupdf: typeof import('mupdf'), + imagesToDestroy: mupdfType.Image[], ): PreparedEntry { // Chromium converts all images to RGB in its PDF output // (even grayscale PNGs are embedded as RGB). @@ -116,10 +120,12 @@ function prepareBareFnEntry( return async (pdfImage, pageIndex, key) => { if (!isRgbImage(pdfImage)) return null; const resultBytes = await fn(createImageContext(pdfImage)); + const newImage = new mupdf.Image(resultBytes); + imagesToDestroy.push(newImage); Logger.debug( ` Page ${pageIndex + 1}, ref "${key}": [all RGB] -> [function]`, ); - return new mupdf.Image(resultBytes); + return newImage; }; } @@ -131,6 +137,7 @@ interface ReplaceStats { async function replaceImagesInDocument( doc: mupdfType.PDFDocument, preparedEntries: PreparedEntry[], + imagesToDestroy: mupdfType.Image[], ): Promise { let replaced = 0; let total = 0; @@ -161,6 +168,7 @@ async function replaceImagesInDocument( total++; const pdfImage = doc.loadImage(value); + imagesToDestroy.push(pdfImage); for (const entry of preparedEntries) { const result = await entry(pdfImage, i, key); if (result) { @@ -192,70 +200,89 @@ export async function replaceImages({ } const mupdf = await importNodeModule('mupdf'); + const imagesToDestroy: 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, imagesToDestroy)); + continue; + } - // 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; - } + const { source, replacement } = item; + + let srcImage: mupdfType.Image; + try { + const srcBuffer = fs.readFileSync(source); + srcImage = new mupdf.Image(srcBuffer); + imagesToDestroy.push(srcImage); + 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; + if (typeof replacement === 'function') { + preparedEntries.push( + prepareFnWithSourceEntry( + srcImage, + source, + replacement, + mupdf, + imagesToDestroy, + ), + ); + continue; + } - let srcImage: mupdfType.Image; - 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 destImage: mupdfType.Image; + try { + const destBuffer = fs.readFileSync(replacement); + destImage = new mupdf.Image(destBuffer); + imagesToDestroy.push(destImage); + Logger.debug( + `Loaded replacement image: ${replacement} (${destImage.getWidth()}x${destImage.getHeight()})`, + ); + } catch (error) { + Logger.logWarn( + `Failed to load replacement image: ${replacement}: ${error}`, + ); + continue; + } - if (typeof replacement === 'function') { preparedEntries.push( - prepareFnWithSourceEntry(srcImage, source, replacement, mupdf), + prepareFileEntry(srcImage, destImage, source, replacement), ); - continue; } - let destImage: mupdfType.Image; - 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}`, - ); - continue; + if (preparedEntries.length === 0) { + return pdf; } - preparedEntries.push( - prepareFileEntry(srcImage, destImage, source, replacement), + using doc = disposable( + mupdf.PDFDocument.openDocument( + pdf, + 'application/pdf', + ) as mupdfType.PDFDocument, ); - } - if (preparedEntries.length === 0) { - return pdf; + const stats = await replaceImagesInDocument( + doc, + preparedEntries, + imagesToDestroy, + ); + 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()); + } finally { + for (const img of imagesToDestroy) { + img.destroy(); + } } - - using doc = disposable( - mupdf.PDFDocument.openDocument( - pdf, - 'application/pdf', - ) as mupdfType.PDFDocument, - ); - - const stats = await replaceImagesInDocument(doc, preparedEntries); - 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()); } From 992e8cd49425b265a1ff1cdfa02365edb385c075 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 12:50:16 +0900 Subject: [PATCH 05/26] feat: add builtinCmykConversion and export public API --- src/index.ts | 2 ++ src/output/image.ts | 16 ++++++++++++++++ tests/image.test.ts | 17 ++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 6bd85b6c..44589168 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,9 @@ export type { VivliostyleConfigSchema, VivliostylePackageMetadata, } from './config/schema.js'; +export type { ImageContext, ReplaceFunction } from './config/resolve.js'; export type { TemplateVariable } from './create-template.js'; +export { builtinCmykConversion } 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 de037401..0c7ba57b 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -63,6 +63,22 @@ function isRgbImage(pdfImage: mupdfType.Image): boolean { return cs?.isRGB() ?? false; } +/** + * Built-in ReplaceFunction that converts RGB images to CMYK + * using mupdf's DeviceCMYK color space conversion. + */ +export async function builtinCmykConversion( + image: ImageContext, +): Promise { + const mupdf = await importNodeModule('mupdf'); + const img = new mupdf.Image(image.asPNG()); + const pixmap = img.toPixmap(); + const cmykPixmap = pixmap.convertToColorSpace(mupdf.ColorSpace.DeviceCMYK); + const result = cmykPixmap.asPAM(); + img.destroy(); + return result; +} + // A prepared entry is a function that attempts to match and replace a PDF image. // Returns the replacement Image on match, or null to try the next entry. type PreparedEntry = ( diff --git a/tests/image.test.ts b/tests/image.test.ts index 36ee94dd..360943f3 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { ImageContext } from '../src/config/resolve.js'; -import { replaceImages } from '../src/output/image.js'; +import { builtinCmykConversion, replaceImages } from '../src/output/image.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesDir = path.join(__dirname, 'fixtures', 'cmyk'); @@ -170,4 +170,19 @@ describe('replaceImages', () => { const destColorSpace = await getImageColorSpace(destPdf); expect(destColorSpace).toBe('CMYK'); }); + + it('builtinCmykConversion 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: [builtinCmykConversion], + }); + + const destColorSpace = await getImageColorSpace(destPdf); + expect(destColorSpace).toBe('CMYK'); + }); }); From 309ef2a1b6a524bc00e78fdc8f68745ddaa0ea3f Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 13:03:17 +0900 Subject: [PATCH 06/26] feat: add cmyk.warnUnreplacedImages to warn about non-CMYK images in PDF --- src/config/resolve.ts | 3 +++ src/config/schema.ts | 6 +++++ src/output/image.ts | 41 +++++++++++++++++++++++++++++++++++ src/output/pdf-postprocess.ts | 6 ++++- tests/image.test.ts | 39 +++++++++++++++++++++++++++++++-- 5 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index a499f8e7..14467efb 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -263,6 +263,7 @@ function resolveMapEntries( export interface CmykConfig { warnUnmapped: boolean; + warnUnreplacedImages: boolean; overrideMap: CmykMapEntry[]; reserveMap: CmykMapEntry[]; mapOutput: string | undefined; @@ -706,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 @@ -717,6 +719,7 @@ export function resolveTaskConfig( if (options.cmyk || cmykOption === true) { return { warnUnmapped: true, + warnUnreplacedImages: true, overrideMap: [], reserveMap: [], mapOutput: undefined, diff --git a/src/config/schema.ts b/src/config/schema.ts index 403c33e6..11af0b59 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($` diff --git a/src/output/image.ts b/src/output/image.ts index 0c7ba57b..84ad0541 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -79,6 +79,47 @@ export async function builtinCmykConversion( return result; } +/** + * 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++) { + const page = 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; + + const img = doc.loadImage(value); + const cs = img.toPixmap().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}`); + } + } + img.destroy(); + }); + } +} + // A prepared entry is a function that attempts to match and replace a PDF image. // Returns the replacement Image on match, or null to try the next entry. type PreparedEntry = ( 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/image.test.ts b/tests/image.test.ts index 360943f3..c6cf1432 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -1,9 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ImageContext } from '../src/config/resolve.js'; -import { builtinCmykConversion, replaceImages } from '../src/output/image.js'; +import { + builtinCmykConversion, + 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'); @@ -186,3 +191,33 @@ describe('replaceImages', () => { expect(destColorSpace).toBe('CMYK'); }); }); + +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.stringContaining('Non-CMYK image remaining in PDF'), + ); + 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: [builtinCmykConversion], + }); + + const spy = vi.spyOn(Logger, 'logWarn'); + await findNonCmykImages(cmykPdf); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); From 673ef1e0452f0e9f5f337838a3a01a9c82a31e3a Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 13:12:31 +0900 Subject: [PATCH 07/26] fix: parenthesize function type in schema typeString for docs generation --- docs/api-javascript.md | 62 ++++++++++++++++++++++++++++++++++++++---- docs/config.md | 31 ++++++++++++++++----- src/config/schema.ts | 2 +- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/docs/api-javascript.md b/docs/api-javascript.md index 5656b517..139ff66b 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -6,6 +6,7 @@ ### Functions - [`build`](#build) +- [`builtinCmykConversion`](#builtincmykconversion) - [`create`](#create) - [`createVitePlugin`](#createviteplugin) - [`defineConfig`](#defineconfig) @@ -14,12 +15,14 @@ ### Interfaces +- [`ImageContext`](#imagecontext) - [`StringifyMarkdownOptions`](#stringifymarkdownoptions) - [`TemplateVariable`](#templatevariable) ### Type Aliases - [`Metadata`](#metadata) +- [`ReplaceFunction`](#replacefunction) - [`StructuredDocument`](#structureddocument) - [`StructuredDocumentSection`](#structureddocumentsection) - [`VivliostyleConfigSchema`](#vivliostyleconfigschema) @@ -64,7 +67,7 @@ build({ ###### cmyk? -`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; \} = `CmykSchema` +`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` ###### config? @@ -272,6 +275,25 @@ build({ *** +### builtinCmykConversion() + +> **builtinCmykConversion**(`image`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> + +Built-in ReplaceFunction that converts RGB images to CMYK +using mupdf's DeviceCMYK color space conversion. + +#### Parameters + +##### image + +[`ImageContext`](#imagecontext) + +#### Returns + +`Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> + +*** + ### create() > **create**(`options`): `Promise`\<`void`\> @@ -296,7 +318,7 @@ Scaffold a new Vivliostyle project. ###### cmyk? -`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; \} = `CmykSchema` +`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` ###### config? @@ -526,7 +548,7 @@ Scaffold a new Vivliostyle project. ###### cmyk? -`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; \} = `CmykSchema` +`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` ###### config? @@ -776,7 +798,7 @@ Open a browser for previewing the publication. ###### cmyk? -`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; \} = `CmykSchema` +`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` ###### config? @@ -1010,6 +1032,20 @@ Unified processor. ## Interfaces +### ImageContext + +#### Methods + +##### asPNG() + +> **asPNG**(): `Uint8Array` + +###### Returns + +`Uint8Array` + +*** + ### StringifyMarkdownOptions Option for convert Markdown to a stringify (HTML). @@ -1047,7 +1083,7 @@ Option for convert Markdown to a stringify (HTML). | `browser.tag?` | `string` | | `browser.type` | `"chrome"` \| `"chromium"` \| `"firefox"` | | `cliVersion` | `string` | -| `cmyk?` | `boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; \} | +| `cmyk?` | `boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} | | `config?` | `string` | | `configData?` | [`VivliostyleConfigSchema`](#vivliostyleconfigschema) \| `null` | | `coreVersion` | `string` | @@ -1212,6 +1248,22 @@ VFM settings. *** +### ReplaceFunction() + +> **ReplaceFunction** = (`image`) => `Uint8Array` \| `Promise`\<`Uint8Array`\> + +#### Parameters + +##### image + +[`ImageContext`](#imagecontext) + +#### Returns + +`Uint8Array` \| `Promise`\<`Uint8Array`\> + +*** + ### StructuredDocument > **StructuredDocument** = `object` diff --git a/docs/config.md b/docs/config.md index 442435d5..8f986171 100644 --- a/docs/config.md +++ b/docs/config.md @@ -429,10 +429,10 @@ pdfPostprocess takes precedence. Convert device-cmyk() colors to CMYK in the output PDF. Can be a boolean or a config object with overrideMap and warnUnmapped options. - - `replaceImage`: ([ReplaceImageEntry](#replaceimageentry))[] + - `replaceImage`: ([ReplaceImageEntry](#replaceimageentry) | ((image: { asPNG(): Uint8Array }) => Uint8Array | Promise))[] 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. #### Type definition @@ -443,7 +443,14 @@ type PdfPostprocessConfig = { | "press-ready-local"; preflightOption?: string[]; cmyk?: boolean | CmykConfig; - replaceImage?: ReplaceImageEntry[]; + replaceImage?: ( + | ReplaceImageEntry + | ((image: { + asPNG(): Uint8Array; + }) => + | Uint8Array + | Promise) + )[]; }; ``` @@ -466,6 +473,9 @@ type PdfPostprocessConfig = { - `warnUnmapped`: boolean Warn when RGB colors not mapped to CMYK are encountered. (default: true) + - `warnUnreplacedImages`: boolean + Warn when non-CMYK-compatible images remain in the PDF after image replacement. (default: true) + - `mapOutput`: string Output the CMYK color map to a JSON file at the specified path. @@ -476,6 +486,7 @@ type CmykConfig = { overrideMap?: "{tuple(Array)}"[]; reserveMap?: "{tuple(Array)}"[]; warnUnmapped?: boolean; + warnUnreplacedImages?: boolean; mapOutput?: string; }; ``` @@ -489,15 +500,21 @@ type CmykConfig = { - `source`: string | RegExp Path to the source image file, or a RegExp pattern to match multiple files. - - `replacement`: string - Path to the replacement image file. When source is a RegExp, supports $1, $2, etc. for captured groups. + - `replacement`: string | ((image: { asPNG(): Uint8Array }) => Uint8Array | Promise) + 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. #### Type definition ```ts type ReplaceImageEntry = { source: string | RegExp; - replacement: string; + replacement: + | string + | ((image: { + asPNG(): Uint8Array; + }) => + | Uint8Array + | Promise); }; ``` diff --git a/src/config/schema.ts b/src/config/schema.ts index 11af0b59..2a908b2c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -322,7 +322,7 @@ const ReplaceFunctionSchema = v.pipe( >, v.metadata({ typeString: - '(image: { asPNG(): Uint8Array }) => Uint8Array | Promise', + '((image: { asPNG(): Uint8Array }) => Uint8Array | Promise)', }), v.description( 'Function that receives an image context and returns replacement image bytes.', From 04bc6665af89315279e626fe0448b2e29d1d74b4 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 13:15:15 +0900 Subject: [PATCH 08/26] feat: add builtinGrayConversion for RGB to grayscale conversion --- docs/api-javascript.md | 20 ++++++++++++++++++++ src/index.ts | 5 ++++- src/output/image.ts | 16 ++++++++++++++++ tests/image.test.ts | 16 ++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/api-javascript.md b/docs/api-javascript.md index 139ff66b..8c6733ec 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -7,6 +7,7 @@ - [`build`](#build) - [`builtinCmykConversion`](#builtincmykconversion) +- [`builtinGrayConversion`](#builtingrayconversion) - [`create`](#create) - [`createVitePlugin`](#createviteplugin) - [`defineConfig`](#defineconfig) @@ -294,6 +295,25 @@ using mupdf's DeviceCMYK color space conversion. *** +### builtinGrayConversion() + +> **builtinGrayConversion**(`image`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> + +Built-in ReplaceFunction that converts RGB images to grayscale +using mupdf's DeviceGray color space conversion. + +#### Parameters + +##### image + +[`ImageContext`](#imagecontext) + +#### Returns + +`Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> + +*** + ### create() > **create**(`options`): `Promise`\<`void`\> diff --git a/src/index.ts b/src/index.ts index 44589168..f8c21ee9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,10 @@ export type { } from './config/schema.js'; export type { ImageContext, ReplaceFunction } from './config/resolve.js'; export type { TemplateVariable } from './create-template.js'; -export { builtinCmykConversion } from './output/image.js'; +export { + builtinCmykConversion, + builtinGrayConversion, +} 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 84ad0541..7a8d1bd8 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -79,6 +79,22 @@ export async function builtinCmykConversion( return result; } +/** + * Built-in ReplaceFunction that converts RGB images to grayscale + * using mupdf's DeviceGray color space conversion. + */ +export async function builtinGrayConversion( + image: ImageContext, +): Promise { + const mupdf = await importNodeModule('mupdf'); + const img = new mupdf.Image(image.asPNG()); + const pixmap = img.toPixmap(); + const grayPixmap = pixmap.convertToColorSpace(mupdf.ColorSpace.DeviceGray); + const result = grayPixmap.asPAM(); + img.destroy(); + return result; +} + /** * Scan PDF for images with non-CMYK-compatible color spaces and log warnings. */ diff --git a/tests/image.test.ts b/tests/image.test.ts index c6cf1432..2bcf4c68 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { ImageContext } from '../src/config/resolve.js'; import { builtinCmykConversion, + builtinGrayConversion, findNonCmykImages, replaceImages, } from '../src/output/image.js'; @@ -190,6 +191,21 @@ describe('replaceImages', () => { const destColorSpace = await getImageColorSpace(destPdf); expect(destColorSpace).toBe('CMYK'); }); + + it('builtinGrayConversion 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: [builtinGrayConversion], + }); + + const destColorSpace = await getImageColorSpace(destPdf); + expect(destColorSpace).toBe('Gray'); + }); }); describe('findNonCmykImages', () => { From d4271d3f6d6d407b27e48d165f04a4cc03ea6caf Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 13:21:56 +0900 Subject: [PATCH 09/26] refactor: share convertImageColorSpace between builtin functions and fast-path --- src/output/image.ts | 83 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/src/output/image.ts b/src/output/image.ts index 7a8d1bd8..a7a2efab 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -63,6 +63,16 @@ function isRgbImage(pdfImage: mupdfType.Image): boolean { return cs?.isRGB() ?? false; } +function convertImageColorSpace( + image: mupdfType.Image, + colorSpace: mupdfType.ColorSpace, + mupdf: typeof import('mupdf'), +): mupdfType.Image { + using pixmap = disposable(image.toPixmap()); + using converted = disposable(pixmap.convertToColorSpace(colorSpace)); + return new mupdf.Image(converted); +} + /** * Built-in ReplaceFunction that converts RGB images to CMYK * using mupdf's DeviceCMYK color space conversion. @@ -71,12 +81,11 @@ export async function builtinCmykConversion( image: ImageContext, ): Promise { const mupdf = await importNodeModule('mupdf'); - const img = new mupdf.Image(image.asPNG()); - const pixmap = img.toPixmap(); - const cmykPixmap = pixmap.convertToColorSpace(mupdf.ColorSpace.DeviceCMYK); - const result = cmykPixmap.asPAM(); - img.destroy(); - return result; + using img = disposable(new mupdf.Image(image.asPNG())); + using result = disposable( + convertImageColorSpace(img, mupdf.ColorSpace.DeviceCMYK, mupdf), + ); + return result.toPixmap().asPAM(); } /** @@ -87,12 +96,11 @@ export async function builtinGrayConversion( image: ImageContext, ): Promise { const mupdf = await importNodeModule('mupdf'); - const img = new mupdf.Image(image.asPNG()); - const pixmap = img.toPixmap(); - const grayPixmap = pixmap.convertToColorSpace(mupdf.ColorSpace.DeviceGray); - const result = grayPixmap.asPAM(); - img.destroy(); - return result; + using img = disposable(new mupdf.Image(image.asPNG())); + using result = disposable( + convertImageColorSpace(img, mupdf.ColorSpace.DeviceGray, mupdf), + ); + return result.toPixmap().asPAM(); } /** @@ -136,6 +144,39 @@ export async function findNonCmykImages(pdf: Uint8Array): Promise { } } +// Map of built-in functions to their target color spaces for fast-path conversion. +// When a built-in function is detected, we skip the byte encode/decode roundtrip +// and convert the pixmap directly. +const builtinColorSpaceMap = new Map< + ReplaceFunction, + (mupdf: typeof import('mupdf')) => mupdfType.ColorSpace +>([ + [builtinCmykConversion, (mupdf) => mupdf.ColorSpace.DeviceCMYK], + [builtinGrayConversion, (mupdf) => mupdf.ColorSpace.DeviceGray], +]); + +function applyReplaceFunction( + fn: ReplaceFunction, + pdfImage: mupdfType.Image, + mupdf: typeof import('mupdf'), + imagesToDestroy: mupdfType.Image[], +): Promise | mupdfType.Image { + const targetCs = builtinColorSpaceMap.get(fn); + if (targetCs) { + // Fast path: direct pixmap conversion without byte roundtrip + const newImage = convertImageColorSpace(pdfImage, targetCs(mupdf), mupdf); + imagesToDestroy.push(newImage); + return newImage; + } + // General path: bytes in, bytes out + return (async () => { + const resultBytes = await fn(createImageContext(pdfImage)); + const newImage = new mupdf.Image(resultBytes); + imagesToDestroy.push(newImage); + return newImage; + })(); +} + // A prepared entry is a function that attempts to match and replace a PDF image. // Returns the replacement Image on match, or null to try the next entry. type PreparedEntry = ( @@ -172,9 +213,12 @@ function prepareFnWithSourceEntry( return async (pdfImage, pageIndex, key) => { if (!isRgbImage(pdfImage)) return null; if (!imagesEqual(pdfImage, srcImage)) return null; - const resultBytes = await fn(createImageContext(pdfImage)); - const newImage = new mupdf.Image(resultBytes); - imagesToDestroy.push(newImage); + const newImage = await applyReplaceFunction( + fn, + pdfImage, + mupdf, + imagesToDestroy, + ); Logger.debug( ` Page ${pageIndex + 1}, ref "${key}": ${sourcePath} -> [function]`, ); @@ -192,9 +236,12 @@ function prepareBareFnEntry( // Only pass RGB images to ReplaceFunction. return async (pdfImage, pageIndex, key) => { if (!isRgbImage(pdfImage)) return null; - const resultBytes = await fn(createImageContext(pdfImage)); - const newImage = new mupdf.Image(resultBytes); - imagesToDestroy.push(newImage); + const newImage = await applyReplaceFunction( + fn, + pdfImage, + mupdf, + imagesToDestroy, + ); Logger.debug( ` Page ${pageIndex + 1}, ref "${key}": [all RGB] -> [function]`, ); From 58926913a64cb3fb4e7a1f9597cd8207003c46cf Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 13:33:28 +0900 Subject: [PATCH 10/26] refactor: use Disposable tracking with Set for mupdf resource lifecycle --- src/output/image.ts | 91 ++++++++++++++++++--------------------------- 1 file changed, 37 insertions(+), 54 deletions(-) diff --git a/src/output/image.ts b/src/output/image.ts index a7a2efab..4f4820ff 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -20,6 +20,8 @@ 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; @@ -67,10 +69,10 @@ function convertImageColorSpace( image: mupdfType.Image, colorSpace: mupdfType.ColorSpace, mupdf: typeof import('mupdf'), -): mupdfType.Image { +): DisposableImage { using pixmap = disposable(image.toPixmap()); using converted = disposable(pixmap.convertToColorSpace(colorSpace)); - return new mupdf.Image(converted); + return disposable(new mupdf.Image(converted)); } /** @@ -82,8 +84,10 @@ export async function builtinCmykConversion( ): Promise { const mupdf = await importNodeModule('mupdf'); using img = disposable(new mupdf.Image(image.asPNG())); - using result = disposable( - convertImageColorSpace(img, mupdf.ColorSpace.DeviceCMYK, mupdf), + using result = convertImageColorSpace( + img, + mupdf.ColorSpace.DeviceCMYK, + mupdf, ); return result.toPixmap().asPAM(); } @@ -97,8 +101,10 @@ export async function builtinGrayConversion( ): Promise { const mupdf = await importNodeModule('mupdf'); using img = disposable(new mupdf.Image(image.asPNG())); - using result = disposable( - convertImageColorSpace(img, mupdf.ColorSpace.DeviceGray, mupdf), + using result = convertImageColorSpace( + img, + mupdf.ColorSpace.DeviceGray, + mupdf, ); return result.toPixmap().asPAM(); } @@ -130,7 +136,7 @@ export async function findNonCmykImages(pdf: Uint8Array): Promise { const resolved = value.resolve(); if (resolved.get('Subtype')?.toString() !== '/Image') return; - const img = doc.loadImage(value); + using img = disposable(doc.loadImage(value)); const cs = img.toPixmap().getColorSpace(); if (cs && !cs.isCMYK() && !cs.isGray()) { const warnKey = `${img.getWidth()}x${img.getHeight()} on page ${i + 1}`; @@ -139,7 +145,6 @@ export async function findNonCmykImages(pdf: Uint8Array): Promise { Logger.logWarn(`Non-CMYK image remaining in PDF: ${warnKey}`); } } - img.destroy(); }); } } @@ -159,35 +164,30 @@ function applyReplaceFunction( fn: ReplaceFunction, pdfImage: mupdfType.Image, mupdf: typeof import('mupdf'), - imagesToDestroy: mupdfType.Image[], -): Promise | mupdfType.Image { +): Promise | DisposableImage { const targetCs = builtinColorSpaceMap.get(fn); if (targetCs) { // Fast path: direct pixmap conversion without byte roundtrip - const newImage = convertImageColorSpace(pdfImage, targetCs(mupdf), mupdf); - imagesToDestroy.push(newImage); - return newImage; + return convertImageColorSpace(pdfImage, targetCs(mupdf), mupdf); } // General path: bytes in, bytes out return (async () => { const resultBytes = await fn(createImageContext(pdfImage)); - const newImage = new mupdf.Image(resultBytes); - imagesToDestroy.push(newImage); - return newImage; + return disposable(new mupdf.Image(resultBytes)); })(); } // A prepared entry is a function that attempts to match and replace a PDF image. -// Returns the replacement Image on match, or null to try the next entry. +// Returns the replacement DisposableImage on match, or null to try the next entry. type PreparedEntry = ( pdfImage: mupdfType.Image, pageIndex: number, key: string | number, -) => Promise; +) => Promise; function prepareFileEntry( srcImage: mupdfType.Image, - destImage: mupdfType.Image, + destImage: DisposableImage, sourcePath: string, replacementPath: string, ): PreparedEntry { @@ -205,7 +205,6 @@ function prepareFnWithSourceEntry( sourcePath: string, fn: ReplaceFunction, mupdf: typeof import('mupdf'), - imagesToDestroy: mupdfType.Image[], ): PreparedEntry { // Chromium converts all images to RGB in its PDF output // (even grayscale PNGs are embedded as RGB). @@ -213,12 +212,7 @@ function prepareFnWithSourceEntry( return async (pdfImage, pageIndex, key) => { if (!isRgbImage(pdfImage)) return null; if (!imagesEqual(pdfImage, srcImage)) return null; - const newImage = await applyReplaceFunction( - fn, - pdfImage, - mupdf, - imagesToDestroy, - ); + const newImage = await applyReplaceFunction(fn, pdfImage, mupdf); Logger.debug( ` Page ${pageIndex + 1}, ref "${key}": ${sourcePath} -> [function]`, ); @@ -229,19 +223,13 @@ function prepareFnWithSourceEntry( function prepareBareFnEntry( fn: ReplaceFunction, mupdf: typeof import('mupdf'), - imagesToDestroy: mupdfType.Image[], ): 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; - const newImage = await applyReplaceFunction( - fn, - pdfImage, - mupdf, - imagesToDestroy, - ); + const newImage = await applyReplaceFunction(fn, pdfImage, mupdf); Logger.debug( ` Page ${pageIndex + 1}, ref "${key}": [all RGB] -> [function]`, ); @@ -257,7 +245,7 @@ interface ReplaceStats { async function replaceImagesInDocument( doc: mupdfType.PDFDocument, preparedEntries: PreparedEntry[], - imagesToDestroy: mupdfType.Image[], + disposables: Set, ): Promise { let replaced = 0; let total = 0; @@ -287,11 +275,12 @@ async function replaceImagesInDocument( if (subtype && subtype.toString() === '/Image') { total++; - const pdfImage = doc.loadImage(value); - imagesToDestroy.push(pdfImage); + 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++; @@ -320,24 +309,24 @@ export async function replaceImages({ } const mupdf = await importNodeModule('mupdf'); - const imagesToDestroy: mupdfType.Image[] = []; + 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, imagesToDestroy)); + preparedEntries.push(prepareBareFnEntry(item, mupdf)); continue; } const { source, replacement } = item; - let srcImage: mupdfType.Image; + let srcImage: DisposableImage; try { const srcBuffer = fs.readFileSync(source); - srcImage = new mupdf.Image(srcBuffer); - imagesToDestroy.push(srcImage); + srcImage = disposable(new mupdf.Image(srcBuffer)); + disposables.add(srcImage); Logger.debug( `Loaded source image: ${source} (${srcImage.getWidth()}x${srcImage.getHeight()})`, ); @@ -348,22 +337,16 @@ export async function replaceImages({ if (typeof replacement === 'function') { preparedEntries.push( - prepareFnWithSourceEntry( - srcImage, - source, - replacement, - mupdf, - imagesToDestroy, - ), + prepareFnWithSourceEntry(srcImage, source, replacement, mupdf), ); continue; } - let destImage: mupdfType.Image; + let destImage: DisposableImage; try { const destBuffer = fs.readFileSync(replacement); - destImage = new mupdf.Image(destBuffer); - imagesToDestroy.push(destImage); + destImage = disposable(new mupdf.Image(destBuffer)); + disposables.add(destImage); Logger.debug( `Loaded replacement image: ${replacement} (${destImage.getWidth()}x${destImage.getHeight()})`, ); @@ -393,7 +376,7 @@ export async function replaceImages({ const stats = await replaceImagesInDocument( doc, preparedEntries, - imagesToDestroy, + disposables, ); Logger.debug(`Replaced ${stats.replaced} of ${stats.total} images`); @@ -401,8 +384,8 @@ export async function replaceImages({ // Create a copy to ensure the data remains valid after the buffer is destroyed return new Uint8Array(outputBuffer.asUint8Array()); } finally { - for (const img of imagesToDestroy) { - img.destroy(); + for (const d of disposables) { + d[Symbol.dispose](); } } } From 08dc6621f35f14328557f7b8e9054bb520217239 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 15:55:22 +0900 Subject: [PATCH 11/26] example: update cmyk example to demonstrate ReplaceFunction and other new features --- examples/cmyk/README.md | 53 +++++++++-- .../NotoSansJP-VariableFont_wght.ttf | Bin examples/cmyk/{ => css}/Noto_Sans_JP/OFL.txt | 0 .../cmyk/{ => css}/Noto_Sans_JP/README.txt | 0 examples/cmyk/css/package.json | 9 ++ examples/cmyk/css/style.css | 85 ++++++++++++++++++ examples/cmyk/manuscript.html | 75 ---------------- examples/cmyk/vivliostyle.config.js | 15 +++- 8 files changed, 151 insertions(+), 86 deletions(-) rename examples/cmyk/{ => css}/Noto_Sans_JP/NotoSansJP-VariableFont_wght.ttf (100%) rename examples/cmyk/{ => css}/Noto_Sans_JP/OFL.txt (100%) rename examples/cmyk/{ => css}/Noto_Sans_JP/README.txt (100%) create mode 100644 examples/cmyk/css/package.json create mode 100644 examples/cmyk/css/style.css delete mode 100644 examples/cmyk/manuscript.html diff --git a/examples/cmyk/README.md b/examples/cmyk/README.md index 5540e18c..8a13cc4a 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. + +![shapes.svg (C50, K50, and C50+K50 placeholders)](shapes.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. + +![ck_rgb.png (RGB version of ck_cmyk.tiff)](ck_rgb.png) + +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 `builtinCmykConversion` / `builtinGrayConversion` + +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: + +![cover.jpg loaded from URL](https://github.com/vivliostyle/vivliostyle-cli/blob/v10.3.1/assets/cover.jpg?raw=true) + +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. + +![ck_rgb.png cropped with object-view-box](ck_rgb.png){style="object-view-box: xywh(1px 0 100px 100px)"} + +In both cases, the `builtinGrayConversion` 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. + +`builtinCmykConversion` and `builtinGrayConversion` are `ReplaceFunction` implementations that convert RGB images to CMYK or grayscale. + +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..d29e7c68 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 { builtinGrayConversion, 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' }, + builtinGrayConversion, + ], }, }); From 24cfe1a7031f03e0182f6748f7d342c6495861c6 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 16:09:02 +0900 Subject: [PATCH 12/26] fix: dispose intermediate Pixmap objects and strengthen tests --- src/output/image.ts | 19 ++++++++++++------- tests/image.test.ts | 34 +++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/output/image.ts b/src/output/image.ts index 4f4820ff..487881d1 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -27,8 +27,8 @@ function imagesEqual(a: mupdfType.Image, b: mupdfType.Image): boolean { 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(); @@ -55,13 +55,15 @@ function imagesEqual(a: mupdfType.Image, b: mupdfType.Image): boolean { function createImageContext(pdfImage: mupdfType.Image): ImageContext { return { asPNG() { - return pdfImage.toPixmap().asPNG(); + using pixmap = disposable(pdfImage.toPixmap()); + return pixmap.asPNG(); }, }; } function isRgbImage(pdfImage: mupdfType.Image): boolean { - const cs = pdfImage.toPixmap().getColorSpace(); + using pixmap = disposable(pdfImage.toPixmap()); + const cs = pixmap.getColorSpace(); return cs?.isRGB() ?? false; } @@ -89,7 +91,8 @@ export async function builtinCmykConversion( mupdf.ColorSpace.DeviceCMYK, mupdf, ); - return result.toPixmap().asPAM(); + using pixmap = disposable(result.toPixmap()); + return pixmap.asPAM(); } /** @@ -106,7 +109,8 @@ export async function builtinGrayConversion( mupdf.ColorSpace.DeviceGray, mupdf, ); - return result.toPixmap().asPAM(); + using pixmap = disposable(result.toPixmap()); + return pixmap.asPAM(); } /** @@ -137,7 +141,8 @@ export async function findNonCmykImages(pdf: Uint8Array): Promise { if (resolved.get('Subtype')?.toString() !== '/Image') return; using img = disposable(doc.loadImage(value)); - const cs = img.toPixmap().getColorSpace(); + 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)) { diff --git a/tests/image.test.ts b/tests/image.test.ts index 2bcf4c68..7598121a 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -159,20 +159,15 @@ describe('replaceImages', () => { const srcImagePath = path.join(fixturesDir, 'ck_rgb.png'); const destImagePath = path.join(fixturesDir, 'ck_cmyk.tiff'); - let functionCalled = false; const destPdf = await replaceImages({ pdf: srcPdf, replaceImageConfig: [ { source: srcImagePath, replacement: destImagePath }, - (_image: ImageContext) => { - functionCalled = true; - return new Uint8Array(); - }, + builtinGrayConversion, ], }); - // File entry matched first, so the function should not have been called - expect(functionCalled).toBe(false); + // File entry matched first producing CMYK, not Gray from the fallback const destColorSpace = await getImageColorSpace(destPdf); expect(destColorSpace).toBe('CMYK'); }); @@ -236,4 +231,29 @@ describe('findNonCmykImages', () => { 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: [builtinGrayConversion], + }); + + 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(); + }); }); From c29019acea570cbb710e1ba26010804220dd8f3b Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Wed, 25 Mar 2026 16:15:47 +0900 Subject: [PATCH 13/26] fix: dispose Page objects, catch ReplaceFunction errors, add error handling tests --- src/output/image.ts | 38 ++++++++++++++++++--------- tests/image.test.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/output/image.ts b/src/output/image.ts index 487881d1..87e0c896 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -129,7 +129,7 @@ export async function findNonCmykImages(pdf: Uint8Array): Promise { 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'); if (!res?.isDictionary()) continue; @@ -217,11 +217,18 @@ function prepareFnWithSourceEntry( return async (pdfImage, pageIndex, key) => { if (!isRgbImage(pdfImage)) return null; if (!imagesEqual(pdfImage, srcImage)) return null; - const newImage = await applyReplaceFunction(fn, pdfImage, mupdf); - Logger.debug( - ` Page ${pageIndex + 1}, ref "${key}": ${sourcePath} -> [function]`, - ); - return newImage; + 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; + } }; } @@ -234,11 +241,18 @@ function prepareBareFnEntry( // Only pass RGB images to ReplaceFunction. return async (pdfImage, pageIndex, key) => { if (!isRgbImage(pdfImage)) return null; - const newImage = await applyReplaceFunction(fn, pdfImage, mupdf); - Logger.debug( - ` Page ${pageIndex + 1}, ref "${key}": [all RGB] -> [function]`, - ); - return newImage; + 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; + } }; } @@ -258,7 +272,7 @@ async function replaceImagesInDocument( 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'); diff --git a/tests/image.test.ts b/tests/image.test.ts index 7598121a..13365a3e 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -172,6 +172,64 @@ describe('replaceImages', () => { 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('builtinCmykConversion converts RGB image to CMYK', async () => { const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf')); @@ -211,7 +269,9 @@ describe('findNonCmykImages', () => { await findNonCmykImages(srcPdf); expect(spy).toHaveBeenCalledWith( - expect.stringContaining('Non-CMYK image remaining in PDF'), + expect.stringMatching( + /Non-CMYK image remaining in PDF: \d+x\d+ on page \d+/, + ), ); spy.mockRestore(); }); From 6375e7ed125fd77ee8fb5bdd99b3ae43183a70bd Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 02:02:12 +0900 Subject: [PATCH 14/26] rename: builtinCmykConversion/builtinGrayConversion to builtinCmykReplacement/builtinGrayReplacement --- docs/api-javascript.md | 12 ++++++------ examples/cmyk/README.md | 6 +++--- examples/cmyk/vivliostyle.config.js | 4 ++-- src/index.ts | 4 ++-- src/output/image.ts | 8 ++++---- tests/image.test.ts | 18 +++++++++--------- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/api-javascript.md b/docs/api-javascript.md index 8c6733ec..66362e7b 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -6,8 +6,8 @@ ### Functions - [`build`](#build) -- [`builtinCmykConversion`](#builtincmykconversion) -- [`builtinGrayConversion`](#builtingrayconversion) +- [`builtinCmykReplacement`](#builtincmykreplacement) +- [`builtinGrayReplacement`](#builtingrayreplacement) - [`create`](#create) - [`createVitePlugin`](#createviteplugin) - [`defineConfig`](#defineconfig) @@ -276,9 +276,9 @@ build({ *** -### builtinCmykConversion() +### builtinCmykReplacement() -> **builtinCmykConversion**(`image`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> +> **builtinCmykReplacement**(`image`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> Built-in ReplaceFunction that converts RGB images to CMYK using mupdf's DeviceCMYK color space conversion. @@ -295,9 +295,9 @@ using mupdf's DeviceCMYK color space conversion. *** -### builtinGrayConversion() +### builtinGrayReplacement() -> **builtinGrayConversion**(`image`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> +> **builtinGrayReplacement**(`image`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> Built-in ReplaceFunction that converts RGB images to grayscale using mupdf's DeviceGray color space conversion. diff --git a/examples/cmyk/README.md b/examples/cmyk/README.md index 8a13cc4a..47fde6ae 100644 --- a/examples/cmyk/README.md +++ b/examples/cmyk/README.md @@ -30,7 +30,7 @@ ck_cmyk.tiff is a cyan-and-key-plate gradient. Its RGB conversion is saved as ck 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 `builtinCmykConversion` / `builtinGrayConversion` +### `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. @@ -42,11 +42,11 @@ The second is ck_rgb.png, cropped with `object-view-box: xywh(1px 0 100px 100px) ![ck_rgb.png cropped with object-view-box](ck_rgb.png){style="object-view-box: xywh(1px 0 100px 100px)"} -In both cases, the `builtinGrayConversion` fallback at the end of the `replaceImage` array converts the image to grayscale. See `vivliostyle.config.js` in this example for the full configuration. +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. -`builtinCmykConversion` and `builtinGrayConversion` are `ReplaceFunction` implementations that convert RGB images to CMYK or grayscale. +`builtinCmykReplacement` and `builtinGrayReplacement` are `ReplaceFunction` implementations that convert RGB images to CMYK or grayscale. A `ReplaceFunction` only receives RGB images; non-RGB images are skipped. diff --git a/examples/cmyk/vivliostyle.config.js b/examples/cmyk/vivliostyle.config.js index d29e7c68..4774b90f 100644 --- a/examples/cmyk/vivliostyle.config.js +++ b/examples/cmyk/vivliostyle.config.js @@ -1,5 +1,5 @@ // @ts-check -import { builtinGrayConversion, defineConfig } from '@vivliostyle/cli'; +import { builtinGrayReplacement, defineConfig } from '@vivliostyle/cli'; export default defineConfig({ theme: './css', @@ -19,7 +19,7 @@ export default defineConfig({ }, replaceImage: [ { source: /^(.*)_rgb\.png$/, replacement: '$1_cmyk.tiff' }, - builtinGrayConversion, + builtinGrayReplacement, ], }, }); diff --git a/src/index.ts b/src/index.ts index f8c21ee9..674dd98f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,8 +21,8 @@ export type { export type { ImageContext, ReplaceFunction } from './config/resolve.js'; export type { TemplateVariable } from './create-template.js'; export { - builtinCmykConversion, - builtinGrayConversion, + builtinCmykReplacement, + builtinGrayReplacement, } from './output/image.js'; export { createVitePlugin } from './vite-adapter.js'; /** @hidden */ diff --git a/src/output/image.ts b/src/output/image.ts index 87e0c896..95d376ff 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -81,7 +81,7 @@ function convertImageColorSpace( * Built-in ReplaceFunction that converts RGB images to CMYK * using mupdf's DeviceCMYK color space conversion. */ -export async function builtinCmykConversion( +export async function builtinCmykReplacement( image: ImageContext, ): Promise { const mupdf = await importNodeModule('mupdf'); @@ -99,7 +99,7 @@ export async function builtinCmykConversion( * Built-in ReplaceFunction that converts RGB images to grayscale * using mupdf's DeviceGray color space conversion. */ -export async function builtinGrayConversion( +export async function builtinGrayReplacement( image: ImageContext, ): Promise { const mupdf = await importNodeModule('mupdf'); @@ -161,8 +161,8 @@ const builtinColorSpaceMap = new Map< ReplaceFunction, (mupdf: typeof import('mupdf')) => mupdfType.ColorSpace >([ - [builtinCmykConversion, (mupdf) => mupdf.ColorSpace.DeviceCMYK], - [builtinGrayConversion, (mupdf) => mupdf.ColorSpace.DeviceGray], + [builtinCmykReplacement, (mupdf) => mupdf.ColorSpace.DeviceCMYK], + [builtinGrayReplacement, (mupdf) => mupdf.ColorSpace.DeviceGray], ]); function applyReplaceFunction( diff --git a/tests/image.test.ts b/tests/image.test.ts index 13365a3e..5aa81e94 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -4,8 +4,8 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import type { ImageContext } from '../src/config/resolve.js'; import { - builtinCmykConversion, - builtinGrayConversion, + builtinCmykReplacement, + builtinGrayReplacement, findNonCmykImages, replaceImages, } from '../src/output/image.js'; @@ -163,7 +163,7 @@ describe('replaceImages', () => { pdf: srcPdf, replaceImageConfig: [ { source: srcImagePath, replacement: destImagePath }, - builtinGrayConversion, + builtinGrayReplacement, ], }); @@ -230,7 +230,7 @@ describe('replaceImages', () => { spy.mockRestore(); }); - it('builtinCmykConversion converts RGB image to CMYK', async () => { + it('builtinCmykReplacement converts RGB image to CMYK', async () => { const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf')); const srcColorSpace = await getImageColorSpace(srcPdf); @@ -238,14 +238,14 @@ describe('replaceImages', () => { const destPdf = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinCmykConversion], + replaceImageConfig: [builtinCmykReplacement], }); const destColorSpace = await getImageColorSpace(destPdf); expect(destColorSpace).toBe('CMYK'); }); - it('builtinGrayConversion converts RGB image to Gray', async () => { + it('builtinGrayReplacement converts RGB image to Gray', async () => { const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf')); const srcColorSpace = await getImageColorSpace(srcPdf); @@ -253,7 +253,7 @@ describe('replaceImages', () => { const destPdf = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinGrayConversion], + replaceImageConfig: [builtinGrayReplacement], }); const destColorSpace = await getImageColorSpace(destPdf); @@ -282,7 +282,7 @@ describe('findNonCmykImages', () => { // Replace RGB image with CMYK first const cmykPdf = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinCmykConversion], + replaceImageConfig: [builtinCmykReplacement], }); const spy = vi.spyOn(Logger, 'logWarn'); @@ -297,7 +297,7 @@ describe('findNonCmykImages', () => { const grayPdf = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinGrayConversion], + replaceImageConfig: [builtinGrayReplacement], }); const spy = vi.spyOn(Logger, 'logWarn'); From 14d0912bb37e5df9655b08d72273d8d0cd19608e Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 09:02:03 +0900 Subject: [PATCH 15/26] test: add failing test for ICC profile state isolation (requires separate WASM instance) --- src/output/image.ts | 143 ++++++++++++++++------- tests/fixtures/cmyk/ICC-PROFILES-LICENSE | 10 ++ tests/fixtures/cmyk/default_cmyk.icc | Bin 0 -> 187484 bytes tests/image.test.ts | 43 ++++++- 4 files changed, 148 insertions(+), 48 deletions(-) create mode 100644 tests/fixtures/cmyk/ICC-PROFILES-LICENSE create mode 100644 tests/fixtures/cmyk/default_cmyk.icc diff --git a/src/output/image.ts b/src/output/image.ts index 95d376ff..710775d8 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -77,40 +77,95 @@ function convertImageColorSpace( return disposable(new mupdf.Image(converted)); } +function resolveColorSpace( + type: 'CMYK' | 'Gray', + mupdf: typeof import('mupdf'), + inputProfile?: Uint8Array, + outputProfile?: Uint8Array, +): { colorSpace: mupdfType.ColorSpace; useICC: boolean } { + if (outputProfile) { + return { + colorSpace: new mupdf.ColorSpace(outputProfile, `custom-${type}`), + useICC: true, + }; + } + // inputProfile without outputProfile: enable ICC engine with device color space + if (inputProfile) { + return { + colorSpace: + type === 'CMYK' + ? mupdf.ColorSpace.DeviceCMYK + : mupdf.ColorSpace.DeviceGray, + useICC: true, + }; + } + return { + colorSpace: + type === 'CMYK' + ? mupdf.ColorSpace.DeviceCMYK + : mupdf.ColorSpace.DeviceGray, + useICC: false, + }; +} + +function createBuiltinReplacement( + type: 'CMYK' | 'Gray', + inputProfile?: Uint8Array, + outputProfile?: Uint8Array, +): ReplaceFunction { + const fn: ReplaceFunction = async (image) => { + const mupdf = await importNodeModule('mupdf'); + const { colorSpace, useICC } = resolveColorSpace( + type, + mupdf, + inputProfile, + outputProfile, + ); + if (useICC) mupdf.enableICC(); + try { + using img = disposable(new mupdf.Image(image.asPNG())); + using result = convertImageColorSpace(img, colorSpace, mupdf); + using pixmap = disposable(result.toPixmap()); + return pixmap.asPAM(); + } finally { + if (useICC) mupdf.disableICC(); + } + }; + // Tag for fast-path detection + (fn as any).__builtinType = type; + (fn as any).__inputProfile = inputProfile; + (fn as any).__outputProfile = outputProfile; + return fn; +} + /** - * Built-in ReplaceFunction that converts RGB images to CMYK - * using mupdf's DeviceCMYK color space conversion. + * Returns a ReplaceFunction that converts RGB images to CMYK. + * When called without arguments, uses mupdf's DeviceCMYK color space. + * ICC profiles can be provided for more accurate conversion. + * + * @param inputProfile - ICC profile for interpreting the source RGB image + * @param outputProfile - ICC profile for the target CMYK color space */ -export async function builtinCmykReplacement( - image: ImageContext, -): Promise { - const mupdf = await importNodeModule('mupdf'); - using img = disposable(new mupdf.Image(image.asPNG())); - using result = convertImageColorSpace( - img, - mupdf.ColorSpace.DeviceCMYK, - mupdf, - ); - using pixmap = disposable(result.toPixmap()); - return pixmap.asPAM(); +export function builtinCmykReplacement( + inputProfile?: Uint8Array, + outputProfile?: Uint8Array, +): ReplaceFunction { + return createBuiltinReplacement('CMYK', inputProfile, outputProfile); } /** - * Built-in ReplaceFunction that converts RGB images to grayscale - * using mupdf's DeviceGray color space conversion. + * Returns a ReplaceFunction that converts RGB images to grayscale. + * When called without arguments, uses mupdf's DeviceGray color space. + * ICC profiles can be provided for more accurate conversion. + * + * @param inputProfile - ICC profile for interpreting the source RGB image + * @param outputProfile - ICC profile for the target Gray color space */ -export async function builtinGrayReplacement( - image: ImageContext, -): Promise { - const mupdf = await importNodeModule('mupdf'); - using img = disposable(new mupdf.Image(image.asPNG())); - using result = convertImageColorSpace( - img, - mupdf.ColorSpace.DeviceGray, - mupdf, - ); - using pixmap = disposable(result.toPixmap()); - return pixmap.asPAM(); +export function builtinGrayReplacement( + inputProfile?: Uint8Array, + outputProfile?: Uint8Array, +): ReplaceFunction { + return createBuiltinReplacement('Gray', inputProfile, outputProfile); } /** @@ -154,26 +209,28 @@ export async function findNonCmykImages(pdf: Uint8Array): Promise { } } -// Map of built-in functions to their target color spaces for fast-path conversion. -// When a built-in function is detected, we skip the byte encode/decode roundtrip -// and convert the pixmap directly. -const builtinColorSpaceMap = new Map< - ReplaceFunction, - (mupdf: typeof import('mupdf')) => mupdfType.ColorSpace ->([ - [builtinCmykReplacement, (mupdf) => mupdf.ColorSpace.DeviceCMYK], - [builtinGrayReplacement, (mupdf) => mupdf.ColorSpace.DeviceGray], -]); - function applyReplaceFunction( fn: ReplaceFunction, pdfImage: mupdfType.Image, mupdf: typeof import('mupdf'), ): Promise | DisposableImage { - const targetCs = builtinColorSpaceMap.get(fn); - if (targetCs) { - // Fast path: direct pixmap conversion without byte roundtrip - return convertImageColorSpace(pdfImage, targetCs(mupdf), mupdf); + const builtinType = (fn as any).__builtinType as 'CMYK' | 'Gray' | undefined; + if (builtinType) { + // Fast path for builtin replacements: direct pixmap conversion + const inputProfile = (fn as any).__inputProfile as Uint8Array | undefined; + const outputProfile = (fn as any).__outputProfile as Uint8Array | undefined; + const { colorSpace, useICC } = resolveColorSpace( + builtinType, + mupdf, + inputProfile, + outputProfile, + ); + if (useICC) mupdf.enableICC(); + try { + return convertImageColorSpace(pdfImage, colorSpace, mupdf); + } finally { + if (useICC) mupdf.disableICC(); + } } // General path: bytes in, bytes out return (async () => { diff --git a/tests/fixtures/cmyk/ICC-PROFILES-LICENSE b/tests/fixtures/cmyk/ICC-PROFILES-LICENSE new file mode 100644 index 00000000..b33d6746 --- /dev/null +++ b/tests/fixtures/cmyk/ICC-PROFILES-LICENSE @@ -0,0 +1,10 @@ +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 diff --git a/tests/fixtures/cmyk/default_cmyk.icc b/tests/fixtures/cmyk/default_cmyk.icc new file mode 100644 index 0000000000000000000000000000000000000000..fce61bf2bcbc9a821364d7fff85a2a4680af3078 GIT binary patch literal 187484 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 `cZ&D4oo+dGP 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 literal 0 HcmV?d00001 diff --git a/tests/image.test.ts b/tests/image.test.ts index 5aa81e94..6e66a9ef 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -163,7 +163,7 @@ describe('replaceImages', () => { pdf: srcPdf, replaceImageConfig: [ { source: srcImagePath, replacement: destImagePath }, - builtinGrayReplacement, + builtinGrayReplacement(), ], }); @@ -238,13 +238,46 @@ describe('replaceImages', () => { const destPdf = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinCmykReplacement], + 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 to trigger enableICC + await replaceImages({ + pdf: srcPdf, + replaceImageConfig: [builtinCmykReplacement(undefined, cmykProfile)], + }); + + // 3. Convert without profile again + const pdf3 = await replaceImages({ + pdf: srcPdf, + replaceImageConfig: [builtinCmykReplacement()], + }); + + // 1 and 3 must be identical + 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')); @@ -253,7 +286,7 @@ describe('replaceImages', () => { const destPdf = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinGrayReplacement], + replaceImageConfig: [builtinGrayReplacement()], }); const destColorSpace = await getImageColorSpace(destPdf); @@ -282,7 +315,7 @@ describe('findNonCmykImages', () => { // Replace RGB image with CMYK first const cmykPdf = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinCmykReplacement], + replaceImageConfig: [builtinCmykReplacement()], }); const spy = vi.spyOn(Logger, 'logWarn'); @@ -297,7 +330,7 @@ describe('findNonCmykImages', () => { const grayPdf = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinGrayReplacement], + replaceImageConfig: [builtinGrayReplacement()], }); const spy = vi.spyOn(Logger, 'logWarn'); From 67d353f02bee0551f8d8de51edd88c18feeaf334 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 09:34:55 +0900 Subject: [PATCH 16/26] feat: ICC profile support via separate WASM instance for state isolation --- docs/api-javascript.md | 42 ++++-- src/output/image.ts | 180 ++++++++++++++++------- tests/fixtures/cmyk/ICC-PROFILES-LICENSE | 1 + tests/fixtures/cmyk/default_gray.icc | Bin 0 -> 2460 bytes tests/image.test.ts | 119 ++++++++++----- 5 files changed, 242 insertions(+), 100 deletions(-) create mode 100644 tests/fixtures/cmyk/default_gray.icc diff --git a/docs/api-javascript.md b/docs/api-javascript.md index 66362e7b..d5cce0fc 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -278,39 +278,57 @@ build({ ### builtinCmykReplacement() -> **builtinCmykReplacement**(`image`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> +> **builtinCmykReplacement**(`inputProfile?`, `outputProfile?`): [`ReplaceFunction`](#replacefunction) -Built-in ReplaceFunction that converts RGB images to CMYK -using mupdf's DeviceCMYK color space conversion. +Returns a ReplaceFunction that converts RGB images to CMYK. +When called without arguments, uses mupdf's DeviceCMYK color space. +ICC profiles can be provided for more accurate conversion. #### Parameters -##### image +##### inputProfile? -[`ImageContext`](#imagecontext) +`Uint8Array`\<`ArrayBufferLike`\> + +ICC profile for interpreting the source RGB image + +##### outputProfile? + +`Uint8Array`\<`ArrayBufferLike`\> + +ICC profile for the target CMYK color space #### Returns -`Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> +[`ReplaceFunction`](#replacefunction) *** ### builtinGrayReplacement() -> **builtinGrayReplacement**(`image`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> +> **builtinGrayReplacement**(`inputProfile?`, `outputProfile?`): [`ReplaceFunction`](#replacefunction) -Built-in ReplaceFunction that converts RGB images to grayscale -using mupdf's DeviceGray color space conversion. +Returns a ReplaceFunction that converts RGB images to grayscale. +When called without arguments, uses mupdf's DeviceGray color space. +ICC profiles can be provided for more accurate conversion. #### Parameters -##### image +##### inputProfile? -[`ImageContext`](#imagecontext) +`Uint8Array`\<`ArrayBufferLike`\> + +ICC profile for interpreting the source RGB image + +##### outputProfile? + +`Uint8Array`\<`ArrayBufferLike`\> + +ICC profile for the target Gray color space #### Returns -`Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> +[`ReplaceFunction`](#replacefunction) *** diff --git a/src/output/image.ts b/src/output/image.ts index 710775d8..95bd343c 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -77,35 +77,113 @@ function convertImageColorSpace( return disposable(new mupdf.Image(converted)); } -function resolveColorSpace( +// 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', - mupdf: typeof import('mupdf'), - inputProfile?: Uint8Array, outputProfile?: Uint8Array, -): { colorSpace: mupdfType.ColorSpace; useICC: boolean } { - if (outputProfile) { +): 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 { - colorSpace: new mupdf.ColorSpace(outputProfile, `custom-${type}`), - useICC: true, + ptr, + [Symbol.dispose]() { + drop(ptr); + }, }; } - // inputProfile without outputProfile: enable ICC engine with device color space - if (inputProfile) { - return { - colorSpace: - type === 'CMYK' - ? mupdf.ColorSpace.DeviceCMYK - : mupdf.ColorSpace.DeviceGray, - useICC: true, + + 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(); } - return { - colorSpace: - type === 'CMYK' - ? mupdf.ColorSpace.DeviceCMYK - : mupdf.ColorSpace.DeviceGray, - useICC: false, - }; + 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; } function createBuiltinReplacement( @@ -113,28 +191,29 @@ function createBuiltinReplacement( inputProfile?: Uint8Array, outputProfile?: Uint8Array, ): ReplaceFunction { + // Only outputProfile triggers ICC path; inputProfile is reserved for future use + const useICC = !!outputProfile; const fn: ReplaceFunction = async (image) => { + if (useICC) { + return convertWithICC(image.asPNG(), type, outputProfile); + } const mupdf = await importNodeModule('mupdf'); - const { colorSpace, useICC } = resolveColorSpace( - type, + using img = disposable(new mupdf.Image(image.asPNG())); + using result = convertImageColorSpace( + img, + type === 'CMYK' + ? mupdf.ColorSpace.DeviceCMYK + : mupdf.ColorSpace.DeviceGray, mupdf, - inputProfile, - outputProfile, ); - if (useICC) mupdf.enableICC(); - try { - using img = disposable(new mupdf.Image(image.asPNG())); - using result = convertImageColorSpace(img, colorSpace, mupdf); - using pixmap = disposable(result.toPixmap()); - return pixmap.asPAM(); - } finally { - if (useICC) mupdf.disableICC(); - } + using pixmap = disposable(result.toPixmap()); + return pixmap.asPAM(); }; - // Tag for fast-path detection - (fn as any).__builtinType = type; - (fn as any).__inputProfile = inputProfile; - (fn as any).__outputProfile = outputProfile; + // 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; } @@ -214,25 +293,18 @@ function applyReplaceFunction( pdfImage: mupdfType.Image, mupdf: typeof import('mupdf'), ): Promise | DisposableImage { - const builtinType = (fn as any).__builtinType as 'CMYK' | 'Gray' | undefined; + const builtinType = getBuiltinType(fn); if (builtinType) { - // Fast path for builtin replacements: direct pixmap conversion - const inputProfile = (fn as any).__inputProfile as Uint8Array | undefined; - const outputProfile = (fn as any).__outputProfile as Uint8Array | undefined; - const { colorSpace, useICC } = resolveColorSpace( - builtinType, + // Fast path for non-ICC builtin replacements: direct pixmap conversion + return convertImageColorSpace( + pdfImage, + builtinType === 'CMYK' + ? mupdf.ColorSpace.DeviceCMYK + : mupdf.ColorSpace.DeviceGray, mupdf, - inputProfile, - outputProfile, ); - if (useICC) mupdf.enableICC(); - try { - return convertImageColorSpace(pdfImage, colorSpace, mupdf); - } finally { - if (useICC) mupdf.disableICC(); - } } - // General path: bytes in, bytes out + // General path: bytes in, bytes out (includes ICC builtins) return (async () => { const resultBytes = await fn(createImageContext(pdfImage)); return disposable(new mupdf.Image(resultBytes)); diff --git a/tests/fixtures/cmyk/ICC-PROFILES-LICENSE b/tests/fixtures/cmyk/ICC-PROFILES-LICENSE index b33d6746..aa9a3656 100644 --- a/tests/fixtures/cmyk/ICC-PROFILES-LICENSE +++ b/tests/fixtures/cmyk/ICC-PROFILES-LICENSE @@ -8,3 +8,4 @@ License: https://github.com/ArtifexSoftware/ghostpdl/blob/ghostpdl-10.07.0/LICEN 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_gray.icc b/tests/fixtures/cmyk/default_gray.icc new file mode 100644 index 0000000000000000000000000000000000000000..e9cd5b1229904fd3c74584f923ebb00ae8936abf GIT binary patch literal 2460 zcmb`IX*iT^8^^EvzGq)7Gh^(Gbu3x448n}DjV1eJ&p3)+f%A@CfpQ8_bc)z^=znq34&w*Aoh~i zNJ~!m=_8jsZC%ViKI^BC@%H2{@m&DmNdJAign9pTGhHL0E_kQ6Xta5mJM+ zAw!4-SwVJ?3*-p}K*3N16c439SIm-U(O0b?|Yx9liqJhKFGx{2HD^0769Qh!UcM zun;cdh6Ezvhyck!3XyW84rxKUkUr!-B1C48FDML(iejL2P!=dhln;uJN<`(LicyuQ zW2kmiFX}#O3^j}TfhMEn(AsEoG!N~MjzFiO3()2026P+x8hRK#f&PTSVCWbo#uVd- z3BW{SHeiY|2QaOeZp-qB4q+#-pK(N-BF+$J zj|;@bSDc9M>gx=D{nAIM~~ zI+;xlBqx)1k{iia$(gNJr@EbRIpHUP3=j zAEAGhl$Yd4hDmOhY>^z2oR^Z7VoQZd6-u>A4NHBMW=Pvf$4Kv%?vQ>agObsb@sLTA zIVf{YW=57KYaz>*EtEYi`&bT^(~|R)+aPyXu3v6WUP0bLK0$uJ{8jlG1-b%TAzGnK z;gZ6%B302!F-oya@v`D8h6IDdh-FkTdKt4ya!QU$DN40U14;|ZOl2SCJmps9XDS30 zmP)uvnM#k!tg51_t7?{Nv+83tyc$a_Qf;4FpW3`SQ{7K}t9pm}6jO@HV`eg&nNKyy z8rB+#8g&{EG%=bi%^1xCn)kF|En}@nttzd%+OW2Xc9ix3?ZIW}WoFCbm(?vB)gkF{ zbW(Mib;fk*x-PnTx*fW2^i=f%^>*v^>HW|*){oV%*MDL_F>o@-Gw3pSZ>VL+H>@&z zu$;WyetGWl&gJinw2i`y4jMf)rW(5%Z#C{Q{%&GwA~0z&nKo554K}Sd9c9s2?yO?g z4KuWvjajbQ1+y>aCg#cJr_JA5=vc&9G+9ioV6F&TQNLo`QrR-Zver^)#jpytI%FlZ zRb`(1wyGpy~ z_R97V_RaRQ4#o}}9J(FRj?Rv|9Y=ZcJU*|HH|u2Tl2if# zom}_0K3%D{GJa);8*t;f?QwhN&U8<7KktF@aQCS4c(LpRHf1pU_{=KimIyfJ{JCKxZH}&^NFlaBh{|s=ccwf{cR- zf<{&|SEsMOxkhG9?3#;fMc0O`Jrj%$_6u$b{ubgEQWr9}j<>FQ-7KHW-^YI)$__0H zoer}MD-D|rw+t^0pNg=I*b^}wX&qS}ITOW=s*HLU?HFAXJs;y1b2Mf#)-Sd-4i^_3 zcP^e19~0lZUSWOu`oRS4gn|TNqIu%p#94u}pdkrLTAkFHOk4V(`cpJgwxo=uTBTN{ zeopgEJCiP+9-rQyp_Q>cV=~h|^T-C&2L6VgETybXS!3Cp?AjbCXI)NDu1aox?!-oi zjmI_-HpOhZlc%3on)h+D-{!7-`TULf<69iIG#7{z2nt5GT5PS^hT0ajZD9NI?fbX? zC=4yURis~3Ui7V)Uwmta!H$X@i#x-24wRUb9NdN36~Aj_xApF0rQ)TTrQ>^C_q3NW z$_mRq?p?e0X1P&$%|61ulzqYqmx|8)s{42C|5h1UIb3B^)mklAy}f$=K=1SabPk?#I@i->(baZd_x#ZdN*AgwN?a_tM7WfHY4LLA<+<*J?wKnQ zSH^qR_B^@jfAvAHNAJKj=W92v+h4!h$LYI#!}7+3o8~vq-D2J9xNUN~z2CUM?T+!C zwgHoY_PeHcJMWp@>l$1!cxlLb=*oTD`_~`v9^4*YIXpPxGxBgWXjJ$x^x@Q_*hlXk zr#@bIvhgYUY2h=mXXVf3p4a`M`A4hJM0jz`cC7z}*NZ3P{PEX+CjI$sBL5}%W%;Dy zWaE_K)P-sL>3gqMy_$HP@OojU;4ksNs^4h5Is2CVwtv=tcH*7j-QxS=57Hlwel+^n vGv_|{{8QYgh55qI(w~ogVSVZQ>i2bWA?+LXTg7+H?_EEfe>`1`TU`7bFWzt9 literal 0 HcmV?d00001 diff --git a/tests/image.test.ts b/tests/image.test.ts index 6e66a9ef..cabdde30 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -26,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', () => { @@ -230,6 +227,30 @@ describe('replaceImages', () => { 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')); @@ -262,19 +283,22 @@ describe('replaceImages', () => { replaceImageConfig: [builtinCmykReplacement()], }); - // 2. Convert with a real ICC profile to trigger enableICC - await replaceImages({ + // 2. Convert with a real ICC profile (uses separate WASM instance) + const pdf2 = await replaceImages({ pdf: srcPdf, replaceImageConfig: [builtinCmykReplacement(undefined, 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 + // 1 and 3 must be identical — ICC state must not leak expect(Buffer.compare(Buffer.from(pdf1), Buffer.from(pdf3))).toBe(0); }); @@ -292,6 +316,33 @@ describe('replaceImages', () => { 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(undefined, 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', () => { From af576e7055b968401ef904e7fbb1059f13c03530 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 09:39:07 +0900 Subject: [PATCH 17/26] docs: update README and example for ICC profile support in builtin replacements --- examples/cmyk/README.md | 2 +- examples/cmyk/vivliostyle.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/cmyk/README.md b/examples/cmyk/README.md index 47fde6ae..2b827bbe 100644 --- a/examples/cmyk/README.md +++ b/examples/cmyk/README.md @@ -46,7 +46,7 @@ In both cases, the `builtinGrayReplacement` fallback at the end of the `replaceI 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` are `ReplaceFunction` implementations that convert RGB images to CMYK or grayscale. +`builtinCmykReplacement()` and `builtinGrayReplacement()` return `ReplaceFunction` implementations that convert RGB images to CMYK or grayscale. Both accept optional `inputProfile` and `outputProfile` arguments (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 avoid polluting global ICC state. A `ReplaceFunction` only receives RGB images; non-RGB images are skipped. diff --git a/examples/cmyk/vivliostyle.config.js b/examples/cmyk/vivliostyle.config.js index 4774b90f..945e31ad 100644 --- a/examples/cmyk/vivliostyle.config.js +++ b/examples/cmyk/vivliostyle.config.js @@ -19,7 +19,7 @@ export default defineConfig({ }, replaceImage: [ { source: /^(.*)_rgb\.png$/, replacement: '$1_cmyk.tiff' }, - builtinGrayReplacement, + builtinGrayReplacement(), ], }, }); From 680a6a27e0ad70de0389882ef9fd4d38055dcc4f Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 09:54:00 +0900 Subject: [PATCH 18/26] refactor: use ColorConversionOptions object for builtin replacement functions --- docs/api-javascript.md | 44 +++++++++++++++++++---------------------- examples/cmyk/README.md | 2 +- src/index.ts | 1 + src/output/image.ts | 29 ++++++++++++--------------- tests/image.test.ts | 8 ++++++-- 5 files changed, 41 insertions(+), 43 deletions(-) diff --git a/docs/api-javascript.md b/docs/api-javascript.md index d5cce0fc..beea4e79 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -16,6 +16,7 @@ ### Interfaces +- [`ColorConversionOptions`](#colorconversionoptions) - [`ImageContext`](#imagecontext) - [`StringifyMarkdownOptions`](#stringifymarkdownoptions) - [`TemplateVariable`](#templatevariable) @@ -278,25 +279,17 @@ build({ ### builtinCmykReplacement() -> **builtinCmykReplacement**(`inputProfile?`, `outputProfile?`): [`ReplaceFunction`](#replacefunction) +> **builtinCmykReplacement**(`options`): [`ReplaceFunction`](#replacefunction) Returns a ReplaceFunction that converts RGB images to CMYK. When called without arguments, uses mupdf's DeviceCMYK color space. -ICC profiles can be provided for more accurate conversion. +An output ICC profile can be provided for profile-based conversion. #### Parameters -##### inputProfile? - -`Uint8Array`\<`ArrayBufferLike`\> - -ICC profile for interpreting the source RGB image - -##### outputProfile? - -`Uint8Array`\<`ArrayBufferLike`\> +##### options -ICC profile for the target CMYK color space +[`ColorConversionOptions`](#colorconversionoptions) = `{}` #### Returns @@ -306,25 +299,17 @@ ICC profile for the target CMYK color space ### builtinGrayReplacement() -> **builtinGrayReplacement**(`inputProfile?`, `outputProfile?`): [`ReplaceFunction`](#replacefunction) +> **builtinGrayReplacement**(`options`): [`ReplaceFunction`](#replacefunction) Returns a ReplaceFunction that converts RGB images to grayscale. When called without arguments, uses mupdf's DeviceGray color space. -ICC profiles can be provided for more accurate conversion. +An output ICC profile can be provided for profile-based conversion. #### Parameters -##### inputProfile? - -`Uint8Array`\<`ArrayBufferLike`\> - -ICC profile for interpreting the source RGB image - -##### outputProfile? - -`Uint8Array`\<`ArrayBufferLike`\> +##### options -ICC profile for the target Gray color space +[`ColorConversionOptions`](#colorconversionoptions) = `{}` #### Returns @@ -1070,6 +1055,17 @@ Unified processor. ## Interfaces +### ColorConversionOptions + +#### Properties + +| Property | Type | +| ------ | ------ | +| `inputProfile?` | `Uint8Array`\<`ArrayBufferLike`\> | +| `outputProfile?` | `Uint8Array`\<`ArrayBufferLike`\> | + +*** + ### ImageContext #### Methods diff --git a/examples/cmyk/README.md b/examples/cmyk/README.md index 2b827bbe..47e15dbc 100644 --- a/examples/cmyk/README.md +++ b/examples/cmyk/README.md @@ -46,7 +46,7 @@ In both cases, the `builtinGrayReplacement` fallback at the end of the `replaceI 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 optional `inputProfile` and `outputProfile` arguments (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 avoid polluting global ICC state. +`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. diff --git a/src/index.ts b/src/index.ts index 674dd98f..2f50d7f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export type { TemplateVariable } from './create-template.js'; export { builtinCmykReplacement, builtinGrayReplacement, + type ColorConversionOptions, } from './output/image.js'; export { createVitePlugin } from './vite-adapter.js'; /** @hidden */ diff --git a/src/output/image.ts b/src/output/image.ts index 95bd343c..f6ece012 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -186,11 +186,16 @@ function getBuiltinType(fn: ReplaceFunction): 'CMYK' | 'Gray' | undefined { return undefined; } +export interface ColorConversionOptions { + inputProfile?: Uint8Array; + outputProfile?: Uint8Array; +} + function createBuiltinReplacement( type: 'CMYK' | 'Gray', - inputProfile?: Uint8Array, - outputProfile?: Uint8Array, + options: ColorConversionOptions = {}, ): ReplaceFunction { + const { outputProfile } = options; // Only outputProfile triggers ICC path; inputProfile is reserved for future use const useICC = !!outputProfile; const fn: ReplaceFunction = async (image) => { @@ -220,31 +225,23 @@ function createBuiltinReplacement( /** * Returns a ReplaceFunction that converts RGB images to CMYK. * When called without arguments, uses mupdf's DeviceCMYK color space. - * ICC profiles can be provided for more accurate conversion. - * - * @param inputProfile - ICC profile for interpreting the source RGB image - * @param outputProfile - ICC profile for the target CMYK color space + * An output ICC profile can be provided for profile-based conversion. */ export function builtinCmykReplacement( - inputProfile?: Uint8Array, - outputProfile?: Uint8Array, + options: ColorConversionOptions = {}, ): ReplaceFunction { - return createBuiltinReplacement('CMYK', inputProfile, outputProfile); + return createBuiltinReplacement('CMYK', options); } /** * Returns a ReplaceFunction that converts RGB images to grayscale. * When called without arguments, uses mupdf's DeviceGray color space. - * ICC profiles can be provided for more accurate conversion. - * - * @param inputProfile - ICC profile for interpreting the source RGB image - * @param outputProfile - ICC profile for the target Gray color space + * An output ICC profile can be provided for profile-based conversion. */ export function builtinGrayReplacement( - inputProfile?: Uint8Array, - outputProfile?: Uint8Array, + options: ColorConversionOptions = {}, ): ReplaceFunction { - return createBuiltinReplacement('Gray', inputProfile, outputProfile); + return createBuiltinReplacement('Gray', options); } /** diff --git a/tests/image.test.ts b/tests/image.test.ts index cabdde30..42f96a0a 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -286,7 +286,9 @@ describe('replaceImages', () => { // 2. Convert with a real ICC profile (uses separate WASM instance) const pdf2 = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinCmykReplacement(undefined, cmykProfile)], + replaceImageConfig: [ + builtinCmykReplacement({ outputProfile: cmykProfile }), + ], }); // ICC profile should produce different output than DeviceCMYK @@ -331,7 +333,9 @@ describe('replaceImages', () => { const pdf2 = await replaceImages({ pdf: srcPdf, - replaceImageConfig: [builtinGrayReplacement(undefined, grayProfile)], + replaceImageConfig: [ + builtinGrayReplacement({ outputProfile: grayProfile }), + ], }); expect(Buffer.compare(Buffer.from(pdf1), Buffer.from(pdf2))).not.toBe(0); From 35ea9f483149a8baacec76bb880d7ff2c93bcbe0 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 10:30:16 +0900 Subject: [PATCH 19/26] feat: add CmykConvertFunction type and schema for overrideMap function support --- src/config/resolve.ts | 13 +++++++++++-- src/config/schema.ts | 29 ++++++++++++++++++++++++++--- src/output/pdf-postprocess.ts | 4 +++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 14467efb..f25a415b 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -261,10 +261,14 @@ function resolveMapEntries( }); } +export type CmykConvertFunction = ( + rgb: RGBValue, +) => CMYKValue | Promise; + export interface CmykConfig { warnUnmapped: boolean; warnUnreplacedImages: boolean; - overrideMap: CmykMapEntry[]; + overrideMap: (CmykMapEntry | CmykConvertFunction)[]; reserveMap: CmykMapEntry[]; mapOutput: string | undefined; } @@ -708,7 +712,12 @@ export function resolveTaskConfig( return { warnUnmapped: cmykOption.warnUnmapped ?? true, warnUnreplacedImages: cmykOption.warnUnreplacedImages ?? true, - overrideMap: resolveMapEntries(cmykOption.overrideMap ?? []), + 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) diff --git a/src/config/schema.ts b/src/config/schema.ts index 2a908b2c..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( diff --git a/src/output/pdf-postprocess.ts b/src/output/pdf-postprocess.ts index d7fd2d96..cc89fee2 100644 --- a/src/output/pdf-postprocess.ts +++ b/src/output/pdf-postprocess.ts @@ -112,7 +112,9 @@ export class PostProcess { if (cmyk) { const mergedMap: CmykMap = { ...cmykMap }; - for (const [rgb, cmykValue] of cmyk.overrideMap) { + for (const item of cmyk.overrideMap) { + if (typeof item === 'function') continue; + const [rgb, cmykValue] = item; const key = JSON.stringify([rgb.r, rgb.g, rgb.b]); mergedMap[key] = cmykValue; } From 7c278c6a39f9169e269a1052a0b4c83361d0f273 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 10:53:00 +0900 Subject: [PATCH 20/26] refactor: async convertStreamColors with InternalColorConverter chain --- src/output/cmyk.ts | 105 +++++--- src/output/pdf-postprocess.ts | 22 +- src/output/pdf-stream.ts | 28 ++- tests/cmyk.test.ts | 8 +- tests/pdf-stream.test.ts | 450 ++++++++++++++++++++-------------- 5 files changed, 386 insertions(+), 227 deletions(-) diff --git a/src/output/cmyk.ts b/src/output/cmyk.ts index e4a02828..10f0c0a2 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'); @@ -130,14 +155,20 @@ export async function convertCmykColors({ 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/pdf-postprocess.ts b/src/output/pdf-postprocess.ts index cc89fee2..234f519f 100644 --- a/src/output/pdf-postprocess.ts +++ b/src/output/pdf-postprocess.ts @@ -14,7 +14,7 @@ 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 { convertCmykColors, mapToConverter } from './cmyk.js'; import { findNonCmykImages, replaceImages } from './image.js'; export type SaveOption = Pick< @@ -111,18 +111,26 @@ export class PostProcess { let pdf = await this.document.save(); if (cmyk) { - const mergedMap: CmykMap = { ...cmykMap }; + // 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') continue; - const [rgb, cmykValue] = item; - const key = JSON.stringify([rgb.r, rgb.g, rgb.b]); - mergedMap[key] = cmykValue; + 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, }); diff --git a/src/output/pdf-stream.ts b/src/output/pdf-stream.ts index 7fdf5a6d..0dd41e11 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,17 @@ 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) { + cmyk = await fn(rgb); + 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..d7f3b3c3 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, }); 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(), ); From 29c301bea68219a3355f720138801d5f8e94394c Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 10:59:32 +0900 Subject: [PATCH 21/26] feat: add builtinCmykConversion for overrideMap function support --- src/index.ts | 7 ++++++- src/output/image.ts | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 2f50d7f8..9f72d511 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,9 +18,14 @@ export type { VivliostyleConfigSchema, VivliostylePackageMetadata, } from './config/schema.js'; -export type { ImageContext, ReplaceFunction } from './config/resolve.js'; +export type { + CmykConvertFunction, + ImageContext, + ReplaceFunction, +} from './config/resolve.js'; export type { TemplateVariable } from './create-template.js'; export { + builtinCmykConversion, builtinCmykReplacement, builtinGrayReplacement, type ColorConversionOptions, diff --git a/src/output/image.ts b/src/output/image.ts index f6ece012..87020851 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -1,7 +1,10 @@ import fs from 'node:fs'; import type * as mupdfType from 'mupdf'; +import type { CMYKValue } from '../global-viewer.js'; import type { + CmykConvertFunction, ImageContext, + RGBValue, ReplaceFunction, ReplaceImageConfig, } from '../config/resolve.js'; @@ -244,6 +247,42 @@ export function builtinGrayReplacement( 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), + }; + }; +} + /** * Scan PDF for images with non-CMYK-compatible color spaces and log warnings. */ From 0bc1f929bae0d03f92455e57c26d91870fb8c13a Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 11:04:30 +0900 Subject: [PATCH 22/26] feat: integrate overrideMap function fallback with converter chain --- tests/cmyk.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ tests/image.test.ts | 19 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/tests/cmyk.test.ts b/tests/cmyk.test.ts index d7f3b3c3..adff45ad 100644 --- a/tests/cmyk.test.ts +++ b/tests/cmyk.test.ts @@ -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/image.test.ts b/tests/image.test.ts index 42f96a0a..84e89ab5 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import type { ImageContext } from '../src/config/resolve.js'; import { + builtinCmykConversion, builtinCmykReplacement, builtinGrayReplacement, findNonCmykImages, @@ -349,6 +350,24 @@ describe('replaceImages', () => { }); }); +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('findNonCmykImages', () => { it('warns about RGB images in PDF', async () => { const srcPdf = fs.readFileSync(path.join(fixturesDir, 'image.pdf')); From 1e3a1eb1d282891ba821ad3869218e0a962406b6 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 12:07:30 +0900 Subject: [PATCH 23/26] feat: add builtinGrayConversion and update example with overrideMap function --- docs/api-javascript.md | 68 ++++++++++++++++++++++++++--- examples/cmyk/README.md | 28 ++++++++---- examples/cmyk/vivliostyle.config.js | 11 +++-- src/index.ts | 1 + src/output/image.ts | 29 ++++++++++++ 5 files changed, 121 insertions(+), 16 deletions(-) diff --git a/docs/api-javascript.md b/docs/api-javascript.md index beea4e79..843500ee 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -6,7 +6,9 @@ ### Functions - [`build`](#build) +- [`builtinCmykConversion`](#builtincmykconversion) - [`builtinCmykReplacement`](#builtincmykreplacement) +- [`builtinGrayConversion`](#builtingrayconversion) - [`builtinGrayReplacement`](#builtingrayreplacement) - [`create`](#create) - [`createVitePlugin`](#createviteplugin) @@ -23,6 +25,7 @@ ### Type Aliases +- [`CmykConvertFunction`](#cmykconvertfunction) - [`Metadata`](#metadata) - [`ReplaceFunction`](#replacefunction) - [`StructuredDocument`](#structureddocument) @@ -69,7 +72,7 @@ build({ ###### cmyk? -`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` +`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: (\[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\] \| (`rgb`) => \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \} \| `Promise`\<\{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\>)[]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` ###### config? @@ -277,6 +280,26 @@ build({ *** +### builtinCmykConversion() + +> **builtinCmykConversion**(`options`): [`CmykConvertFunction`](#cmykconvertfunction) + +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. + +#### Parameters + +##### options + +[`ColorConversionOptions`](#colorconversionoptions) = `{}` + +#### Returns + +[`CmykConvertFunction`](#cmykconvertfunction) + +*** + ### builtinCmykReplacement() > **builtinCmykReplacement**(`options`): [`ReplaceFunction`](#replacefunction) @@ -297,6 +320,25 @@ An output ICC profile can be provided for profile-based conversion. *** +### builtinGrayConversion() + +> **builtinGrayConversion**(`options`): [`CmykConvertFunction`](#cmykconvertfunction) + +Returns a CmykConvertFunction that converts RGB colors to grayscale (K only). +Internally delegates to builtinGrayReplacement and maps the Gray value to K. + +#### Parameters + +##### options + +[`ColorConversionOptions`](#colorconversionoptions) = `{}` + +#### Returns + +[`CmykConvertFunction`](#cmykconvertfunction) + +*** + ### builtinGrayReplacement() > **builtinGrayReplacement**(`options`): [`ReplaceFunction`](#replacefunction) @@ -341,7 +383,7 @@ Scaffold a new Vivliostyle project. ###### cmyk? -`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` +`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: (\[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\] \| (`rgb`) => \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \} \| `Promise`\<\{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\>)[]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` ###### config? @@ -571,7 +613,7 @@ Scaffold a new Vivliostyle project. ###### cmyk? -`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` +`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: (\[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\] \| (`rgb`) => \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \} \| `Promise`\<\{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\>)[]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` ###### config? @@ -821,7 +863,7 @@ Open a browser for previewing the publication. ###### cmyk? -`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` +`boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: (\[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\] \| (`rgb`) => \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \} \| `Promise`\<\{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\>)[]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} = `CmykSchema` ###### config? @@ -1117,7 +1159,7 @@ Option for convert Markdown to a stringify (HTML). | `browser.tag?` | `string` | | `browser.type` | `"chrome"` \| `"chromium"` \| `"firefox"` | | `cliVersion` | `string` | -| `cmyk?` | `boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} | +| `cmyk?` | `boolean` \| \{ `mapOutput?`: `string`; `overrideMap?`: (\[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\] \| (`rgb`) => \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \} \| `Promise`\<\{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\>)[]; `reserveMap?`: \[`string` \| \{ `b`: `number`; `g`: `number`; `r`: `number`; \}, \{ `c`: `number`; `k`: `number`; `m`: `number`; `y`: `number`; \}\][]; `warnUnmapped?`: `boolean`; `warnUnreplacedImages?`: `boolean`; \} | | `config?` | `string` | | `configData?` | [`VivliostyleConfigSchema`](#vivliostyleconfigschema) \| `null` | | `coreVersion` | `string` | @@ -1175,6 +1217,22 @@ Option for convert Markdown to a stringify (HTML). ## Type Aliases +### CmykConvertFunction() + +> **CmykConvertFunction** = (`rgb`) => `CMYKValue` \| `Promise`\<`CMYKValue`\> + +#### Parameters + +##### rgb + +`RGBValue` + +#### Returns + +`CMYKValue` \| `Promise`\<`CMYKValue`\> + +*** + ### Metadata > **Metadata** = `object` diff --git a/examples/cmyk/README.md b/examples/cmyk/README.md index 47e15dbc..723551d0 100644 --- a/examples/cmyk/README.md +++ b/examples/cmyk/README.md @@ -50,16 +50,27 @@ A `ReplaceFunction` can also be used as the `replacement` in a `{ source, replac A `ReplaceFunction` only receives RGB images; non-RGB images are skipped. +## `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. `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). `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`, `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, `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. 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 @@ -77,8 +88,9 @@ SUCCESS Finished building output.pdf 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 + 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.50371 CMYK OK + 0.26049 0.00000 0.00000 3.73724 CMYK OK + 0.28051 0.00000 0.00000 2.70549 CMYK OK ``` diff --git a/examples/cmyk/vivliostyle.config.js b/examples/cmyk/vivliostyle.config.js index 945e31ad..85f977d6 100644 --- a/examples/cmyk/vivliostyle.config.js +++ b/examples/cmyk/vivliostyle.config.js @@ -12,9 +12,14 @@ export default defineConfig({ ['#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 }], + [ + { 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: [ diff --git a/src/index.ts b/src/index.ts index 9f72d511..1f546968 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export type { TemplateVariable } from './create-template.js'; export { builtinCmykConversion, builtinCmykReplacement, + builtinGrayConversion, builtinGrayReplacement, type ColorConversionOptions, } from './output/image.js'; diff --git a/src/output/image.ts b/src/output/image.ts index 87020851..afd3241f 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -283,6 +283,35 @@ export function builtinCmykConversion( }; } +/** + * 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. */ From 4c4f95e6d4210cee482ae09fcc94f74a16b31a14 Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 12:16:24 +0900 Subject: [PATCH 24/26] fix: dispose Page in cmyk, catch converter errors, fix namePtr leak, add builtinGrayConversion tests --- docs/config.md | 27 +++++++++++++++++++++++---- src/output/cmyk.ts | 2 +- src/output/image.ts | 21 ++++++++++++--------- src/output/pdf-stream.ts | 6 +++++- tests/image.test.ts | 21 +++++++++++++++++++++ 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/docs/config.md b/docs/config.md index 8f986171..a519b432 100644 --- a/docs/config.md +++ b/docs/config.md @@ -460,10 +460,10 @@ type PdfPostprocessConfig = { - `CmykConfig` - - `overrideMap`: ("{tuple(Array)}")[] + - `overrideMap`: ("{tuple(Array)}" | ((rgb: { r: number; g: number; b: number }) => { c: number; m: number; y: number; k: number } | Promise<{ c: number; m: number; y: number; k: number }>))[] 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`: ("{tuple(Array)}")[] Pre-register RGB to CMYK color mappings for use in SVG or other non-CSS contexts. @@ -483,7 +483,26 @@ type PdfPostprocessConfig = { ```ts type CmykConfig = { - overrideMap?: "{tuple(Array)}"[]; + overrideMap?: ( + | "{tuple(Array)}" + | ((rgb: { + r: number; + g: number; + b: number; + }) => + | { + c: number; + m: number; + y: number; + k: number; + } + | Promise<{ + c: number; + m: number; + y: number; + k: number; + }>) + )[]; reserveMap?: "{tuple(Array)}"[]; warnUnmapped?: boolean; warnUnreplacedImages?: boolean; diff --git a/src/output/cmyk.ts b/src/output/cmyk.ts index 10f0c0a2..8fb5b1cf 100644 --- a/src/output/cmyk.ts +++ b/src/output/cmyk.ts @@ -150,7 +150,7 @@ 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'); diff --git a/src/output/image.ts b/src/output/image.ts index afd3241f..db987dbb 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -146,15 +146,18 @@ async function convertWithICC( 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); + 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); diff --git a/src/output/pdf-stream.ts b/src/output/pdf-stream.ts index 0dd41e11..0d81e528 100644 --- a/src/output/pdf-stream.ts +++ b/src/output/pdf-stream.ts @@ -218,7 +218,11 @@ export async function convertStreamColors( let cmyk: CMYKValue | null = null; for (const fn of converters) { - cmyk = await fn(rgb); + try { + cmyk = await fn(rgb); + } catch { + continue; + } if (cmyk !== null) break; } diff --git a/tests/image.test.ts b/tests/image.test.ts index 84e89ab5..f1bdb9ee 100644 --- a/tests/image.test.ts +++ b/tests/image.test.ts @@ -6,6 +6,7 @@ import type { ImageContext } from '../src/config/resolve.js'; import { builtinCmykConversion, builtinCmykReplacement, + builtinGrayConversion, builtinGrayReplacement, findNonCmykImages, replaceImages, @@ -368,6 +369,26 @@ describe('builtinCmykConversion', () => { }); }); +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')); From b8f450924b3eff60f3d319a00a7029b2af4779fb Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 12:38:01 +0900 Subject: [PATCH 25/26] refactor: remove unused inputProfile from ColorConversionOptions --- docs/api-javascript.md | 1 - src/output/image.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/docs/api-javascript.md b/docs/api-javascript.md index beea4e79..542b4bb3 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -1061,7 +1061,6 @@ Unified processor. | Property | Type | | ------ | ------ | -| `inputProfile?` | `Uint8Array`\<`ArrayBufferLike`\> | | `outputProfile?` | `Uint8Array`\<`ArrayBufferLike`\> | *** diff --git a/src/output/image.ts b/src/output/image.ts index f6ece012..e1b07e5f 100644 --- a/src/output/image.ts +++ b/src/output/image.ts @@ -187,7 +187,6 @@ function getBuiltinType(fn: ReplaceFunction): 'CMYK' | 'Gray' | undefined { } export interface ColorConversionOptions { - inputProfile?: Uint8Array; outputProfile?: Uint8Array; } @@ -196,7 +195,6 @@ function createBuiltinReplacement( options: ColorConversionOptions = {}, ): ReplaceFunction { const { outputProfile } = options; - // Only outputProfile triggers ICC path; inputProfile is reserved for future use const useICC = !!outputProfile; const fn: ReplaceFunction = async (image) => { if (useICC) { From 3ba474e97cfcdd0c16ee9b0c669f55a03684ea9a Mon Sep 17 00:00:00 2001 From: Koutaro Mukai Date: Thu, 26 Mar 2026 13:34:17 +0900 Subject: [PATCH 26/26] docs: use cmyk.overrideMap and cmyk.mapOutput in README headings and references --- examples/cmyk/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/cmyk/README.md b/examples/cmyk/README.md index 723551d0..1ae885e1 100644 --- a/examples/cmyk/README.md +++ b/examples/cmyk/README.md @@ -50,20 +50,20 @@ A `ReplaceFunction` can also be used as the `replacement` in a `{ source, replac A `ReplaceFunction` only receives RGB images; non-RGB images are skipped. -## `overrideMap` +## `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. `overrideMap` is a last resort for keeping the output fully CMYK. +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). `overrideMap` can convert these directly to CMYK values. +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`, `overrideMap` also accepts functions. `builtinCmykConversion()` and `builtinGrayConversion()` are provided for automatic conversion. +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, `overrideMap` is a tool for assisting intentional CMYK workflows, not for converting everything automatically. +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 @@ -71,7 +71,7 @@ By design, this feature cannot produce PDFs that freely mix RGB and CMYK colors 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 @@ -90,7 +90,7 @@ SUCCESS Finished building output.pdf 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.50371 CMYK OK - 0.26049 0.00000 0.00000 3.73724 CMYK OK - 0.28051 0.00000 0.00000 2.70549 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 ```