Building a Client-Side PDF Redaction Engine With PDF.js and pdf-lib

E

Elias Miller

Guest
Every few months, another high-profile document leak hits the headlines. A government agency releases redacted court transcripts, or a corporate legal team publishes "censored" financial filings. Within ten minutes, someone on Reddit or Hacker News drags their cursor across the black bars, hits Ctrl+C, pastes it into Notepad, and suddenly, classified names and bank balances appear in plain text.

The culprit is almost always the same: pseudo-redaction.



Someone took a PDF editor, drew an opaque black annotation rectangle over the text, and exported it. Visually, the sensitive data was hidden. Structurally, the raw text glyphs remained untouched inside the PDF’s content streams.



Even worse? When casual users try to fix this using popular "Free Online PDF Editors," they commit an even greater security cardinal sin: uploading unredacted, confidential documents to an unknown cloud server over the public internet.



In this article, let’s tear down why PDF redaction fails at the binary level, and walk through how we built an irreversible, zero-server document sanitization engine running 100% client-side in the browser.

1. The Anatomy of a PDF Leak​


A PDF is not an image; it is an object-oriented document format built on postscript-like streams.

When you see a line of text on a page, the PDF structure looks something like this:

Code:
ps
BT
/F1 12 Tf
72 712 Td
(CONFIDENTIAL: User SSN is 000-12-3456) Tj
ET



If an editor merely adds a black rectangle above that text block:

Code:
ps
0 0 0 rg
70 710 200 15 re
f



The rendering engine executes sequentially: it paints the text, then draws a black box on top of it. The text stream remains intact. Anyone using a parser (or even a standard browser preview) can still extract the text, run search queries on it, or inspect the document tree.

Fig

Figure 1: Why Pseudo-Redaction Fails (Text Steam Survives Overlay)




To truly redact a PDF, you have two choices:

  1. Stream-level AST Parsing: Parse the content stream, locate character matrices intersecting the bounding box, slice the PostScript operators, and rewrite the font mapping tables. (Extremely fragile; edge cases with kerning, font sub-setting, and vector graphics frequently fail or leave phantom character artifacts).
  2. True Physical Raster Sanitization: Render the target page into an isolated graphics context, physically overwrite the pixel memory with opaque blocks, discard the underlying text objects entirely, and reconstruct the page.



For security-critical applications, Option 2 is mathematically foolproof. If the glyphs no longer exist in the byte array, no parser can ever reconstruct them.

2. The Cloud Privacy Paradox​


Before diving into code, let's address the elephant in the room: cloud-based PDF utilities.

When an employee needs to hide trade secrets or medical records, they search for "redact pdf free online". They upload their sensitive file to a SaaS platform.



Think about the irony:

  • The user’s goal is data privacy.
  • The SaaS model requires uploading unredacted plaintext over HTTPS to a remote container, storing it in an ephemeral S3 bucket, and processing it on a remote VM.



Even with TLS and "we delete files after 1 hour" promises, you are expanding your attack surface by transmitting raw PII outside your local machine.



The web has evolved. With modern HTML5 Canvas, WebAssembly, and typed arrays, the browser sandbox is more than capable of handling enterprise-grade PDF manipulation with zero bytes leaving the client.

3. Engineering the Engine: Hybrid Sanitization Pipeline​


When building our client-side redaction tool, we faced an architectural dilemma:

If you rasterize an entire 200-page document just to redact two words on page 3, you destroy vector crispness for 198 pristine pages and blow up the file size from 2MB to 80MB.



Our solution was a Selective Hybrid Pipeline:

  • Pristine Pages: Cloned losslessly as raw vector streams.
  • Redacted Pages: Irrevocably burned in-memory via high-DPI canvas buffers.



Here is the architectural workflow:

Figure 2: VersaPDF  Hybrid Client-Side Sanitation Pipeline




4. The Code: Irreversible In-Browser Destruction​


Let’s look at the core execution pipeline implemented with pdf-lib and pdfjs-dist.

Step 1: Capturing Normalized Coordinates​


The user draws redaction boxes over a responsive preview container. We record coordinates as normalized percentages (0.0 to 1.0) rather than raw screen pixels, ensuring accuracy across any zoom factor or device pixel ratio:

Code:
typescript
export interface RedactionBox {
  id: string;
  pageIndex: number; // 0-based
  x: number;         // 0.0 - 1.0
  y: number;         // 0.0 - 1.0
  width: number;     // 0.0 - 1.0
  height: number;    // 0.0 - 1.0
  color: 'black' | 'white';
}

Step 2: The Hybrid Compilation Loop​


When the user clicks "Export Sanitized Document", we read the binary buffer locally:

Code:
typescript
import { PDFDocument } from 'pdf-lib';
async function sanitizePdf(file: File, redactions: RedactionBox[], pdfJsDoc: any): Promise<Blob> {
  const arrayBuffer = await file.arrayBuffer();
  // 1. Load original document strictly in-memory
  const originalPdf = await PDFDocument.load(arrayBuffer, { ignoreEncryption: true });
  const newPdf = await PDFDocument.create();
  const totalPages = originalPdf.getPageCount();
  for (let i = 0; i < totalPages; i++) {
    const pageBoxes = redactions.filter((b) => b.pageIndex === i);
    if (pageBoxes.length === 0) {
      // BRANCH A: Clean page -> Lossless vector clone
      const [copiedPage] = await newPdf.copyPages(originalPdf, [i]);
      newPdf.addPage(copiedPage);
    } else {
      // BRANCH B: Dirty page -> Physical pixel wipeout
      const pdfPage = await pdfJsDoc.getPage(i + 1);
      
      // 2.0x scale guarantees ~200 DPI crisp print quality
      const scale = 2.0;
      const viewport = pdfPage.getViewport({ scale });
      const canvas = document.createElement('canvas');
      canvas.width = Math.floor(viewport.width);
      canvas.height = Math.floor(viewport.height);
      const ctx = canvas.getContext('2d')!;
      // Render base document into canvas
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      await pdfPage.render({ canvasContext: ctx, viewport }).promise;
      // Physically burn opaque pixels directly into the canvas buffer!
      pageBoxes.forEach((box) => {
        const rx = Math.round(box.x * viewport.width);
        const ry = Math.round(box.y * viewport.height);
        const rw = Math.round(box.width * viewport.width);
        const rh = Math.round(box.height * viewport.height);
        ctx.fillStyle = box.color === 'white' ? '#ffffff' : '#000000';
        ctx.fillRect(rx, ry, rw, rh);
      });
      // Convert sanitized canvas back to binary image stream
      const sanitizedBlob = await new Promise<Blob | null>((res) =>
        canvas.toBlob(res, 'image/jpeg', 0.94)
      );
      if (!sanitizedBlob) throw new Error('Rasterization failure');
      const imgBytes = await sanitizedBlob.arrayBuffer();
      const embeddedImg = await newPdf.embedJpg(imgBytes);
      // Re-create the page preserving original physical dimensions
      const originalPage = originalPdf.getPage(i);
      const { width, height } = originalPage.getSize();
      const newPage = newPdf.addPage([width, height]);
      newPage.drawImage(embeddedImg, {
        x: 0,
        y: 0,
        width,
        height,
      });
    }
  }
  // 3. Serialize and return local blob URL
  const finalPdfBytes = await newPdf.save();
  return new Blob([finalPdfBytes], { type: 'application/pdf' });
}

5. Performance Challenges & Memory Guards​


Executing high-resolution rasterization in a browser tab comes with pitfalls:

  1. Garbage Collection Pressure:
  2. Large PDFs can generate hundreds of megabytes of raw image data during processing.
  • Never retain canvas instances in global variables.
  • Set canvas.width = 0; canvas.height = 0; after converting to blob to free memory immediately.
  • Always call URL.revokeObjectURL(prevUrl) when regenerating downloads.
  1. Touch & Pan Frictionless UX:
  2. Users redact on laptops, iPads, and trackpads. Implementing an overlay that distinguishes between drawing a redaction box and zooming/panning requires a drag threshold:

Code:
typescript
const DRAG_THRESHOLD = 8; // Screen pixels
if (screenDistance < DRAG_THRESHOLD) {
  // Treat as tap or gesture, do not draw accidental mini-boxes
  return;
}

6. The Verdict: Local-First is the Real Future of Utility Software​


For years, developers were trained to default to the cloud:

"Need to convert a PDF? Spin up an AWS Lambda with Poppler."

"Need to redact an invoice? Send it to our REST endpoint."


This architecture was never about user convenience; it was driven by legacy browser limitations and SaaS monetization models.

Today, with the power of modern client-side engines:

  • Zero server hosting bill: Deployable for $0 on Cloudflare Pages or GitHub Pages.
  • True zero-knowledge privacy: Your sensitive tax files or medical records never leave your RAM.
  • Zero data liability for builders: You cannot leak user data if you never store it in the first place.



If you are manipulating user files in 2026, ask yourself: Does this data really need to touch your cloud backend? Chances are, the user's browser is already more than fast enough to do it itself.
 

Thread statistics

Created
Elias Miller,
Replies
0
Views
0
Back
Top