Invert PDF Colors takes a document and flips it to something readable at night. It shipped a long time ago, nobody complained, and the output file was — we discovered last week — byte-for-byte identical to the input in every colour operand it contained.
It had never inverted anything. Here is how something that visibly broken stays invisible.
The command looked right
gs -sDEVICE=pdfwrite -dNOPAUSE -dQUIET -dBATCH -dSAFER \
-sOutputFile=out.pdf \
-c '{1 exch sub} {1 exch sub} {1 exch sub} {1 exch sub} setcolortransfer' \
-f in.pdf
setcolortransfer takes four PostScript procedures — red, green, blue, gray — each handed one
colour sample and expected to leave its replacement. {1 exch sub} is 1 - sample. That is
an inversion, and the arithmetic is correct.
The problem is the word transfer. Ghostscript's pdfwrite device honours a parameter called
-dTransferFunctionInfo, and its default is /Preserve. Under /Preserve, Ghostscript
copies every colour operand through unchanged and writes the transfer function into the output
as an ExtGState /TR entry — a hint, recorded for whatever opens the file later.
Nothing is baked in. The tool was politely asking each reader to invert the page on our behalf.
Some readers say yes and some say nothing
That request is honoured by PDFium, which is Chrome and Edge. It is discarded by MuPDF.
We only found this because MuPDF is what we render PDF previews with. Someone could convert a document on the site, preview the result on the site, and see no change whatsoever — while the same file downloaded and opened in Chrome looked correctly inverted.
We measured it rather than assumed it. Two 200×200 pages, each painting a white page with a
black bar, identical except that one carries an ExtGState /TR of {1 exch sub}:
baseline.pdf centre [0,0,0] corner [255,255,255]
withtr.pdf centre [0,0,0] corner [255,255,255]
Pixel-for-pixel the same. If /TR were honoured the second would be inverted — black page,
white bar. The control that makes this conclusive: putting /ca 0.5 in the same ExtGState
dictionary is honoured by MuPDF, so the dictionary is being read and /TR specifically is
being dropped.
The fix is one flag, and one thing to check first
Adding -dTransferFunctionInfo=/Apply makes Ghostscript bake the transform into the colour
values, and no /TR is written at all.
Do not reach it by raising -dCompatibilityLevel instead. PDF 2.0 promotes to /Apply as a
side effect, and also drops /Producer, /Creator, /Title and /Author on the way past.
Before shipping it we had one real worry. An ordinary text PDF leaves its paper unpainted —
there is no white rectangle, just black glyphs on nothing. If pdfwrite does not emit a
background under /Apply, inverting turns the ink white and leaves the paper white, and the
page comes back blank: strictly worse than the no-op it replaced.
The Ghostscript source says the rectangle is written — gdev_pdf_fillpage skips the erase only
when the resolved colour equals white, which a non-identity transfer makes false. But reading
is not running, so we ran it, on the same version production uses:
in.pdf mean 254.5 dark_fraction 0.002 corner (255, 255, 255)
out.pdf mean 0.5 dark_fraction 0.998 corner ( 0, 0, 0)
Exact complements. The backdrop is written and the whole page inverts.
The part we got wrong twice
A separate investigation concluded that sepia mode would still come out untinted after
/Apply, because apply_transfer_gray collapses DeviceGray to one component and ordinary
black-on-white text is DeviceGray. The proposed fix was to force the content through
-sColorConversionStrategy=RGB first. We very nearly shipped it.
Measured instead:
invert ( 0, 0, 0)
sepia, as shipped ( 76, 38, 0) correct
sepia + ColorConversionStrategy=RGB ( 76, 76, 76) tint destroyed
Sepia was already right. (76, 38, 0) is exactly what those procedures should give for white
input — 1-0.70, 1-0.85, 1-1.00. The proposed remedy flattens the result to neutral grey:
it causes the symptom it was meant to cure.
What we'd take from it
A tool that silently does nothing produces no error, no support ticket and no bug report. The
only reason this surfaced is that our own preview engine happened to be one of the readers
that ignores /TR, which turned an invisible bug into a visible disagreement.
There was also no test of any kind on this handler — which is most of the reason a tool that never changed a colour ran for months. There is now, and it asserts the flag that decides whether any colour is rewritten.