javascript电子签名应用开发思路
前言
介绍
移动端pdf预览、
移动端签名、
添加签名(多个、实时预览)、
签名与pdf合成新pdf
背景
大致看了一下同事发的 一定签电子签名小程序 录屏,结合看了下公司之前开发过的一版内部使用的电子签名的原型和app,稍微有了些思路,想着下班后找些时间写个demo看看效果。
项目概括
整个项目实现了电子签名的基本功能:
- 移动端pdf文件预览
- 添加签名,添加日期
- 签名/日期可以自由移动/缩放/删除(日期不能缩放),结合pdf可以实现实时预览
- 合成pdf
需求分析和代码实现(部分)
点击查看全部代码
页面组件化
使用tpl定义组件模板、ejs-loader处理tpl文件(其实可以不用),本项目的组件划分如下图
index.js为组件的入口文件,index.tpl为组件的模板,index.scss为组件的样式。具体的可以看代码
pdf相关
移动端预览
使用pdfh5进行pdf文件的预览
由于项目是基于webpack5搭建的,然后使用gitee上面的pdfh5包总是有问题,时间有限所以此项目将pdfh5文件在入口html中引入。
下图为渲染pdf文件到指定容器中的方法,接受两个参数为渲染的容器以及pdf文件的地址。
签名相关
签字页面布局以及演示如上图所示。
绘制签名
- 坐标问题。由于左侧是功能区,所以画笔ctx的坐标需要根据touch事件获取到的坐标 - 左/上侧的边距
- 清晰度问题。第一次上手的时候,怎么写怎么模糊,也是令人头大。一开始以为是因为shadowBlur没设置的原因,但是后面添加上之后会好一点,但是还是模糊。这里我的解决方法是在初始化时将canvas的宽高设置成某个倍数,在touchstart事件触发的时候放大画布、touchend事件触发时缩小画布来解决。
- 提示文本显示问题。在初次绘制以及重置时会在canvas绘制“签名区”三个提示文本
- 更改笔的颜色/粗细问题、清空/保存。在入口js中实例化Sign类后调用原型上的方法
//...
function handleTouchstart(e) {
const { ctx } = this;
!scaleLock && ctx.scale(this.scaleRatio, this.scaleRatio) && (scaleLock = true);
emptyLock && this.clearCanvas();
emptyLock = false;
const { clientX, clientY } = e.originalEvent.touches[0];
ctx.beginPath();
ctx.moveTo(clientX - this.space.left, clientY - this.space.top);
}
function handleTouchmove(e) {
const { ctx } = this;
const { clientX, clientY } = e.originalEvent.touches[0];
ctx.lineTo(clientX - this.space.left, clientY - this.space.top);
ctx.stroke();
this.signLock = false;
}
function handleTouchend() {
const { ctx } = this;
scaleLock = false;
ctx.scale(1 / this.scaleRatio, 1 / this.scaleRatio);
}
class Sign{
//...
// 绘制提示
drawBaseText() {
const { ctx } = this;
const canvasDom = this.canvas[0];
ctx.rotate(Math.PI / 2);
ctx.lineWidth = 1;
ctx.font = "120px '微软雅黑'";
ctx.fillStyle = '#F2F4F5';
ctx.strokeStyle = '#f2f4f5';
//位置可以通过measureText进行更好的调整,此处久不弄了
ctx.strokeText('签字区', canvasDom.height / 2 - 200, -canvasDom.width / 2 + 50);
ctx.fillText('签字区', canvasDom.height / 2 - 200, -canvasDom.width / 2 + 50);
ctx.restore();
ctx.rotate(-Math.PI / 2);
this.ctx.strokeStyle = this.color;
this.ctx.lineWidth = this.lineWidth;
emptyLock = true;
}
// 更改笔的颜色
changePenColor(color) {
this.color = color;
this.ctx.strokeStyle = color;
this.ctx.shadowColor = color;
}
// 更改笔的粗细
changePenWidth(width) {
// eslint-disable-next-line
width = Number(width);
this.lineWidth = width;
this.ctx.lineWidth = width || 1;
}
// 清空签名板
clearCanvas() {
const canvasDom = this.canvas[0];
this.ctx.clearRect(0, 0, canvasDom.width, canvasDom.height);
}
// 重签
rewrite() {
this.clearCanvas();
this.drawBaseText();
emptyLock = true;
}
// 保存
async save() {
const base64pic = await this.getSignPic_base64();
const arr = getFromStorage('signArr');
const obj = {
id: (`${Math.random()}`).slice(2, 5) + (`${Date.now()}`).slice(-5, -1),
img: base64pic,
};
arr ? pushToStorage('signArr', obj) : addToStorage('signArr', [obj]);
}
//...
}
生成签名图片
由于需求的是手机横向进行签名,所以默认情况下生成的图片是垂直方向的。所以需要修改一下方向,我的思路如下面的代码(是Sign类中的部分原型方法):toDataURL获取旋转前的图片=>创建canvas并将图片在此Canvas上根据需求进行旋转并绘制 =>根据此canvas获取旋转后的图片 => 保存到local
class Sign{
// ...
// 获取未处理的图片(旋转前)
getUnhandlePic() {
return this.canvas[0].toDataURL();
}
// 获取旋转后的canvas
getRotatedCanvas(dataURl) {
return new Promise((resolve) => {
const imgView = new Image();
imgView.src = dataURl;
imgView.onload = () => {
const canvasForRotate = document.createElement('canvas');
canvasForRotate.width = imgView.height;
canvasForRotate.height = imgView.width;
const ctxForRotatedCanvas = canvasForRotate.getContext('2d');
ctxForRotatedCanvas.translate(0, imgView.width);
ctxForRotatedCanvas.rotate(-Math.PI / 2);
ctxForRotatedCanvas.drawImage(imgView, 0, 0);
resolve(canvasForRotate);
// canvasForRotate.toBlob((blob) => {
// file = new File([blob], 'sign.png', { type: 'image/png' }); // 签名的图片文件
// });
};
});
}
// 获取旋转后的签名图片(base64) => 存储
async getSignPic_base64() {
const canvasRes = await this.getRotatedCanvas(this.getUnhandlePic());
return canvasRes.toDataURL();
}
// ...
}
添加签名
添加本地的签名文件到预览的pdf文件上(全部代码可以看plugins/float.js)
添加签名有两个方式:点击底部印章和点击pdf显示的区域。
点击底部印章后弹出所有的签名列表,如果没有保存的签名的话只会显示添加签名的按钮,点击按钮进入签名页。如果有签名,点击签名会在x:100、y:100的默认位置添加一个浮动的签名。
直接点击pdf会弹出所有的签名列表,之后的操作和上面一直,不同的是直接点击pdf会记录当前点击的位置,然后在当前点击的位置添加对应的签名
代码上,编写了一个float类,用于维护各自的实例。代码本身不难,但是比较繁琐的有以下几个点:
计算
首先初次加载要计算一下计算中可能需要的数据,包括
- pdf容器的偏移值(用于计算签名相较于pdf的偏移:wrapSpaceLeft、wrapSpaceTop)
- 单页pdf容器的大小(单页pdf图片外层容器实际显示的尺寸,实测比pdf图片大,如果直接使用pdf图片的实际显示尺寸会有偏差,所以特地拿出来)
- pdf页面之间的距离(下边距pdfBetweenSpace)
- 单页pdf显示的大小(用于计算缩放比例:pageDisplaySize)
- 单页pdf实际的大小(用于计算缩放比例:pageOriginalSize)
- 缩放比例(ratio)
// 获取偏移值
function getOffsetValue(elem) {
// let { offsetTop } = elem;
// let { offsetLeft } = elem;
// let { offsetParent } = elem;
const marginTop = parseInt($(elem).css('margin-top'), 10);
const paddingTop = parseInt($(elem).css('padding-top'), 10);
const marginLeft = parseInt($(elem).css('margin-left'), 10);
const paddingLeft = parseInt($(elem).css('padding-left'), 10);
// while (offsetParent) {
// offsetTop += offsetParent.offsetTop;
// offsetLeft += offsetParent.offsetLeft;
// offsetParent = offsetParent.offsetParent;
// }
return {
top: marginTop + paddingTop,
left: marginLeft + paddingLeft,
};
}
// 计算pdf缩放比例
function calculateRatio() {
ratio = pageDisplaySize.width / pageOriginalSize.width;
}
// 计算相关尺寸
function calculateDisplaySize() {
return new Promise((resolve) => {
const viewOffset = getOffsetValue($('.pdfViewer')[0]);
const page1 = $('.canvasImg1');
const img = document.createElement('img');
wrapSpaceLeft = viewOffset.left;
wrapSpaceTop = viewOffset.top;
pageDisplaySize.width = page1[0].width;
pageDisplaySize.height = page1[0].height;
pageWrapDisplaySize = page1.parent().height();
pdfBetweenSpace = parseInt(page1.parent().css('margin-bottom'), 10);
img.onload = () => {
pageOriginalSize.width = img.width;
pageOriginalSize.height = img.height;
calculateRatio();
resolve();
};
img.src = page1.attr('src');
});
}
添加
按上面的操作即可添加一个签名到页面上。代码层面的思路是:考虑到之后合成时需要相对于pdf的坐标,还需要计算当前添加的签名位于pdf文件的第几页,所以签名元素添加到pdf容器内部会比较方便。于是添加实际上是往pdfViewer元素内append一个签名元素
class Float{
//...
// 添加签名
const addSignatureHandler = (e) => {
const tar = $(e.currentTarget);
const signItem = signArr.find((item) => item.id === tar.attr('data-id'));//根据id找到签名文件。
if (!signItem) return;
floatArr.push(new Float({ ...signItem, ...specifiedPos || {} })); //添加一个Float实例
drawToggleHandler();
};
//...
}
floatArr为所有的签名数组。
显示的位置/大小
默认的显示大小为100px*100px、默认的位置也为100px/100px。如果是点击pdf添加的签名,会记录当前点击的位置然后在当前的位置上添加。这块儿的代码应该是可以再优化的,因为是在写帖子之前发现一定签上面有这个功能,然后临时加的🙈。
通过传入参数进行初始化。
class Float{
//...
constructor(opt) {
this.x = opt.x || 100; // 偏移
this.y = opt.y || 100;
this.width = 0; // 实际大小
this.height = 0;
this.dispWidth = 0; // 显示大小
this.dispHeight = 0;
this.target = null;// 当前操作的对象
this.page = 0;// 当前的页数
this.img = opt.img; // 当前的图片
this.id = opt.id + (`${Date.now()}`).slice(-5, -1); // id
this.canScale = opt.canScale !== undefined ? opt.canScale : true; // 能否缩放
this.init();
}
// 获取大小
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;
}
//...
}
init(){
//添加元素
$('.pdfViewer').append(this.canScale ?
`<div class='box' data-id=${this.id} 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' data-id=${this.id} class='box' style='width:120px;height:40px'>
<div class='sign-img' style="background:url(${this.img}) center / cover no-repeat;width:100%;height:100%;"></div>
<div class='del-btn'><i class='iconfont icon-delete'></i><div>
</div>`);
// hack获取添加后的元素
Promise.resolve().then(() => {
this.target = $([].find.call($('.box'), ((item) => $(item).attr('data-id') === this.id))); // 当前操作的对象
this.dispHeight = this.target.height(); //可以优化一下去除这个代码
this.dispWidth = this.target.width();
this.getSize(); //计算尺寸
this.getPosition(); //计算位置
this.bindEvent();
});
大小的话,添加完元素需要马上计算当前元素的展示大小、实际尺寸、合成时的页数和合成时的位置,因为需要考虑添加了不移动不缩放直接提交的情况。此次使用Promise.resolve()简单hack了一下,如果要完整可以参考vue的nextTick源码。
合成时的位置/大小
位于第几页pdf
比较麻烦的就是这个部分的计算了,但是其实也没什么
// 计算当前签名在提交时的位置
function calculatePosition() {
const tar = this.target[0];
const offsetXTemp = tar.offsetLeft;// box距离左侧的偏移
const offsetYTemp = tar.offsetTop;// box距离上面的偏移
const { height: pageDisplaySizeHeight } = pageDisplaySize; // 获取单张pdf显示的高度
// 当前签名所在的pdf的页数
this.page = Math.floor(offsetYTemp / pageDisplaySizeHeight);
// 当前签名左上角在当前pdf页的y轴偏移值 = 签名左上角偏移值 - 外层容器的padding-top - 上一页的高度 - 页面之间的间隔
// eslint-disable-next-line
const pageInnerOffsetY = offsetYTemp - wrapSpaceTop - this.page * pageWrapDisplaySize - this.page * pdfBetweenSpace;
// x轴偏移值 / ratio = x
const x = offsetXTemp / ratio - wrapSpaceLeft / ratio;
/*
由于合成需要的是 距离pdf左下角的位置
合成时图片左下角距离pdf左下角的距离 = 显示的图片左下角距离pdf左下角的距离 / 缩放比例
= (显示pdf的高度 - 显示的图片左上角在当前pdf页面的偏移 + 显示的图片的高度) / 缩放比例
*/
const y = (pageDisplaySizeHeight - pageInnerOffsetY - this.dispHeight) / ratio;
return {
x, y,
};
}
class Float{
//...
// 获取大小
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;
}
//...
}
缩放
当拖拽的是签名右下角的缩放图标时,触发缩放操作。
// 缩放相关
function handlePinTouchstart(e) {
e.stopPropagation();
e.preventDefault();
this.target.addClass('dashed-border');
const touch = e.originalEvent.touches[0];
this.dispHeight = this.target.height();
this.dispWidth = this.target.width();
beginXP = touch.clientX;
beginYP = touch.clientY;
}
function handlePinTouchmove(e) {
e.stopPropagation();
e.preventDefault();
const tar = this.target;
const touch = e.originalEvent.touches[0];
let widthTemp = this.dispWidth + (touch.clientX - beginXP);
let heightTemp = this.dispHeight + (touch.clientY - beginYP);
if (widthTemp < 50) {
console.log('宽度不能小于50');
widthTemp = 50;
}
if (heightTemp < 50) {
console.log('高度不能小于50');
heightTemp = 50;
}
tar.css('width', `${widthTemp}px`).css('height', `${heightTemp}px`);
}
function handlePinTouchend() {
this.target.removeClass('dashed-border');
this.dispHeight = this.target.height();
this.dispWidth = this.target.width();
this.getSize();
this.getPosition();
}
移动
移动的话代码中忘记做边界判断了,具体可以看情况增加
// 移动相关
function handleTouchstart(e) {
e.stopPropagation();
e.preventDefault();
const tar = $(e.target).parent()[0];
const touch = e.originalEvent.touches[0];
offsetX = tar.offsetLeft;
offsetY = tar.offsetTop;
beginX = touch.clientX;
beginY = touch.clientY;
}
function handleTouchmove(e) {
e.stopPropagation();
e.preventDefault();
const tar = this.target;
const { clientX, clientY } = e.originalEvent.touches[0];
const moveX = clientX - beginX;
const moveY = clientY - beginY;
tar.css('left', `${offsetX + moveX}px`).css('top', `${offsetY + moveY}px`);
}
function handleTouchend(e) {
e.stopPropagation();
e.preventDefault();
// 重置初始值
beginX = 0;
beginY = 0;
this.getPosition();
}
删除
// 删除相关
function handleDel(e) {
e.stopPropagation();
e.preventDefault();
const { id } = this;
this.target.remove();
this.signDeleteHandler && this.signDeleteHandler(id);
}
class _Float{
init(){
this.bindEvent()
}
bindEvent(){
this.target.on('touchstart', '.sign-img', handleTouchstart.bind(this))
.on('touchmove', '.sign-img', handleTouchmove.bind(this))
.on('touchend', '.sign-img', handleTouchend.bind(this))
.on('touchstart', '.pin', handlePinTouchstart.bind(this))
.on('touchmove', '.pin', handlePinTouchmove.bind(this))
.on('touchend', '.pin', handlePinTouchend.bind(this))
.on('click', '.del-btn', handleDel.bind(this));
}
}
// 继承Float类,添加删除回调
class Float extends _Float {
signDeleteHandler(id) {
floatArr = floatArr.filter((item) => item.id !== id);
}
}
服务端
express + pdf-lib + multer + cors搭建服务
nodemon用于调试
项目基本结构
路由开发
三个路由:获取默认文件,获取合成后的文件,合成文件
//router/pdf.js
const router = require('express').Router();
const multer = require('multer'); // file-uploader-handler
const mtStorage = multer.memoryStorage() //设置存储虚拟路径
const uploader = multer({
storage:mtStorage
})
const PdfHandler = require('../controller/pdf')
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
//controller/pdf.js
const { createReadStream, writeFile } = require('fs')
const { resolve } = require('path')
const compound = require('../utils/compound')
exports.getDefaultPdf = (req,res,next) => {
try{
res.setHeader('Content-Type','application/pdf')
let rs = createReadStream(resolve(__dirname, '../assets/pdf/contract.pdf'))
rs.pipe(res)
}catch(err){
next(err)
}
}
exports.getPdfByFilename = (req,res,next) => {
try{
const filename = req.params.filename;
res.setHeader('Content-Type','application/pdf');
let rs = createReadStream(resolve(__dirname,'../output/'+ filename +'.pdf'));
rs.pipe(res)
}catch(err){
next(err)
}
}
exports.compound = async (req,res,next) => {
try{
const name = req.body.name || 'test' + Date.now(),
path = resolve(__dirname,'../output/'+name+'.pdf')
let pdfBytes = await compound(req.files['imgs'],req.body.config)
// 写入文件
writeFile(path,pdfBytes,(err,suc)=>{
if(err){
console.log(err)
res.json({
code:404,
message:'写入失败'
})
}
res.json({
success:true,
message:'合成成功',
filename:name
})
})
}catch(err){
next(err)
}
}
pdf-lib合成pdf文件与图片
const { PDFDocument } = require('pdf-lib')
const { readFileSync } = require('fs')
const path = require('path')
function drawImgs(sourcePdf,files,configs){
let len = files.length, idx = 0;
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,
})
}
return new Promise(async (resolve,reject)=>{
for(let i = 0; i < len; i++){
await drawImg(sourcePdf,files[i],configs[i])
idx++;
if(idx === len ){
resolve()
}
}
})
}
async function compound(files, config) {
let pdfPath = path.resolve(__dirname, '../assets/pdf/contract.pdf'),
formPdfBytes = readFileSync(pdfPath);
const pdfDoc = await PDFDocument.load(formPdfBytes)
await drawImgs(pdfDoc,files,JSON.parse(config))
const pdfBytes = await pdfDoc.save()
return pdfBytes
}
module.exports = compound
其他(工具方法)
时间
export default 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) => {
// eslint-disable-next-line
format = format.replace(new RegExp(`(${key})`), (node, $1) => String(obj[key]).padStart($1.length, '0'));
});
return format;
}
事件总线
function isArray(data) {
return Array.isArray(data);
}
class EventBus {
constructor() {
this.eventPool = {};
}
on(type, event) {
const oldEvts = this.eventPool[type];
if (oldEvts) {
const oldEventTemp = !isArray(oldEvts) ? [oldEvts] : oldEvts;
this.eventPool[type] = isArray(event)
? [...oldEventTemp, ...event]
: [...oldEventTemp, event];
} else {
this.eventPool[type] = isArray(event) ? [...event] : [event];
}
}
emit(type, ...args) {
const evTemp = this.eventPool[type];
isArray(evTemp) ? evTemp.forEach((ev) => {
ev.apply(this, args);
}) : evTemp.apply(this, args);
}
off(type, event) {
if (event) {
const oldEvts = this.eventPool[type];
if (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;
}
}
once(type, event) {
this.on(type, function wrapper() {
// eslint-disable-next-line
event.apply(this, arguments);
this.off(type, wrapper);
});
}
}
const eb = new EventBus();
export default eb;
模板替换
export default (tpl, data) => (data ? tpl.replace(/{{(.*?)}}/g, (node, key) => data[key]) : tpl)
base64转换file对象
export function dataURLToFile(url) {
return new Promise((resolve) => {
const imgView = new Image();
imgView.src = url;
imgView.onload = () => {
const canvasTemp = document.createElement('canvas');
canvasTemp.width = imgView.width;
canvasTemp.height = imgView.height;
const ctxForRotatedCanvas = canvasTemp.getContext('2d');
ctxForRotatedCanvas.drawImage(imgView, 0, 0);
canvasTemp.toBlob((blob) => {
const file = new File([blob], 'sign.png', { type: 'image/png' }); // 签名的图片文件
resolve(file);
});
};
});
总结
虽然写完了demo,但是还是有很多的不足之处。比如开发之前没有认真的去分析需求,造成了后期发现新需求的时候迫不得已硬改代码,但其实应该在开发之前就应该构思好的。写浮动签名那个类的时候,忘记考虑浮动时间的情况了(只能移动不能缩放,直接添加会模糊等等)。又比如由于是写一个demo,并且是个微单页应用,当时为了能够放在手机上调试只是简单模拟了下单页应用(通过捕捉路由监听移动端左滑),后期可以优化成真正的单页应用或者是结合原生使用webView进行适配开发。又比如能力和时间有限,代码有些地方有冗余和不好的地方... 我改名成许比如好了哈哈。
一直都都想要更新一些帖子,不仅仅是分享自己掌握的知识,更是对自己的学习进行一次记录。但是每次都由于种种原因(懒懒懒)导致没有去写,希望之后能够保持更新自己的帖子。
转载自:https://juejin.cn/post/7042515283419856910