javascript电子签名应用开发思路

3,855 阅读7分钟

前言

介绍

移动端pdf预览、
移动端签名、
添加签名(多个、实时预览)、
签名与pdf合成新pdf

背景

大致看了一下同事发的 一定签电子签名小程序 录屏,结合看了下公司之前开发过的一版内部使用的电子签名的原型和app,稍微有了些思路,想着下班后找些时间写个demo看看效果。

项目概括

整个项目实现了电子签名的基本功能:

- 移动端pdf文件预览
- 添加签名,添加日期
- 签名/日期可以自由移动/缩放/删除(日期不能缩放),结合pdf可以实现实时预览
- 合成pdf

all.gif

需求分析和代码实现(部分)

点击查看全部代码

页面组件化

使用tpl定义组件模板、ejs-loader处理tpl文件(其实可以不用),本项目的组件划分如下图

image-20211216153733701.png

index.js为组件的入口文件,index.tpl为组件的模板,index.scss为组件的样式。具体的可以看代码

pdf相关

移动端预览

使用pdfh5进行pdf文件的预览

由于项目是基于webpack5搭建的,然后使用gitee上面的pdfh5包总是有问题,时间有限所以此项目将pdfh5文件在入口html中引入。

下图为渲染pdf文件到指定容器中的方法,接受两个参数为渲染的容器以及pdf文件的地址。

image-20211216152227225.png

签名相关

sign.gif

签字页面布局以及演示如上图所示。

绘制签名

  1. 坐标问题。由于左侧是功能区,所以画笔ctx的坐标需要根据touch事件获取到的坐标 - 左/上侧的边距
  2. 清晰度问题。第一次上手的时候,怎么写怎么模糊,也是令人头大。一开始以为是因为shadowBlur没设置的原因,但是后面添加上之后会好一点,但是还是模糊。这里我的解决方法是在初始化时将canvas的宽高设置成某个倍数,在touchstart事件触发的时候放大画布、touchend事件触发时缩小画布来解决。
  3. 提示文本显示问题。在初次绘制以及重置时会在canvas绘制“签名区”三个提示文本
  4. 更改笔的颜色/粗细问题、清空/保存。在入口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会记录当前点击的位置,然后在当前点击的位置添加对应的签名

add-signature.gif

代码上,编写了一个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用于调试

项目基本结构

image-20211216175603744.png

路由开发

三个路由:获取默认文件,获取合成后的文件,合成文件

//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进行适配开发。又比如能力和时间有限,代码有些地方有冗余和不好的地方... 我改名成许比如好了哈哈。

一直都都想要更新一些帖子,不仅仅是分享自己掌握的知识,更是对自己的学习进行一次记录。但是每次都由于种种原因(懒懒懒)导致没有去写,希望之后能够保持更新自己的帖子。