Adding a watermark to a PDF sounds simple — stamp some text on every page. But doing it correctly means handling font measurement for accurate centering, applying opacity so the content stays readable, rotating text for the classic diagonal stamp, and supporting multiple position options.
Here's how the PDF Watermark tool at Ultimate Tools works under the hood — using pdf-lib for server-side processing and PDF.js for a live canvas preview.
Architecture: Server Route + Live Preview
The feature has two parts:
- Live preview — rendered client-side using PDF.js. As the user changes text, position, font size, or opacity, the canvas redraws immediately. No network request.
-
Final processing — handled server-side via a Next.js API route using pdf-lib. When the user clicks "Apply Watermark", the PDF is sent to
/api/watermark-pdf/, processed, and returned as a download.
This split keeps the preview fast while ensuring the actual watermark is embedded correctly in the PDF structure.
The API Route
// app/api/watermark-pdf/route.ts
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
export const runtime = 'nodejs';
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get('file') as File;
const rawText = (formData.get('text') as string) ?? 'CONFIDENTIAL';
const position = (formData.get('position') as WatermarkPosition) ?? 'center';
const fontSize = Math.max(12, Math.min(96, Number(formData.get('fontSize') ?? 48)));
const opacity = Math.max(0.05, Math.min(1, Number(formData.get('opacity') ?? 0.3)));
The text is sanitised before use to strip control characters:
function sanitiseText(text: string): string {
return text.replace(/[\x00-\x1F\x7F]/g, '').slice(0, 200).trim();
}
Loading the PDF and Embedding the Font
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: false,
});
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const pages = pdfDoc.getPages();
ignoreEncryption: false ensures password-protected PDFs throw an error rather than silently producing a broken output.
Per-Page Position Calculation
Each page in a PDF has its own dimensions (not all pages in a PDF are the same size), so we calculate coordinates per page:
for (const page of pages) {
const { width, height } = page.getSize();
const textWidth = font.widthOfTextAtSize(text, fontSize);
const textHeight = font.heightAtSize(fontSize);
let x: number, y: number;
let rotate = degrees(0);
switch (position) {
case 'top-left':
x = 40;
y = height - textHeight - 30;
break;
case 'top-right':
x = width - textWidth - 40;
y = height - textHeight - 30;
break;
case 'bottom-left':
x = 40;
y = 30;
break;
case 'bottom-right':
x = width - textWidth - 40;
y = 30;
break;
case 'center':
default:
x = width / 2 - textWidth / 2;
y = height / 2 - textHeight / 2;
rotate = degrees(45);
break;
}
Key points:
-
pdf-lib uses a bottom-left origin —
y = 0is the bottom of the page,y = heightis the top. This is the opposite of CSS. - The center position rotates the text 45° (diagonal stamp) and positions it in the middle of the page.
-
font.widthOfTextAtSize()andfont.heightAtSize()give us the exact text dimensions for the chosen font and size — important for accurate centering.
Drawing the Watermark Text
page.drawText(text, {
x,
y,
size: fontSize,
font,
color: rgb(0.5, 0.5, 0.5), // mid-grey
opacity,
rotate,
});
}
The opacity value ranges from 0.05 to 1.0. For a typical "CONFIDENTIAL" stamp, 0.3 (30%) is readable but doesn't obscure the underlying content.
Returning the Modified PDF
const pdfBytes = await pdfDoc.save();
const outputName = file.name.replace(/\.pdf$/i, '') + '_watermarked.pdf';
return new NextResponse(new Uint8Array(pdfBytes), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${outputName}"`,
'Content-Length': String(pdfBytes.byteLength),
},
});
Live Canvas Preview (Client-Side)
The preview runs in a useEffect whenever the PDF file or any watermark setting changes:
useEffect(() => {
if (!file || !canvasRef.current) return;
let cancelled = false;
async function renderPreview() {
const pdfjs = await import('/pdfjs.mjs');
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
if (cancelled) return;
const pdfPage = await pdf.getPage(1);
// ... scale to container width, render to canvas ...
// Then draw watermark overlay on top of the rendered page
const { text, position, fontSize, opacity } = settings;
ctx.save();
ctx.globalAlpha = opacity / 100;
ctx.fillStyle = '#888888';
ctx.font = `bold ${scaledFontSize}px Helvetica, Arial, sans-serif`;
if (position === 'center') {
ctx.translate(w / 2, h / 2);
ctx.rotate(-Math.PI / 4); // 45° diagonal
ctx.fillText(text, -textWidth / 2, textHeight / 2);
} else {
ctx.fillText(text, x, y);
}
ctx.restore();
}
renderPreview();
return () => { cancelled = true; };
}, [file, settings]);
The preview uses Canvas 2D's ctx.rotate() and ctx.translate() to mimic the server-side positioning. Note that Canvas rotation is clockwise (-Math.PI / 4 = 45° counter-clockwise), while pdf-lib's degrees(45) is counter-clockwise — so they match visually.
The cancelled flag handles cleanup if the component unmounts or settings change before the async render completes.
Edge Cases
Password-protected PDFs — pdf-lib throws on load with ignoreEncryption: false. The API returns a user-friendly error: "This PDF is password-protected and cannot be watermarked."
Variable page sizes — iterating pdfDoc.getPages() and calling page.getSize() per page handles mixed-size documents (e.g. portrait + landscape pages in one PDF).
Long text overflow — textWidth is measured before positioning. For corner positions, this means the text may still overflow on narrow pages. A production solution would either truncate or scale down the font.
Summary
| Concern | Solution |
|---|---|
| Text positioning |
font.widthOfTextAtSize() + font.heightAtSize() per page |
| Diagonal stamp |
degrees(45) with centered x/y |
| Opacity |
opacity param on page.drawText()
|
| Live preview | Canvas 2D mirrors server-side transform |
| Cleanup |
cancelled flag + renderTask.cancel()
|
The full tool is live at Watermark PDF — no upload, processes in the browser on the client side for preview, server-side for the final PDF.
Top comments (0)