Reference: Check a PDF redaction
There are two ways to black out a name in a PDF. The first deletes the text and then draws a black rectangle where it used to be. The second just draws the black rectangle.
On screen they are indistinguishable.
In the file they are entirely different documents. In the second one every character of the name is still there — selectable, copyable, and extractable by any PDF library in about one line of code.
This mistake keeps reaching production in court filings, disclosure releases and regulatory submissions, from organisations that employ lawyers and document teams. It survives because there is no feedback. The person doing the redacting sees a black box either way, and nothing tells them which one they made until somebody else selects the text.
A page is a program
The reason the two operations look the same is worth understanding, because it is also the reason you can tell them apart.
A page's content stream is a sequence of operators executed in order onto a blank canvas. A very small one looks like this.
Text, then a rectangle over it
BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET
0 0 0 rg
74 656 130 17 re f
Reading it out: begin text, select font F1 at 12pt, move to
(76, 660), show the string, end text. Then set the non-stroking colour to black
(rg), build a rectangle at (74, 656) 130 wide and 17 high
(re), and fill it (f).
There is no z-index here, and no concept of one object being above another. There is only order. Later paints over earlier. The rectangle covers the name for the same reason a second coat of paint covers the first.
Now swap the two halves.
The same two objects, opposite order
0 0 0 rg
74 656 130 17 re f
BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET
Same objects, same coordinates — and now the name is drawn on top of the black box and is perfectly legible. Which is exactly what a table's shaded header row is: a filled rectangle, painted first, with text on it.
That single fact is the whole of what follows.
Check it yourself in one line
If you have a PDF with a black box in it, start here.
Does the text come back out?
pdftotext -layout suspect.pdf - | less
If the redacted words appear in that output, they were never removed. That is the entire test, and it is worth running on anything you are about to send.
To look at the operators rather than the text, decompress the streams first.
Content streams are Flate-compressed, so strings and
grep see nothing useful without this step.
Reading the page as the program it is
qpdf --qdf --object-streams=disable suspect.pdf decompressed.pdf
grep -n --text -E ' (re|f|Tj|TJ)$' decompressed.pdf | head -40
Now you can see for yourself whether the Tj comes before the
re f.
The naive version fires on everything
I built the obvious detector first: find dark filled rectangles, find text underneath them, report. It fired on an enormous number of completely honest documents. Every table with a shaded header. Every highlighted paragraph. Every coloured callout box.
Paint order fixes that, and it fixes it by construction rather than by tuning. A background painted after its text would have hidden that text, so it would not be a background. If you can read the words, the shape came first. If the shape came second, it is hiding something.
Four more guards sit on top of paint order in the shipped signal. Each one is there because the version without it produced a false positive on a real document.
Opacity. A highlighter is a filled rectangle painted over text,
and it hides nothing. The graphics state carries a non-stroking alpha
(ca, set through a named /ExtGState), and a paint has
to be at least 90% opaque before it counts as a cover at all.
Size. A covering paint larger than 40% of the page area is a
watermark, a stamp or a page background. Without that bound, one document with a
full-page DRAFT stamp rendered as an opaque image reported every
single line on the page as exposed.
Invisible text. Text rendering modes 3 and 7 paint no glyphs. That is the OCR layer underneath a scanned page — text designed to be invisible on screen and extractable by machine. Those runs are never collected as candidates, because reporting them would fire on every scanned document ever produced.
Image versus drawn shape. A filled rectangle over a line of text is conclusive. An image over a line of text is not: a signature stamp, a logo or a pasted-in scan legitimately lands on top of a word. So a page whose covers are all images still gets reported — one severity band lower, with a note saying why.
There is a size floor as well, because a 2-point fill cannot be hiding a word: a cover has to be at least 6 points wide and 3 tall. And the run has to be genuinely underneath the paint, not merely adjacent — at least 70% of the run's box has to fall inside the cover.
Getting the words back out is a separate problem
Finding that a run of text sits under a box tells you there is a leak. Saying
what leaked needs one more step, because the bytes inside a Tj
string are character codes, not characters, and what they mean depends on the
font.
A font can carry a /ToUnicode CMap that maps them.
Fonts without a /ToUnicode map are common. There the honest answer
is that the coverage is real and the words could not be decoded, rather than a
guess. Simple fonts usually turn out to be WinAnsi-encoded, where the byte is
the character, which covers most of the practical cases.
The recovered words go back in the response and nowhere else. Files are parsed in memory and discarded with the reply — the evidence lives exactly as long as the HTTP response does.
Things that are not redaction
All of these leave every character in the file.
- Drawing a filled rectangle over the text.
- Using a black highlighter annotation.
- Setting the text colour to match the background.
- Placing an image over the area.
-
Adding a
/Redactannotation and never applying it.
The last one is the cruellest, because it is the right tool used incompletely. The annotation is a mark requesting redaction. The content only leaves when the tool applies the marks, and most viewers draw the pending marks as solid black boxes. The half-finished state looks exactly like the finished one, so it is checked first and reported on its own.
What does work: your tool's redaction feature, followed by actually applying it. Or exporting the page to a flattened image, which removes the text layer along with your searchable text.
What this kind of check cannot see
Worth being explicit, because a checker that implies more coverage than it has is worse than none.
Form XObjects. Reusable content blocks are their own little content streams. They are not recursed into, so a covering box inside one is invisible here. Any count is a floor rather than a total, and the report says so on pages that use them.
Already-flattened pages. No text layer, nothing to find — which also means the redaction worked.
Everything outside the page content. A name taken off the visible page can still be sitting in the document metadata, an attachment, a form field, or an earlier revision of the file. Different problem, different checks.
There is one more approximation worth naming. No font width array is loaded, so glyph advance is assumed to be half an em. A run whose width is misjudged by a fifth still passes or fails the 70% test the same way, because redaction boxes are drawn with generous margins around what they hide.
Nothing on this page describes a real document, organisation or person. The examples are constructed and the names are invented. This post is about a class of mistake, not about anybody's filing.
Try it on a file whose history you know
I built the redaction check because I got tired of running the
qpdf/pdftotext dance on documents people sent me and
wondering whether I had missed one. It walks each page's content stream tracking
the transformation matrix, fill colour, alpha and paint order, computes boxes
for the text runs, and reports the runs that ended up under something opaque —
with the recovered text, because if a checker can read it so can anyone who
received the document.
The most useful four minutes you can spend with it: take a document of your own, draw a black rectangle over a line in whatever editor you have, export it, and check that. Watching it recover words from a file whose history you personally know is worth more than this post.
Check a PDF redaction — free, nothing stored, and a fictional sample opens automatically so you can see the output before deciding whether to trust it with anything of your own.