DEV Community

sunshey
sunshey

Posted on • Originally published at smartontools.blogspot.com

How to Add Electronic Signatures to PDFs in the Browser (Vue 3 + HTML5 Canvas)

A client emailed me a contract last week. I needed to sign it and send it back. The usual options? Print it, sign with a pen, scan it back in. Or upload it to some online tool that processes it on their server.

Neither worked for me. So I built client-side PDF signing into my toolkit.

Here's how we add electronic signatures to PDFs entirely in the browser using Vue 3, HTML5 Canvas, and pdf-lib.


Why Client-Side?

Most online PDF signing works like this:

  1. Upload your PDF to their server
  2. They render a signature overlay
  3. You download the signed version

The problem: your contract, along with your signature, just sat on someone else's computer. For legal documents, that's not ideal.

Client-side signing solves this. The PDF never leaves the user's device. The browser loads the libraries, renders the signature, embeds it into the PDF, and downloads the result.


The Stack

  • Vue 3 — UI and state management
  • HTML5 Canvas — Signature rendering (both text and freehand)
  • Google Fonts — 12 signature-style web fonts
  • pdf-lib — PDF manipulation and image embedding
npm install pdf-lib
Enter fullscreen mode Exit fullscreen mode

Feature 1: Text-Based Signatures

Users type their name and pick a font style. We render it to a Canvas, trim the transparent edges, and embed it as a PNG into the PDF.

Loading and Rendering a Signature Font

// Google Fonts mapping
const fontLinks = {
  greatvibes: 'Great+Vibes',
  alexbrush: 'Alex+Brush',
  // ... 10 more styles
};

async function loadFont(styleKey) {
  const fontName = fontLinks[styleKey];
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  // Only load needed characters to minimize font size
  const text = encodeURIComponent(name.value || '');
  link.href = `https://fonts.googleapis.com/css2?family=${fontName}&text=${text}&display=swap`;
  document.head.appendChild(link);

  // Wait for font to load
  await document.fonts.load(`1em "${fontName.replace(/\+/g, ' ')}"`);
}
Enter fullscreen mode Exit fullscreen mode

Using the text= parameter reduces font downloads from ~200KB to ~5KB per font. For a name like "John Smith," we only load those 10 characters.

Rendering to Canvas

function renderSignature(canvas, name, style, color) {
  const ctx = canvas.getContext('2d');
  const dpr = window.devicePixelRatio || 1;
  const rect = canvas.getBoundingClientRect();

  // Scale for retina displays
  canvas.width = rect.width * dpr;
  canvas.height = 120 * dpr;
  ctx.scale(dpr, dpr);

  ctx.clearRect(0, 0, rect.width, 120);
  ctx.fillStyle = color;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // Auto-size based on name length
  let fontSize = Math.min(64, rect.width / (name.length * 0.6));
  fontSize = Math.max(24, fontSize);

  ctx.font = `${fontSize}px "${style.fontFamily}", cursive`;
  ctx.fillText(name, rect.width / 2, 60);
}
Enter fullscreen mode Exit fullscreen mode

Key detail: we scale the Canvas by devicePixelRatio so signatures look crisp on Retina displays. Without this, text looks blurry on MacBooks and modern phones.

Trimming Transparent Edges

Nobody wants a signature with a huge white bounding box. We trim the Canvas to the actual ink:

function trimCanvas(source) {
  const ctx = source.getContext('2d');
  const w = source.width;
  const h = source.height;
  const imgData = ctx.getImageData(0, 0, w, h);
  const data = imgData.data;

  let minX = w, minY = h, maxX = 0, maxY = 0;

  // Find bounding box of non-transparent pixels
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const alpha = data[(y * w + x) * 4 + 3];
      if (alpha > 0) {
        minX = Math.min(minX, x);
        minY = Math.min(minY, y);
        maxX = Math.max(maxX, x);
        maxY = Math.max(maxY, y);
      }
    }
  }

  if (maxX < minX) return source.toDataURL('image/png');

  const padding = 16;
  const trimW = maxX - minX + 1 + padding * 2;
  const trimH = maxY - minY + 1 + padding * 2;

  const trimmed = document.createElement('canvas');
  trimmed.width = trimW;
  trimmed.height = trimH;
  trimmed.getContext('2d')
    .putImageData(ctx.getImageData(minX, minY, trimW, trimH), padding, padding);

  return trimmed.toDataURL('image/png');
}
Enter fullscreen mode Exit fullscreen mode

Feature 2: Freehand Drawing

For users who prefer to draw their actual signature, we use a second Canvas with mouse/touch event handlers.

function startDrawing(e) {
  isDrawing = true;
  const { x, y } = getCoordinates(e);
  ctx.beginPath();
  ctx.moveTo(x, y);
}

function draw(e) {
  if (!isDrawing) return;
  const { x, y } = getCoordinates(e);
  ctx.lineTo(x, y);
  ctx.stroke();
}

function stopDrawing() {
  isDrawing = false;
}

// Handle both mouse and touch
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('touchstart', (e) => {
  e.preventDefault(); // Prevent scrolling while signing
  startDrawing(e);
});
Enter fullscreen mode Exit fullscreen mode

Critical touch event detail: e.preventDefault() on touchstart prevents the page from scrolling while the user is drawing their signature on a phone.


Embedding the Signature into a PDF

Once we have the signature as a data URL, we use pdf-lib to embed it as a PNG and draw it at the desired position.

import { PDFDocument } from 'pdf-lib';

async function embedSignature(pdfFile, signatureDataUrl, x, y, width, height) {
  const pdfBytes = await pdfFile.arrayBuffer();
  const pdfDoc = await PDFDocument.load(pdfBytes);
  const pages = pdfDoc.getPages();
  const firstPage = pages[0];

  // Convert data URL to embedded image
  const signatureImage = await pdfDoc.embedPng(signatureDataUrl);

  // Draw signature on page
  firstPage.drawImage(signatureImage, {
    x,
    y,
    width,
    height,
  });

  const signedPdfBytes = await pdfDoc.save();
  return signedPdfBytes;
}
Enter fullscreen mode Exit fullscreen mode

Coordinate system note: pdf-lib uses a bottom-left origin (PDF standard), while browsers use top-left. We convert coordinates by subtracting from page height:

const pageHeight = firstPage.getHeight();
const pdfY = pageHeight - y - height; // Convert from top-left to bottom-left
Enter fullscreen mode Exit fullscreen mode

Placing the Signature: Drag and Drop

We render the PDF pages as images using pdfjs-dist, then overlay a draggable signature element. Users drag it to the exact position they want.

<template>
  <div class="pdf-container" ref="container">
    <img :src="pageImage" class="page-image" />
    <div
      class="signature-overlay"
      :style="signatureStyle"
      @mousedown="startDrag"
    >
      <img :src="signatureDataUrl" />
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

The drag position is calculated relative to the PDF page image. When the user clicks "Apply Signature," we map the pixel coordinates to PDF points (72 points per inch) using the page's scale ratio.


Challenges and Solutions

Font loading race conditions
If the user clicks "Use This Signature" before the Google Font loads, the Canvas renders in a fallback font. We disable the button until document.fonts.load() resolves.

High-DPI displays
Without devicePixelRatio scaling, signatures look blurry on Retina screens. We scale the Canvas dimensions and context, then use CSS to display it at the original size.

Cross-origin font loading
Google Fonts loads via CSS @font-face. We dynamically inject the <link> tag and wait for the font to appear in document.fonts before rendering.

Large PDFs
For PDFs with many pages, we only render the first page for signature placement. The signature is embedded into the selected page(s) during final processing.


Complete Flow

  1. User uploads PDF
  2. First page renders as an image for placement
  3. User creates a text or drawn signature
  4. User drags signature to position on the page
  5. App maps pixel coordinates to PDF points
  6. pdf-lib embeds the signature PNG into the PDF
  7. Signed PDF downloads automatically

All in the browser. Zero server involvement.


Try the Live Tool

If you want to sign PDFs without writing code:

👉 en.sotool.top/sign-pdf

Free, no signup, browser-based. Your file and signature never leave your device.

The source code is open source if you want to see the full Vue 3 + Canvas + pdf-lib integration.


Have you built client-side document manipulation features? What challenges did you run into with Canvas rendering or PDF embedding?

Top comments (0)