DEV Community

sunshey
sunshey

Posted on

How I Add Watermarks to PDFs in the Browser with Vue 3 and pdf-lib

Adding a watermark to a PDF sounds simple — draw some text over every page, right? But doing it client-side with consistent positioning, rotation, and transparency across all pages is trickier than it looks.

I built en.sotool.top/watermark/ to handle text and image watermarks entirely in the browser. Here's how it works with Vue 3 and pdf-lib.


Why Client-Side?

PDFs often contain sensitive information. Draft contracts, internal policies, design mockups. Even a simple watermark tool should not force users to upload files to a server.

Client-side benefits:

  • No upload bandwidth or size limits
  • No server storage or cleanup
  • Instant processing for normal files
  • Works offline after the page loads

The tradeoff is that pdf-lib doesn't have a built-in "watermark" API. You have to draw text or images onto each page manually.


The Stack

  • Vue 3 — UI and state
  • pdf-lib — Load, manipulate, and save PDFs
  • HTML5 Canvas — Render text watermarks as images
  • File API — Read the uploaded file
  • lucide-vue-next — Icons
npm install pdf-lib
Enter fullscreen mode Exit fullscreen mode

Loading the PDF

import { PDFDocument, rgb, degrees } from 'pdf-lib'

const pdfFile = ref<File | null>(null)
const totalPages = ref(0)
const watermarkText = ref('DRAFT')
const opacity = ref(0.2)
const fontSize = ref(72)
const angle = ref(-45)

async function handleFile(files: File[]) {
  if (files.length === 0) return
  pdfFile.value = files[0]
  const bytes = await files[0].arrayBuffer()
  const pdf = await PDFDocument.load(bytes)
  totalPages.value = pdf.getPageCount()
}
Enter fullscreen mode Exit fullscreen mode

Rendering Text as a Watermark Image

pdf-lib doesn't have a drawWatermark method, so we render text as a PNG using Canvas, then embed it.

async function createWatermarkImage(text: string, size: number, color: string) {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')!

  ctx.font = `bold ${size}px Arial, sans-serif`
  const metrics = ctx.measureText(text)
  const textW = metrics.width
  const textH = size * 1.2

  canvas.width = Math.ceil(textW)
  canvas.height = Math.ceil(textH)

  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.font = `bold ${size}px Arial, sans-serif`
  ctx.fillStyle = color
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(text, canvas.width / 2, canvas.height / 2)

  const blob = await new Promise<Blob | null>(resolve =>
    canvas.toBlob(resolve, 'image/png'),
  )
  if (!blob) throw new Error('Canvas to blob failed')
  return new Uint8Array(await blob.arrayBuffer())
}
Enter fullscreen mode Exit fullscreen mode

Key detail: we use textAlign: 'center' and textBaseline: 'middle' so the text is perfectly centered in the canvas. This makes positioning on the PDF page much more predictable.


Applying the Watermark to Each Page

async function addWatermark() {
  if (!pdfFile.value) return

  const bytes = await pdfFile.value.arrayBuffer()
  const pdf = await PDFDocument.load(bytes)
  const pages = pdf.getPages()

  const hex = hexToRgb(watermarkColor.value)
  const pngBytes = await createWatermarkImage(
    watermarkText.value,
    fontSize.value,
    `rgba(${hex.r}, ${hex.g}, ${hex.b}, ${opacity.value})`,
  )
  const watermarkImage = await pdf.embedPng(pngBytes)

  for (const page of pages) {
    const { width, height } = page.getSize()
    const imgDims = watermarkImage.scale(1)

    page.drawImage(watermarkImage, {
      x: (width - imgDims.width) / 2,
      y: (height - imgDims.height) / 2,
      width: imgDims.width,
      height: imgDims.height,
      rotate: degrees(angle.value),
      opacity: opacity.value,
    })
  }

  const blob = new Blob([await pdf.save()], { type: 'application/pdf' })
  downloadBlob(blob, 'watermarked.pdf')
}
Enter fullscreen mode Exit fullscreen mode

The watermark is centered on each page and rotated by the user-specified angle (typically -45° for diagonal watermarks).


Image Watermarks

For logo watermarks, the user uploads a PNG and we skip the Canvas rendering step:

async function loadImageWatermark(file: File) {
  const bytes = new Uint8Array(await file.arrayBuffer())
  const pngImage = await pdf.embedPng(bytes)
  return pngImage
}
Enter fullscreen mode Exit fullscreen mode

Transparent PNGs work best. Opaque images will cover the underlying content.


Lessons Learned

Canvas centering matters. Use textAlign: 'center' and textBaseline: 'middle' when rendering text to Canvas. Otherwise the watermark position on the PDF page will be offset.

Opacity is handled at the PDF level. pdf-lib supports an opacity property on drawImage, so we can set the watermark's transparency directly instead of relying on the Canvas alpha channel. This keeps the output file smaller.

Angle in degrees, not radians. pdf-lib's rotate expects a degrees() wrapped value, not a raw number. Forgetting to wrap it produces weird rotation angles.

Tile for large documents. For PDFs with many pages, consider tiling the watermark (multiple instances per page) rather than centering it once. The current implementation centers one watermark per page, which works well for most use cases.

Don't re-encode the PDF. Use copyPages or draw directly onto existing pages. Re-saving the entire PDF can increase file size unnecessarily.


Try It

The 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 Security?

If you need digital signatures, password protection, or batch watermarking across hundreds of files, Wondershare PDFelement is a solid desktop option.

This post contains affiliate links.


Have you built PDF manipulation tools in the browser? What rendering approach did you use?

Top comments (0)