Ask anyone how to dark-mode a PDF in the browser and you get the same two lines back: render the page to a canvas with pdf.js, put filter: invert(1) on it, done. It is the answer in every thread on the subject, and it is wrong in three separate ways.
Two of them show up in the first screenshot. Photos come out as film negatives. And the whole thing is a costume worn by the viewer: mail the file to a colleague, or open it on a tablet, and it is blinding white again, because you never touched the document.
The third is the one that took me longest to accept. 255 - x is not "the dark version of this color." It is the complement. A dark navy heading at rgb(20, 40, 120) inverts to rgb(235, 215, 135), which is khaki. Every colored element in the document comes out wearing the wrong hue, and no amount of tuning fixes it, because the operation itself is answering a different question than the one you asked.
I started where most people do, one step past the filter: rasterize the page, run a smarter per-pixel mapping over the bitmap, ship that. It works, and it is still the fallback path in my code. But a bitmap of a document is not a document, and every property you want back afterward has to be rebuilt by hand. Eventually I stopped treating the page as an image at all. Here is what is underneath.
A page is a little postfix program
A PDF page's /Contents is a content stream: a byte string of operands followed by operators, evaluated like a stack language.
0 0 0 rg % set fill color to black (DeviceRGB)
BT /F1 12 Tf 72 700 Td (Hello) Tj ET % draw some text
1 0 0 RG % set stroke color to red
72 690 m 300 690 l S % draw a line
Once you see this, dark mode stops being an image-processing problem and becomes a source rewriting problem. You don't repaint the picture; you find the instructions that set colors, rewrite those, and pass every other byte through untouched. The text operators still run. The glyphs are still glyphs.
That single change is what buys you the properties a canvas filter can never have: the output text is selectable and searchable, vector art stays razor sharp at 800% zoom, and the file size barely moves because you never rasterized anything.
The color operator zoo, and the one that ruins your day
The absolute operators are easy, and you can tabulate them by operand count:
const COLOR_OPS: Record<string, number> = {
g: 1, G: 1, // DeviceGray
rg: 3, RG: 3, // DeviceRGB
k: 4, K: 4, // DeviceCMYK
};
Lowercase is fill, uppercase is stroke. Read the operands, map them through your color function, emit the replacement.
Then there is sc / scn, which sets a color in whatever color space is currently selected. The space was selected earlier by cs / CS, naming an entry in the page's /ColorSpace resource dictionary. So 0.2 0.4 0.9 scn might be three RGB components, or it might be something else entirely, and you cannot know without resolving the resource. Worse, the selected space is part of the graphics state, so q and Q push and pop it. To know how many operands to even consume, you have to track what the original stream thought the current space was. You are not writing a regex over a byte string. You are writing an interpreter with a stack.
There is no way around that on the way in. But there is a trick on the way out. Every rewritten color is emitted as plain rg or RG, regardless of what came in. Those operators are absolute; they are always legal, and they reset the current color space to DeviceRGB as a side effect. Because you never re-emit a space-relative operator, you only ever have to read the color space state machine, never write a consistent one back. A CMYK 0 0 0 1 k becomes 1 1 1 rg. An ICCBased scn becomes rg. The nastiest class of bug in this project (emitting operands that don't match the space the viewer thinks it's in) is designed out of existence rather than debugged.
Refuse loudly, and refuse the whole page
The list of things a real-world PDF can contain is longer than the list of things you can confidently recolor. My rewriter throws a health-check failure and gives up on the entire page when it meets:
- a color space it can't reduce to gray / RGB / CMYK:
Indexed,Separation,DeviceN,Lab,Pattern -
scn/SCNwith a pattern name operand (/P1 scn) - an
shshading operator, because a gradient would stay bright on a dark page - an inline image (
BI ... ID <binary> EI), where re-slicing raw bytes out of the token stream is a good way to corrupt a file - any stream that fails to decode or tokenize
The design decision worth stealing is not the list, it is the granularity. The tempting move is per-operator: skip the thing you don't understand, keep going. That gives you a page that is 90% dark with one glowing white gradient panel in the middle, which reads as broken software. Failing the whole page instead lets the caller fall back to the raster pipeline for that page only, so the failure mode degrades to "this page is a picture of a dark page" rather than "this page is wrong." A mixed document gets real vector text on the pages that qualify and a correct-looking fallback on the ones that don't.
One trap on the way out: the PDF spec's default fill color is black. A stream that draws text without ever setting a color is relying on that default, and on a dark page black is now the wrong answer. So the rewritten stream gets a prologue that paints the background rectangle and then explicitly sets both fill and stroke to the mapped foreground, before a single original byte runs.
The accident that made scanned pages work
Here is my favorite thing I learned, and I found it by accident.
An image mask (/ImageMask true) is a one-bit stencil. It carries no color of its own; the viewer paints it using the current fill color, exactly like a glyph. And it turns out that is precisely how scanners encode text: the JBIG2 layer of a scanned page is a stencil mask of the letterforms.
Which means two things fell out of one decision.
In the object-rewriting path, I never wrote a single line of code for scanned text. Changing the fill color operators is enough. The stencil follows the fill color, so scanned black-on-white text flips to light-on-dark on its own.
In the raster path there is a scanner that walks pdf.js's operator list with a simulated graphics state (transform matrix plus clip box, pushed and popped on save/restore) to find the bounding boxes of images worth protecting from inversion. That scanner deliberately ignores paintImageMaskXObject. Protecting those rectangles would have felt correct, and it would have carefully preserved the one thing the user came to change: the black-on-white scanned text.
Two unrelated-looking features, one property of the format.
What I'd tell someone starting this
Resist the image-processing framing for as long as you can. The moment you rasterize, every property you care about afterward (selectable text, sharp vectors, sane file size, scanned pages) becomes a reconstruction job instead of a preservation job.
And whatever your color mapping is, make it hue-preserving. Mine splits on saturation: near-grayscale colors get pulled toward the theme background by luminance (white to background, black to white), while chromatic colors keep their hue and get only their lightness flipped, so that navy heading lands on light blue rather than khaki. The two results are cross-faded across a saturation band, because the anti-aliased edges of colored glyphs sit right in between and a hard switch leaves a visible seam around every letter. That scalar math lives in three places in my codebase, once readable and twice inlined per-pixel, purely because returning a fresh tuple for every pixel of a full-page bitmap is a GC problem you do not want.
If you want to poke at the output rather than the theory, the running implementation is a browser-only PDF color inverter built on exactly the above (File API in, Web Worker for the raster path, nothing leaves the tab), and the full two-pipeline flow is written up here.
Happy to go deeper on the tokenizer or the clip-box simulation in the comments if that's useful to anyone.
Top comments (1)
Rewriting everything to
rgis the right trick, and it quietly breaks one class of input: a file that claims PDF/A. PDF/A-1 admitsDeviceRGBonly if the document carries an RGB output intent, so recolouring a CMYK-intent archive file leaves the XMP asserting a conformance level the bytes no longer meet. Nothing renders wrong, it just stops passing veraPDF.We hit the same axis from the generating side, Chromium into PDF/A: Skia tags every image
XObjectwith a v4.3sRGBprofile and clause 6.2.2 stops at v2.4, so one logo fails 1b.Feels like a page-level refusal alongside your shading and pattern cases. Do you read
/OutputIntentsalready?