A Black Box Drawn Over a PDF Isn't Redaction — Here's How I Fixed It Client-Side

javascript dev.to

I wanted a complete PDF editor running entirely in the browser — page reordering, annotations, highlights, a drawn signature, watermark — on the same zero-upload principle as the rest of my site's tools. The interesting technical problem wasn't the interface. It was redaction.

A black box drawn over text in a PDF covers it visually, but the text stays in the document underneath, selectable and copyable. I wanted to fix that properly.

The naive approach (and why it's wrong)

Most "redact PDF" tools out there just draw a shape over the text:

// ❌ Just draws a black rectangle over existing content
const page = pdfDoc.getPage(pageIndex);
page.drawRectangle({
  x, y, width, height,
  color: rgb(0, 0, 0)
});
// The original text is still in the page's content stream:
// selectable, copyable, extractable with any PDF parser
Enter fullscreen mode Exit fullscreen mode

Open the resulting PDF in any reader, select "all text on the page" (or just copy the black area itself), and the "hidden" content reappears in the clipboard. At the data level, nothing was removed — the PDF just gained a colored rectangle on top.

The fix: rasterize, but only where it matters

The only way to truly remove content while staying fully client-side, with no server-side content-stream rewriting engine, is to convert the page into an image after applying the blackout. A bitmap has no "text underneath" to extract — there's no text left, just pixels.

The obvious trade-off: rasterizing bloats file size and kills text selectability. So the rule is to rasterize only pages that actually contain a redaction, and leave every other page exactly as it is in the original.

// Simplified export logic
for (let i = 0; i < totalPages; i++) {
  const hasRedaction = redactionsByPage[i]?.length > 0;

  if (!hasRedaction) {
    // ✅ No redaction: page copied intact, stays vector
    const [copied] = await outputDoc.copyPages(sourceDoc, [i]);
    outputDoc.addPage(copied);
    continue;
  }

  // 🔥 Page with redactions: rasterized at high resolution
  const bitmap = await renderPageToCanvas(pdfjsDoc, i, { scale: 3 });
  burnRedactionsIntoCanvas(bitmap, redactionsByPage[i]);
  burnAnnotationsIntoCanvas(bitmap, annotationsByPage[i]);

  const jpg = await outputDoc.embedJpg(bitmap.toJPEG(0.92));
  const newPage = outputDoc.addPage([bitmap.width, bitmap.height]);
  newPage.drawImage(jpg, { x: 0, y: 0, width: bitmap.width, height: bitmap.height });
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here. First, rasterization happens at a higher scale (3x) than on-screen display, otherwise the "burned" pages look noticeably blurrier than the vector pages that stayed in the document. Second, on rasterized pages, non-redaction annotations (text, highlights, shapes, signature, watermark) get burned into the same bitmap too — mixing rendering types on the same page causes inconsistencies across different PDF readers.

The result: a 20-page document with one redaction on page 12 produces a PDF where 19 pages stay light, vector, and text-selectable, and only page 12 is an image — with nothing extractable under the blackout.

Architecture: canvas + coordinated overlay

The rendering pattern reuses something I'd already built for a PDF form-filler tool: each page is a PDF.js rendering <canvas> plus an HTML overlay of the same size, where annotations live as elements positioned as a percentage of the current viewport, not absolute pixels. That keeps them aligned with the underlying text at any zoom level — pixel-anchored annotations would drift on every resize.

Rendering is also guarded by a per-page render token: whenever scale or content changes, a new token is generated, and an in-flight render that's no longer the latest one self-cancels instead of painting a stale frame over a fresh one. Without this, rapidly resizing the window during a heavy render produces visually broken overlapping frames — a classic async-canvas race condition.

What I learned

  • "Redaction" carries an implicit irreversibility requirement that a graphic overlay alone doesn't satisfy.
  • Rasterize selectively, not the whole document — treating an entire PDF as "less trustworthy" because of one sensitive page is a bad trade-off.
  • Anchor annotations to the viewport, not to screen pixels.
  • A per-page render token avoids race conditions on async canvases during rapid resize/zoom.
  • Rasterize at a higher scale than the on-screen display scale, or burned pages look noticeably worse than the vector ones next to them.
  • Code written for one tool pays off on the next one — the rendering/overlay engine and drawn-signature logic came from an earlier PDF form-filler tool and saved a lot of time here.

Everything runs client-side: no upload, no server processing, the file never leaves the browser.

Source: dev.to

arrow_back Back to Tutorials