DEV Community

ZerocloudPDF
ZerocloudPDF

Posted on

You Don't Need Acrobat to Redact a PDF: A Browser-Native Pipeline Using pdf.js and pdf-lib

Redaction sounds simple. Draw a black box over the sensitive part, export, done. But a PDF is not a photograph. It is a structured document format with a content stream, a font table, an object graph, and in most cases, a full history of every edit ever made to the file. A black rectangle sitting on top of that structure does not delete anything. It just draws over it.

This article is a walkthrough of what a PDF actually contains, why the common approach to redaction fails at the byte level, and how a true client side redaction pipeline works, using the architecture behind ZeroCloudPDF's Redact PDF tool as the working example.

A PDF is not an image, it is a program

Open any PDF in a text editor (skip the binary streams, but the structure is visible) and you will find something like this inside a page object:

BT
/F1 12 Tf
72 720 Td
(Account Number: 4521 8890 1122 3344) Tj
ET
Enter fullscreen mode Exit fullscreen mode

That Tj operator is a text showing instruction. The string inside the parentheses is literal, extractable text sitting in the content stream. It is not a picture of the number 4521 8890 1122 3344, it is the actual characters, indexed against a font's glyph table so a viewer can render, search, copy, and select it.

A PDF page is a sequence of these drawing operators: move to a position, set a font, paint a rectangle, draw a line, show text. The visual layer you see is a rendering of this instruction stream, but the instruction stream itself is what a parser reads.

This matters because most "redaction" tools operate on the rendering, not the instruction stream.

Why the black box approach fails

Here is what typically happens when a PDF editor lets you "redact" by drawing a rectangle:

  1. A new annotation object is added to the page, often a Square or FreeText annotation with a black fill.
  2. The annotation is placed in the page's /Annots array, which sits above the content stream in z-order.
  3. The file is saved.

Nothing in step 1 through 3 touches the original Tj operator. The text is still there, in the same content stream, at the same byte offsets. The annotation is a separate object drawn on top during rendering. Anyone can:

  • Select the text underneath with a normal text selection tool, since the text layer is unaffected by an annotation drawn above it.
  • Delete or hide the annotation object using a script (a few lines with pikepdf, PyMuPDF, or even certain PDF viewers' own annotation editing mode) and recover full visibility.
  • Run pdftotext or any content stream parser directly against the file and extract the string, since text extraction reads the content stream, not the rendered pixels.

There is a second, less obvious failure mode. PDF supports incremental updates. When many editors save a modified file, they do not rewrite the whole document. They append a new cross reference section and leave the old one in the file, marked as superseded but not removed. This means a PDF that has been edited multiple times can contain the entire prior version of a page, including a version from before any redaction was applied, still physically present in the file bytes. Opening the file in a hex viewer or running qpdf --check / mutool clean diagnostics on it will often surface these residual object generations.

So the redaction has visually hidden the number, has not removed it from the content stream, and may have left an entire unredacted version of the page sitting in the same file. This is the exact failure pattern documented repeatedly in real incidents where legal filings, government documents, and leaked PDFs turned out to have their black boxes trivially reversible.

What actual redaction requires

For a redaction to hold, three things need to be true of the output file:

  1. The original text operators for the covered region must not exist anywhere in the new file's content stream.
  2. There must be no prior incremental revision of the page left in the file for a parser to recover.
  3. The file must be generated fresh, not saved as a modified copy of the input.

The only approach that reliably satisfies all three is rasterization. Instead of editing the PDF's internal structure, you render each page to a bitmap, burn the redaction shapes into that bitmap, and then build an entirely new PDF from the resulting images. The original content stream, fonts, and object graph never make it into the output file at all, because the output file was never derived from the input file's internal structure in the first place. It was derived from pixels.

How this pipeline actually runs in the browser

Here is the technical sequence a client side redaction tool needs to implement, using the libraries most commonly used for this in JavaScript: pdf.js for parsing and rendering, and pdf-lib for generating the output document.

Step 1: Parse and render, never touch the DOM with the raw file

The uploaded PDF is read as an ArrayBuffer directly from the browser's File API. It never leaves memory and is never written to any network request.

const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
Enter fullscreen mode Exit fullscreen mode

pdf.js parses the object graph internally (cross reference table, page tree, content streams, fonts) and exposes a render method that paints a given page onto an HTML canvas at a chosen resolution:

const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
Enter fullscreen mode Exit fullscreen mode

At this point the canvas holds a pixel level snapshot of the page. The original text operators were used to produce these pixels, but the pixels themselves carry no reference back to the original string data. This is the critical transition: structured, extractable text becomes an unstructured bitmap.

Step 2: Draw the redaction shapes onto the same raster surface

The user drawn rectangles and circles are not stored as PDF annotation objects at any point. They are drawn directly onto the canvas that already holds the rendered page, using standard 2D canvas fill operations:

ctx.fillStyle = "#000000";
ctx.fillRect(shape.x, shape.y, shape.width, shape.height);
Enter fullscreen mode Exit fullscreen mode

Because this happens on the same canvas surface as the rendered page content, the black fill and the underlying pixels merge into a single flat layer. There is no longer a separable "annotation" sitting above a separable "text layer." There is one bitmap.

Step 3: Extract the flattened bitmap and discard the source

The canvas is converted to an image buffer:

const flattenedImage = canvas.toDataURL("image/png");
Enter fullscreen mode Exit fullscreen mode

This is the point where any hidden structure from the source document, unused object generations from incremental updates, orphaned content streams from prior edits, embedded font subsets, metadata dictionaries tied to the original page, is left behind. None of it is referenced by a PNG data URL. A canvas bitmap has no concept of PDF object history.

Step 4: Build a new PDF from scratch

pdf-lib is used to construct an entirely new PDFDocument, not to modify the parsed input:

const outputDoc = await PDFLib.PDFDocument.create();
const pngImage = await outputDoc.embedPng(flattenedImage);
const newPage = outputDoc.addPage([viewport.width, viewport.height]);
newPage.drawImage(pngImage, { x: 0, y: 0, width: viewport.width, height: viewport.height });
Enter fullscreen mode Exit fullscreen mode

Repeat for every page. The final outputDoc.save() call serializes a document whose entire object graph was created in this session, with a single image per page and no inherited cross reference history from the original file. There is no content stream containing Tj operators anywhere in the output, because none were ever written to it. There is nothing to select, search, or extract, because a flattened page has no text layer at all, only pixels.

This is also why the search box in a viewer stops finding anything on a properly redacted page, even outside the redacted area. It is a side effect of the method, not a separate feature: once a page is rasterized, the whole page loses its text layer, not just the covered region.

Why this has to run client side for sensitive documents

The technical steps above can run on a server just as easily as in a browser. The reason it matters where they run comes down to custody, not capability.

The moment a file is uploaded for server side processing, it exists in at least one place outside the user's control: in transit over the network, in server memory during processing, and very often in temporary storage or logs afterward, even briefly. For a document someone is actively trying to redact, usually because it contains something like a bank account number, a national ID, or a medical record, that upload step is itself the exposure the user is trying to avoid. A redaction workflow that requires trusting a third party's server, retention policy, and breach history is solving the visual problem while reintroducing the custody problem.

Running the entire pipeline described above inside the browser, using only pdf.js, pdf-lib, and the Canvas API, means the file's ArrayBuffer never crosses a network boundary. There is no upload step to secure, log, or audit, because there is no upload step. The processing works with the network disabled, which is a useful practical test: if a redaction tool still functions in airplane mode, that is a strong signal the file genuinely never left the device, rather than a policy claim about what a server does or does not retain.

This is also why image based redaction (Aadhaar cards, ID scans, screenshots) benefits from the same pipeline as PDFs. An uploaded JPG or PNG is drawn onto a canvas, shapes are burned in the same way, and the output is re-encoded fresh from that canvas. A side effect worth knowing: a canvas re-encode does not carry over the original file's embedded metadata, since the canvas only ever held pixel data to begin with. The output image is a new file, not the original file with a layer added.

The practical checklist this leaves you with

If you are evaluating whether a redaction tool, any tool, actually redacts:

  • Try selecting text in the area you covered. If it highlights or copies, the text layer is intact underneath.
  • Run the output through a text extractor (pdftotext, or paste into a search box) and search for the redacted string.
  • Check whether the tool works with your network disconnected. If it requires connectivity to process a file that is already loaded in the browser, it is very likely uploading it somewhere.
  • Check the file size and structure of the output. A properly flattened, image based PDF will not compress the same way as a text based PDF, since it no longer contains a font subset or text operators, only raster image data per page.

Redaction is a permanent, destructive operation by design. The moment a tool makes it reversible, easily or otherwise, it has stopped being redaction and become a visual suggestion.

Top comments (0)