DEV Community

ZerocloudPDF
ZerocloudPDF

Posted on

How to Stamp a Digital Signature Across Every Page of a PDF Using pdf-lib and HTML5 Canvas

Most PDF signature workflows force you to upload your document to a remote server. The file travels over HTTPS, sits in temporary storage, gets processed by a backend service you cannot inspect, and hopefully gets deleted afterward. For contracts, legal documents, or anything containing personal data, this is an unacceptable tradeoff.

There is a better way. Modern browsers can capture a signature, embed it into a PDF, and generate the final document without a single network request carrying your file bytes. This post walks through the exact pipeline behind ZeroCloudPDF's Sign PDF tool.

Architecture Overview

The pipeline has three layers. First, an HTML5 Canvas captures the signature — drawn, uploaded as a photo, or typed. Second, pdf.js renders the target PDF page so the user can position the signature visually. Third, pdf-lib embeds the signature image into the actual PDF and produces the final bytes.

All three libraries run inside the browser. No server receives your document. No API key is required. The entire flow works in airplane mode.

Layer 1: Capturing the Signature

The capture surface is a standard HTML5 Canvas, listening for pointer events and drawing strokes between coordinates. Pointer events unify mouse and touch input, so no separate touch handlers are needed.

canvas.addEventListener('pointerdown', (e) => {
  drawing = true;
  lastX = e.offsetX;
  lastY = e.offsetY;
});

canvas.addEventListener('pointermove', (e) => {
  if (!drawing) return;
  ctx.beginPath();
  ctx.moveTo(lastX, lastY);
  ctx.lineTo(e.offsetX, e.offsetY);
  ctx.stroke();
  lastX = e.offsetX;
  lastY = e.offsetY;
});
Enter fullscreen mode Exit fullscreen mode

The raw canvas export isn't what gets embedded, though. A signature drawn in the corner of a 460×180 canvas leaves a lot of transparent dead space, and that dead space becomes a visible offset once it's placed on the page. The fix is to scan the alpha channel for the actual ink bounds and export just that region, plus a small margin:

function cropCanvasToContent(canvas, padding = 6) {
  const w = canvas.width, h = canvas.height;
  const ctx = canvas.getContext('2d');
  const { data } = ctx.getImageData(0, 0, w, h);

  let minX = w, minY = h, maxX = -1, maxY = -1;
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      if (data[(y * w + x) * 4 + 3] > 8) { // alpha threshold
        if (x < minX) minX = x;
        if (x > maxX) maxX = x;
        if (y < minY) minY = y;
        if (y > maxY) maxY = y;
      }
    }
  }
  if (maxX < minX) return canvas.toDataURL('image/png');

  minX = Math.max(0, minX - padding);
  minY = Math.max(0, minY - padding);
  maxX = Math.min(w - 1, maxX + padding);
  maxY = Math.min(h - 1, maxY + padding);

  const cropped = document.createElement('canvas');
  cropped.width = maxX - minX + 1;
  cropped.height = maxY - minY + 1;
  cropped.getContext('2d').drawImage(
    canvas, minX, minY, cropped.width, cropped.height, 0, 0, cropped.width, cropped.height
  );
  return cropped.toDataURL('image/png');
}
Enter fullscreen mode Exit fullscreen mode

This same function handles all three capture modes — drawn, typed, and uploaded — since all three can produce a canvas with more transparent padding than actual ink.

Uploaded photos need one more step. If someone signs on paper and uploads a photo, the background is rarely a perfect transparent PNG — it's a white or off-white rectangle. A naive per-pixel brightness threshold strips most of that:

const BRIGHTNESS_THRESHOLD = 235;

function removeNearWhiteBackground(img) {
  const off = document.createElement('canvas');
  off.width = img.naturalWidth;
  off.height = img.naturalHeight;
  const ctx = off.getContext('2d');
  ctx.drawImage(img, 0, 0);

  const imageData = ctx.getImageData(0, 0, off.width, off.height);
  const data = imageData.data;
  for (let i = 0; i < data.length; i += 4) {
    const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
    if (brightness > BRIGHTNESS_THRESHOLD) data[i + 3] = 0;
  }
  ctx.putImageData(imageData, 0, 0);
  return cropCanvasToContent(off);
}
Enter fullscreen mode Exit fullscreen mode

Worth being honest about the limits here: this is a naive threshold, not real background segmentation. It works well for ink on plain, evenly lit white paper. It will not reliably handle shadows, textured paper, or colored paper — a shadowed patch can stay under the threshold and remain opaque, and dark paper can vanish along with the background. We don't market it as full background removal anywhere in the product; it's a best-effort convenience for the common case.

Layer 2: Rendering the PDF for Visual Positioning

Before embedding, the user needs to see where the signature lands. pdf.js renders the page to a canvas and hands back a viewport object that maps PDF points to CSS pixels — the bridge between the visual interface and the document's coordinate system.

async function renderPage(pdfBytes, pageNumber, targetCanvas) {
  const pdf = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
  const page = await pdf.getPage(pageNumber);
  const viewport = page.getViewport({ scale: 1.5 });

  targetCanvas.width = viewport.width;
  targetCanvas.height = viewport.height;
  await page.render({ canvasContext: targetCanvas.getContext('2d'), viewport }).promise;

  return viewport;
}
Enter fullscreen mode Exit fullscreen mode

The scale factor only affects preview sharpness. The signature's actual size in the exported PDF is computed independently, in PDF points, not preview pixels.

Layer 3: Embedding with pdf-lib — and the Bug That Actually Matters

This is where most tutorials get it wrong, and where we did too, the first time.

PDF uses a coordinate system with the origin at the bottom-left of the page. Canvas uses top-left. So converting a click position requires flipping the Y axis:

const pdfX = clickXRatio * pageWidth;
const pdfY = pageHeight - (clickYRatio * pageHeight);
Enter fullscreen mode Exit fullscreen mode

That part is the easy bug to catch — skip the flip and your signature lands on the wrong edge, obviously and immediately.

The harder bug: drawImage in pdf-lib treats (x, y) as the image's own corner, not its center. On screen, a signature field is visually centered on wherever the user clicked or dragged it. If you feed the raw click point straight into drawImage as (x, y), you've anchored the image's corner to that point instead of its center — so the exported signature sits shifted right and down from where it visually appeared in the preview. Since a signature is normally much wider than it is tall, the horizontal shift is the one people notice first; the vertical shift is smaller but just as real.

The fix is to subtract half the drawn width and height, re-centering the image on the same point the preview centers it on:

function signAllPages(pdfDoc, signatureImage, field) {
  const pageWidth = /* page.getWidth() */;
  const pageHeight = /* page.getHeight() */;

  // field.widthRatio / heightRatio are fractions of the page — this lets
  // a resized signature field export at its resized size, not a fixed one.
  const sigWidthPt = field.widthRatio * pageWidth;
  const sigHeightPt = field.heightRatio * pageHeight;

  const pdfX = (field.xRatio * pageWidth) - (sigWidthPt / 2);
  const pdfY = pageHeight - (field.yRatio * pageHeight) - (sigHeightPt / 2);

  pdfDoc.getPages().forEach((page) => {
    page.drawImage(signatureImage, { x: pdfX, y: pdfY, width: sigWidthPt, height: sigHeightPt });
  });
}
Enter fullscreen mode Exit fullscreen mode

Because embedPng is called once and the same image object is reused across every drawImage call, pdf-lib stores the signature as a single PDF XObject and has every page reference it by name — not a hundred embedded copies for a hundred pages. Output size grows by roughly one PNG, regardless of page count.

Verifying Zero Server Contact

Two ways to check this yourself. First, open DevTools, filter the Network tab to Fetch/XHR, and run the signing flow — if nothing appears past the initial page load, your file stayed local. Second, turn on airplane mode after the page loads and try signing a PDF. It should work exactly the same.

Why This Matters for Document Workflows

Legal contracts, rental agreements, and financial forms all require signatures. Uploading these to a server-based tool means trusting that server with the full text of the contract, the identities of all parties, and the final signed output. A browser-based pipeline removes that trust requirement entirely — there's nothing to trust because nothing is sent.

If you're starting from a photo of a signed page rather than a digital PDF, the image to PDF converter handles that conversion locally first. For a full walkthrough of the signing flow end to end, see the detailed guide on the ZeroCloudPDF blog.

Conclusion

Client-side PDF signing isn't a theoretical exercise — pdf-lib, pdf.js, and the Canvas API are enough to build a complete pipeline that matches server-based tools on functionality while removing the privacy tradeoff entirely. The coordinate math is the genuinely hard part, and getting the corner-vs-center anchoring right is the detail that separates a demo from something people can actually rely on. Everything else is standard browser API usage.

Written by the ZeroCloudPDF team. We build browser-based PDF tools that never upload your files to a server.

Top comments (0)