DEV Community

monkeymore studio
monkeymore studio

Posted on

Building a Browser-Based PDF Watermark Tool with pdf-lib and QPDF

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;
}
Enter fullscreen mode Exit fullscreen mode

Text to Image Result

interface TextImageResult {
  width: number;
  height: number;
  buffer: Uint8Array;  // PNG image bytes
}
Enter fullscreen mode Exit fullscreen mode

WorkerFunctions Interface (pdflib)

interface WorkerFunctions {
  createWatermark: (
    file: File,
    text: { width: number; height: number; buffer: Uint8Array },
    watermarkImage: File | null,
  ) => Promise<ArrayBuffer | null>;
  // ... other functions
}
Enter fullscreen mode Exit fullscreen mode

WorkerFunctions Interface (QPDF)

interface WorkerFunctions {
  overlay: (
    file: File,
    overlayBuffer: ArrayBuffer,
  ) => Promise<ArrayBuffer | null>;
  // ... other functions
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

QPDF overlay command:

# Overlay watermark.pdf onto every page of input.pdf
qpdf input.pdf --overlay watermark.pdf --repeat=1 -- output.pdf
Enter fullscreen mode Exit fullscreen mode

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:

  1. pdf-lib: Creates the watermark page with complex layout
  2. 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

  1. Privacy First: Files never leave the browser
  2. CJK Support: Full Unicode text watermarking
  3. Flexible: Text and/or image watermarks
  4. Professional: Grid layout with rotation and opacity
  5. Fast: QPDF efficiently applies watermarks to all pages
  6. 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:

Add Watermark to PDF Online →

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)