Vue3 -- PDF展示、添加签名(带笔锋)、导出
文章目录
- 笔锋签名
- 方案一
- 实现要点
- 实现过程
- 组件引用
- 页面元素
- 添加引用
- 实现代码
- 效果展示
- 缺点
- 方案二
- 修改页面元素
- 替换引用
- 修改代码
- 效果展示
- 完整代码地址
实现功能的时候采用了两个方案,主要是第一个方案最后的实现效果并不太理想,但实现起来比较简单,要求不高时可以使用。
该 DEMO 会一次性加载并展示所有的 PDF 页面,目的是方便在手机上观看时上下滑动,如果要做成上一页下一页的效果,需要自行实现。
笔锋签名
我是用开源项目 smooth-signature 实现带笔锋签名的功能。
Gitee 地址是 https://github.com/linjc/smooth-signature
npm install --save smooth-signature
使用起来也比较简单,首先获取到需要操作的画布 canvas ,然后生成一个笔锋签名对象 SmoothSignature,optionSign 是初始化的一些简单属性。
const signature = new SmoothSignature(canvas, optionSign);
这样一来,我们的 canvas 就可以画线条了,同时我们可以通过 signature 去做一些操作,比如清空签名、撤回一步的操作等。
方案一
实现要点
- 读取 PDF 文件,并将 PDF 页面渲染到 Canvas 画布上,这里需要动态生成 Canvas
- 将每一个 Canvas 都包装成 SmoothSignature
- 添加一个标识,判断是否允许在 Canvas 上画线(手机滑动会和签名画线冲突,用按钮来控制什么时候允许画线)。
- 保存 PDF 时,先将每一个 Canvas 中的内容转化成图片格式 image/JPEG ,或者 image/PNG ,PNG格式的文件可能会比较大。
- 最后用生成的图片导出一个新的 PDF (实质上 PDF 每一页都是一张图片)。
实现过程
组件引用
smooth-signature | 笔锋签名 |
pdfjs-dist | PDF展示等功能 |
jspdf | PDF导出相关功能 |
npm install --save smooth-signature
npm install --save pdfjs-dist@2.0.943
npm install --save jspdf
页面元素
主要是读取文件、切至签名功能、切回预览功能、撤回签名、清除所有签名以及下载PDF的功能。
<template><div :class="`tab-header`"><div id="editor"><Input:class="`button-common`"type="file"ref="fielinput"accept=".pdf"id="fielinput"@change="uploadFile"/><Button :class="`button-common`" v-if="isSign" @click="handleSign">切回预览</Button><Button :class="`button-common`" v-else @click="handleSign">切至签名</Button><Button :class="`button-common`" @click="handleUndo">撤回</Button><Button :class="`button-common`" @click="handleClear">清除</Button><Button :class="`button-common`" @click="savePDF">下载PDF</Button></div><div><div id="parentDiv"><div ref="contentDiv" id="contentDiv"></div></div></div></div>
</template>
<script lang="ts">
引用
......
实现代码
......
</script>
<style lang="less" scoped>.tab-header {background: rgb(146, 175, 138);padding-left: 1%;padding-right: 1%;}.button-common {margin-right: 2px;max-width: 200px;}#contentDiv {// display: inline-block;}#parentDiv {position: absolute;overflow: auto;top: 5%;bottom: 1%;display: inline-block;}#signShower {position: absolute;left: 50%;top: 5%;bottom: 1%;display: inline-block;}
</style>
添加引用
这里要注意的是,需要给 pdfJS 指定工作路径
import { Button, Input } from 'ant-design-vue';import { defineComponent, ref } from 'vue';import SmoothSignature from 'smooth-signature';import * as pdfJS from 'pdfjs-dist';import * as pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';import JsPDF from 'jspdf';pdfJS.GlobalWorkerOptions.workerSrc = pdfjsWorker;
实现代码
代码中添加了主要的注释,可以查看下述代码
export default defineComponent({components: { Button, Input },setup() {const fielinput = ref(null);const contentDiv = ref(null);//签名相关const isSign = ref(false); //控制是否允许签名const canvass = ref([]); //保存所有画布元素const signatures = ref([]); //所有签名对象const historys = ref([]); //签名历史 用于回退或者清除,因为是一次性展示多个页面,会存在多个包装好的签名对象,存放历史列表方便操作//PDF展示相关const pdfData = ref(null); // PDF 内容const scale = ref(2); //放大比例 ,有的时候展示可能会比较模糊,可以放大展示//上传控件选择事件,加载选中的 PDF 文件const uploadFile = (e: Event) => {// 断言为HTMLInputElementconst target = e.target as HTMLInputElement;const files = target.files;let reader = new FileReader();reader.readAsDataURL(files[0]);reader.onload = () => {let data = atob(reader.result.substring(reader.result.indexOf(',') + 1));loadPdfData(data);};};//加载PDFfunction loadPdfData(data) {//移除所有旧的 Canvas 画布元素removeChild();//重置对象状态isSign.value = false;canvass.value = [];signatures.value = [];// 引入pdf.js的字体,如果没有引用的话字体可能会不显示let CMAP_URL = 'https://unpkg.com/pdfjs-dist@2.0.943/cmaps/';//读取base64的pdf流文件pdfData.value = pdfJS.getDocument({data: data, // PDF base64编码cMapUrl: CMAP_URL,cMapPacked: true,});//渲染全部页面renderAllPages();}//移除页面上旧的元素function removeChild() {var content = contentDiv.value;var child = content.lastElementChild;while (child) {content.removeChild(child);child = content.lastElementChild;}}//渲染全部页面function renderAllPages() {pdfData.value.promise.then((pdf) => {for (let i = 1; i <= pdf.numPages; i++) {pdf.getPage(i).then((page) => {let viewport = page.getViewport(scale.value);//动态生成 Canvas 画布并设置宽高var canvas = document.createElement('canvas');canvas.height = viewport.height;canvas.width = viewport.width;let ctx = canvas.getContext('2d');let renderContext = {canvasContext: ctx,viewport: viewport,};//将 PDF 页面渲染到 Canvas 上page.render(renderContext).then(() => {});//将画布包装成 SmoothSignatureinitSignatureCanvas(canvas);//将画布元素放入到 div 容器中展示canvass.value.push(canvas);contentDiv.value.appendChild(canvas);});}});}//初始化签名对象const initSignatureCanvas = (canvas) => {const optionSign = {width: canvas.width,height: canvas.height,maxHistoryLength: 100, //最大历史记录};const signature = new SmoothSignature(canvas, optionSign);//初始化时 先移除它内部添加的监听事件,默认不能签名signature.removeListener();//签名对象 addHistory 方法做一下修改,在原来逻辑的基础上添加这一行// historys.value.push(signature); 方便处理历史签名记录signature.addHistory = function () {if (!signature.maxHistoryLength || !signature.canAddHistory) return;signature.canAddHistory = false;signature.historyList.push(signature.canvas.toDataURL());signature.historyList = signature.historyList.slice(-signature.maxHistoryLength);historys.value.push(signature);};signatures.value.push(signature);};/*** 签名预览转换* 允许签名:调用 signature 对象中的 addListener 方法,添加监听事件* 不允许签名:调用 signature 对象中的 removeListener 方法,移除监听事件*/const handleSign = () => {isSign.value = !isSign.value;if (signatures.value && signatures.value.length > 0) {if (isSign.value) {for (let i = 0; i < signatures.value.length; i++) {signatures.value[i].addListener();}} else {for (let i = 0; i < signatures.value.length; i++) {signatures.value[i].removeListener();}}}};/*** 后退操作* 调用历史签名记录中的 signature 对象中的 undo 方法会撤回当前对象中的最后一次的画线记录* 注意:后退后不要忘记将列表中最后一个元素移除*/const handleUndo = () => {if (historys.value && historys.value.length > 0) {const signatureList = historys.value;let signature = signatureList.pop();signature.undo();historys.value = signatureList;}};// 清除所有 循环把所有签名历史都处理了const handleClear = async () => {while (historys.value && historys.value.length > 0) {handleUndo();}};// 下载PDFconst savePDF = () => {//生成新的 PDFlet pdf = new JsPDF('', 'pt', 'a4');if (canvass.value.length > 0) {//将 canvas 内容转化成 JPEGfor (let i = 0; i < canvass.value.length; i++) {const ccccc = canvass.value[i];let pageData = ccccc.toDataURL('image/JPEG');if (i > 0) {pdf.addPage();}pdf.addImage(pageData,'JPEG',0,0,ccccc.width / scale.value,ccccc.height / scale.value,);}//到处新的PDF return pdf.save('TestPdf.pdf');}};return {fielinput,uploadFile,contentDiv,isSign,handleSign,handleUndo,handleClear,savePDF,};},mounted() {},});
效果展示
缺点
1、生成的新的PDF每一页都是一个图片,这就表示 PDF 中的内容无法被解析,文字再也无法被选中了。
2、因为生成的是图片,所以最终效果可能会变模糊,可以通过放大比例去优化展示效果,但是始终不是一个最优的解决方案。
方案二
方案二使用一个新的组件 pdf-lib 来处理最后生成的 PDF
方案二仍旧使用 pdfjs-dist 在 Canvas 上展示 PDF,并使用 smooth-signature 使得画布拥有笔锋签名效果。
不同的是,这一次签名画布和 PDF 展示画布并不再是同一个画布,而是上下重叠的两个分离的画布
这样一来,我们可以将签名画布上的内容生成一个透明背景的 PNG 图片,然后以水印的方式添加到原来的 PDF 文件中。
修改页面元素
需要两个 Div 容器 ,父容器的滚动条需要同步滚动,否则会出现签名在滚动,但是 PDF 页面不动的情况
<template><div :class="`tab-header`"><div id="editor"><Input:class="`button-common`"type="file"ref="fielinput"accept=".pdf"id="fielinput"@change="uploadFile"/><Button :class="`button-common`" v-if="isSign" @click="handleSign">点击预览</Button><Button :class="`button-common`" v-else @click="handleSign">点击签名</Button><Button :class="`button-common`" @click="handleUndo">撤回</Button><Button :class="`button-common`" @click="handleClear">清除</Button><Button :class="`button-common`" @click="savePDF">下载PDF</Button></div><div><div id="parentDiv1"><div ref="contentDiv" id="contentDiv"></div></div><div id="parentDiv2"><div ref="signContentDiv" id="signContentDiv"></div></div></div></div>
</template>
替换引用
//import JsPDF from 'jspdf';import { PDFDocument } from 'pdf-lib';
修改代码
文章底部附完整代码
...
const signCanvass = ref([]); //保存所有签名画布
const base64 = ref(null); //读取的pdf的base64数据
上传文件的方法中添加一行保存PDF base64 ,生成新的 PDF 时使用
const uploadFile = (e: Event) => {...reader.onload = () => {base64.value = reader.result;...};
};
加载 PDF 时,我们要重置的对象增加了,而且加载完之后我们要让两个父容器滚动同步
function loadPdfData(data) {removeChild();...signCanvass.value = []; //重置...renderAllPages();//两个DIV协同滚动var div1 = document.getElementById('parentDiv1');var div2 = document.getElementById('parentDiv2');div1.addEventListener('scroll', function () {div2.scrollLeft = div1.scrollLeft;div2.scrollTop = div1.scrollTop;});div2.addEventListener('scroll', function () {div1.scrollLeft = div2.scrollLeft;div1.scrollTop = div2.scrollTop;});
}
移除页面元素的时候,我们要将两个 div 容器中的元素都移除掉
function removeChild() {var content = contentDiv.value;var child = content.lastElementChild;while (child) {content.removeChild(child);child = content.lastElementChild;}var signContent = signContentDiv.value;var child2 = signContent.lastElementChild;while (child2) {signContent.removeChild(child2);child2 = signContent.lastElementChild;}
}
渲染 PDF 页面的时候,每一个页面都会生成两个相同大小的画布,一个用来展示,一个用来签名,两个画布是重叠的。
function renderAllPages() {pdfData.value.promise.then((pdf) => {for (let i = 1; i <= pdf.numPages; i++) {pdf.getPage(i).then((page) => {// 获取DOM中为预览PDF准备好的canvasDOM对象let viewport = page.getViewport(scale.value);var canvas = document.createElement('canvas');//用来展示var sighCanvas = document.createElement('canvas');//用来签名canvas.height = viewport.height;canvas.width = viewport.width;sighCanvas.height = viewport.height;sighCanvas.width = viewport.width;let ctx = canvas.getContext('2d');let renderContext = {canvasContext: ctx,viewport: viewport,};page.render(renderContext).then(() => {});initSignatureCanvas(sighCanvas);canvass.value.push(canvas);signCanvass.value.push(sighCanvas);contentDiv.value.appendChild(canvas);signContentDiv.value.appendChild(sighCanvas);});}});
}
主要是保存 PDF 的功能与原来完全不一样。
因为我们前面说的签名画布和 PDF 页是同步生成的,所以页码(下标)也是相对应的。
所以我们只要把签名页面转成一个透明背景的 PNG ,然后添加到 PDF 对应页码的页面上,新的 PDF 文件就是我们需要的签名文件 。
const savePDF = async () => {const pdfDoc = await PDFDocument.load(base64.value);const pages = pdfDoc.getPages();for (let i = 0; i < pages.length; i++) {//对应下标的 签名画布中的内容生成 png图片const eleImgCover = await pdfDoc.embedPng(signCanvass.value[i].toDataURL('image/PNG'));//页面中添加水印pages[i].drawImage(eleImgCover, {x: 0,y: 0,width: eleImgCover.width / scale.value, //这里要进行缩放,因为之前的画布我们是放大过的height: eleImgCover.height / scale.value, //这里要进行缩放,因为之前的画布我们是放大过的});}//生成blob流const pdfBytes = await pdfDoc.save();saveBlob(pdfBytes, 'TestPdf');
};
//网上找的 保存 bolb流 的方法
function saveBlob(data, fileName) {if (typeof window.navigator.msSaveBlob !== 'undefined') {window.navigator.msSaveBlob(new Blob([data], { type: 'application/pdf' }),fileName + '.pdf',);} else {let url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' })); //定义下载的链接let link = document.createElement('a'); //创建一个超链接元素link.style.display = 'none'; //隐藏该元素link.href = url; //创建下载的链接link.setAttribute('download', fileName + '.pdf');document.body.appendChild(link);link.click(); //点击下载document.body.removeChild(link); //下载完成移除元素window.URL.revokeObjectURL(url); //释放掉blob对象}
}
效果展示
文字内容可以解析、能够被选中
完整代码地址
方案一
方案二
相关文章:

Vue3 -- PDF展示、添加签名(带笔锋)、导出
文章目录笔锋签名方案一实现要点实现过程组件引用页面元素添加引用实现代码效果展示缺点方案二修改页面元素替换引用修改代码效果展示完整代码地址实现功能的时候采用了两个方案,主要是第一个方案最后的实现效果并不太理想,但实现起来比较简单࿰…...

行测-判断推理-图形推理-样式规律-属性规律-曲直性
左边的图全是由曲线构成的选C1 3 5全是由曲线构成的2 4 6全是由直线构成的第三行的图形有曲有直选A1 3 5有曲有直2 4 6全是直线选D图形有曲有直,排除B D外曲内直->内曲外直->外曲内直->内曲外直->外曲内直->内曲外直所以问号出的图形应该是内曲外直选…...

idea集成Alibaba Cloud Toolkit插件
idea集成Alibaba Cloud Toolkit插件 使用该插件主要是简化打包、上传、启动服务的相关操作。 很早之前的方式是使用开发工具(eclipse,idea),使用maven命令完成项目打包(这里指jar),然后通过shell工…...

Win11 文件夹打开慢或卡顿解决方案
问题 目前是 2023/2/27, 我的 Win11 系统点开一个文件夹要等待 2-3 秒才能加载出来, 使用体验极差。网上查阅大量资料, 有些人在系统更新后这个情况就消失了, 但是我这一直存在, 系统也是当前的最新版, 没有修复。 目前得出的结论是, 因为 Win11 的工具栏占用了过多的资源, 需…...

【PostgreSQL的idle in transaction连接状态】
在平时查询pg_stat_activity这个视图的时候,每一行包含了一个进程的相关信息,包含当前正在执行的SQL,或者会话的状态等等,state字段表示当前进程的状态。在PostgreSQL数据库里,其实代码里总共定义了7种BackendState&am…...

cityengine自定义纹理库资源
背景 cityengine虽然可以将shp生成带纹理的三维模型,但是纹理不一定满足我们的要求,这时候我们就想用我们自己制作的纹理 粗略了解规则文件 了解Building_From_Footprint.cga这个规则文件,具体文件位置默认在 “C:\Users[电脑用户名:如Administrator]\Documents\CityEng…...

taobao.top.secret.bill.detail( 服务商的商家解密账单详情查询 )
¥免费必须用户授权 服务商的商家解密账单详情查询,仅对90天内的账单提供SLA保障。 公共参数 请求地址: HTTP地址 http://gw.api.taobao.com/router/rest 公共请求参数: 公共响应参数: 请求参数 响应参数 点击获取key和secret 请求示例 TaobaoClient…...

2023软件测试金三银四常见的软件测试面试题-【抓包和网络协议篇】
八、抓包与网络协议 8.1 抓包工具怎么用 我原来的公司对于抓包这块,在App的测试用得比较多。我们会使用fiddler抓取数据检查结果,定位问题,测试安全,制造弱网环境; 如:抓取数据通过查看请求数据,请求行&…...

vue脚手架多页自动化生成实践
前言 在前端开发过程中,常常面对多种业务场景。到目前为止,前端对于不同场景的处理通常会采用不同的渲染方案来组合处理,常见的渲染方案包括:CSR(Client Side Rendering)、SSR(Server Side Rendering)、SSG(Static Site Generati…...
【SQL语句优化】
SQL语句优化是提高数据库查询性能的重要手段之一,下面是几种常见的SQL语句优化方法和案例: 减少查询的数据量 减少查询的数据量:使用 WHERE 子句和索引来限制检索行数,只检索需要的行,避免检索全部行数据。 例子&am…...

阿里P8:做测试10年我的一些经验分享,希望你们少走弯路
我是在2015年毕业的,当时是读的普通本科,不上不下的专业水平,毕业的时候,恰好遇到了金融危机。校园招聘里阴差阳错的巧合,让我走上了软件测试工程师的道路。 入职第一天,来了个高大上的讲师,记…...

栈在括号匹配中的应用(栈/链栈 纯C实现)
目录 1 问题背景 2 具体思路 3 代码实现 3.1 顺序栈实现 3.2 链栈实现 1 问题背景 栈的括号匹配问题是指在给定一个字符串(包含多种括号),判断其中的括号是否能够正确匹配,即每个左括号是否有一个对应的右括号与之匹配&#x…...

C语言Switch语句用法
C switch 语句 一个 switch 语句允许测试一个变量等于多个值时的情况。每个值称为一个 case,且被测试的变量会对每个 switch case 进行检查。 语法 C 语言中 switch 语句的语法: switch(expression){case constant-expression :statement(s);break;…...
Curl编码请求参数,API接口请求示例参数
请求参数请求参数:num_iid610947572360 参数说明:num_iid:1688商品ID sales_data:&sales_data1 获取近30天成交数据 agent:&agent1 获取1688分销代发价格数据请求示例 测试入口 Curl PHP PHPsdk JAVA C# Python-- 请求示例 url 默认请求参数已经…...

【C/C++】类型限定符extern、const、Volatile、register
1、extern: 声明一个变量,extern声明的变量没有建立存储空间。 extern int a ; //变量在定义的时候创建存储空间。 ①当我们在编译器中试图运行以下代码,系统会报错。 错误原因是“无法解析外部符号_a”.系统认为变量a是没有开辟内存空间的…...

day54【代码随想录】二刷数组
文章目录前言一、二分查找(力扣724)二、移除元素(力扣27)【双指针】三、有序数组的平方(力扣977)【双指针】四、合并两个有序数组(力扣88)五、长度最小的子数组(力扣209&…...

哪个品牌蓝牙耳机性价比高?性价比高的平价蓝牙耳机推荐
现如今,随着蓝牙技术的进步,蓝牙耳机在人们日常生活中的便捷性更胜从前。越来越多的蓝牙耳机品牌被大众看见、认可。那么,哪个品牌的蓝牙耳机性价比高?接下来,我给大家推荐几款性价比高的平价蓝牙耳机,一起…...
揭秘关于TFRcord的五脏六腑
揭秘关于TFRcord的五脏六腑 前言:本篇文章将演示如何创建、解析和使用tf.Example消息,以及如何在.tfrecord文件之间对tf.Example消息进行序列化、写入和读取。 教程讲解使用的都是结构化数据,文章最后还会演示如果将图片写成.tfrecord文件&am…...
【Shell学习笔记】3.Shell 传递参数及数组
前言 本章介绍Shell的传递参数和数组。 Shell 传递参数 我们可以在执行 Shell 脚本时,向脚本传递参数,脚本内获取参数的格式为:$n。n 代表一个数字,1 为执行脚本的第一个参数,2 为执行脚本的第二个参数,…...
【终结Bug】ModuleNotFoundError: No module named ‘cv2’
解决方案: 打开 cmd键入 pip install opencv_python -i https://pypi.tuna.tsinghua.edu.cn/simple...
在软件开发中正确使用MySQL日期时间类型的深度解析
在日常软件开发场景中,时间信息的存储是底层且核心的需求。从金融交易的精确记账时间、用户操作的行为日志,到供应链系统的物流节点时间戳,时间数据的准确性直接决定业务逻辑的可靠性。MySQL作为主流关系型数据库,其日期时间类型的…...

装饰模式(Decorator Pattern)重构java邮件发奖系统实战
前言 现在我们有个如下的需求,设计一个邮件发奖的小系统, 需求 1.数据验证 → 2. 敏感信息加密 → 3. 日志记录 → 4. 实际发送邮件 装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其…...

【OSG学习笔记】Day 18: 碰撞检测与物理交互
物理引擎(Physics Engine) 物理引擎 是一种通过计算机模拟物理规律(如力学、碰撞、重力、流体动力学等)的软件工具或库。 它的核心目标是在虚拟环境中逼真地模拟物体的运动和交互,广泛应用于 游戏开发、动画制作、虚…...

[ICLR 2022]How Much Can CLIP Benefit Vision-and-Language Tasks?
论文网址:pdf 英文是纯手打的!论文原文的summarizing and paraphrasing。可能会出现难以避免的拼写错误和语法错误,若有发现欢迎评论指正!文章偏向于笔记,谨慎食用 目录 1. 心得 2. 论文逐段精读 2.1. Abstract 2…...

DBAPI如何优雅的获取单条数据
API如何优雅的获取单条数据 案例一 对于查询类API,查询的是单条数据,比如根据主键ID查询用户信息,sql如下: select id, name, age from user where id #{id}API默认返回的数据格式是多条的,如下: {&qu…...
大学生职业发展与就业创业指导教学评价
这里是引用 作为软工2203/2204班的学生,我们非常感谢您在《大学生职业发展与就业创业指导》课程中的悉心教导。这门课程对我们即将面临实习和就业的工科学生来说至关重要,而您认真负责的教学态度,让课程的每一部分都充满了实用价值。 尤其让我…...

分布式增量爬虫实现方案
之前我们在讨论的是分布式爬虫如何实现增量爬取。增量爬虫的目标是只爬取新产生或发生变化的页面,避免重复抓取,以节省资源和时间。 在分布式环境下,增量爬虫的实现需要考虑多个爬虫节点之间的协调和去重。 另一种思路:将增量判…...
C++.OpenGL (14/64)多光源(Multiple Lights)
多光源(Multiple Lights) 多光源渲染技术概览 #mermaid-svg-3L5e5gGn76TNh7Lq {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-3L5e5gGn76TNh7Lq .error-icon{fill:#552222;}#mermaid-svg-3L5e5gGn76TNh7Lq .erro…...

Windows安装Miniconda
一、下载 https://www.anaconda.com/download/success 二、安装 三、配置镜像源 Anaconda/Miniconda pip 配置清华镜像源_anaconda配置清华源-CSDN博客 四、常用操作命令 Anaconda/Miniconda 基本操作命令_miniconda创建环境命令-CSDN博客...

基于Java+VUE+MariaDB实现(Web)仿小米商城
仿小米商城 环境安装 nodejs maven JDK11 运行 mvn clean install -DskipTestscd adminmvn spring-boot:runcd ../webmvn spring-boot:runcd ../xiaomi-store-admin-vuenpm installnpm run servecd ../xiaomi-store-vuenpm installnpm run serve 注意:运行前…...