I needed a dead-simple way to stamp PDFs with "DRAFT" or "CONFIDENTIAL" without sending them to a server. Most tools want you to upload the file, wait for processing, then download it. For sensitive documents that's a dealbreaker.
So I built en.sotool.top/watermark — pick a PDF, type some text, pick a position, and get a watermarked file. All in the browser. No server involved.
Here's how it works under the hood with Vue 3 and pdf-lib.
Why Client-Side?
The obvious reason is privacy. Contracts, drafts, financials — people don't want them on a stranger's server. Beyond that:
- No upload bandwidth limits
- No file size caps from your backend
- Nothing to clean up on a server
- Works offline after the page loads
The catch? You're limited by what the browser can do. For watermarks, pdf-lib is perfect because it can overlay an image onto existing pages without re-rendering the whole PDF.
The Stack
- Vue 3 — UI and state
- pdf-lib — Load, modify, save PDFs
- HTML Canvas — Draw rotated text with transparency
- File API — Read the uploaded PDF
npm install pdf-lib
Loading the PDF
First, read the file into an ArrayBuffer and load it with pdf-lib.
import { PDFDocument } from 'pdf-lib';
async function loadPdf(file) {
const arrayBuffer = await file.arrayBuffer();
return PDFDocument.load(arrayBuffer);
}
That gives you a document object with access to every page.
Generating the Watermark Image
The trick is drawing the watermark text onto an HTML Canvas, then embedding that canvas as a PNG. This lets you control font, color, rotation, and transparency however you want.
function createWatermarkCanvas(text, options = {}) {
const {
fontSize = 60,
color = 'rgba(128, 128, 128, 0.3)',
rotation = -45,
fontFamily = 'Arial',
} = options;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = `${fontSize}px ${fontFamily}`;
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
const textHeight = fontSize;
// Size the canvas to fit the rotated text
const diagonal = Math.sqrt(textWidth ** 2 + textHeight ** 2);
const size = Math.ceil(diagonal + fontSize);
canvas.width = size;
canvas.height = size;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.translate(size / 2, size / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.fillText(text, 0, 0);
return canvas;
}
A few things worth noting:
- The canvas is sized to the text diagonal so rotated text doesn't get clipped.
- Negative rotation gives that classic bottom-left-to-top-right watermark look.
- The
rgbacolor bakes transparency straight into the PNG.
Convert it to a PNG blob:
async function canvasToPngBytes(canvas) {
return new Promise((resolve) => {
canvas.toBlob(async (blob) => {
const arrayBuffer = await blob.arrayBuffer();
resolve(new Uint8Array(arrayBuffer));
}, 'image/png');
});
}
Drawing It on Every Page
Embed the PNG into the PDF and draw it on each page.
async function addWatermarkToPdf(pdfDoc, watermarkBytes, position = 'center') {
const watermarkImage = await pdfDoc.embedPng(watermarkBytes);
const pages = pdfDoc.getPages();
pages.forEach((page) => {
const { width, height } = page.getSize();
const imgDims = watermarkImage.scale(1);
const { x, y } = calculatePosition(
width,
height,
imgDims.width,
imgDims.height,
position
);
page.drawImage(watermarkImage, {
x,
y,
width: imgDims.width,
height: imgDims.height,
opacity: 0.3,
});
});
return pdfDoc;
}
pdf-lib uses a bottom-left origin, not top-left like the canvas. That's easy to forget and will bite you the first time you place a watermark.
The 9-Position Grid
Users usually want a watermark in one of nine spots. The math is straightforward once you remember the coordinate system.
function calculatePosition(pageW, pageH, imgW, imgH, position) {
const margin = 20;
const positions = {
'top-left': { x: margin, y: pageH - imgH - margin },
'top-center': { x: (pageW - imgW) / 2, y: pageH - imgH - margin },
'top-right': { x: pageW - imgW - margin, y: pageH - imgH - margin },
'center-left': { x: margin, y: (pageH - imgH) / 2 },
'center': { x: (pageW - imgW) / 2, y: (pageH - imgH) / 2 },
'center-right': { x: pageW - imgW - margin, y: (pageH - imgH) / 2 },
'bottom-left': { x: margin, y: margin },
'bottom-center': { x: (pageW - imgW) / 2, y: margin },
'bottom-right': { x: pageW - imgW - margin, y: margin },
};
return positions[position] || positions['center'];
}
Tiled watermarks would loop over a grid instead. The current tool only does single-position placement, which covers most real use cases.
Image Watermarks
For logos, read the uploaded image and embed it directly.
async function addImageWatermark(pdfDoc, imageFile, position = 'center') {
const imageBytes = new Uint8Array(await imageFile.arrayBuffer());
let watermarkImage;
if (imageFile.type === 'image/png') {
watermarkImage = await pdfDoc.embedPng(imageBytes);
} else if (imageFile.type === 'image/jpeg' || imageFile.type === 'image/jpg') {
watermarkImage = await pdfDoc.embedJpg(imageBytes);
} else {
throw new Error('Use PNG or JPEG');
}
// Don't let a giant logo cover the whole page
const maxWidth = 200;
const scale = Math.min(1, maxWidth / watermarkImage.width);
const imgDims = watermarkImage.scale(scale);
const pages = pdfDoc.getPages();
pages.forEach((page) => {
const { width, height } = page.getSize();
const { x, y } = calculatePosition(width, height, imgDims.width, imgDims.height, position);
page.drawImage(watermarkImage, { x, y, width: imgDims.width, height: imgDims.height, opacity: 0.5 });
});
return pdfDoc;
}
A Minimal Vue 3 Component
Here's a stripped-down version you can actually run:
<script setup>
import { ref } from 'vue';
import { PDFDocument } from 'pdf-lib';
const file = ref(null);
const watermarkText = ref('DRAFT');
const position = ref('center');
const loading = ref(false);
function createWatermarkCanvas(text, options = {}) {
const { fontSize = 60, color = 'rgba(128, 128, 128, 0.3)', rotation = -45 } = options;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = `${fontSize}px Arial`;
const metrics = ctx.measureText(text);
const size = Math.ceil(Math.sqrt(metrics.width ** 2 + fontSize ** 2) + fontSize);
canvas.width = size;
canvas.height = size;
ctx.font = `${fontSize}px Arial`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.translate(size / 2, size / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.fillText(text, 0, 0);
return canvas;
}
function calculatePosition(pageW, pageH, imgW, imgH, pos) {
const margin = 20;
const map = {
'top-left': { x: margin, y: pageH - imgH - margin },
'top-center': { x: (pageW - imgW) / 2, y: pageH - imgH - margin },
'top-right': { x: pageW - imgW - margin, y: pageH - imgH - margin },
'center-left': { x: margin, y: (pageH - imgH) / 2 },
'center': { x: (pageW - imgW) / 2, y: (pageH - imgH) / 2 },
'center-right': { x: pageW - imgW - margin, y: (pageH - imgH) / 2 },
'bottom-left': { x: margin, y: margin },
'bottom-center': { x: (pageW - imgW) / 2, y: margin },
'bottom-right': { x: pageW - imgW - margin, y: margin },
};
return map[pos] || map['center'];
}
async function applyWatermark() {
if (!file.value) return;
loading.value = true;
try {
const arrayBuffer = await file.value.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const canvas = createWatermarkCanvas(watermarkText.value);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const watermarkBytes = new Uint8Array(await blob.arrayBuffer());
const watermarkImage = await pdfDoc.embedPng(watermarkBytes);
const imgDims = watermarkImage.scale(1);
const pages = pdfDoc.getPages();
pages.forEach((page) => {
const { width, height } = page.getSize();
const { x, y } = calculatePosition(width, height, imgDims.width, imgDims.height, position.value);
page.drawImage(watermarkImage, { x, y, width: imgDims.width, height: imgDims.height, opacity: 0.3 });
});
const pdfBytes = await pdfDoc.save();
const blobUrl = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
const a = document.createElement('a');
a.href = blobUrl;
a.download = 'watermarked.pdf';
a.click();
URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error(e);
alert('Failed to add watermark. Check the file.');
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<input type="file" accept=".pdf" @change="e => file = e.target.files[0]" />
<input v-model="watermarkText" placeholder="Watermark text" />
<select v-model="position">
<option v-for="p in ['top-left','top-center','top-right','center-left','center','center-right','bottom-left','bottom-center','bottom-right']" :key="p" :value="p">
{{ p }}
</option>
</select>
<button @click="applyWatermark" :disabled="loading">
{{ loading ? 'Processing...' : 'Download Watermarked PDF' }}
</button>
</div>
</template>
The production version adds drag-and-drop, a live preview, and image watermarks. That code lives in src/views/Watermark.vue in the repo.
Lessons Learned
Canvas sizing will trip you up. If the canvas is too small, rotated text gets clipped at the corners. Size it to the diagonal of the text bounding box plus padding.
PDF coordinates start at the bottom-left. This is the opposite of canvas. I placed a watermark just off the top edge more than once before I got the y math right.
pdf-lib overlays, it doesn't re-render. The watermark sits on top of the existing content. That's usually what you want, but if you need it behind the content you have to mess with content stream order — much harder.
Opacity stacks. You can set alpha in the canvas color, in the PNG, and again in drawImage. Combining 0.3 + 0.3 gives you 0.09, which is basically invisible. Pick one layer and stick with it.
Fonts are system fonts unless you load your own. The canvas uses whatever Arial-like font is available. For a custom font, load it with FontFace first or it'll fall back.
Try It
The watermark tool is live at en.sotool.top/watermark.
Free, no signup, nothing uploads to a server.
Full source is on GitHub. The watermark logic is in src/views/Watermark.vue.
Want More Advanced PDF Tools?
If you need OCR, form editing, digital signatures, or batch processing, Wondershare PDFelement is a solid desktop option. It keeps everything local.
This post contains affiliate links.
Have you built client-side PDF tools? What did you use — pdf-lib, PDF.js, or something else?
Top comments (0)