The black box in your PDF is a shape, not a delete key

Two ways to black out a name render identically and produce entirely different files. Paint order is what tells them apart.

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.

Two PDF files that render identically. In one the text object was deleted before the rectangle was drawn; in the other the text object is still in the content stream underneath the rectangle. File A — text deleted, then box drawn File B — only the box drawn Name: Name: identical on screen · different content streams 0 0 0 rg 74 656 130 17 re f No text object anywhere on the page. Nothing to recover. BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET 0 0 0 rg 74 656 130 17 re f The name is still in the file. Redacted. Not redacted.
A constructed pair. Same page box, same coordinates, same rendered result — and in File B the string is sitting in the content stream underneath the rectangle. The name is invented.

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.

Paint order as a stack. A shape painted before text becomes a background; the same shape painted after the text covers it. Shape first, then text 1 · fill a grey band 2 · show the words renders as ACCOUNT SUMMARY Ordinary design. The band had to come first, or the words would not be readable. Text first, then shape 1 · show the words 2 · fill a black box renders as A cover. The words are under the paint and still in the content stream. There is no z-index in a PDF page. Later paints over earlier.
The same two operators in the two possible orders. Everything a checker can say about a black box follows from which one it found.

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.

The four guards as a decision flow, each labelled with the false positive it prevents. a paint overlapping text drawn before it 1 · is the paint opaque? non-stroking alpha of 0.9 or more no a highlighter. It hides nothing. 2 · is it small enough to be a box? at most 40% of the page area no a watermark, a stamp or a page background. 3 · was the text actually drawn? render modes 3 and 7 paint no glyphs no the OCR layer under a scanned page. 4 · a drawn shape, or an image? a filled shape is the conclusive case image could be a signature stamp or a logo — still reported, one severity band lower. reported: text readable under a cover
Each branch on the right is a document class the naive detector reported and should not have. The guards are subtraction, not scoring.

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.

Character codes from a Tj string resolved through a ToUnicode CMap into readable text. bytes inside the Tj string \001\002\003\004\005 /ToUnicode CMap <01> <0041> <02> <0044> <03> <004D> <04> <0049> <05> <004E> recovered text ADMIN Without a map, the coverage is still reported and the words are not guessed.
The content-stream bytes are not text in any useful sense until the font's map resolves them. Synthetic example.

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 /Redact annotation 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.