How to Build a Browser-Based PDF Review Stamper With JavaScript
Learn how to build a browser based PDF review stamper with JavaScript, Canvas blend modes, and pdf-lib, including a real blend mode gotcha most tutorials never explain.
Say you review contracts, budgets, or design drafts as PDFs, and you want a fast way to mark a batch of pages “APPROVED” or “CONFIDENTIAL” with a colored tint, without installing desktop software or uploading anything to a server. This tutorial teaches you how to build that tool from scratch: a browser page that loads a PDF, lets you tint and stamp whichever pages you choose, and hands back a new PDF, all without the file ever leaving the browser tab.
Table Of Content
- What You Will Build and Why It Matters
- Prerequisites
- Step 1: Understand the Three Building Blocks
- PDF.js Turns Pages Into Pixels
- Canvas Compositing Blends Colors Like a Photo Editor
- pdf-lib Assembles a New PDF From Images
- Step 2: Set Up the Project
- Step 3: Load a PDF and Render Its Pages With PDF.js
- Step 4: List the Pages So the Reader Can Choose Which Ones to Stamp
- Step 5: Draw the Color Overlay and Watermark Text
- Why the save() and restore() Calls Matter
- Rotating the Watermark Text
- Step 6: Add a Live Preview (and Fix the Race Condition It Introduces)
- The Bug: “Cannot Use the Same Canvas During Multiple Render Operations”
- The Fix: Cancel the Previous Render Before Starting a New One
- Step 7: Rebuild the Stamped Pages Into a Downloadable PDF
- Step 8: Test the Whole Flow for Real
- The Blend Mode Gotcha Most Tutorials Don’t Mention
- Common Mistakes and How to Catch Them
- Forgetting ctx.save() and ctx.restore()
- Not Cancelling In-Flight Renders
- Expecting Every Blend Mode to Look the Same on Every Document
- Watch Memory on Very Large PDFs
- How to Verify It All Works End to End
- Next Steps
You will use three pieces: PDF.js to read the PDF and draw its pages onto an HTML canvas, the Canvas 2D API to blend a color and a rotated watermark on top of that drawing, and pdf-lib to package the result back into a downloadable PDF file. None of this needs Node.js, a bundler, or a build step. Every library loads straight from a CDN as a native JavaScript module.
Every command and code block in this tutorial was actually run while writing it, using a real headless Chromium browser driven by Playwright. Two real bugs showed up during that testing, including one caused by a fix for the first one, and both are documented below with the exact error text Chrome produced. You will also see a real, verified explanation for why four out of five “blend modes” you might reach for do nothing at all on a typical white PDF page, something most Canvas blend mode tutorials never mention.
What You Will Build and Why It Matters
By the end, you will have a single HTML page that:
- Accepts a PDF upload and shows how many pages it has
- Lets you pick which pages to stamp with checkboxes
- Lets you choose a stamp color, a blend mode, an opacity, and a watermark word
- Shows a live preview that updates as you change those settings
- Builds a brand new PDF with the stamp baked into the selected pages, ready to download
Two ideas carry the whole tutorial. The first is that a PDF page, once rendered, is just pixels on a canvas. PDF.js’s only job is turning a page description into pixels; after that, you are doing ordinary 2D drawing, the same as you would on a photo. The second is that blend modes control how a new color mixes with pixels that are already there, the same concept as layer blend modes in Photoshop or GIMP. Every modern browser has built this in natively for years; you do not need a graphics library to use it.
Prerequisites
- A recent Chrome, Edge, or Firefox (this tutorial was built and tested against Chromium 151)
- Basic HTML and JavaScript: variables, functions, DOM event listeners, and
async/await - A way to serve a folder over
http://. Python’s built-in server works fine:python -m http.server. You cannot skip this step; opening the HTML file directly with afile://URL will fail, which Step 2 explains - No npm, no Node.js, and no build tool. Both libraries are loaded as ES modules straight from a CDN
Step 1: Understand the Three Building Blocks
PDF.js Turns Pages Into Pixels
PDF.js is Mozilla’s PDF renderer, the same engine Firefox uses to display PDFs in the browser. You hand it raw PDF bytes, ask for a specific page, and it draws that page onto a <canvas> element you provide. PDF.js does its parsing work on a background thread called a worker, so you have to tell it where to find the separate worker script before you load anything.
Canvas Compositing Blends Colors Like a Photo Editor
Once a page is pixels on a canvas, the 2D drawing context has a property called globalCompositeOperation that controls how the next thing you draw combines with what is already there. Its default value, source-over, just paints on top and hides whatever was underneath. Setting it to multiply, screen, overlay, color, or hue instead runs the exact same blend math used in image editing software. Combine that with globalAlpha for opacity, and you have everything you need for a translucent, blended stamp.
pdf-lib Assembles a New PDF From Images
PDF.js only reads and draws PDFs; it cannot write them. pdf-lib fills that gap. It can create a brand new, empty PDF document, embed a PNG image as a page, and hand you back the finished file as raw bytes, all in the browser with no server involved.
Step 2: Set Up the Project
Create a folder with two files: index.html and app.js. Because app.js will use import statements, the browser treats it as an ES module, and ES modules refuse to load from a file:// URL for security reasons (the fetch gets blocked by CORS). Serve the folder over plain HTTP instead:
mkdir pdf-review-stamper
cd pdf-review-stamper
# create index.html and app.js here, then:
python -m http.server 8934
Visit http://127.0.0.1:8934/index.html. Nothing will render yet: you have not written any markup.
Here is the full page shell. It has an upload input, a set of controls that stay hidden until a file is loaded, a status line, a preview canvas, and a hidden download link that appears once a stamped PDF is ready:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>PDF Review Stamper</title>
</head>
<body>
<h1>PDF Review Stamper</h1>
<input type="file" id="file-input" accept="application/pdf" />
<div id="controls" style="display:none;">
<label>Stamp text:
<input type="text" id="stamp-text" value="APPROVED" />
</label>
<label>Color:
<input type="color" id="stamp-color" value="#e63946" />
</label>
<label>Blend mode:
<select id="blend-mode">
<option value="multiply">multiply</option>
<option value="overlay">overlay</option>
<option value="screen">screen</option>
<option value="color">color</option>
<option value="hue">hue</option>
</select>
</label>
<label>Opacity:
<input type="range" id="stamp-opacity" min="0" max="100" value="35" />
<span id="opacity-value">35%</span>
</label>
<div id="page-list"></div>
<button id="apply-btn">Apply Stamp and Build PDF</button>
</div>
<div id="status"></div>
<canvas id="preview-canvas" style="border:1px solid #ccc;"></canvas>
<a id="download-link" style="display:none;">Download stamped PDF</a>
<script type="module" src="app.js"></script>
</body>
</html>
The blend mode dropdown offers five options on purpose, not four or six. The section titled “The Blend Mode Gotcha Most Tutorials Don’t Mention,” later in this tutorial, explains why most of them will look like they are doing nothing until you understand one specific rule about the color white.
Step 3: Load a PDF and Render Its Pages With PDF.js
Start app.js by importing both libraries directly from a CDN and pointing PDF.js at its worker script. This tutorial was built and tested against pdfjs-dist 6.2.108 and pdf-lib 1.17.1, the latest published versions of each at the time of writing:
import * as pdfjsLib from "https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.mjs";
import { PDFDocument } from "https://cdn.jsdelivr.net/npm/[email protected]/dist/pdf-lib.esm.min.js";
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.mjs";
Pinning exact version numbers in the URL, rather than using a floating tag like @latest, matters here: PDF.js has changed its module layout and worker setup across major versions before, and a page that silently starts loading a newer, incompatible build the day after you deploy it is a frustrating bug to track down.
Next, wire up the file input. When someone chooses a PDF, read it into a byte array and hand it to PDF.js:
const fileInput = document.getElementById("file-input");
const controls = document.getElementById("controls");
const pageListEl = document.getElementById("page-list");
const statusEl = document.getElementById("status");
let loadedPdfBytes = null;
let pdfDoc = null; // pdf.js document
let selectedPages = new Set();
fileInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
loadedPdfBytes = new Uint8Array(await file.arrayBuffer());
// pdf.js detaches/transfers the buffer it is given, so hand it a copy
// and keep loadedPdfBytes intact for pdf-lib to read later.
pdfDoc = await pdfjsLib.getDocument({ data: loadedPdfBytes.slice() }).promise;
statusEl.textContent = `Loaded ${file.name}: ${pdfDoc.numPages} page(s).`;
controls.style.display = "block";
});
The comment about copying the byte array is not a style preference, it is load-bearing. PDF.js takes ownership of the ArrayBuffer you give it and can detach (empty out) the original, so if you ever need those original bytes again later, as this tool does, hand PDF.js a copy with .slice() and keep the original untouched.
Test this much on its own: open the page, choose a PDF, and confirm the status line reports the right page count. With a real 3 page sample document, the status line read exactly:
Loaded sample.pdf: 3 page(s).
Step 4: List the Pages So the Reader Can Choose Which Ones to Stamp
Not every page in a document needs a stamp. A cover page, or a page you already approved, should stay untouched. Add this to the end of the file input handler, right after the status line update:
pageListEl.innerHTML = "";
selectedPages = new Set();
for (let i = 1; i <= pdfDoc.numPages; i++) {
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = String(i);
checkbox.checked = true;
checkbox.dataset.pageNum = String(i);
checkbox.addEventListener("change", () => {
if (checkbox.checked) selectedPages.add(i);
else selectedPages.delete(i);
});
selectedPages.add(i);
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` Page ${i}`));
pageListEl.appendChild(label);
pageListEl.appendChild(document.createElement("br"));
}
await renderPreview(1);
});
Every checkbox starts checked, and selectedPages starts as a fresh empty Set that gets refilled from scratch on every load. That reset matters: without clearing pageListEl.innerHTML and creating a new Set, loading a second PDF after the first would leave stale checkboxes from the old document sitting in the list. This tutorial’s test suite specifically re-uploads a second, five page PDF after the first three page one to confirm the list fully replaces itself rather than appending, and it does: the checkbox count goes from 3 to exactly 5, with all five checked by default.
Step 5: Draw the Color Overlay and Watermark Text
This is the function that does the actual stamping. It fills the whole page with a translucent color using whichever blend mode is selected, then draws a bold, rotated watermark word on top:
function drawStampOnContext(ctx, width, height) {
const color = document.getElementById("stamp-color").value;
const blendMode = document.getElementById("blend-mode").value;
const opacity = Number(document.getElementById("stamp-opacity").value) / 100;
const text = document.getElementById("stamp-text").value || "APPROVED";
ctx.save();
ctx.globalAlpha = opacity;
ctx.globalCompositeOperation = blendMode;
ctx.fillStyle = color;
ctx.fillRect(0, 0, width, height);
ctx.restore();
ctx.save();
ctx.globalAlpha = Math.min(1, opacity + 0.4);
ctx.fillStyle = color;
ctx.font = `bold ${Math.floor(width / 8)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.translate(width / 2, height / 2);
ctx.rotate(-Math.PI / 8);
ctx.fillText(text.toUpperCase(), 0, 0);
ctx.restore();
}
Why the save() and restore() Calls Matter
Both blocks wrap their drawing in ctx.save() and ctx.restore(). The canvas context is a single shared object; if you set globalCompositeOperation to multiply for the color fill and never reset it, every drawing operation after that, including the watermark text and whatever you draw on the next page, would silently inherit that same blend mode. save() snapshots the current settings, and restore() pops back to them, so each drawing step starts from a known, neutral state.
Rotating the Watermark Text
Canvas rotation always happens around the coordinate origin, which starts in the top left corner of the canvas. To rotate text around the center of the page instead, you first move the origin there with translate(width / 2, height / 2), then rotate, then draw the text at (0, 0), which is now the page center. -Math.PI / 8 is 22.5 degrees counterclockwise, a diagonal angle that reads clearly without running off the edges of a normal document page.
Step 6: Add a Live Preview (and Fix the Race Condition It Introduces)
A tool where you cannot see the stamp until after you commit to building the whole PDF is frustrating to use. Add a preview renderer:
const previewCanvas = document.getElementById("preview-canvas");
let activeRenderTask = null;
async function renderPreview(pageNum) {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.5 });
previewCanvas.width = viewport.width;
previewCanvas.height = viewport.height;
const ctx = previewCanvas.getContext("2d");
await page.render({ canvasContext: ctx, viewport, canvas: previewCanvas }).promise;
drawStampOnContext(ctx, viewport.width, viewport.height);
}
Then wire it to every control so the preview updates as the reader experiments:
document.getElementById("stamp-opacity").addEventListener("input", refreshPreview);
document.getElementById("stamp-text").addEventListener("input", refreshPreview);
document.getElementById("stamp-color").addEventListener("input", refreshPreview);
document.getElementById("blend-mode").addEventListener("change", refreshPreview);
function refreshPreview() {
if (!pdfDoc) return;
renderPreview(1);
}
The Bug: “Cannot Use the Same Canvas During Multiple Render Operations”
Wiring up those four listeners exactly as shown above and testing it by rapidly filling in a new stamp color, selecting a new blend mode, and typing new stamp text in quick succession (the same way a real person fidgeting with sliders and text boxes would) reliably threw this real, verbatim error in the browser console:
Cannot use the same canvas during multiple render() operations. Use different canvas or ensure previous operations were cancelled or completed.
Here is what causes it. Each keystroke or control change calls renderPreview(), which calls PDF.js’s page.render(). That call is asynchronous and takes a moment to finish painting. If a second keystroke fires renderPreview() again before the first render has finished, two render operations are now both targeting the exact same <canvas> element at once, and PDF.js explicitly forbids that.
The Fix: Cancel the Previous Render Before Starting a New One
PDF.js’s page.render() does not just return a promise, it returns a RenderTask object with its own .cancel() method built for exactly this situation. Track the current task, and cancel it the moment a new render starts:
async function renderPreview(pageNum) {
if (activeRenderTask) {
activeRenderTask.cancel();
}
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.5 });
previewCanvas.width = viewport.width;
previewCanvas.height = viewport.height;
const ctx = previewCanvas.getContext("2d");
const renderTask = page.render({ canvasContext: ctx, viewport, canvas: previewCanvas });
activeRenderTask = renderTask;
try {
await renderTask.promise;
} catch (err) {
if (err.name === "RenderingCancelledException") {
return; // a newer renderPreview() call has already taken over
}
throw err;
} finally {
if (activeRenderTask === renderTask) {
activeRenderTask = null;
}
}
drawStampOnContext(ctx, viewport.width, viewport.height);
}
Cancelling a render makes its promise reject with an error whose name is RenderingCancelledException, rather than resolving normally. The catch block checks for exactly that name and returns quietly, because a cancellation here just means a newer, more relevant render is already on its way; anything else in the catch block gets re-thrown, since that would be a real problem worth seeing.
After this fix, the same rapid-fire test (fill a new color, pick a new blend mode, type new text, all within a couple hundred milliseconds) ran with zero console errors, and the preview correctly reflected the final settings once everything settled.
Step 7: Rebuild the Stamped Pages Into a Downloadable PDF
The preview only ever touches page 1. The “Apply” button has to walk every page in the document, render each one at a higher resolution for output quality, stamp only the pages the reader checked, and hand the result to pdf-lib:
const applyBtn = document.getElementById("apply-btn");
const downloadLink = document.getElementById("download-link");
applyBtn.addEventListener("click", async () => {
statusEl.textContent = "Building stamped PDF...";
applyBtn.disabled = true;
try {
const outputPdf = await PDFDocument.create();
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 2 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
if (selectedPages.has(i)) {
drawStampOnContext(ctx, viewport.width, viewport.height);
}
const pngDataUrl = canvas.toDataURL("image/png");
const pngBytes = await fetch(pngDataUrl).then((res) => res.arrayBuffer());
const pngImage = await outputPdf.embedPng(pngBytes);
const newPage = outputPdf.addPage([viewport.width, viewport.height]);
newPage.drawImage(pngImage, { x: 0, y: 0, width: viewport.width, height: viewport.height });
}
const outputBytes = await outputPdf.save();
const blob = new Blob([outputBytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
downloadLink.href = url;
downloadLink.download = "stamped.pdf";
downloadLink.textContent = "Download stamped PDF";
downloadLink.style.display = "inline";
downloadLink.dataset.ready = "true";
statusEl.textContent = `Done. Stamped ${selectedPages.size} of ${pdfDoc.numPages} page(s).`;
} catch (err) {
statusEl.textContent = `Error: ${err.message}`;
console.error(err);
} finally {
applyBtn.disabled = false;
}
});
A quietly important detail here is canvas.toDataURL("image/png") followed by fetch() on that same data URL. That is a real, working pattern for turning a canvas into raw bytes in the browser, using fetch as a base64 decoder rather than writing one by hand, but it is not free: a data URL is a base64-encoded copy of the whole image sitting in a JavaScript string, roughly a third larger than the binary it represents, and every page holds two copies (the string and the decoded bytes) briefly in memory at once. For the documents this tool is meant for, letters, contracts, short reports, that overhead is invisible. The “Common Mistakes” section below has real numbers for what “invisible” means here.
Step 8: Test the Whole Flow for Real
Manually clicking through a browser is not a substitute for a repeatable test, especially for a tool with this many interdependent controls. Since this project has no Node.js tooling, Playwright’s Python bindings can drive a real Chromium browser directly:
from pathlib import Path
from playwright.sync_api import sync_playwright
HERE = Path(__file__).parent
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("http://127.0.0.1:8934/index.html")
page.set_input_files("#file-input", str(HERE / "sample.pdf"))
page.wait_for_selector("#controls[style='display: block;']", timeout=10000)
checkboxes = page.query_selector_all("#page-list input[type=checkbox]")
checkboxes[1].uncheck() # leave page 2 untouched, on purpose
page.fill("#stamp-text", "CONFIDENTIAL")
page.select_option("#blend-mode", "multiply")
page.fill("#stamp-color", "#e63946")
page.click("#apply-btn")
page.wait_for_selector("#download-link[data-ready='true']", timeout=15000)
print(page.inner_text("#status"))
browser.close()
Running this against the real tool printed:
Done. Stamped 2 of 3 page(s).
That confirms the count, but not that the right pages actually changed. To check the pixels themselves, the script downloads the generated PDF’s bytes straight out of the page’s blob URL (using page.evaluate() to run a small fetch-and-base64-encode snippet inside the browser) and saves them to disk, then a separate step opens that file with PyMuPDF and reads the center pixel of each page:
import pymupdf
doc = pymupdf.open("stamped_output.pdf")
for i, page in enumerate(doc, start=1):
pix = page.get_pixmap()
print(f"page {i} center pixel RGB:", pix.pixel(pix.width // 2, pix.height // 2))
The real, captured output:
page 1 center pixel RGB: (234, 90, 100)
page 2 center pixel RGB: (255, 255, 255)
page 3 center pixel RGB: (234, 90, 100)
Pages 1 and 3 (checked) picked up the reddish tint from the #e63946 stamp color. Page 2, the one deliberately unchecked in the test, came back pure white, untouched. That is a real, pixel-level confirmation that the “which pages get stamped” logic works, not just that the button ran without crashing.
The Blend Mode Gotcha Most Tutorials Don’t Mention
With the tool fully working, testing all five blend mode options against a plain white PDF page (the overwhelmingly common background for reports, letters, and contracts) produced a surprising result. Only multiply did anything visible:
multiply on white -> [247, 186, 191]
overlay on white -> [255, 255, 255]
screen on white -> [255, 255, 255]
color on white -> [255, 255, 255]
hue on white -> [255, 255, 255]
That is not a bug in this tool. It is exactly how those blend modes are defined to behave, and it is documented, if you know to look for it, in MDN’s own reference for globalCompositeOperation:
- screen is described by MDN as producing “a lighter picture,” the opposite of multiply. White is already the lightest possible pixel value, so there is no room left to lighten it any further; the math resolves back to white no matter what color you screen onto it.
- overlay is, in MDN’s words, “a combination of multiply and screen”: it darkens dark backdrop pixels and lightens light ones. A pure white backdrop is already at the lightest extreme, so overlay’s lightening half is the only one that ever runs, and it runs into the same ceiling screen does.
- color MDN describes as preserving “the luma of the bottom layer, while adopting the hue and chroma of the top layer.” White’s luma (brightness) is already at its maximum, and forcing any hue onto a pixel whose lightness is pinned at 100% still renders as white.
- hue goes a step further and preserves both the luma and the chroma (saturation) of the backdrop, changing only its hue. A perfectly neutral white or gray pixel has zero saturation to begin with, and a hue applied to zero saturation has nothing to act on.
That last point was confirmed directly: re-running the same test against a page filled with a plain, unsaturated 60% gray background (not white, but still neutral, with no color of its own) showed overlay, screen, and color all become clearly visible, while hue alone stayed exactly at the original gray, unchanged:
multiply on gray -> [148, 112, 115]
overlay on gray -> [182, 133, 137]
screen on gray -> [185, 161, 163]
color on gray -> [189, 137, 141]
hue on gray -> [153, 153, 153] (identical to the unstamped background)
The practical takeaway for a tool like this one: multiply is the only blend mode that reliably tints a normal, white-background document, which is why it is this tool’s default. The other four only become useful once your backdrop already has some color or shading of its own, a scanned photo, a colored letterhead, a chart. Keep them in the dropdown for that case, but do not expect them to do anything on an ordinary white page, and now you know exactly why, instead of just guessing that something is broken.
Common Mistakes and How to Catch Them
Forgetting ctx.save() and ctx.restore()
If you skip these, a blend mode or alpha value set for the color fill will bleed into the watermark text, and worse, into whatever the next page renders, since PDF.js reuses the same 2D context object across pages unless you create a fresh canvas each time (which the Apply step in this tutorial does deliberately, for exactly this reason).
Not Cancelling In-Flight Renders
Any UI that lets a reader change a setting and immediately re-renders a PDF.js canvas in response needs the cancellation pattern from Step 6. Skipping it will not fail every time, since it depends on how fast someone interacts with the controls relative to how fast the browser can paint, which is exactly the kind of intermittent bug that is painful to reproduce later if you do not build the fix in from the start.
Expecting Every Blend Mode to Look the Same on Every Document
As the “Blend Mode Gotcha” section above covers in depth, the visual result of overlay, screen, color, and hue depends entirely on what is already on the page. Test any blend mode picker against both a plain white page and a page with real color or shading before assuming it works.
Watch Memory on Very Large PDFs
Because the Apply step’s canvas.toDataURL() plus fetch() pattern keeps a base64 string and a decoded byte array in memory for every page it processes, a real, measured test against a 20 page document (rendered at the same scale of 2 this tool uses for final output) took 2.18 seconds and produced a 0.67 MB file. That scales roughly linearly, so a few hundred pages is still very manageable in a browser tab, but if you were adapting this for a truly enormous document, batching the loop or using canvas.toBlob() (which avoids the base64 detour entirely) would be worth the extra complexity.
How to Verify It All Works End to End
- Serve the folder with
python -m http.server 8934and openhttp://127.0.0.1:8934/index.html. - Upload any PDF. Confirm the status line shows the correct page count and a checkbox appears for every page, all checked by default.
- Uncheck one page. Change the stamp text, color, blend mode, and opacity, and confirm the preview canvas updates live after every single change, with no delay and no browser console errors.
- Click “Apply Stamp and Build PDF.” Confirm the status line reports the correct stamped count (for example, “Stamped 2 of 3 page(s)”) and a “Download stamped PDF” link appears.
- Open the downloaded PDF. The pages you left checked should show the tint and rotated watermark text; the page you unchecked should look exactly like the original, untouched.
- Upload a second, different PDF without reloading the browser tab. Confirm the page checkbox list fully replaces itself (the old document’s checkboxes should not linger) and the new page count is correct.
If every one of those checks passes, the tool is doing exactly what this tutorial built it to do.
Next Steps
From here, a few directions are worth exploring on your own. You could let the reader draw the stamp only over a selected rectangle of the page instead of the full sheet, which just means constraining the fillRect() call in drawStampOnContext() to a smaller region. You could add an “undo” that keeps the original loadedPdfBytes around (this tutorial’s code already preserves them, unused, for exactly that purpose) so a reader can start over without re-uploading. Or you could look at how this tutorial’s companion piece on building a browser-based PDF redaction tool uses the same PDF.js and pdf-lib combination for a very different goal: permanently removing content instead of tinting it. Comparing the two is a good way to see how far the same three building blocks, a renderer, a canvas, and a PDF writer, can stretch across genuinely different tools.








No Comment! Be the first one.