Axiomatic ToolsFree Fast Essential
All posts

Our PDF inverter never inverted anything

Ghostscript wrote the transfer function into the file as a hint instead of applying it. Half the world's PDF readers ignore that hint — including the one we render previews with.

Axiomatic Tools Teamengineeringpdfghostscript

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.