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:
- Edit the content stream. Find the text operators that fall under each box and remove or rewrite them. This keeps the rest of the page as live text, but it's hard to get right: fonts with custom encodings, text drawn as paths, overlapping objects, form XObjects nested inside each other.
- Rebuild the page as a picture. Render the page to pixels, paint the boxes into those pixels, and replace the page with an image. Nothing from the original page's content survives except what's visible.
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.
- Files that pdf-lib can't copy page by page stop with a message suggesting Repair PDF, rather than producing a partial document.
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
- Search for every form of the sensitive value: names with and without initials, numbers with and without separators.
- Draw boxes over anything in scans, images or signatures, where Find can't see.
- Read the page list in the warning and make sure losing live text on those pages is acceptable.
- Draw a box wherever the tool says a match couldn't be placed.
- Pick 200 DPI unless the file is for print.
- Let the check run, and treat "Not saved" as the tool doing its job.
- Open the result and confirm it with your eyes, then confirm with Ctrl+F or PDF to text.
- Share the redacted file, never the original, and keep the original somewhere private.
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:
- Redact PDF: https://bestlatestapps.com/tools/redact-pdf/
- PDF to text: https://bestlatestapps.com/tools/pdf-to-text/
- Unlock PDF: https://bestlatestapps.com/tools/unlock-pdf/
- How I built a document scanner: https://bestlatestapps.com/blog/a-document-scanner-in-an-afternoon/
- All of my free web tools: https://bestlatestapps.com/tools/
- My portfolio: https://bestlatestapps.com/alok/
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