DEV Community

SIKOUTRIS
SIKOUTRIS

Posted on

Why a black box doesn't redact a PDF (and how to remove the text for real with MuPDF WebAssembly)

A black rectangle over a line of text in a PDF hides nothing. It sits in a layer on top of the page, and the text underneath is still there: you can select it, copy it, search for it, or extract it with a single command. In January 2019, a court filing by Paul Manafort's lawyers was "redacted" this way, and journalists read the hidden passages by copying and pasting them.

I ran into this while building a browser-based redaction tool, so here is what actually happens inside the file, and how to remove text for real with MuPDF compiled to WebAssembly, without uploading the document anywhere.

Why the black box fails

A PDF page is a content stream: a list of drawing operators. Text is drawn by operators like Tj, with the glyphs and their positions. When you draw a black rectangle "over" it, most editors do one of two things:

  • add a Square annotation, which lives in the page's /Annots array, separate from the content stream;
  • or append a filled rectangle (re f) at the end of the content stream.

In both cases the original Tj operator is untouched. Any text extractor reads the operators, not the pixels, so it ignores the box entirely.

You can check it in a few lines with the mupdf npm package (same engine as the browser build):

import * as mupdf from "mupdf";

const doc = mupdf.Document.openDocument(bytes, "application/pdf");
const page = doc.loadPage(0);
console.log(page.toStructuredText().asText());
Enter fullscreen mode Exit fullscreen mode

On a test file where I covered an IBAN with a black square annotation, this printed:

Client IBAN: FR76 3000 6000 0112 3456 7890 189
Enter fullscreen mode Exit fullscreen mode

The box was there on screen. The number was there in the file.

Real redaction: rewrite the content stream

Real redaction means removing the glyphs from the content stream itself, and dealing with anything else that overlaps the area (images, vector drawings). MuPDF does this with Redact annotations followed by applyRedactions():

const needle = "FR76 3000 6000 0112 3456 7890 189";
const page = doc.loadPage(0);

for (const hit of page.search(needle)) {
  for (const q of hit) {
    // a quad is 8 numbers: ul, ur, ll, lr corners
    const rect = [
      Math.min(q[0], q[4]), Math.min(q[1], q[3]),
      Math.max(q[2], q[6]), Math.max(q[5], q[7]),
    ];
    page.createAnnotation("Redact").setRect(rect);
  }
}

page.applyRedactions(
  true,                                    // draw a black box where text was
  mupdf.PDFPage.REDACT_IMAGE_PIXELS,       // blank the covered pixels of images
  mupdf.PDFPage.REDACT_LINE_ART_NONE,      // keep vector drawings
  mupdf.PDFPage.REDACT_TEXT_REMOVE         // delete the glyphs
);
Enter fullscreen mode Exit fullscreen mode

After saving and reopening, the same extraction gives:

Client IBAN:
Enter fullscreen mode Exit fullscreen mode

and page.search("FR76") returns nothing. I also saved the file decompressed and searched the raw bytes: the string is gone.

Three details matter here.

Save with garbage collection. PDFs can be saved incrementally: changes are appended and the old objects stay in the file. If you save without cleaning, the previous version of the content stream may still be in there. Use options like:

const out = doc.saveToBuffer("garbage=4,compress,clean").asUint8Array().slice();
Enter fullscreen mode Exit fullscreen mode

garbage=4 drops unreferenced objects and merges duplicates, clean rewrites the content streams.

Copy the buffer. asUint8Array() returns a view on the WebAssembly heap. If the heap grows (open a second document, render a page), the view can become invalid and you get an empty or corrupted download. The .slice() makes a real copy. This one cost me a confusing bug where large files sometimes downloaded as zero bytes.

Images are not text. If the sensitive data is in a scanned image, REDACT_TEXT_REMOVE does nothing for it. REDACT_IMAGE_PIXELS blanks the pixels under the rectangle, which is what you want for a scan. There is no text to find in a scan without OCR, so the user has to draw the zone by hand.

The rest of the leak surface

Even with the text gone, a PDF often carries more than its pages:

  • the /Info dictionary and the XMP /Metadata stream (author, software, sometimes the original file path);
  • form fields whose values live in /AcroForm, not in the page content. Search will not find a value typed in a field unless you flatten the form first, which is how I once missed an IBAN in a test;
  • annotations and comments, embedded files, JavaScript.

Deleting /Metadata from the catalog, emptying /Info, then saving with garbage=4 removes the old metadata objects as well.

Doing it in the browser

The whole thing runs client side: the .wasm file (about 10 MB, cached after the first visit) loads once, the PDF is read from a file input as an ArrayBuffer, processed in memory, and downloaded with a Blob URL. Nothing leaves the machine, which is the point when the document is a contract or a medical record. A strict Content Security Policy works as long as you allow 'wasm-unsafe-eval' in script-src.

One licensing note: MuPDF is AGPL. If you ship it on a public website, you publish your source code, or you buy a commercial licence from Artifex.

I put all of this into a free tool, pdfanonym.com, which runs entirely in the browser. But even if you never use it, try the extraction check above on the last "redacted" PDF you received. The results can be surprising.

Top comments (0)