字
字节笔记本
2026年2月22日
JavaScript 电子签名应用开发思路
本文介绍如何使用 JavaScript 开发一个完整的电子签名应用,包括移动端 PDF 预览、手写签名、签名位置调整以及服务端 PDF 合成功能。
项目功能概述
整个项目实现了电子签名的核心功能:
- 移动端 PDF 文件预览 - 使用 pdfh5 库实现 PDF 文件在移动端的渲染
- 手写签名 - 基于 Canvas 实现流畅的手写签名功能
- 添加日期 - 支持在 PDF 上添加日期戳
- 签名/日期操作 - 支持自由移动、缩放、删除(日期不能缩放)
- 实时预览 - 结合 PDF 实现签名位置的实时预览
- 合成 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 合成等核心功能。主要技术亮点包括:
- Canvas 高清签名: 通过缩放画布实现高清手写签名
- 坐标系统转换: 处理屏幕坐标与 PDF 坐标的转换
- 组件化架构: 清晰的代码组织和模块划分
- 服务端合成: 使用 pdf-lib 实现可靠的 PDF 文件处理
这个方案可以应用于电子合同签署、在线审批、数字化办公等场景。
分享: