DEV Community

Manoj Vishwakarma
Manoj Vishwakarma

Posted on

The CMYK Problem Nobody Warns You About When Building a PDF Editor in the Browser

I spent three weeks building a browser-based PDF editor before I realized something was quietly wrong with every color in my application.

I loaded a PDF with a specific shade of teal, defined in CMYK for print. It looked fine on the canvas. I picked that color with the eyedropper, applied it to some text, exported the PDF, and compared the output.

The colors didn't match.

Not dramatically off. Just enough to notice when you compared them side by side. That was the start of a rabbit hole I didn't expect.

What Actually Happens When You Render a CMYK PDF in the Browser

Most of us reach for pdf.js when we need to display PDFs in the browser. It works great. But here is something that is easy to miss: browsers only understand sRGB. They have no concept of CMYK color at all.

So when pdf.js encounters a CMYK color value in a PDF, it converts it to RGB for rendering. That conversion is lossy. The RGB value it produces is an approximation, good enough for a screen preview, but it's not the original color anymore.

Here is where it gets tricky. If your user picks that color from the canvas (using the EyeDropper API or a color input), they get the converted RGB value. If you then use that RGB value when exporting back to PDF, you have lost the original CMYK data entirely.

The color round-trips through two lossy conversions: CMYK to RGB (by pdf.js for display) and then RGB back to CMYK (by your export code). Each step introduces drift.

Why You Can't Just Convert Back

My first instinct was simple. Just convert the sampled RGB back to CMYK and call it a day.

I tried that. The problem is that RGB to CMYK conversion is not the inverse of CMYK to RGB. The color spaces don't map one-to-one. Different CMYK values can produce the same RGB color on screen, and going back from that RGB value gives you a different CMYK than the one you started with.

On top of that, professional print work relies on specific CMYK values for brand colors. Pantone 320 C is Pantone 320 C. You can't just give the printer "something close" and hope it works.

The Dual Representation Approach

The solution I landed on after some research was to maintain two color values for every color in the editor, side by side.

One is the source color: the original CMYK (or RGB) value from the PDF, preserved exactly as it was. This is what you use when exporting.

The other is the preview color: the sRGB approximation that the browser can actually display. This is what you show on the canvas and in color pickers.

The two stay linked together throughout the editing pipeline. When the user picks a color, you don't just store the hex value. You look up which source color that preview corresponds to, and you keep both.

// Instead of storing just this:
element.color = '#1dc4e2';

// You store both the preview and the original source:
element.color = '#1dc4e2';
element.colorSource = { type: 'cmyk', c: 0.11, m: 0.24, y: 0.0, k: 0.13 };
Enter fullscreen mode Exit fullscreen mode

When it's time to export, you check if there's a source color. If it's CMYK, you write those exact values into the PDF. No conversion, no drift.

Palette Snapping

There is still a practical problem though. When a user samples a color from the canvas, you get an RGB hex value. How do you figure out which original CMYK color it came from?

The approach I use is palette snapping. You take all the original colors from the PDF template and build a palette with both their source values and their RGB preview equivalents. When the user picks a color, you compare it against the palette and snap to the nearest match if it's close enough.

const palette = normalizePalette([
  { source: { type: 'cmyk', c: 0.11, m: 0.24, y: 0.0, k: 0.13 } },
  { source: { type: 'cmyk', c: 0.0, m: 0.0, y: 0.0, k: 1.0 } },
]);

const nearest = findNearestPaletteEntry(palette, pickedHex);
if (nearest.entry && shouldSnapToPalette(nearest)) {
  // Snap: use the original CMYK source
  element.colorSource = nearest.entry.source;
  element.color = nearest.entry.previewHex;
} else {
  // Custom color: no CMYK source available
  element.color = pickedHex;
  element.colorSource = null;
}
Enter fullscreen mode Exit fullscreen mode

"Close enough" is determined by a distance metric. Simple RGB Euclidean distance works for most cases, but if you want perceptual accuracy, CIE76 Delta-E is better because it accounts for how humans actually perceive color differences.

Why Not Just Use a Full ICC Pipeline?

The proper, correct solution for color management is ICC profiles. Tools like MuPDF and PDFium do this right. They load the ICC profile embedded in the PDF and perform a proper color space transformation.

But those tools are 5 to 15 megabytes of WASM. They need ICC profile files bundled with your app. They require a custom rendering pipeline instead of using the browser's native canvas. For many browser-based PDF editors, that's too heavy.

The dual representation approach is a pragmatic middle ground. It's a few kilobytes, runs in pure JavaScript, and preserves CMYK fidelity for the common case where users are working with a fixed set of template colors. It won't handle arbitrary ICC profiles or spot colors, but for most web-to-print workflows, it does the job.

What I Built

I packaged this approach into a small TypeScript library called cmyk-preview-toolkit. It handles:

  • CMYK to RGB conversion using the same polynomial that pdf.js uses internally
  • RGB to CMYK reverse conversion with GCR (Grey Component Replacement)
  • HSL and CIE Lab color space conversions
  • Palette building and nearest-match snapping
  • Immutable state helpers for managing dual color representations
  • A React hook if you're building with React

It has zero runtime dependencies and ships as both ESM and CommonJS. The whole thing tree-shakes down to a few kilobytes.

npm install cmyk-preview-toolkit
Enter fullscreen mode Exit fullscreen mode

Here is what the export step looks like with pdf-lib:

import { cmyk, rgb } from 'pdf-lib';

const source = element.colorSource;
if (source?.type === 'cmyk') {
  // Use the preserved CMYK values directly
  page.drawText('Hello', {
    color: cmyk(source.c, source.m, source.y, source.k)
  });
} else {
  // Fall back to RGB
  const { r, g, b } = hexToRgb(element.color);
  page.drawText('Hello', {
    color: rgb(r / 255, g / 255, b / 255)
  });
}
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

A few things I picked up along the way that might save someone else some time:

Test with print, not just screens. A color that looks identical on two monitors can print very differently. If your users are sending PDFs to commercial printers, you need to verify with actual printed output.

The EyeDropper API returns sRGB. There is no way around this. Browser APIs operate in sRGB. Any solution for preserving CMYK has to work around this constraint, not fight it.

Perceptual distance matters. Two colors can be mathematically close in RGB but look obviously different to a human eye, and the reverse is also true. CIE76 Delta-E is not perfect, but it's a big improvement over raw RGB distance for palette snapping decisions.

Keep it simple until you can't. Full ICC color management is the correct answer in theory. But if your users are working with a known set of template colors (which is the case for most web-to-print apps), the dual representation approach gives you 95% of the benefit at 5% of the complexity.

If you are building anything that touches CMYK PDFs in the browser, I hope this saves you the same three weeks of confusion it cost me.

The source code is on GitHub if you want to look under the hood or contribute.

Top comments (1)

Collapse
 
trinhcuong-ast profile image
Kai Alder

Oh man, the CMYK rabbit hole. I ran into something similar building an image annotation tool — users would pick colors from uploaded print assets and the colors would drift just enough to drive the design team crazy.

The dual representation approach is solid. One thing that tripped me up: even between different CMYK profiles (like US Web Coated vs Fogra39) the same CMYK values render differently. Did you end up handling profile-specific conversions or just stick with a default profile?

Also curious if you've looked into using WebAssembly for the color conversion pipeline. Been wondering if something like littlecms compiled to WASM could give more accurate round-trips than doing the math in pure JS.