Building redaction that destroys the text instead of hiding it
Black rectangles over a PDF are how confidential documents get published with their secrets intact. It has happened to newspapers, law firms and governments, and it keeps happening because the failure is invisible: the page looks correct, and the text is still in the file, selectable by anyone who copies it out. This tool cannot fail that way, and the reason is that it throws away the document.
What it is built with
- pdf.js for rasterising PDF pages
- Canvas 2D for drawing the boxes and re-encoding
- pdf-lib for reassembling the redacted images into a PDF
Step 1: understand why the common approach fails
A PDF is not a picture of a page, it is a list of drawing instructions. Adding a filled black rectangle adds one more instruction, on top of the ones that draw the text. The text instructions are still there, in order, with their coordinates. Copy and paste reads them. So does every text-extraction library ever written.
Annotation tools in most PDF readers do exactly this, which is why redaction as an annotation is not redaction.
Step 2: rasterise, then draw, then discard the original
The approach that cannot leak is to render each page to pixels, draw the boxes onto those pixels, and build a new document out of the results. The text instructions are not covered; they are gone, because the page that contained them was replaced by an image of itself with the boxes already in it.
The order matters and it is the whole trick: rasterise first, draw second, and never carry the original page objects into the output.
// 1. Render the page to a canvas. The text becomes pixels.
await page.render({ canvasContext: ctx, viewport }).promise;
// 2. Burn the boxes into those pixels.
ctx.fillStyle = '#000';
for (const box of boxes) ctx.fillRect(box.x, box.y, box.w, box.h);
// 3. Build the output from the IMAGE. The original page objects,
// text instructions included, are never copied across.
const png = await canvas.convertToBlob({ type: 'image/png' });
out.addPage().drawImage(await out.embedPng(await png.arrayBuffer()));
Step 3: admit what it costs
The output is no longer searchable, no longer selectable, and larger than the input. That is a real loss and the tool says so up front rather than in a footnote.
It is the correct trade for this particular job. A redacted document that is still searchable is a document whose redactions can be searched, and there is no version of this tool where the text survives and the redaction holds. Every other decision here follows from accepting that.
The same pipeline handles photographs, where there was never any text layer to worry about, and the boxes are burned in for consistency rather than necessity.
Step 4: test it the way an attacker would read it
The tempting test is to search the output bytes for the secret string and assert it is absent. That test passes whether or not the tool does anything, because PDF content streams are Flate-compressed and a plain byte search cannot see text that is perfectly selectable when the file is opened.
The test that means something extracts text the way a reader does. This site keeps a `pdfText` helper in its shared test fixtures for exactly this reason, and a comment above it explaining the trap, because it is the kind of mistake that gets made once per codebase.
What went wrong
The most useful part of any build write-up, and the part usually left out.
- A byte search for the secret in a PDF proves nothing: content streams are compressed. Extract text with a real parser.
- Rasterising at screen resolution produces an unreadable document. The render scale has to come from what the output is for, not from the viewport.
- A large document rasterised all at once will exhaust memory. Pages have to be rendered, drawn and released one at a time.
- Redacting an image does not remove its metadata. The two tools are separate, and a redacted photo can still carry the coordinates where it was taken.
Use the tool: Redact a document or photo · How YappyKit works