DEV Community

Cover image for The black box in your PDF is a shape, not a delete key
Haruo
Haruo

Posted on Originally published at tamperlens.com

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

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, and 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, FOIA releases and
regulatory submissions, from organisations that employ lawyers and document
teams. It survives not because people are careless but 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 PDF 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:

BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET
0 0 0 rg
74 656 120 16 re f
Enter fullscreen mode Exit fullscreen mode

Reading it out: begin text, select font F1 at 12pt, move to (76, 660), show the
string Dana Whitfield, end text. Then set the non-stroking colour to black
(rg), build a rectangle at (74, 656) 120 wide and 16 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:

0 0 0 rg
74 656 120 16 re f
BT /F1 12 Tf 76 660 Td (Dana Whitfield) Tj ET
Enter fullscreen mode Exit fullscreen mode

Same objects, same coordinates, opposite order — 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:

pdftotext -layout suspect.pdf - | less
Enter fullscreen mode Exit fullscreen mode

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:

qpdf --qdf --object-streams=disable suspect.pdf decompressed.pdf
grep -n --text -E ' (re|f|Tj|TJ)$' decompressed.pdf | head -40
Enter fullscreen mode Exit fullscreen mode

Now you can read the page as the program it is, and see for yourself whether the
Tj comes before the re f.

Why "is there a dark rectangle near text" is the wrong check

The naive detector — find dark filled rectangles, find text underneath, report —
fires on an enormous number of completely honest documents. Every table with a
shaded header. Every highlighted paragraph. Every coloured callout box.

Paint order separates them cleanly, and it does so by construction rather than
by tuning: a background that was painted after its text would have hidden that
text, so it would not be a background. If you can read the text, the shape came
first. If a shape came second, it is hiding something.

Three more guards matter in practice, and each one exists because the version
without it produced false positives:

Transparency. 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), so anything meaningfully translucent is ignored.

Size. A shape covering more than about 40% of the page is a watermark, a
stamp or a page background. Nobody redacts a name by covering half the page.

Invisible text. Rendering mode 3 (3 Tr) draws nothing. That is the OCR
layer underneath a scanned page — text that is designed to be invisible on
screen and extractable by machine. Reporting it as a failed redaction would fire
on every scanned document ever produced.

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:

1 begincodespacerange
<00> <FF>
endcodespacerange
5 beginbfchar
<01> <0041>
<02> <0044>
<03> <004D>
<04> <0049>
<05> <004E>
endbfchar
Enter fullscreen mode Exit fullscreen mode

With that, the content-stream bytes \001\002\003\004\005 — which are not text
in any useful sense — resolve to ADMIN.

Fonts without a /ToUnicode map are common, and there the honest answer is "the
coverage is real, the words could not be decoded here" 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.

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 not applying it. This 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.

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. A covering box inside one is invisible unless you recurse into them, so any count is a floor rather than a total.
  • 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.

If you would rather not do it by hand

I built Tamperlens because I got tired of running the qpdf/pdftotext dance on documents people sent me and wondering whether I had missed one. Drop a PDF in and 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. Nothing is stored: files are parsed in memory and discarded with the response.

There is a fictional sample on the page that opens automatically, so you can see
the output before deciding whether to trust it with anything of your own. It
contains a shaded table band with text on it as well as two real covers, so you
can watch the discrimination work in the same report.

The most useful four minutes you can spend with it: take any document, draw a
black rectangle over a line in whatever editor you have, export it, and check
that. Watching it recover the words from a file whose history you personally know
is worth more than this post.

Top comments (5)

Collapse
 
to21as profile image
Tobias

The paint-order plus alpha plus size discrimination is the right design, and the "what this cannot see" section is what makes the rest trustworthy. One item I would add to it, because it is getting more common rather than less.

A tagged PDF carries its text more than once. Besides the content stream, the structure tree holds text in /Alt and /ActualText on structure elements and marked-content sequences, and /ActualText is specified to substitute for the painted content when anything extracts or reads the document. Redaction tooling operates on the content stream, and pruning the tag tree to match is a separate step that is easy to skip. So a correctly applied redaction can still leave the name sitting in an /ActualText string, invisible to every check that reads the page as a program.

Worth flagging because the population of tagged PDFs is growing fast: accessible output is now expected for a lot of customer-facing and public-sector documents in the EU, which means more redaction is happening on documents that carry a parallel copy of their own text.

Same category as your metadata and attachment bullets, one layer deeper.

Collapse
 
haruodev profile image
Haruo • Edited

Author here — thank you, this was the most useful comment I've had on anything I've written.

You were right, and the engine was worse than not-looking: it skipped BDC property dictionaries whole, which is exactly where an inline /ActualText sits, and it doesn't parse the structure tree at all. The part that stung is that /ActualText was already in the codebase three times — every one of them a reason not to fire. There's a discriminator that drops concealed text substantially contained in the visible text, and tagged PDFs and /ActualText spans are the cases it exists to excuse. So I'd reasoned about this construct carefully in one direction and never asked the other.

It's now being fixed. The lexer reads /ActualText and /OC as of today, retained but deliberately not yet scored — it differs from the painted glyphs legitimately all the time (ligatures, hyphenation, list markers), so "differs" isn't a finding and I want the base rate measured before anything gets a severity. Chasing "what else is like this?" also turned up optional content groups, which may be the sharper version: not a second copy of the text, the same text with its layer switched off.

One thing I'd push back on gently — I've kept /Alt separate from /ActualText. /ActualText replaces painted content, so text in it that isn't on the page is anomalous. /Alt describes a figure, so text in it that isn't on the page is the whole point. Comparing them the same way would flag every well-tagged document.

Your EU accessibility framing is what moved this from a curiosity to a roadmap item — "more redaction happening on documents that carry a parallel copy of their own text" is the sentence that made the case. Thank you.

Collapse
 
to21as profile image
Tobias

You're right on /Alt and I was sloppy to bracket them together. The spec is explicit that /ActualText is an exact replacement for the content while /Alt is a description of it, so a difference means opposite things in the two cases. Comparing /Alt against painted glyphs would flag every properly tagged figure.

The part I'd keep is narrower: /Alt can't be scored by comparison, but it's still somewhere a name survives a correct content-stream redaction. "Photograph of Dana Whitfield outside the courthouse" as the alt on a figure you just blacked out is a real leak, and no paint-order check will ever see it. That reads to me as surface-for-review when it falls inside a redacted region, never auto-score. A different bucket from /ActualText rather than the same one.

On measuring the base rate before you score /ActualText: the legitimate cases you listed share a shape. Ligatures, hyphenation across a line break, list markers, they're all /ActualText carrying a normalization of the same glyphs, not different content. So the diff worth measuring probably isn't the raw one. NFKC both sides, fold whitespace, rejoin soft-hyphenated words, then compare. "fi" against "fi" collapses, "hyphen" plus "ation" against "hyphenation" collapses, and what survives is /ActualText carrying tokens with no counterpart in the painted run at all. My guess is that crushes the base rate far enough to make scoring possible, and if it doesn't, the leftovers are the interesting sample anyway.

On optional content being the sharper version, I think you're right, and there's a nastier layer beneath it. Visibility isn't one flag. A group's usage dictionary carries separate /View, /Print and /Export entries, and the configuration's /AS array applies them automatically. So a layer can be off for View and on for Print: the document looks redacted on screen, in your viewer, in a screenshot, and then prints the name. That one has no feedback loop at all, which is the same failure you opened the article with.

Thread Thread
 
haruodev profile image
Haruo

Following up now that the work this thread prompted has shipped — in the order of your three points.

/Alt as surface-for-review, never auto-score: agreed, and that's the bucket it's in on the roadmap. The honest state today: the structure tree still isn't parsed, so /Alt is invisible to the engine and stays on the "what this cannot see" list. When it lands it lands as you framed it — surfaced when it falls inside a redacted region, judged by a person, compared against nothing.

The normalized diff: this is close to what got built. The comparison that decides whether concealed text merely restates the page NFKC-folds both sides, strips zero-width characters and bidi controls, and tokenizes before comparing — "fi" against "fi" collapses, and what survives is concealed text carrying tokens with no counterpart in the painted run. There is a test suite literally titled "normalisation is not optional". You were right about what it does to the base rate: the survivors are a small set, and they're the interesting one.

Visibility is not one flag: that sentence is now the changelog line for engine 1.29.0, and your comment is why the work happened. The default configuration is read — including /BaseState /OFF, which hides a group by naming it nowhere — /OC is tracked both as marked content and on the XObject itself, and the usage dictionary's per-context entries with the configuration's /AS array are part of the read. Re-measured against the corpus afterwards: exactly one document moved. Which looked like an argument that it didn't matter, until your closing point — the screen-vs-print case is precisely the one with no feedback loop, so today's base rate says nothing about what it costs when it happens.

Thanks again. Two engine releases now trace back to this thread.

Thread Thread
 
to21as profile image
Tobias

Two releases is a better outcome than I expected from a comment. Thanks for coming back with the detail.

The "exactly one document moved" number is the one I'd handle carefully, and I think your own closing point is why. Corpus frequency is a reasonable instrument for a feature that occurs naturally. It's the wrong instrument for one that's chosen. /BaseState /OFF hiding a group by naming it nowhere isn't something a producer does by accident, it's what you'd reach for if you wanted a reviewer to miss something. So its rate across ordinary documents is mostly a measurement of how many adversarial documents you happened to collect. That check earns its place on the documents that don't exist yet.

I got the same lesson from the other direction: my own validation looked clean until I widened the inputs, and the base rate on the original set was zero because the original set was the set I'd thought to build.

One heads-up for when the structure tree lands, because it'll bite immediately on anything Chromium produced. Chromium emits PDF 2.0-only structure types and writes no /RoleMap at all: <strong> becomes Strong, <em> becomes Em, and nothing in the file maps them to standard types. It also never emits LBody, so a list item resolves to Lbl plus whatever was inside it. A parser resolving role names against the 1.7 type set sees a properly tagged document as full of unknown types, which for your purposes could read as structure that isn't there. I synthesise both in a fix-up step, and it was the most surprising thing about Chromium's tagged output.