In this article, we'll explore how to implement a pure client-side PDF watermarking tool that runs entirely in the browser. This tool can add both text and image watermarks to PDF documents, making it perfect for branding, copyright protection, and document identification.
Why Browser-Based PDF Watermarking?
Traditional PDF watermarking typically requires:
- Uploading sensitive documents to a server
- Trusting third-party services with your files
- Potential privacy and security risks
Browser-based processing solves all these issues:
- ✅ Files never leave your computer - complete privacy
- ✅ No document transmission over the network
- ✅ Instant processing with no upload delays
- ✅ Zero server costs
- ✅ No risk of documents being stored or misused
The Challenge: PDF Watermarking
PDF watermarking is complex because:
- Watermarks must appear on every page
- Text watermarks need to support multiple languages (especially CJK characters)
- Watermarks should be semi-transparent and optionally rotated
- Image watermarks need proper scaling and positioning
- The original document content must remain intact and selectable
Architecture Overview
This tool uses a hybrid approach combining pdf-lib and QPDF:
Key Data Structures
Watermark Text Options
interface WatermarkTextOptions {
fontSize: number;
fontFamily: string;
color: string;
backgroundColor: string;
lineHeight: number;
padding: {
top: number;
right: number;
bottom: number;
left: number;
};
maxWidth: number;
textAlign: "left" | "center" | "right";
wrapText: boolean;
}
Text to Image Result
interface TextImageResult {
width: number;
height: number;
buffer: Uint8Array; // PNG image bytes
}
WorkerFunctions Interface (pdflib)
interface WorkerFunctions {
createWatermark: (
file: File,
text: { width: number; height: number; buffer: Uint8Array },
watermarkImage: File | null,
) => Promise<ArrayBuffer | null>;
// ... other functions
}
WorkerFunctions Interface (QPDF)
interface WorkerFunctions {
overlay: (
file: File,
overlayBuffer: ArrayBuffer,
) => Promise<ArrayBuffer | null>;
// ... other functions
}
Implementation Deep Dive
1. User Interface Component
The watermark component provides inputs for both text and image watermarks:
// app/[locale]/_components/qpdf/watermark.tsx
export const Watermark = () => {
const [files, setFiles] = useState<File[]>([]);
const { value: watermarkText, onChange: onChangeWatermarkText } =
useInputValue<string>("");
const [watermarkImage, setWatermarkImage] = useState<File | null>(null);
const { createWatermark } = usePdflib();
const { overlay } = useQpdf();
const t = useTranslations("Watermark");
const mergeInMain = async () => {
// Convert text to image (supports CJK characters)
const { width, height, buffer } = (await renderTextToImage(watermarkText, {
fontSize: 24,
fontFamily: '"PingFang SC", "Microsoft YaHei", SimSun, Arial, sans-serif',
color: "#333333",
lineHeight: 1.4,
padding: { top: 15, right: 20, bottom: 15, left: 20 },
maxWidth: 500,
textAlign: "left",
wrapText: true,
})) as { width: number; height: number; buffer: Uint8Array };
// Create watermark page with pdf-lib
const overlayBuffer = await createWatermark(
files[0]!,
{ width, height, buffer },
watermarkImage,
);
// Apply watermark using QPDF overlay
const outputFile = await overlay(files[0]!, overlayBuffer!);
if (outputFile) {
autoDownloadBlob(new Blob([outputFile]), "watermark.pdf");
}
};
return (
<PdfPage
process={mergeInMain}
onFiles={onPdfFiles}
title={t("title")}
desp={t("desp")}
>
<div>
<fieldset className="fieldset">
<label className="fieldset-legend">Watermark Text</label>
<input
type="text"
className="input"
placeholder="Enter watermark text"
onChange={onChangeWatermarkText}
/>
<label className="fieldset-legend">Watermark Image</label>
<PdfSelector onPdfFiles={onPdfFilesInternal} />
</fieldset>
</div>
</PdfPage>
);
};
Key design decisions:
- Text is converted to image for CJK character support
- Both text and image watermarks can be combined
- Two-step process: create watermark page, then overlay
2. Text to Image Conversion
Converts text to PNG image using HTML Canvas (supports all Unicode characters):
// utils/text2image.js
export async function renderTextToImage(text, options = {}) {
const {
fontSize = 16,
fontFamily = "SimSun, Microsoft YaHei, Arial, sans-serif",
color = "#000000",
backgroundColor = "transparent",
lineHeight = 1.2,
padding = { top: 10, right: 10, bottom: 10, left: 10 },
maxWidth = 500,
textAlign = "left",
wrapText = true,
} = options;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Set font for measurement
ctx.font = `${fontSize}px ${fontFamily}`;
// Handle multi-line text
let lines = [text];
if (wrapText && text.length > 0) {
lines = wrapTextToLines(
ctx,
text,
maxWidth - padding.left - padding.right,
fontSize,
lineHeight,
);
}
// Calculate dimensions
const textWidth = Math.max(
...lines.map((line) => Math.ceil(ctx.measureText(line).width)),
);
const textHeight = lines.length * fontSize * lineHeight;
const canvasWidth = textWidth + padding.left + padding.right;
const canvasHeight = textHeight + padding.top + padding.bottom;
// Set canvas size
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Clear and set background
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Set text style
ctx.fillStyle = color;
ctx.textBaseline = "top";
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = textAlign;
// Draw multi-line text
const startX =
padding.left + (textAlign === "center" ? (canvasWidth - textWidth) / 2 : 0);
let currentY = padding.top;
lines.forEach((line) => {
ctx.fillText(line, startX, currentY);
currentY += fontSize * lineHeight;
});
// Convert to PNG
return new Promise((resolve) => {
canvas.toBlob((blob) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
width: canvasWidth,
height: canvasHeight,
buffer: new Uint8Array(reader.result),
});
reader.readAsArrayBuffer(blob);
}, "image/png");
});
}
// Auto-wrap text to lines
function wrapTextToLines(ctx, text, maxWidth, fontSize, lineHeight) {
const words = text.split(""); // Split by character for CJK
const lines = [];
let currentLine = "";
words.forEach((word) => {
const testLine = currentLine + word;
const metrics = ctx.measureText(testLine);
const testWidth = Math.ceil(metrics.width);
if (testWidth > maxWidth && currentLine !== "") {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
Why convert text to image?
- Supports all Unicode characters (Chinese, Japanese, Korean, Arabic, etc.)
- No need to embed large font files in the PDF
- Consistent rendering across all PDF viewers
- Smaller final PDF size
3. Image Selector Component
A simple file picker for watermark images:
// app/[locale]/_components/pdfselector.tsx
export const PdfSelector = ({ onPdfFiles }: PdfSelectorProps) => {
const filesHandle = useRef<FileSystemFileHandle[]>([]);
const [files, setFiles] = useState<File[]>([]);
async function openFile() {
try {
const f = await window?.showOpenFilePicker({
multiple: true,
types: [
{
description: "Image File",
accept: {
"image/jpeg": [".jpg", ".jpeg"],
"image/png": [".png"],
},
},
],
mode: "read",
});
if (!f || f.length == 0) return;
const filesInternal = await Promise.all(
f.map(async (e) => await e.getFile()),
);
setFiles(filesInternal);
} catch (error) {
console.error("Error opening file:", error);
}
}
useEffect(() => {
onPdfFiles(files);
}, [files]);
return (
<div className="h-full">
<div className="flex w-full items-center justify-center">
<button
onClick={openFile}
className="rounded-lg bg-blue-600 px-4 py-2 text-white"
>
Select Image
</button>
</div>
{files.length > 0 && (
<Image
className="h-48"
src={URL.createObjectURL(files[0]!)}
alt={files[0]!.name}
fill
style={{ objectFit: "contain" }}
/>
)}
</div>
);
};
4. Watermark Page Creation (pdf-lib Worker)
Creates a watermark page with text and/or image:
// hooks/pdflib.worker.js
async function createWatermark(file, text, watermarkImage) {
const existingPdfBytes = await file.arrayBuffer();
let imageBytes = null;
if (watermarkImage) {
imageBytes = await watermarkImage.arrayBuffer();
}
const pdfDoc = await PDFDocument.load(existingPdfBytes);
const pages = pdfDoc.getPages();
const firstPage = pages[0];
const { width, height } = firstPage.getSize();
const newDoc = await PDFDocument.create();
// Embed watermark image if provided
let embeddImage = null;
if (imageBytes) {
if (watermarkImage.type === "image/jpeg") {
embeddImage = await newDoc.embedJpg(imageBytes);
} else {
embeddImage = await newDoc.embedPng(imageBytes);
}
}
const newPage = newDoc.addPage([width, height]);
// Draw text watermark as image
if (text && text.buffer) {
const { width: textWidth, height: textHeight, buffer } = text;
const textImage = await newDoc.embedPng(buffer);
// Create grid of watermarks
const rows = 6;
const columns = Math.round(width / (textWidth + 20));
for (let row = 0; row < rows; ++row) {
for (let column = 0; column < columns; ++column) {
newPage.drawImage(textImage, {
x: column * (width / columns) + 20,
y: row * (height / rows) + 30,
width: textWidth,
height: textHeight,
opacity: 0.3, // Semi-transparent
rotate: degrees(45), // Rotated 45 degrees
});
}
}
}
// Draw image watermark if provided
if (embeddImage) {
const rows = 6;
const columns = 6;
for (let row = 0; row < rows; ++row) {
for (let column = 0; column < columns; ++column) {
newPage.drawImage(embeddImage, {
x: column * (width / columns) + 20,
y: row * (height / rows) + 30,
width: width / rows - 20,
height: height / columns - 20,
opacity: 0.3,
});
}
}
}
const pdfBytes = await newDoc.save();
return pdfBytes;
}
Watermark layout:
- Grid pattern: 6 rows × dynamic columns
- 30% opacity for subtle appearance
- 45-degree rotation for text watermarks
- Spaced evenly across the page
5. Watermark Overlay (QPDF Worker)
Applies the watermark page as an overlay using QPDF:
// hooks/pdf.worker.js
async overlay(file, overlayArraybuffer) {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Write input files to virtual filesystem
qpdf.FS.writeFile(`/input.pdf`, uint8Array);
qpdf.FS.writeFile(`/watermark.pdf`, new Uint8Array(overlayArraybuffer));
// Build QPDF overlay command
const params = [
"/input.pdf",
"--overlay",
"/watermark.pdf",
"--repeat=1", // Repeat watermark on every page
"--",
"/output.pdf",
];
console.log("Overlay params:", params);
// Execute QPDF
qpdf.callMain(params);
// Read output from virtual filesystem
const outputFile = qpdf.FS.readFile("/output.pdf");
return outputFile;
}
QPDF overlay command:
# Overlay watermark.pdf onto every page of input.pdf
qpdf input.pdf --overlay watermark.pdf --repeat=1 -- output.pdf
Command explanation:
-
--overlay: Specify the watermark PDF to overlay -
--repeat=1: Apply watermark to every page - The watermark page is repeated on each page of the input PDF
Complete Processing Flow
Key Technical Decisions
1. Why Two-Step Process?
The watermarking uses two steps:
- pdf-lib: Creates the watermark page with complex layout
- QPDF: Efficiently overlays the watermark on all pages
Benefits:
- pdf-lib provides flexible drawing API
- QPDF provides efficient page overlay
- Separation of concerns
2. Why Text to Image Conversion?
Converting text to image solves several problems:
- CJK Support: Canvas can render any Unicode character
- No Font Embedding: Avoids 10+ MB font files in PDF
- Consistent Rendering: Same appearance in all PDF viewers
- Rotation: Easy to rotate text watermarks
3. Why Grid Layout?
The grid layout (6×6) ensures:
- Watermark appears across entire page
- Even distribution
- Professional appearance
- Hard to remove (covers most content)
4. Opacity and Rotation
- 30% opacity: Visible but doesn't obscure content
- 45° rotation: Harder to remove, covers more area
- Grid spacing: Balanced coverage without overwhelming
Benefits of This Architecture
- Privacy First: Files never leave the browser
- CJK Support: Full Unicode text watermarking
- Flexible: Text and/or image watermarks
- Professional: Grid layout with rotation and opacity
- Fast: QPDF efficiently applies watermarks to all pages
- No Server Required: Zero backend infrastructure
Try It Yourself
Want to add watermarks to your PDFs without uploading them to a server? Try our free browser-based tool:
All processing happens locally in your browser - your files never leave your computer!
Conclusion
Building a browser-based PDF watermarking tool demonstrates how combining pdf-lib and QPDF can handle complex PDF manipulation tasks. The text-to-image conversion enables full Unicode support, while the two-step process (create watermark page, then overlay) provides both flexibility and efficiency.
This approach is ideal for:
- Adding copyright notices to documents
- Branding company documents
- Protecting confidential information
- Creating draft/watermarked versions
- Supporting multilingual watermarks
The grid layout with rotation and opacity creates professional-looking watermarks that are visible but not intrusive, making this tool suitable for a wide range of document protection and branding needs.


Top comments (0)