DEV Community

Cover image for How We Built a True Client-Side PDF Redaction Engine in the Browser
Suprabhat Mondal
Suprabhat Mondal

Posted on

How We Built a True Client-Side PDF Redaction Engine in the Browser

When users need to redact sensitive data from a PDF—such as tax returns, medical records, or legal contracts—they often rely on online web utilities. However, most free PDF redact tools on the web perform visual masking, simply drawing a black rectangle (#000000) over the existing text object.

This is a massive security risk: the underlying vector text and streams remain fully preserved in the file's binary object stream, allowing anyone to open the PDF, highlight the text under the black box, and copy it out.

To solve this, we built a zero-upload PDF Redaction Engine using WebAssembly, HTML5 Canvas, and pdf-lib. Here is how it permanently strips text objects and burns redactions into document streams directly inside the browser.


The Architecture: Visual Masking vs. True Vector Stripping

True redaction requires two distinct operations:

  1. Visual Burn-In: Converting vector text and images in the target bounding box into a raw raster image overlay.
  2. Binary Cleanup: Removing the underlying TJ / Tj operator strings and metadata dictionaries from the document object structure.

High-Level Execution Pipeline

  1. Document Loading: Parse the uploaded PDF into memory as an ArrayBuffer.
  2. Coordinate Mapping: Render PDF pages on an HTML5 <canvas> via pdf.js to capture pixel (x, y) selections.
  3. Coordinate Normalization: Translate CSS viewport pixels into standard PDF point space ($72\text{ points per inch}$, bottom-left origin).
  4. Stream Transformation: Mutate the page content streams with pdf-lib.

Under the Hood: Code Implementation

Step 1: Normalizing Viewport Coordinates to PDF Space

PDF coordinate systems start at the bottom-left $(0,0)$, whereas HTML5 Canvas coordinates originate at the top-left $(0,0)$. We must compute a coordinate transformation matrix before passing selection bounds to the PDF generator:

/**
 * Translates HTML Canvas selection bounds to PDF page points
 */
function translateCanvasToPdfCoordinates(selection, canvasDims, pdfPageDims) {
    const scaleX = pdfPageDims.width / canvasDims.width;
    const scaleY = pdfPageDims.height / canvasDims.height;

    const pdfX = selection.x * scaleX;
    // Invert the Y axis to match PDF coordinate system (bottom-left origin)
    const pdfY = pdfPageDims.height - ((selection.y + selection.height) * scaleY);
    const pdfWidth = selection.width * scaleX;
    const pdfHeight = selection.height * scaleY;

    return { x: pdfX, y: pdfY, width: pdfWidth, height: pdfHeight };
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Modifying Content Streams with pdf-lib

Once coordinates are translated, we load the raw PDF buffer using pdf-lib and inject opaque vector shapes directly into the target page stream:

import { PDFDocument, rgb } from 'pdf-lib';

async function burnRedactionArea(pdfArrayBuffer, pageIndex, bounds) {
    // Load the PDF into memory without sending data to a server
    const pdfDoc = await PDFDocument.load(pdfArrayBuffer);
    const pages = pdfDoc.getPages();
    const targetPage = pages[pageIndex];

    // Draw an opaque, unselectable vector rectangle over the region
    targetPage.drawRectangle({
        x: bounds.x,
        y: bounds.y,
        width: bounds.width,
        height: bounds.height,
        color: rgb(0, 0, 0), // Solid black fill
    });

    // Save mutated byte array
    const modifiedPdfBytes = await pdfDoc.save();
    return modifiedPdfBytes;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Stream Flattening to De-vectorize Underlying Data

To prevent users from extracting hidden vector text layers beneath the redaction, the engine converts target pages into high-DPI canvas rasters, rendering them as flattened, unsearchable image objects before repacking:

async function flattenPageToRaster(canvas, quality = 0.95) {
    return new Promise((resolve) => {
        canvas.toBlob((blob) => {
            const reader = new FileReader();
            reader.onloadend = () => resolve(new Uint8Array(reader.result));
            reader.readAsArrayBuffer(blob);
        }, 'image/jpeg', quality);
    });
}
Enter fullscreen mode Exit fullscreen mode

Try It Out

We have deployed this privacy-first, zero-upload implementation inside our free suite of web tools. Try redacting or editing your documents locally without network latency or paywalls:

👉 Check out the live implementation: FixMyPDFs

Have you tackled client-side coordinate transformations or stream manipulation in WebAssembly? Let's discuss in the comments below!

Top comments (0)