Redacting a PDF So the Text Is Really Gone: Rebuilding Pages with pdf.js and pdf-lib, Then Checking the Result

Why a black rectangle isn't a redaction, how my browser tool finds words in PDF coordinates, rasterises only the pages that need it, and refuses to save until the hidden text can't be found

By Alok Singh (@aloksinghdev.bsky.social)
Published:

Originally published at https://bestlatestapps.com/blog/redacting-a-pdf-so-the-text-is-really-gone/.

Why a black rectangle isn't a redaction, how my browser tool finds words in PDF coordinates, rasterises only the pages that need it, and refuses to save until the hidden text can't be found

There's a familiar kind of news story: a document is published with black bars over the sensitive parts, someone selects the text under the bars, pastes it, and there it all is. The bars were shapes drawn on top. The words were still in the file.

That failure is the whole design brief for the Redact PDF tool on my site. A redaction tool has one job, and "looks redacted" isn't it. This article explains how the tool makes the text actually go away, using its real source, pdf-redact.js, and the two open-source libraries it builds on: pdf.js (Mozilla's PDF renderer, the one Firefox uses) to read and draw pages, and pdf-lib to write the new file. Snippets are taken or trimmed from the tool's code; where I generalise, I say so.


1. Why drawing a box isn't redacting

A PDF page is a program: a content stream of drawing operators. Text is drawn with text operators that reference a font and carry the characters. When an editor "adds a black box", it usually appends a rectangle fill to that stream, or adds an annotation. The text operators are untouched. A viewer paints the box over the glyphs, but text extraction, search, copy and paste and screen readers read the operators, not the pixels.

Real redaction has to remove the text operators, along with anything else that could carry the content: annotations, form field values, alternative text. There are two broad ways to do that:

The tool takes the second route, deliberately. It costs selectable text on the pages you redact, and it's very hard to get wrong. For a free tool that people will use on bank statements and ID scans, I'll take "hard to get wrong" every time.


2. Only rebuild what needs rebuilding

Rasterising a 40-page contract because one page has an account number would be a poor trade. So the tool tracks which pages have at least one box and rebuilds only those:

`` javascript /** Sorted unique page numbers (1-based) that have at least one box. Pure. */ function affected(boxes) { var seen = {}; boxes.forEach(function (b) { seen[b.page] = 1; }); return Object.keys(seen).map(Number).sort(function (a, b) { return a - b; }); } CODEBLOCK0 javascript function toPage(ev) { var r = layer.getBoundingClientRect(); return { x: (ev.clientX - r.left) / r.width * view1.width, y: (ev.clientY - r.top) / r.height * view1.height }; } CODEBLOCK1 javascript function locate(items, term, viewTransform, scale, mul) { var out = [], q = String(term || "").toLowerCase(); if (!q) return out; items.forEach(function (it) { var s = String(it.str || ""), low = s.toLowerCase(); if (!s || low.indexOf(q) < 0) return; var tx = mul(viewTransform, it.transform); var h = Math.hypot(tx[2], tx[3]) || Math.abs(it.height || 10); var w = (it.width || 0) * scale; var per = s.length ? w / s.length : 0; for (var at = low.indexOf(q); at >= 0; at = low.indexOf(q, at + q.length)) { var pad = h * 0.18; out.push({ x: tx[4] + per * at - pad, y: tx[5] - h - pad, w: per * q.length + 2 * pad, h: h * 1.25 + 2 * pad }); } }); return out; } CODEBLOCK2 javascript var low = st.joined.toLowerCase().replace(/\s+/g, " "); if (low.indexOf(term.toLowerCase()) >= 0 && !hits.length) { // split across text pieces, or only in a comment or form value: cover the whole page so it is still removed boxes.push({ page: n, x: 0, y: 0, w: 0, h: 0, term: term, whole: true }); unplaced.push(n); pagesHit[n] = 1; } CODEBLOCK3 javascript var out = await PDFLib.PDFDocument.create(); var keep = []; for (var i = 1; i <= doc.numPages; i++) if (pages.indexOf(i) < 0) keep.push(i - 1); var copied = src && keep.length ? await out.copyPages(src, keep) : []; for (var n = 1; n <= doc.numPages; n++) { if (pages.indexOf(n) < 0) { out.addPage(copied[ci++]); continue; } var page = await doc.getPage(n), v1 = page.getViewport({ scale: 1 }); var sc = fitScale(v1.width, v1.height, dpi / 72), v = page.getViewport({ scale: sc }); var c = document.createElement("canvas"); c.width = Math.floor(v.width); c.height = Math.floor(v.height); var cx = c.getContext("2d"); cx.fillStyle = "#fff"; cx.fillRect(0, 0, c.width, c.height); await page.render({ canvasContext: cx, viewport: v }).promise; cx.fillStyle = "#000"; boxes.forEach(function (b) { if (b.page === n && !b.whole) cx.fillRect(Math.floor(b.x * sc), Math.floor(b.y * sc), Math.ceil(b.w * sc) + 1, Math.ceil(b.h * sc) + 1); }); var blob = await new Promise(function (r) { c.toBlob(r, "image/jpeg", 0.92); }); c.width = c.height = 0; var img = await out.embedJpg(new Uint8Array(await blob.arrayBuffer())); out.addPage([v1.width, v1.height]).drawImage(img, { x: 0, y: 0, width: v1.width, height: v1.height }); } CODEBLOCK4 javascript var check = await pdfjsLib.getDocument({ data: result.slice() }).promise, left = []; for (var k = 1; k <= check.numPages; k++) { var st = await pageStrings(await check.getPage(k)); if (pages.indexOf(k) >= 0 && st.items.some(function (it) { return String(it.str).trim(); })) left.push("text on page " + k); var low = st.joined.toLowerCase(); terms.forEach(function (t) { if (low.indexOf(t.toLowerCase()) >= 0) left.push("“" + t + "” on page " + k); }); } if (left.length) throw new Error("Not saved: the check still found " + left.join(", ") + ". Find the word again, or draw a box on that page, then save."); ` Two independent checks run on the new file: 1. **Rebuilt pages contain no text at all.** They should be a single image. Any extractable text means something went wrong. 2. **Every searched term is absent from every page**, including annotation strings, across the whole document, not only the rebuilt pages. If either check fails, no download is offered, and the message says what was found and where. When it passes, the status line says what was checked, and still ends with "Open it and look before you share it." A program can confirm that text is absent. Only a person can confirm that nothing sensitive is visible. Verifying output with a *different code path* from the one that produced it is a pattern I'd recommend well beyond PDFs. Here, pdf-lib wrote the file and pdf.js reads it back, so a bug in how the file was written shows up as a failed check rather than a leaked document. ------------------------------------------------------------------------ ## 7. Trade-offs and limits, stated plainly Every approach to redaction has costs. These are this one's: - **Redacted pages lose live text.** They can't be searched, selected or read by a screen reader, and links and form fields on them are gone. If you need those pages accessible, you'd want an OCR pass afterwards; the site's [OCR PDF](https://bestlatestapps.com/tools/ocr-pdf/) tool can add a text layer, which of course won't include what's under the boxes. - **Find only finds letters.** A scanned page is a picture, so Find has nothing to match. Draw boxes on scans. - **Manual boxes are checked for "no text on the page", not for meaning.** The tool can't know that the thing you boxed was the thing that needed boxing. - **Unchanged pages are copied as they are.** If a page with no redaction contains something sensitive that you didn't search for, it's still there. Search for every variant: with and without spaces, formatted and unformatted numbers. - **Password-protected files** have to be opened first. The tool detects pdf.js's PasswordException` and points to Unlock PDF, which only works with a password you know.

On privacy: the PDF is read into memory in the tab, pdf.js renders it there, and pdf-lib writes the new file there. The tool's code doesn't upload the document or the words you search for, and the search field has browser autocomplete turned off, so the term isn't offered back to the next person who uses the same browser. For a tool whose entire purpose is protecting sensitive text, sending the file to a server first would defeat the point.


8. A redaction checklist


Wrapping up

Redaction fails when a tool optimises for how a page looks rather than what a file contains. Rebuilding only the affected pages as pictures, placing boxes in PDF coordinates, and refusing to save until a separate reader confirms the text is gone turns "looks redacted" into "is redacted". The cost, losing live text on those pages, is shown to you before you commit.

Try it, or browse the related PDF tools:

If you've ever received a "redacted" document where the hidden text was still selectable, you already know why the tool refuses to save until the check passes.

— Alok Kumar Singh