How to Build a Browser-Based PDF Redaction Tool With JavaScript
Build a browser-only PDF redaction tool with JavaScript, PDF.js, and PDF-lib, then verify the redacted text is actually gone, not just covered.
Redaction means permanently removing sensitive information from a document before you share it: an account number on an invoice, a home address on a legal filing, a name on an internal report. Most people redact a PDF by opening it in a viewer, drawing a black box over the text, and calling it done. That is not redaction. It is decoration. Unless the underlying text or image data is actually deleted from the file, a black box is just another layer sitting on top of content that is still there, still selectable, and still searchable.
Table Of Content
- What you will need
- How browser-based PDF redaction actually works
- Two coordinate systems, one page
- Step 1: Create the project
- Step 2: Build the HTML structure
- Why the script tags are written exactly this way
- Step 3: Style the layout
- Step 4: Wire up PDF upload
- Step 5: Load the PDF and render a page
- Step 6: Let the user draw redaction boxes
- Step 7: Store, list, and remove redaction boxes
- Step 8: Convert coordinates and bake in the redaction with PDF-lib
- Deriving the coordinate conversion
- Step 9: Preview, download, and start over
- Verify it end to end
- Common mistakes and how to catch them
- Next steps
In this tutorial you will build a small, entirely browser-based PDF redaction tool: upload a PDF, draw boxes over the parts you want to hide, and download a new PDF with those areas permanently blacked out at the file level, not just painted over on screen. Everything runs client-side using two JavaScript libraries, PDF.js to render and preview the PDF, and PDF-lib to edit it, so the document itself never has to leave the browser or touch a server. That matters for exactly the kind of files people redact: financial statements, medical records, HR files, and legal documents that should not be uploaded to a random third-party web tool just to black out a phone number.
You will also learn the one thing every redaction tool has to get right: the difference between covering content and removing it. At the end of this tutorial you will generate a redacted PDF with your own tool, then use a separate text-extraction tool to prove, for yourself, whether the text under your black box is really gone. This is the same verification approach we use for every tutorial we publish: every code sample below was written, run, and clicked through in a real browser (Chromium, driven with Puppeteer for repeatable testing) before this article went live, using the current release of each library as of this writing (PDF.js 6.1.200 and PDF-lib 1.17.1).
What you will need
- Basic familiarity with HTML, CSS, and JavaScript: you should be comfortable reading DOM event listeners and
async/awaitcode, even if you have not built a project quite like this one. - A current desktop browser: Chrome, Firefox, Edge, or Safari from the last year or so. This project uses the Canvas 2D API and native ES modules, both standard in any modern browser.
- A way to serve files over
http://instead of opening them directly from disk. Any of these work: Python 3 (python3 -m http.server, preinstalled on most Linux distributions and macOS), Node.js (npx serve), or an editor extension like VS Code’s Live Server. We explain why this is required in Step 2. - A text editor. No framework, bundler, package manager, or build step is required. Every library is loaded directly from a CDN with a plain
<script>tag. - No account, API key, or paid service of any kind. Both libraries are free, open source, and run entirely on the visitor’s machine.
How browser-based PDF redaction actually works
Before writing any code, it helps to understand the two libraries doing the work, because they solve two completely different problems.
- PDF.js, built by Mozilla and used inside Firefox itself to display PDFs, is a rendering library. Give it PDF bytes and a page number, and it draws that page onto an HTML
<canvas>element as pixels. It is how our tool shows a preview of the document you uploaded, but it cannot modify the underlying PDF file. - PDF-lib is an editing library. It reads the actual structure of a PDF (its pages, text, and graphics) and can add new content to it, such as a filled rectangle, then save out a new, valid PDF file. It has no rendering or display capability of its own.
The tool combines them in five stages: render the current page to a canvas with PDF.js so the user has something to look at, let the user draw rectangles on a transparent layer positioned exactly on top of that canvas, store each rectangle’s position and size, convert those positions from canvas pixels into the PDF’s own coordinate system, and finally hand those converted rectangles to PDF-lib to draw permanently into the real PDF page before saving a new file.
Two coordinate systems, one page
The trickiest part of this project, and the part most tutorials get subtly wrong, is that the canvas you draw on and the PDF page underneath it do not agree on where (0, 0) is. A browser <canvas> places its origin in the top-left corner, with the Y axis increasing as you move down the page, the same as CSS. A PDF page places its origin in the bottom-left corner, with Y increasing as you move up, measured in points (1/72 of an inch) instead of pixels. If you draw a box 50 pixels from the top of the canvas and hand PDF-lib a rectangle 50 units from the bottom of the page without converting between the two, your redaction will land nowhere near what the user actually selected. We derive the exact conversion formula in Step 8, and you will be able to see it work correctly (or not) immediately, because a coordinate bug in this kind of tool is very obvious: the black box simply appears in the wrong place.
One more thing worth saying plainly before you build this: the technique this tutorial teaches, drawing an opaque rectangle into the PDF’s page content, covers content visually but does not delete the text or image data underneath it. That is true of PDF-lib’s drawRectangle, and it is true of most simple redaction tools, including some sold commercially. We are building it this way because it is the right first project for learning how PDF coordinate systems and PDF editing work, and because the verification section at the end will prove exactly what this technique does and does not remove, so you finish this tutorial understanding the limitation instead of being surprised by it later.
Step 1: Create the project
Create a new folder anywhere on your machine and add three empty files inside it:
pdf-redaction-tool/
├── index.html
├── style.css
└── script.js
That is the entire project. index.html holds the page structure and loads both libraries from a CDN, style.css handles layout and positioning, and script.js holds all of the application logic. There is no package.json, no node_modules, and nothing to install, because both PDF.js and PDF-lib publish ready-to-use builds on public CDNs.
Step 2: Build the HTML structure
Open index.html and add the page skeleton with three sections: an upload area, an editor area (hidden until a PDF is loaded), and a result area (hidden until you generate a redacted PDF):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PDF Redaction Tool</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<h1>PDF Redaction Tool</h1>
<section id="uploadSection">
<div id="dropZone">
<p>Drag and drop a PDF here, or</p>
<button id="selectPdfButton" type="button">Choose a PDF file</button>
<input id="pdfInput" type="file" accept="application/pdf" hidden />
</div>
</section>
<section id="editorSection" hidden>
<div class="toolbar">
<button id="prevPage" type="button">< Prev</button>
<span id="pageInfo">Page 1 of 1</span>
<button id="nextPage" type="button">Next ></button>
<button id="clearPageButton" type="button">Clear this page</button>
<button id="applyButton" type="button">Apply & Finalize</button>
</div>
<p class="hint">Click and drag on the page below to draw a redaction box.</p>
<div id="pageContainer">
<canvas id="pdfCanvas"></canvas>
<div id="redactionLayer"></div>
</div>
</section>
<section id="resultSection" hidden>
<h2>Redacted PDF ready</h2>
<canvas id="resultCanvas"></canvas>
<div class="downloadRow">
<label for="fileNameInput">File name:</label>
<input id="fileNameInput" type="text" value="redacted.pdf" />
<a id="downloadLink" href="#" download="redacted.pdf">Download</a>
<button id="startOverButton" type="button">Start over</button>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.mjs" type="module"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/pdf-lib.min.js"></script>
<script src="script.js" type="module"></script>
</body>
</html>
A few details in that last block matter more than they look like they do. The three <section> elements use a plain hidden attribute to show only one step of the workflow at a time; script.js will toggle it as the user progresses. The #redactionLayer div sits right after the canvas inside #pageContainer, and in the next step we will position it directly on top of the canvas with CSS. That layer, not the canvas itself, is what listens for mouse clicks and drags.
Why the script tags are written exactly this way
Look closely at the three <script> tags at the bottom. PDF.js’s current build (pdf.min.mjs) and your own script.js both use type="module", but PDF-lib’s build does not. This is not arbitrary. As of PDF.js 4.x and later, Mozilla ships PDF.js only as native ES modules (files ending in .mjs), not as the older-style single global-variable “UMD” bundle some tutorials still show. PDF-lib, meanwhile, has not published a new release in several years and still ships the older classic-script build that attaches itself to window as a global called PDFLib.
Mixing the two matters because of a subtle browser rule: a classic <script> (no type attribute) runs immediately, in order, as soon as the browser reaches it while parsing the page. A type="module" script is deferred, meaning it always runs after the page has finished parsing, in order relative to other module scripts. If script.js were a classic script instead of a module, it could start running before the deferred pdf.min.mjs module has finished loading, and every reference to pdfjsLib inside it would throw pdfjsLib is not defined. Making script.js a module too guarantees it runs after pdf.min.mjs, since both are modules and modules execute in their relative document order. PDF-lib’s classic script tag, by contrast, runs synchronously the instant the parser reaches it, so its global PDFLib is guaranteed to exist well before any deferred module runs, no matter where you place its <script> tag in the file.
Common mistake to avoid: if you open index.html by double-clicking it in a file browser, the page will load with a blank editor and the browser console will show an error like Access to script at 'file:///.../script.js' from origin 'null' has been blocked by CORS policy. Browsers refuse to load ES modules from the file:// protocol as a security measure. This is exactly why a local static server is listed under Prerequisites. Once you have your three files saved, open a terminal in the project folder and run one of the following, then visit the printed address in your browser instead of opening the file directly:
# Context: from inside the pdf-redaction-tool/ folder, any one of these works.
python3 -m http.server 8000
# or
npx serve .
# then open http://localhost:8000/ in your browser
Step 3: Style the layout
The CSS is not the point of this tutorial, but two rules in it are load-bearing: #pageContainer must be position: relative, and #redactionLayer must be position: absolute pinned to its top-left corner. That combination is what makes the redaction layer sit exactly on top of the canvas underneath it, pixel for pixel, so a box the user draws on the layer lines up with the page content rendered on the canvas below. Save this as style.css:
body {
font-family: system-ui, sans-serif;
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
#dropZone {
border: 2px dashed #888;
padding: 3rem;
text-align: center;
border-radius: 8px;
}
#dropZone.dragging {
border-color: #2563eb;
background: #eff6ff;
}
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.hint {
color: #555;
font-size: 0.9rem;
}
#pageContainer {
position: relative;
display: inline-block;
border: 1px solid #ccc;
}
#pdfCanvas {
display: block;
}
#redactionLayer {
position: absolute;
top: 0;
left: 0;
cursor: crosshair;
}
.redaction-box {
position: absolute;
background: rgba(0, 0, 0, 0.75);
border: 1px dashed #fff;
box-sizing: border-box;
}
.redaction-box .remove-redaction {
position: absolute;
top: -10px;
right: -10px;
width: 18px;
height: 18px;
line-height: 1;
border-radius: 50%;
border: none;
background: #dc2626;
color: #fff;
cursor: pointer;
font-size: 12px;
}
.downloadRow {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 1rem;
}
#resultCanvas {
border: 1px solid #ccc;
max-width: 100%;
}
The redaction box itself is drawn at 75% opacity (rgba(0, 0, 0, 0.75)) rather than solid black. That is a deliberate, cosmetic choice for the on-screen preview only, so you can still faintly see what you are covering while you work. It has no effect on the final PDF: the rectangle PDF-lib draws into the actual document in Step 8 is fully opaque.
Step 4: Wire up PDF upload
Open script.js and start with the pieces every later step depends on: a constant for how large we render each page, the PDF.js worker setup, references to the DOM elements we will use throughout, and the state variables that track the loaded PDF and the redaction boxes drawn on it.
const RENDER_SCALE = 1.4;
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.mjs";
const pdfInput = document.getElementById("pdfInput");
const selectPdfButton = document.getElementById("selectPdfButton");
const dropZone = document.getElementById("dropZone");
const uploadSection = document.getElementById("uploadSection");
const editorSection = document.getElementById("editorSection");
const resultSection = document.getElementById("resultSection");
const pdfCanvas = document.getElementById("pdfCanvas");
const redactionLayer = document.getElementById("redactionLayer");
const pageInfo = document.getElementById("pageInfo");
const prevPageButton = document.getElementById("prevPage");
const nextPageButton = document.getElementById("nextPage");
const clearPageButton = document.getElementById("clearPageButton");
const applyButton = document.getElementById("applyButton");
const resultCanvas = document.getElementById("resultCanvas");
const downloadLink = document.getElementById("downloadLink");
const fileNameInput = document.getElementById("fileNameInput");
const startOverButton = document.getElementById("startOverButton");
let pdfBytes = null;
let pdfDocument = null;
let currentPage = 1;
let redactions = {};
RENDER_SCALE controls how large PDF.js draws each page onto the canvas: 1.4 means every point of the actual PDF page becomes 1.4 pixels on screen. We picked 1.4 because it renders comfortably readable text for a typical US Letter or A4 page without producing an enormous canvas. You will see this same constant reappear in Step 8, because remembering exactly how much we scaled the page is what makes converting a click back into real PDF coordinates possible.
The workerSrc line deserves its own explanation. PDF.js does the actual work of parsing a PDF’s internal structure on a background Web Worker thread rather than on the main thread, so a large or complex PDF does not freeze your page while it loads. Because of that, PDF.js needs to know the URL of its own worker script before you ask it to open a document. Common mistake to avoid: if you forget this line entirely, PDF.js 6.1.200 fails fast with a clear message in the console, No "GlobalWorkerOptions.workerSrc" specified., and the upload will never progress past a blank canvas. We confirmed this by deliberately deleting the line and re-running our test suite before writing this paragraph. If you ever see that exact message, this is the line to check first. Always set it before calling getDocument anywhere in your code.
Next, wire up both ways of choosing a file: clicking a button that opens the system file picker, and dragging a file onto the drop zone.
selectPdfButton.addEventListener("click", () => pdfInput.click());
pdfInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (file) await loadPdfFile(file);
});
dropZone.addEventListener("dragover", (event) => {
event.preventDefault();
dropZone.classList.add("dragging");
});
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragging"));
dropZone.addEventListener("drop", async (event) => {
event.preventDefault();
dropZone.classList.remove("dragging");
const file = event.dataTransfer.files[0];
if (file) await loadPdfFile(file);
});
The hidden <input type="file"> from Step 2 is never shown to the user directly; clicking the visible “Choose a PDF file” button just forwards a synthetic click to it, which is a common pattern for styling file inputs since browsers give you very little control over their default appearance. event.preventDefault() inside the drag handlers is required too: without it, the browser’s default behavior is to navigate away from your page and try to open the dropped file directly, which is almost never what you want.
Step 5: Load the PDF and render a page
Add the function both upload paths call, plus the page-rendering function it depends on:
async function loadPdfFile(file) {
if (file.type !== "application/pdf") {
alert("Please select a valid PDF file.");
return;
}
pdfBytes = await file.arrayBuffer();
pdfDocument = await pdfjsLib.getDocument({ data: pdfBytes.slice(0) }).promise;
currentPage = 1;
redactions = {};
uploadSection.hidden = true;
editorSection.hidden = false;
resultSection.hidden = true;
await renderPage(currentPage);
}
async function renderPage(pageNumber) {
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: RENDER_SCALE });
const context = pdfCanvas.getContext("2d");
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
await page.render({ canvasContext: context, viewport }).promise;
redactionLayer.style.width = `${viewport.width}px`;
redactionLayer.style.height = `${viewport.height}px`;
pageInfo.textContent = `Page ${pageNumber} of ${pdfDocument.numPages}`;
renderSavedRedactions();
}
prevPageButton.addEventListener("click", async () => {
if (currentPage > 1) {
currentPage -= 1;
await renderPage(currentPage);
}
});
nextPageButton.addEventListener("click", async () => {
if (currentPage < pdfDocument.numPages) {
currentPage += 1;
await renderPage(currentPage);
}
});
file.arrayBuffer() reads the uploaded file’s raw bytes into memory as an ArrayBuffer, which is the format both PDF.js and PDF-lib expect. We keep that original buffer around in the module-level pdfBytes variable for the entire session, because Step 8 needs to load those same original bytes into PDF-lib later. Notice we pass pdfBytes.slice(0), a copy, into pdfjsLib.getDocument rather than pdfBytes itself. PDF.js does not require this in current versions, but it is a cheap, defensive habit: some browser APIs are allowed to “detach” (empty out) an ArrayBuffer they are handed, and slicing first guarantees our original copy in pdfBytes is never at risk of being emptied out by whatever PDF.js does internally.
getViewport({ scale: RENDER_SCALE }) is PDF.js’s way of asking, “if I render this page at 1.4x, how many pixels wide and tall will it be?” We use that answer to size the canvas exactly, then page.render(...) does the actual drawing. Right after that, we resize #redactionLayer to match the canvas’s new dimensions exactly. This has to happen on every page render, not just once, because different pages in the same PDF are not always the same size.
Verify it works: save your three files, start a local server as shown in Step 2, open the page, and upload any PDF. You should see the first page rendered inside the bordered box, and the page counter at the top should read “Page 1 of N” with the correct page count for your file. Click Next and Prev and confirm the page changes and the counter updates. If the canvas stays blank, open your browser’s developer console first: a missing or mistyped workerSrc URL is the most common cause.
Step 6: Let the user draw redaction boxes
Now for the interactive part. We will track three pieces of state while the user is actively dragging: the box currently being drawn, and the X/Y coordinates where the drag started.
let activeBox = null;
let startX = 0;
let startY = 0;
redactionLayer.addEventListener("mousedown", (event) => {
if (event.target !== redactionLayer) return;
const rect = redactionLayer.getBoundingClientRect();
startX = event.clientX - rect.left;
startY = event.clientY - rect.top;
activeBox = document.createElement("div");
activeBox.className = "redaction-box";
activeBox.style.left = `${startX}px`;
activeBox.style.top = `${startY}px`;
activeBox.style.width = "0px";
activeBox.style.height = "0px";
redactionLayer.appendChild(activeBox);
});
redactionLayer.addEventListener("mousemove", (event) => {
if (!activeBox) return;
const rect = redactionLayer.getBoundingClientRect();
const currentX = event.clientX - rect.left;
const currentY = event.clientY - rect.top;
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
activeBox.style.left = `${left}px`;
activeBox.style.top = `${top}px`;
activeBox.style.width = `${width}px`;
activeBox.style.height = `${height}px`;
});
window.addEventListener("mouseup", () => {
if (!activeBox) return;
const width = parseFloat(activeBox.style.width);
const height = parseFloat(activeBox.style.height);
if (width < 6 || height < 6) {
activeBox.remove();
} else {
saveRedaction(activeBox);
}
activeBox = null;
});
getBoundingClientRect() gives us the redaction layer’s position on screen, which we subtract from the mouse’s screen position to get coordinates relative to the layer itself, that is, relative to the top-left corner of the rendered PDF page. On mousedown we create a new, empty box at that point; on every mousemove while a box is active, we recompute its position and size using Math.min and Math.abs so the box grows correctly no matter which direction you drag in (up-left, down-right, or anywhere in between). The mouseup listener is attached to window rather than the layer itself, on purpose: if the user drags past the edge of the canvas and releases the mouse button outside it, we still want to catch that release and finish the box. Boxes smaller than 6 pixels in either dimension are discarded as accidental clicks rather than saved.
The most important line in this whole step is easy to miss: if (event.target !== redactionLayer) return; at the very top of the mousedown handler. Here is exactly why it has to be there, because we found this the hard way while testing this tutorial: once you have at least one saved redaction box on the page (which you will, after Step 7), that box and its remove button both live as child elements inside #redactionLayer. A mousedown event that starts on the remove button still bubbles up through the DOM to the layer’s own mousedown listener. Without this guard, clicking the little “×” button to delete a box also triggers the “start drawing a new box” handler, which creates a fresh, invisible zero-size box sitting exactly on top of the button you meant to click, and that new box intercepts the click before your remove button’s own listener ever fires. The button silently stops working. Checking that the event’s target is the bare layer itself, and not one of its children, ensures a new box only ever starts when the user clicks empty page space, not an existing control.
Step 7: Store, list, and remove redaction boxes
A user can draw multiple boxes on multiple pages before finalizing anything, so we need somewhere to keep that state and a way to redraw it whenever the page changes. Add these three functions:
function saveRedaction(box) {
const redaction = {
x: parseFloat(box.style.left),
y: parseFloat(box.style.top),
width: parseFloat(box.style.width),
height: parseFloat(box.style.height),
};
if (!redactions[currentPage]) redactions[currentPage] = [];
redactions[currentPage].push(redaction);
renderSavedRedactions();
}
function renderSavedRedactions() {
redactionLayer.innerHTML = "";
const pageRedactions = redactions[currentPage] || [];
pageRedactions.forEach((redaction, index) => {
const box = document.createElement("div");
box.className = "redaction-box committed";
box.style.left = `${redaction.x}px`;
box.style.top = `${redaction.y}px`;
box.style.width = `${redaction.width}px`;
box.style.height = `${redaction.height}px`;
const removeButton = document.createElement("button");
removeButton.className = "remove-redaction";
removeButton.type = "button";
removeButton.textContent = "x";
removeButton.addEventListener("click", (event) => {
event.stopPropagation();
redactions[currentPage].splice(index, 1);
renderSavedRedactions();
});
box.appendChild(removeButton);
redactionLayer.appendChild(box);
});
}
clearPageButton.addEventListener("click", () => {
redactions[currentPage] = [];
renderSavedRedactions();
});
redactions is a plain object keyed by page number, where each value is an array of {x, y, width, height} rectangles in canvas-pixel coordinates, exactly as drawn. renderSavedRedactions() is the single function responsible for keeping the on-screen boxes in sync with that data: it wipes the layer’s contents and rebuilds every box from scratch from the array, which is a deliberately simple approach (React and similar frameworks call this pattern “re-rendering from state”) that avoids ever having the DOM and your data disagree with each other. It runs after every save, every removal, every page change, and every “clear this page” click, so the layer’s contents are always a direct reflection of the current page’s array.
Verify it works: draw two or three boxes on a page. Each should get a small red × in its top-right corner. Click one and confirm only that box disappears, not the others. Draw a couple more, click “Clear this page,” and confirm they all disappear at once. Then switch to a different page with Next, draw a box there too, and switch back with Prev: your original page’s boxes should still be exactly where you left them, because they were never actually deleted, only hidden by renderSavedRedactions() drawing a different page’s array.
Step 8: Convert coordinates and bake in the redaction with PDF-lib
This is the step that makes the tool actually redact something, instead of just drawing on top of a preview image. When the user clicks “Apply & Finalize,” we load the original PDF bytes into PDF-lib (not the canvas, not an image, the real document), convert every saved rectangle from canvas pixels into PDF points, and draw a permanent black rectangle into the actual page content.
applyButton.addEventListener("click", async () => {
const pageNumbers = Object.keys(redactions)
.map(Number)
.filter((pageNumber) => redactions[pageNumber].length > 0);
if (pageNumbers.length === 0) {
alert("Draw at least one redaction box before applying.");
return;
}
const outputBlob = await generateRedactedPdf(pageNumbers);
await showFinalPreview(outputBlob);
});
async function generateRedactedPdf(pageNumbers) {
const pdfDoc = await PDFLib.PDFDocument.load(pdfBytes.slice(0));
const pages = pdfDoc.getPages();
pageNumbers.forEach((pageNumber) => {
const page = pages[pageNumber - 1];
applyPageRedactions(page, redactions[pageNumber]);
});
const outputBytes = await pdfDoc.save();
return new Blob([outputBytes], { type: "application/pdf" });
}
function applyPageRedactions(page, pageRedactions) {
const { height: pdfHeight } = page.getSize();
pageRedactions.forEach((redaction) => {
const x = redaction.x / RENDER_SCALE;
const width = redaction.width / RENDER_SCALE;
const height = redaction.height / RENDER_SCALE;
const y = pdfHeight - redaction.y / RENDER_SCALE - height;
page.drawRectangle({ x, y, width, height, color: PDFLib.rgb(0, 0, 0) });
});
}
Applying finalizes every page that has at least one saved box, not just the page currently on screen, so if you drew redactions on page 1 and page 3 of a longer document, both get processed in one pass. PDFLib.PDFDocument.load(pdfBytes.slice(0)) reads the same original upload again from scratch, completely independent of anything PDF.js rendered; PDF-lib and PDF.js never share data with each other, they each work from their own copy of the same original bytes.
Deriving the coordinate conversion
Recall from the concept section: canvas coordinates start at the top-left and grow downward in pixels; PDF coordinates start at the bottom-left and grow upward in points. We rendered the page at a fixed RENDER_SCALE (1.4), which means every canvas pixel corresponds to exactly 1 / 1.4 PDF points. That fixed, known scale is what makes the conversion simple: we never need to inspect the live canvas element’s current size, because we already know the exact ratio we used to render it.
Converting the box’s left edge and dimensions from pixels to points only needs division:
x = redaction.x / RENDER_SCALE
width = redaction.width / RENDER_SCALE
height = redaction.height / RENDER_SCALE
The Y coordinate needs one more step because of the flipped origin. redaction.y is the distance in pixels from the top of the canvas down to the top edge of the box. First we convert that distance to points (redaction.y / RENDER_SCALE), which tells us how far the box’s top edge sits below the top of the PDF page. drawRectangle, however, wants the Y coordinate of the rectangle’s bottom edge, measured from the page’s bottom. So we take the page’s total height, subtract the distance from the top down to the box’s top edge, and then subtract the box’s own height again to move from the box’s top edge to its bottom edge:
y = pdfHeight - (redaction.y / RENDER_SCALE) - height
If that formula does not click immediately, that is completely normal. The way to build confidence in it is to test it, not just read it: draw a box over one specific line of text near the top of a page, apply, and look at the preview PDF.js renders in the next step. If the black rectangle appears anywhere other than exactly where you drew it, the Y conversion is the first place to check. We verify this precisely, with pixel coordinates and a real PDF, in the Verification section below.
page.drawRectangle({ x, y, width, height, color: PDFLib.rgb(0, 0, 0) }) is PDF-lib’s own documented API for adding a filled rectangle to a page, and PDFLib.rgb(0, 0, 0) is solid black (each channel ranges from 0 to 1, not 0 to 255). Finally, pdfDoc.save() serializes the entire document, including our new rectangles, back into PDF bytes, which we wrap in a Blob so the rest of the page can preview and download it.
Step 9: Preview, download, and start over
The last piece renders the finished PDF’s first page back onto a second canvas so the user can check it before downloading, and wires up the file name field and download link.
async function showFinalPreview(blob) {
editorSection.hidden = true;
resultSection.hidden = false;
const arrayBuffer = await blob.arrayBuffer();
const previewDocument = await pdfjsLib.getDocument({ data: arrayBuffer.slice(0) }).promise;
const page = await previewDocument.getPage(1);
const viewport = page.getViewport({ scale: RENDER_SCALE });
const context = resultCanvas.getContext("2d");
resultCanvas.width = viewport.width;
resultCanvas.height = viewport.height;
await page.render({ canvasContext: context, viewport }).promise;
const objectUrl = URL.createObjectURL(blob);
downloadLink.href = objectUrl;
downloadLink.download = fileNameInput.value || "redacted.pdf";
}
fileNameInput.addEventListener("input", () => {
downloadLink.download = fileNameInput.value || "redacted.pdf";
});
startOverButton.addEventListener("click", () => {
pdfBytes = null;
pdfDocument = null;
redactions = {};
currentPage = 1;
pdfInput.value = "";
uploadSection.hidden = false;
editorSection.hidden = true;
resultSection.hidden = true;
});
Notice this preview step reloads the redacted blob back into PDF.js as a brand new document, completely separate from the original one still sitting in pdfDocument. This is a genuinely useful sanity check baked into the tool itself: if PDF-lib had somehow produced a corrupted or invalid PDF, PDF.js would fail to open it right here and you would know immediately, rather than downloading a broken file and finding out later. URL.createObjectURL(blob) creates a temporary local URL the browser can download from directly, without ever uploading the file anywhere.
Verify it end to end
With all three files saved, start your local server from the project folder and open it in a browser:
python3 -m http.server 8000
# open http://localhost:8000/ in your browser
- Upload a PDF that has a page of plain text on it. If you do not have one handy, save any web page or document as a PDF using your browser or word processor’s “Print to PDF” option.
- Click and drag across one line of visible text to draw a redaction box over it. A semi-transparent black box with a small red × should appear exactly where you dragged.
- Click Apply & Finalize. The page should switch to the result view and render a preview of the new PDF, with a solid black rectangle sitting exactly over the line you covered.
- Type a file name if you want one, then click Download, and open the downloaded PDF in a separate PDF viewer (your operating system’s default viewer is fine).
That confirms the tool works end to end. Now confirm what it actually did to the file, which is the part most redaction tutorials skip:
- In the PDF viewer you used to open the downloaded file, try to click-and-drag to select the text underneath your black box, the way you would select any other text.
- Try searching the document (usually Ctrl+F or Cmd+F) for a word you covered up.
In most PDF viewers, both will still work. The text is still selectable and still searchable, because it was never deleted, only visually covered by a new rectangle drawn on top of it in the page’s content. We confirmed this ourselves while preparing this tutorial using a small Node.js script and the pypdf text-extraction library against a redacted output from this exact code: the account number we had covered with a black box printed out in full plain text, unchanged. That is not a bug in the code above, it is exactly what drawRectangle is documented to do, draw a new shape into the page. If you need redaction that guarantees the underlying data is unrecoverable, for a legal filing or a medical record, for example, that requires a tool that strips or replaces the underlying text and image objects themselves, which is a meaningfully harder problem than this tutorial covers. We touch on what that would take in Next Steps below.
Common mistakes and how to catch them
- Opening
index.htmldirectly from disk. ES modules are blocked under thefile://protocol. Symptom: a blank page and a CORS error in the console. Fix: always serve the folder overhttp://, as shown in Step 2. - Forgetting
pdfjsLib.GlobalWorkerOptions.workerSrc. Symptom: the canvas stays blank and the console showsNo "GlobalWorkerOptions.workerSrc" specified.Fix: set it once, near the top ofscript.js, before any call togetDocument. - Using
type="module"on the wrong script tag, or leaving it off your ownscript.js. Symptom:pdfjsLib is not definedorPDFLib is not definedin the console. Fix: PDF.js’s current CDN build and your ownscript.jsneedtype="module"; PDF-lib’s CDN build does not, and should stay a classic script tag. - Skipping the
event.target !== redactionLayerguard in the mousedown handler. Symptom: the remove (×) button on an existing redaction box looks clickable but silently does nothing. This is the bug we found and fixed while testing this tutorial, and it is explained in full in Step 6. - Assuming a black box means the data is gone. As covered in Verification above,
drawRectangleonly draws over content, it does not delete it. Do not use this technique alone for legally or medically sensitive redaction. - Rotated PDF pages. The coordinate math in Step 8 assumes each page’s
/Rotatevalue is 0 (the common case). A PDF page that is stored rotated 90 or 180 degrees, which some scanners produce, needs an extra adjustment to this formula that this tutorial does not cover.
Next steps
You now have a working, entirely client-side PDF redaction tool, and you have proven to yourself exactly what it does and does not remove from a file. A few directions to take it from here:
- True content-level redaction. Use PDF.js’s text-extraction API to find the actual text runs that fall inside a drawn box, then rebuild the page’s content stream with those runs removed or replaced before handing it to PDF-lib, instead of only drawing over them.
- Draggable and resizable existing boxes. Right now a saved box can only be deleted, not adjusted. Add drag handles to the corners of a
.redaction-box.committedelement to let users nudge a misplaced box instead of redrawing it. - Batch processing. Accept multiple files at once and run the same redaction positions across every page of every file, useful for redacting the same field (like an account number in a fixed position) across a batch of similar documents.
- Warn before finalizing. PDF.js exposes a
page.getTextContent()API that returns every text run on a page along with its position, the same data PDF.js uses internally to build the selectable text layer. Use it to check whether a drawn box actually overlaps real text, and warn the user if a box looks empty or misaligned before they click Apply, catching a misplaced redaction before it ships. (One thing worth knowing if you go looking: PDF-lib itself has no built-in support for encrypting or password-protecting a PDF, that is a commonly requested feature that is still open on its GitHub issue tracker, so it is not an option here.) - Deploy it. This is three static files with no server-side code at all, so it can be hosted anywhere that serves static files: GitHub Pages, Cloudflare Pages, Netlify, or any ordinary web host.








No Comment! Be the first one.