跳到正文
DEV Community· Miller Elias·· 3 小時前AI 評分53

如何用 TypeScript 建立 100% 瀏覽器內 PDF 簽署流程,避免把已簽署合約上載至 SaaS

Stop Uploading Signed Contracts to SaaS: How to Build 100% Client-Side PDF Signing in TypeScript

AI 導讀

Miller Elias 示範如何使用 TypeScript、pdfjs-dist 和 pdf-lib,建立全程在瀏覽器內執行、毋須上載文件的 PDF 簽署流程。流程處理螢幕與 PDF 座標換算、高 DPI 簽名繪製,以及以亮度閾值移除印章圖片的白色背景。簽名以透明 PNG 嵌入 PDF,避免把整頁點陣化,保留原有字型和可選取文字。

正文

Every week, millions of developers and business owners upload sensitive non-disclosure agreements, offer letters, and vendor contracts to third-party e-sign platforms just to slap a handwritten signature on page 4.

Think about the privacy equation: to perform what is essentially a 2D composite placement of a signature glyph onto an existing document, we routinely transmit proprietary PDFs over public networks to external server farms, exposing them to server-side cache retention, worker logs, and third-party data breach vectors.

Why are we sending contracts across the wire for something modern client-side JavaScript can execute in under 30 milliseconds?

In this deep dive, we'll break down the mechanics of building a zero-upload, 100% in-browser PDF signing engine using TypeScript, pdfjs-dist, and pdf-lib. We'll solve the three biggest technical headaches along the way: PostScript coordinate inversion, Retina canvas blur, and instant client-side chroma keying for physical stamp uploads.

1. The Core Misconception: Visual Signing vs. Cryptographic PDF Bloat

Many developers assume that allowing a user to sign a document requires a heavyweight digital certificate infrastructure (PKI / X.509 cryptographic signing).

While certified digital signatures (such as eIDAS Qualified Electronic Signatures) serve regulated high-trust legal niches, the vast majority of day-to-day business documents—standard commercial contracts, invoices, rental leases, and internal acknowledgments—rely on Simple Electronic Signatures (SES). Under the US ESIGN Act and EU eIDAS regulations, an SES simply requires demonstrating intent through an applied mark or image.

The fatal architectural flaw of many in-browser PDF signers is that they take the easy route: they rasterize the entire PDF page into a giant JPEG, paste the signature on top, and stitch it back together.

Doing that destroys all vector sharpness, wipes out searchable text streams, and blows up a 400KB contract into a 15MB file.

A production-grade signer must preserve the underlying document's vector integrity, embedding the signature as a transparent overlay directly into the PDF's existing content stream.

2. The Coordinate System Nightmare: Screen Space vs. PostScript

The single biggest gotcha when placing signatures on a PDF is that web browsers and PDF engines disagree on where the universe begins.

Screen Canvas Space (DOM / HTML5 Canvas)

Origin (0, 0) is at the top-left. X increments to the right, and Y increments downwards.

PDF Cartesian Point Space (PostScript standard)

Origin (0, 0) is at the bottom-left. Measurements are in typographic points (1/72 of an inch). X increments to the right, and Y increments upwards.

If you naively take the pixel position from your drag-and-drop React overlay and pass it to pdf-lib, your signature will end up upside-down or hovering in the opposite quadrant of the page.

Here is the exact mathematical conversion formula:

// Screen coordinate: element.x, element.y (measured from top-left)
// Canvas dimensions: canvasWidth, canvasHeight (rendered by PDF.js)
// Actual PDF Page dimensions: pdfPageWidth, pdfPageHeight (from PDF document spec)

const scaleFactorX = pdfPageWidth / canvasWidth;
const scaleFactorY = pdfPageHeight / canvasHeight;

const pdfWidth = element.width * scaleFactorX;
const pdfHeight = element.height * scaleFactorY;

const pdfX = element.x * scaleFactorX;

// Invert the Y-axis: subtract the element's bottom edge from the total height
const pdfY = pdfPageHeight - (element.y + element.height) * scaleFactorY;

With this mapping, regardless of whether the user views the document on a mobile screen, a 1080p monitor, or a 4K display, the signature lands on the exact millimeter of the signing line.

3. High-DPI Capture: Eliminating the "Blurry Signature" Problem

When capturing freehand signatures via HTML5 canvas, the default canvas resolution produces fuzzy, jagged strokes on Retina and high-density mobile screens.

To get fountain-pen smoothness, you must decouple the canvas's physical drawing buffer from its CSS presentation width using window.devicePixelRatio:

function setupCrispCanvas(canvas: HTMLCanvasElement) {
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  const dpr = window.devicePixelRatio || 1;
  const rect = canvas.getBoundingClientRect();

  // Set internal raster buffer to high-DPI resolution
  canvas.width = rect.width * dpr;
  canvas.height = rect.height * dpr;

  // Scale the rendering context so coordinate math stays in CSS pixels
  ctx.scale(dpr, dpr);

  // Smooth out drawing paths
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.lineWidth = 2.5;
  ctx.strokeStyle = '#0F172A';
}

When users draw with a finger or stylus, their input coordinates map directly to CSS space, while the underlying image data exports at crisp, print-ready 300+ DPI.

4. Real-Time Chroma Keying: Stripping White Backgrounds from Stamp Uploads

A common user story is signing on a piece of paper, taking a photo on a smartphone, and uploading it.

The problem? The photo has an off-white or yellowish paper background. If you drop that JPEG onto a contract, it casts an ugly opaque box over the text and signature lines underneath.

Instead of sending the image to a cloud AI background-remover, you can perform instant luminance thresholding right in an offscreen canvas:

function makeSignatureTransparent(imageSrc: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      const canvas = document.createElement('canvas');
      canvas.width = img.naturalWidth;
      canvas.height = img.naturalHeight;

      const ctx = canvas.getContext('2d');
      if (!ctx) return reject('Canvas context unavailable');

      ctx.drawImage(img, 0, 0);

      const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      const data = imgData.data;

      // Iterate through RGBA values (4 bytes per pixel)
      for (let i = 0; i < data.length; i += 4) {
        const r = data[i];
        const g = data[i + 1];
        const b = data[i + 2];

        // If the pixel is near-white paper (brightness > 225), set Alpha to 0
        if (r > 225 && g > 225 && b > 225) {
          data[i + 3] = 0;
        }
      }

      ctx.putImageData(imgData, 0, 0);
      resolve(canvas.toDataURL('image/png'));
    };
    img.onerror = reject;
    img.src = imageSrc;
  });
}

This takes under 15ms for an average mobile snapshot, transforming paper scans into clean transparent PNG stamps that blend seamlessly over contract lines.

5. The Complete Client-Side Embedding Pipeline

Once the user places their signature, typed name, or date stamp onto the document canvas, we burn the graphic into the PDF binary using pdf-lib:

import { PDFDocument } from 'pdf-lib';

interface SignatureElement {
  pageNumber: number; // 1-indexed
  pngDataUrl: string;
  x: number;
  y: number;
  width: number;
  height: number;
}

export async function signPdfDocument(
  pdfFile: File,
  elements: SignatureElement[],
  renderedCanvasWidth: number,
  renderedCanvasHeight: number
): Promise<Uint8Array> {
  const arrayBuffer = await pdfFile.arrayBuffer();
  const pdfDoc = await PDFDocument.load(arrayBuffer, { ignoreEncryption: true });

  for (const el of elements) {
    const targetPage = pdfDoc.getPage(el.pageNumber - 1);
    const { width: pageWidth, height: pageHeight } = targetPage.getSize();

    // Decode base64 PNG data into a binary buffer
    const base64Data = el.pngDataUrl.split(',')[1];
    const binaryString = atob(base64Data);
    const bytes = new Uint8Array(binaryString.length);
    for (let i = 0; i < binaryString.length; i++) {
      bytes[i] = binaryString.charCodeAt(i);
    }

    // Embed as a native PNG object inside the PDF
    const embeddedImage = await pdfDoc.embedPng(bytes);

    // Compute inverted coordinate offsets
    const scaleFactorX = pageWidth / renderedCanvasWidth;
    const scaleFactorY = pageHeight / renderedCanvasHeight;

    const pdfWidth = el.width * scaleFactorX;
    const pdfHeight = el.height * scaleFactorY;
    const pdfX = el.x * scaleFactorX;
    const pdfY = pageHeight - (el.y + el.height) * scaleFactorY;

    // Stamp directly onto the page without re-rasterizing the document
    targetPage.drawImage(embeddedImage, {
      x: Math.max(0, pdfX),
      y: Math.max(0, pdfY),
      width: pdfWidth,
      height: pdfHeight,
    });
  }

  return await pdfDoc.save();
}

Because this only modifies the target page stream and embeds a compressed PNG object, the exported PDF retains 100% of its original fonts, selectable paragraphs, and minimal file size.

6. Inspecting the Live Pipeline

If you want to test how this coordinate translation and client-side stamp thresholding behave with actual multi-page PDFs, you can try out the live implementation over at VersaPDF's Sign Tool.

You can even keep your browser's DevTools Network tab open while dropping signatures: you will see zero outbound file payloads, as the entire rendering, thresholding, and byte-compilation pipeline runs strictly inside local memory.

What's Next?

Client-side document tooling has matured to the point where browsers can handle virtually every common office workflow. Have you transitioned any document processing tasks from server-side workers to client WebAssembly/Web APIs? How are you handling PDF coordinate discrepancies in your projects? Let's discuss below!

來源:DEV Community · dev.to