字节笔记本

2026年2月22日

JavaScript 电子签名应用开发思路

本文介绍如何使用 JavaScript 开发一个完整的电子签名应用,包括移动端 PDF 预览、手写签名、签名位置调整以及服务端 PDF 合成功能。

项目功能概述

整个项目实现了电子签名的核心功能:

  1. 移动端 PDF 文件预览 - 使用 pdfh5 库实现 PDF 文件在移动端的渲染
  2. 手写签名 - 基于 Canvas 实现流畅的手写签名功能
  3. 添加日期 - 支持在 PDF 上添加日期戳
  4. 签名/日期操作 - 支持自由移动、缩放、删除(日期不能缩放)
  5. 实时预览 - 结合 PDF 实现签名位置的实时预览
  6. 合成 PDF - 服务端将签名与 PDF 合成为新文件

技术栈

  • 前端: Webpack 5 + JavaScript + SCSS
  • PDF 预览: pdfh5
  • 服务端: Express + pdf-lib + multer + cors
  • 调试工具: nodemon

核心实现

1. 页面组件化设计

项目采用组件化架构,每个组件包含:

  • index.js - 组件入口文件
  • index.tpl - 组件模板(使用 EJS)
  • index.scss - 组件样式

2. PDF 移动端预览

使用 pdfh5 进行 PDF 文件预览。由于项目基于 Webpack 5 搭建,直接在入口 HTML 中引入 pdfh5:

javascript
// 渲染 PDF 到指定容器
function renderPDF(container, pdfUrl) {
  // 初始化 pdfh5 实例
  const pdfh5 = new Pdfh5(container, {
    pdfurl: pdfUrl
  });
}

3. 签名功能实现

3.1 绘制签名

核心要点:

  • 坐标处理: 左侧功能区会占用空间,需要根据 touch 事件获取的坐标减去边距
  • 清晰度优化: 初始化时将 canvas 宽高设置为实际尺寸的倍数,在 touchstart 时放大画布,touchend 时缩小
  • 提示文本: 初次绘制和重置时显示"签名区"提示
javascript
class Sign {
  constructor(options) {
    this.canvas = options.canvas;
    this.ctx = this.canvas.getContext('2d');
    this.scaleRatio = options.scaleRatio || 2;
    this.space = options.space; // 边距配置
    this.init();
  }

  init() {
    // 设置 canvas 实际尺寸为显示尺寸的倍数
    const rect = this.canvas.getBoundingClientRect();
    this.canvas.width = rect.width * this.scaleRatio;
    this.canvas.height = rect.height * this.scaleRatio;

    this.bindEvents();
    this.drawBaseText();
  }

  handleTouchstart(e) {
    const { ctx, scaleRatio } = this;
    // 放大画布以提高清晰度
    ctx.scale(scaleRatio, scaleRatio);

    const { clientX, clientY } = e.touches[0];
    // 减去边距获取相对坐标
    ctx.beginPath();
    ctx.moveTo(clientX - this.space.left, clientY - this.space.top);
  }

  handleTouchmove(e) {
    const { ctx } = this;
    const { clientX, clientY } = e.touches[0];
    ctx.lineTo(clientX - this.space.left, clientY - this.space.top);
    ctx.stroke();
  }

  handleTouchend() {
    const { ctx, scaleRatio } = this;
    // 恢复画布比例
    ctx.scale(1 / scaleRatio, 1 / scaleRatio);
  }
}

3.2 生成签名图片

由于签名时手机横向握持,生成的图片需要旋转:

javascript
class Sign {
  // 获取未处理的图片(旋转前)
  getUnhandlePic() {
    return this.canvas.toDataURL();
  }

  // 获取旋转后的 canvas
  getRotatedCanvas(dataURL) {
    return new Promise((resolve) => {
      const img = new Image();
      img.src = dataURL;
      img.onload = () => {
        const canvas = document.createElement('canvas');
        // 旋转后宽高互换
        canvas.width = img.height;
        canvas.height = img.width;

        const ctx = canvas.getContext('2d');
        ctx.translate(0, img.width);
        ctx.rotate(-Math.PI / 2);
        ctx.drawImage(img, 0, 0);

        resolve(canvas);
      };
    });
  }

  // 获取旋转后的签名图片(base64)
  async getSignPic_base64() {
    const canvas = await this.getRotatedCanvas(this.getUnhandlePic());
    return canvas.toDataURL();
  }
}

4. 签名添加到 PDF

4.1 计算相关尺寸

添加签名前需要计算:

  • PDF 容器的偏移值(用于计算签名相对 PDF 的位置)
  • 单页 PDF 容器的显示尺寸
  • PDF 页面之间的间距
  • 缩放比例(显示尺寸 / 实际尺寸)
javascript
// 计算 PDF 缩放比例
function calculateRatio() {
  ratio = pageDisplaySize.width / pageOriginalSize.width;
}

// 获取元素偏移值
function getOffsetValue(elem) {
  return {
    top: parseInt($(elem).css('margin-top'), 10) +
         parseInt($(elem).css('padding-top'), 10),
    left: parseInt($(elem).css('margin-left'), 10) +
          parseInt($(elem).css('padding-left'), 10)
  };
}

4.2 浮动签名类

javascript
class Float {
  constructor(options) {
    this.x = options.x || 100;
    this.y = options.y || 100;
    this.width = 0;      // 实际大小
    this.height = 0;
    this.dispWidth = 0;  // 显示大小
    this.dispHeight = 0;
    this.page = 0;       // 所在页数
    this.img = options.img;
    this.canScale = options.canScale !== false;
    this.init();
  }

  init() {
    // 添加签名元素到 PDF 容器
    const template = this.canScale ?
      `<div class='box' style='left:${this.x}px;top:${this.y}px'>
        <img class='sign-img' src='${this.img}' />
        <div class='pin'><i class='iconfont icon-resize'></i></div>
        <div class='del-btn'><i class='iconfont icon-delete'></i></div>
      </div>` :
      `<div class='box' style='width:120px;height:40px'>
        <div class='sign-img' style="background:url(${this.img}) center / cover no-repeat"></div>
        <div class='del-btn'><i class='iconfont icon-delete'></i></div>
      </div>`;

    $('.pdfViewer').append(template);

    // 异步获取元素引用并计算尺寸
    Promise.resolve().then(() => {
      this.target = $(`.box[data-id="${this.id}"]`);
      this.dispHeight = this.target.height();
      this.dispWidth = this.target.width();
      this.getSize();
      this.getPosition();
      this.bindEvent();
    });
  }

  // 计算实际尺寸(用于服务端合成)
  getSize() {
    this.height = this.dispHeight / ratio;
    this.width = this.dispWidth / ratio;
  }

  // 计算合成时的位置
  getPosition() {
    const calcRes = calculatePosition.call(this);
    this.x = calcRes.x;
    this.y = calcRes.y;
  }
}

4.3 位置计算

计算签名在 PDF 中的位置和所在页数:

javascript
function calculatePosition() {
  const tar = this.target[0];
  const offsetXTemp = tar.offsetLeft;
  const offsetYTemp = tar.offsetTop;

  // 计算所在页数
  this.page = Math.floor(offsetYTemp / pageDisplaySize.height);

  // 计算在当前页内的 Y 轴偏移
  const pageInnerOffsetY = offsetYTemp - wrapSpaceTop -
    this.page * pageWrapDisplaySize -
    this.page * pdfBetweenSpace;

  // X 轴偏移(转换为 PDF 坐标系)
  const x = offsetXTemp / ratio - wrapSpaceLeft / ratio;

  // Y 轴偏移(相对于 PDF 左下角)
  const y = (pageDisplaySize.height - pageInnerOffsetY - this.dispHeight) / ratio;

  return { x, y };
}

4.4 缩放和移动

javascript
// 缩放处理
function handlePinTouchmove(e) {
  e.stopPropagation();
  e.preventDefault();

  const touch = e.touches[0];
  let widthTemp = this.dispWidth + (touch.clientX - beginXP);
  let heightTemp = this.dispHeight + (touch.clientY - beginYP);

  // 最小尺寸限制
  if (widthTemp < 50) widthTemp = 50;
  if (heightTemp < 50) heightTemp = 50;

  this.target.css({
    width: `${widthTemp}px`,
    height: `${heightTemp}px`
  });
}

// 移动处理
function handleTouchmove(e) {
  e.stopPropagation();
  e.preventDefault();

  const { clientX, clientY } = e.touches[0];
  const moveX = clientX - beginX;
  const moveY = clientY - beginY;

  this.target.css({
    left: `${offsetX + moveX}px`,
    top: `${offsetY + moveY}px`
  });
}

5. 服务端 PDF 合成

5.1 项目结构

text
server/
├── app.js              # 入口文件
├── router/
│   └── pdf.js          # 路由定义
├── controller/
│   └── pdf.js          # 控制器
├── utils/
│   └── compound.js     # PDF 合成工具
└── assets/
    └── pdf/
        └── contract.pdf  # 默认 PDF 模板

5.2 路由配置

javascript
// router/pdf.js
const router = require('express').Router();
const multer = require('multer');
const PdfHandler = require('../controller/pdf');

const storage = multer.memoryStorage();
const uploader = multer({ storage });

router.get('/default', PdfHandler.getDefaultPdf);
router.get('/getpdf/:filename', PdfHandler.getPdfByFilename);
router.post('/compound',
  uploader.fields([{ name: 'imgs', maxCount: 4 }]),
  PdfHandler.compound
);

module.exports = router;

5.3 PDF 合成核心

使用 pdf-lib 库将签名图片嵌入 PDF:

javascript
const { PDFDocument } = require('pdf-lib');
const { readFileSync } = require('fs');
const path = require('path');

async function drawImgs(sourcePdf, files, configs) {
  const drawImg = async (sourcePdf, file, config) => {
    const pic = await sourcePdf.embedPng(file.buffer);
    const page = sourcePdf.getPage(config.page);

    page.drawImage(pic, {
      height: config.height,
      width: config.width,
      x: config.x,
      y: config.y
    });
  };

  for (let i = 0; i < files.length; i++) {
    await drawImg(sourcePdf, files[i], configs[i]);
  }
}

async function compound(files, config) {
  const pdfPath = path.resolve(__dirname, '../assets/pdf/contract.pdf');
  const formPdfBytes = readFileSync(pdfPath);

  const pdfDoc = await PDFDocument.load(formPdfBytes);
  await drawImgs(pdfDoc, files, JSON.parse(config));

  return await pdfDoc.save();
}

module.exports = compound;

6. 工具方法

6.1 日期格式化

javascript
function dateFormat(format = 'YYYY-MM-DD', date = new Date()) {
  const obj = {
    'Y+': date.getFullYear(),
    'M+': date.getMonth() + 1,
    'D+': date.getDate(),
    'h+': date.getHours(),
    'm+': date.getMinutes(),
    's+': date.getSeconds(),
  };

  Object.keys(obj).forEach((key) => {
    format = format.replace(
      new RegExp(`(${key})`),
      (node, $1) => String(obj[key]).padStart($1.length, '0')
    );
  });

  return format;
}

6.2 事件总线

javascript
class EventBus {
  constructor() {
    this.eventPool = {};
  }

  on(type, event) {
    const oldEvts = this.eventPool[type];
    if (oldEvts) {
      const oldEventTemp = Array.isArray(oldEvts) ? oldEvts : [oldEvts];
      this.eventPool[type] = Array.isArray(event)
        ? [...oldEventTemp, ...event]
        : [...oldEventTemp, event];
    } else {
      this.eventPool[type] = Array.isArray(event) ? [...event] : [event];
    }
  }

  emit(type, ...args) {
    const evTemp = this.eventPool[type];
    if (Array.isArray(evTemp)) {
      evTemp.forEach((ev) => ev.apply(this, args));
    } else if (evTemp) {
      evTemp.apply(this, args);
    }
  }

  off(type, event) {
    if (event) {
      const oldEvts = this.eventPool[type];
      if (Array.isArray(event)) {
        this.eventPool[type] = oldEvts.filter((ev) => !event.includes(ev));
      } else {
        this.eventPool[type] = oldEvts.filter((ev) => ev !== event);
      }
    } else {
      this.eventPool[type] = null;
    }
  }
}

6.3 Base64 转 File 对象

javascript
export function dataURLToFile(url) {
  return new Promise((resolve) => {
    const img = new Image();
    img.src = url;
    img.onload = () => {
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;

      const ctx = canvas.getContext('2d');
      ctx.drawImage(img, 0, 0);

      canvas.toBlob((blob) => {
        const file = new File([blob], 'sign.png', { type: 'image/png' });
        resolve(file);
      });
    };
  });
}

项目源码

完整代码已开源在 GitHub:

总结

本项目实现了一个完整的电子签名解决方案,涵盖了前端签名绘制、PDF 预览、签名位置计算以及服务端 PDF 合成等核心功能。主要技术亮点包括:

  1. Canvas 高清签名: 通过缩放画布实现高清手写签名
  2. 坐标系统转换: 处理屏幕坐标与 PDF 坐标的转换
  3. 组件化架构: 清晰的代码组织和模块划分
  4. 服务端合成: 使用 pdf-lib 实现可靠的 PDF 文件处理

这个方案可以应用于电子合同签署、在线审批、数字化办公等场景。

分享: