提供html2canvas+jsPDF将HTML页面以A4纸方式导出为PDF后,内容分页时存在截断的解决思路

前言

最近公司有个系统要做一个质量报告导出为PDF的需求,这个报表的内容是固定格式,但是不固定内容多少的,网上找了很多资料,没有很好的解决我的问题,pdfmakde、还有html2Canvas+jsPDF以及Puppeteer无头浏览器的方案都不能很好解决这个问题,最后还是选择了html2Canvas+jsPDF+算法去实现

使用pdfmakde存在以下问题

  • 这个库虽然可以帮你处理分页的问题,但是不支持写很复杂的布局样式,似乎连flex都不支持
  • 这个库使用的是一种抽象写法,需要自己将html结构进行抽象传入,如果业务已经做完了,那么后续自己抽象那将是重新开发的工作量,好在有个开源库可以实现这个语法转换一个可以将html的结构转成pdfmakde的工具

使用html2Canvas+jsPDF会存在的问题

  • html2Canvas截图是一整个截图,是一个长图,没有分页功能
  • jsPDF也是,你添加什么图片它渲染,也不存在分页功能

解题思路

假设html页面生成的图片高度是2000px,一页A4纸是500px,那么应该分成4页,理论就是这样的,但是会存在断开页刚好把内容切割了

  • canvas有个方法getImageData,这个方法可以获取到某个区域的像素rgba信息,我们可以通过判断每个切割点的地方颜色是否符合我们想要的切割点
  • 例如上面那个例子,假如在第一次500px的位置切割会分割文字,那么我们可以让第一页的高度缩小1px,再判断是否合适,不合适就继续递归,直到找到合适的位置进行切割
  • 如此我们就可以按照内容进行不同的分页策略,例如一页塞不下的图片也可以被单独分割到下一页
  • 然后利用jsPDF的addPage新增一页来放置即可,所以有可能2000px的内容被分成6页的情况

案例实现

文末会给出完整源码

废话不多说,直接上案例详解

1. 首先准备好相应的库,我这里懒得建一个vue或者react项目,就直接用html那种方式了
  • html2canvas.min.js
  • jsPDF.js
  • 创建一个普通的html项目,准备一些图片资源,结构非常简单
    在这里插入图片描述
2. 编写对应的代码
  • index.html代码
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="viewporthead" content="width=device-width, initial-scale=1.0" /><title>HTML页面转换成PDF导出</title><style>.print_wrapper {padding: 10px;width: 886px;margin-left: 50%;transform: translateX(-50%);}.header {font-size: 30px;font-weight: 600;text-align: center;}.sub_wrapper {padding: 10px;margin-top: 10px;}.sub_row {display: flex;flex-direction: row;line-height: 40px;}.sub_row_item {flex: 1;height: 100%;text-align: center;}button {padding: 10px;border-radius: 3px;background-color: #2775b6;border: 0;color: #fff;}.operator {padding: 10px;}.attachment_title {font-size: 26px;font-weight: 600;margin: 20px 0;}table {border: #000;border-spacing: 0px;}tr {height: 40px;}td {text-align: center;vertical-align: middle;padding: 5px;}.img {width: 200px;height: 200px;overflow: hidden;margin-top: 2px;}</style></head><body><div class="operator"><button id="fillData">填充数据</button><button id="exportPdf">导出为PDF</button></div><div class="print_wrapper"><div id="main" class="main"><div class="container"><div class="header"><span>测试标题</span></div><div class="sub_wrapper"><div class="sub_row"><div class="sub_row_item"><span>姓名:</span><span>ZmSama</span></div><div class="sub_row_item"><span>性别:</span><span></span></div><div class="sub_row_item"><span>居住地址:</span><span>广东省惠州市惠城区</span></div></div><div class="sub_row"><div class="sub_row_item"><span>姓名:</span><span>ZmSama</span></div><div class="sub_row_item"><span>性别:</span><span></span></div><div class="sub_row_item"><span>居住地址:</span><span>广东省惠州市惠城区</span></div></div><div class="sub_row"><div class="sub_row_item"><span>姓名:</span><span>ZmSama</span></div><div class="sub_row_item"><span>性别:</span><span></span></div><div class="sub_row_item"><span>居住地址:</span><span>广东省惠州市惠城区</span></div></div></div><div class="attachment_wrapper"><div class="attachment_title"><span>附件清单一</span></div><table width="100%" border="1" cellspacing="0" cellpadding="0"><thead><tr><th>序号</th><th>物料编号</th><th>物料名称</th><th>单位</th><th>数量</th><th>单价</th></tr></thead><tbody id="table1"><tr><td>1</td><td>10000000001</td><td>R5 7500F</td><td>PCS</td><td>1</td><td>1099</td></tr></tbody></table></div><div class="attachment_wrapper"><div class="attachment_title"><span>附件清单二</span></div><table width="100%" border="1" cellspacing="0" cellpadding="0"><thead><tr><th>序号</th><th>物料编号</th><th>物料名称</th><th>单位</th><th>数量</th><th>单价</th></tr></thead><tbody id="table2"><tr><td>1</td><td>10000000001</td><td>R5 7500F</td><td>PCS</td><td>1</td><td>1099</td></tr></tbody></table></div><div class="attachment_wrapper"><div class="attachment_title"><span>附件清单三</span></div><table width="100%" border="1" cellspacing="0" cellpadding="0"><thead><tr><th>序号</th><th colspan="5">实物图片</th></tr></thead><tbody id="table3"><tr><td width="50">1</td><td colspan="5"><img class="img" src="./demo.jpeg" /></td></tr></tbody></table></div></div></div></div></body><script src="html2canvas.min.js"></script><script src="jsPDF.js"></script><script src="index.js"></script>
</html>

运行效果如下
在这里插入图片描述

  • index.js代码如下
// 自调用函数,用来初始化事件
;(function init() {const fillDataBtn = document.querySelector('#fillData')const exportPdfBtn = document.querySelector('#exportPdf')fillDataBtn.addEventListener('click', fillDataHandler)exportPdfBtn.addEventListener('click', exportPDFHandler)
})()// 填充数据
function fillDataHandler() {// 给表格1插入20条数据const table1 = document.querySelector('#table1')const table1Str = Array(20).fill(null).map((_, i) => {return `<tr><td>${i + 1}</td><td>1000000000${i}</td><td>R5 7500F</td><td>PCS</td><td>${i}</td><td>${(Math.random() * 1000).toFixed(4)}</td></tr>`}).join('')table1.innerHTML = table1Str// 给表格2插入40条数据const table2 = document.querySelector('#table2')const table2Str = Array(40).fill(null).map((_, i) => {return `<tr><td>${i + 1}</td><td>1000000000${i}</td><td>R5 7500F</td><td>PCS</td><td>${i}</td><td>${(Math.random() * 1000).toFixed(4)}</td></tr>`}).join('')table2.innerHTML = table2Str// 给表格三插入内容const table3 = document.querySelector('#table3')const table3Str = Array(4).fill(null).map((_, i) => {return `<tr><td width="50">${i + 1}</td><td colspan="5"><img class="img" src="./demo.jpeg" /></td></tr>`}).join('')table3.innerHTML = table3Str
}// 导出PDF
function exportPDFHandler() {const page = document.querySelector('#main')getPDF(page, '测试导出')
}/*** 导出PDF方法* @param {*} html 要导出的html页面* @param {*} title 导出标题*/
function getPDF(html, title) {html2canvas(html, {allowTaint: true,useCORS: true,scale: 2, // 将分辨率提高到特定的DPI 提高2倍background: '#FFFFFF',}).then(canvas => {// 原始dom转化的canvas高度let originHeight = canvas.height// 每张pdf应该从哪个点开始从原始canvas中截取的点,通过计算像素点去算let pagePosition = 0let a4Width = 190let a4Height = 277 //A4大小,210mmx297mm,四边各保留10mm的边距,显示区域190x277 //一页pdf显示html页面生成的canvas高度;// 按照比例计算出当前A4纸应该截取的canvas高度,因为比例是一样的,A4的宽比上canvas的宽等于A4的高比上canvas的高// 因为canvas的单位和jsPDF所需要的单位是不一样的,需要进行比例转换let onePageHeight = Math.floor((canvas.width / a4Width) * a4Height) //pdf页面偏移// 记录生成多少页let pageIndex = 0const options = {orientation: 'p',unit: 'mm',format: 'a4',putOnlyUsedFonts: true,floatPrecision: 16, // or "smart", default is 16}let pdf = new jspdf.jsPDF(options) //A4纸,纵向pdf.setDisplayMode('fullwidth', 'continuous', 'FullScreen')// 如果内容不超过一页高度,那么直接打印一页即可if (originHeight < onePageHeight) {// 这里记得要算比例pdf.addImage(canvas.toDataURL('image/jpeg', 1.0),'JPEG',10,10,a4Width,(a4Width / canvas.width) * originHeight)pdf.save(title + '.pdf')} else {createPage()}// 递归函数,超过一页将走这里function createPage() {pageIndex++// 如果原始canvas的高度已经被分割完了,直接退出if (originHeight.length <= 0) {return}// 创建一个canvas用于分次截图const newCanvas = document.createElement('canvas')const newCtx = newCanvas.getContext('2d')// 截取的宽度不影响计算,直接使用原canvas的宽度即可newCanvas.width = canvas.width// 通过计算获取真实应该切割的高度const realHeight = getCutLineHeight(canvas, onePageHeight, pagePosition)if (realHeight > 0) {// 得出来的高度不能直接使用到新的canvas中,还需要减去已经绘制了的高度才是当前块要绘制的高度newCanvas.height = realHeight// 绘制canvas的时候使用canvas对应的真实高度// tips:核心就在这里,每次创建的新canvas实际是根据不同的计算位置去原本的canvas中将合适部分截取出来,这样就达到了分页展示的目的newCtx.drawImage(canvas,0,pagePosition,canvas.width,realHeight,0,0,canvas.width,realHeight)// 但是转换成pdf时就需要将真实高度转换成pdf的单位,利用比例算出高度// 注意canvas的单位和jsPDF的单位不一致,要转换一下pdf.addImage(newCanvas.toDataURL('image/jpeg', 1.0),'JPEG',10,10,a4Width,(a4Width / canvas.width) * realHeight)}// 分到最后的一份的时候会多出一个空白页,所以需要删除if (realHeight === 0) {// 这个pageIndex会记录页数,刚好最后一页就是空白的,删除即可,注意索引问题pdf.deletePage(pageIndex)}// 记录这个每次计算的页高度,每次截取一段真实高度之后要记录起来,用于算下下一章的真实高度pagePosition += realHeight// 减少原始canvas高度,代表被截取了originHeight -= realHeight// 如果原始canvas还是有高度,那么递归深入if (realHeight > 0 && originHeight > 0) {// 先添加一页空白pdfpdf.addPage()// 再进行下一页的判断createPage()} else {pdf.save(title + '.pdf')}}})
}/*** 判断传入的那一条线的颜色是否是纯黑色* 这里因为我的例子是表格,所以最佳的切割位置是表格的边框线上,所以我才判断纯黑色切割,但是因为偏差问题,就算肉眼看到是存黑的,也会存在其他杂色* 所以需要增加误差判断,例如我这里的1000就是误差,允许里面有1000个杂色像素,其余都是黑色像素* @param {*} data 传入的是一个canvas的getImageData返回的颜色数组,这个data是一个类数组,需要转换一下,同时这个是颜色大数组,4个为一组代表一个颜色,所以要先分组* @returns*/
function computedRGBIsPureDark(data) {const temp = JSON.parse(JSON.stringify(Array.from(data)))// 先分组,4个一组,分别代表rgbaconst _data = groupArray(temp)// 先针对原数组删除头尾两个颜色点,这个似乎是表格带的,不需要_data.splice(0, 2) // 删除头两个元素_data.splice(-2, 2) // 删除尾两个元素const pureDraks = []_data.forEach(item => {const r = item[0]const g = item[1]const b = item[2]const a = item[3]if (r === 0 && g === 0 && b === 0 && a === 255) {pureDraks.push(item)}})// 允许一定的误差存在,因为不一定是完全纯黑的,哪怕视线上是黑的,也可能是灰色的if (pureDraks.length > _data.length - 1000) {return true} else {return false}
}// 数组分组函数,每四个一组
function groupArray(array) {let groups = []for (let i = 0; i < array.length; i += 4) {groups.push(array.slice(i, i + 4))}return groups
}/*** 计算真实应该在原本的canvas上切割的高度* @param {*} canvas 要检索的canvas对象* @param {*} onePageHeight 一页的高度,这里是A4纸高度* @param {*} pagePosition 已经计算好的当前页的前面所有页的高度总和,例如如果当前检索的是第三张,那么这个就是前两张的高度* @returns*/
function getCutLineHeight(canvas, onePageHeight, pagePosition) {const ctx = canvas.getContext('2d')// 获取这个位置的一行像素let position = onePageHeight + pagePositionconst realHeight = recursionLineColor(position)// 要减去已经渲染到界面的那部分高度,得到的就是真实当前张应该渲染的高度return realHeight - pagePosition// 递归退减函数,向上逐个像素判断function recursionLineColor(h) {// 这里就是取1px高,一个canvas宽的一条线的像素,拿出来校验,是否刚好落在表格的边框上const lineGrb = ctx.getImageData(0, h, canvas.width, 1)// 判断这行是否是纯黑色,如果是那么代表这个位置可以被切断,否则递归减少一个像素,继续判断直至找到一行纯黑色为止if (!computedRGBIsPureDark(lineGrb.data)) {// 否则位置上移一个,继续递归position -= 1return recursionLineColor(position)} else {// 找到了目标就返回当前计算的高度,注意这个高度是相对于整个原始canvas的,意味着如果是判断的第二张,此时里面包含了第一张的高度return h}}
}
运行效果如下

只有一页的时候
在这里插入图片描述
有多页的时候(点一下填充数据)(数据太多,我缩小了界面可以查看)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

总结

理论上我们可以用这种思路解决任何情况的canvas转PDF的问题,只是算法不同,如果不是表格的,那么也可以尝试通过给一点不同的背景色进行分割标识即可,代码注释都很详细,有兴趣的朋友可以直接参考源码研究一下,还是挺有意思的。
如果觉得有意思感谢您给我点个start【Thanks♪(・ω・)ノ】

  • github仓库地址

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/pingmian/62136.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

UPLOAD LABS | UPLOAD LABS 靶场初识

关注这个靶场的其它相关笔记&#xff1a;UPLOAD LABS —— 靶场笔记合集-CSDN博客 0x01&#xff1a;UPLOAD LABS 靶场简介 UPLOAD LABS 靶场是一个专门用于学习文件上传漏洞攻击和防御的靶场。它提供了一系列文件上传漏洞的实验环境&#xff0c;用于帮助用户了解文件上传漏洞的…

探索Python词云库WordCloud的奥秘

文章目录 探索Python词云库WordCloud的奥秘1. 背景介绍&#xff1a;为何选择WordCloud&#xff1f;2. WordCloud库简介3. 安装WordCloud库4. 简单函数使用方法5. 应用场景示例6. 常见Bug及解决方案7. 总结 探索Python词云库WordCloud的奥秘 1. 背景介绍&#xff1a;为何选择Wo…

2024年9月中国电子学会青少年软件编程(Python)等级考试试卷(六级)答案 + 解析

一、单选题 1、下面代码运行后出现的图像是&#xff1f;&#xff08; &#xff09; import matplotlib.pyplot as plt import numpy as np x np.array([A, B, C, D]) y np.array([30, 25, 15, 35]) plt.bar(x, y) plt.show() A. B. C. D. 正确答案&#xff1a;A 答案…

深度学习与持续学习:人工智能的未来与研究方向

文章目录 1. 持续学习与深度学习1.1 深度学习的局限1.2 持续学习的定义 2. 目标与心智2.1 奖励假说2.2 心智的构成 3. 对研究方法的建议3.1 日常写作记录3.2 中立对待流行趋势 1. 持续学习与深度学习 1.1 深度学习的局限 深度学习注重“瞬时学习”&#xff0c;如ChatGPT虽在语…

数据分析——读取

读取(以ysck.txt文件为例)

【Axure高保真原型】天气模板

今天和大家分享天气模板的原型模板&#xff0c;里面包括晴天、多云、阴天、小雨、大雨、暴雨、强雷阵雨、小雪、中雪、大雪、暴雪、雨夹雪、微风、强风、狂风、龙卷风、轻雾、大雾等&#xff0c;后续也可以自行添加。 这个模板是用中继器制作的&#xff0c;所以使用也很方便&a…

java内存管理介绍

1. 堆&#xff08;Heap&#xff09;&#xff1a; • 这是Java对象存储的主要区域&#xff0c;类似于一个大仓库&#xff0c;用于存放所有动态分配的对象实例。堆内存由JVM自动管理&#xff0c;包括对象的分配和回收。 2. 栈&#xff08;Stack&#xff09;&#xff1a; • 每个线…

neo4j图数据库community-5.50创建多个数据库————————————————

1.找到neo4J中的conf文件&#xff0c;我的路径是&#xff1a;D:\Program Files\neo4j-community-5.5.0-windows\neo4j-community-5.5.0\conf 这里找自己的安装路径&#xff0c; 2.用管理员模式打开conf文件&#xff0c;右键管理员&#xff0c;记事本或者not 3.选中的一行新建一…

《Unity Shader 入门精要》高级纹理

立方体纹理 图形学中&#xff0c;立方体纹理&#xff08;Cubemap&#xff09;是环境映射&#xff08;Environment Mapping&#xff09;的一种实现方法。环境映射可以模拟物体周围的环境&#xff0c;而使用了环境映射的物体可以看起来像镀了层金属一样反射出周围的环境。 对立…

【逐行注释】自适应观测协方差R的AUKF(自适应无迹卡尔曼滤波,MATLAB语言编写),附下载链接

文章目录 自适应R的UKF逐行注释的说明运行结果部分代码各模块解释 自适应R的UKF 自适应无迹卡尔曼滤波&#xff08;Adaptive Unscented Kalman Filter&#xff0c;AUKF&#xff09;是一种用于状态估计的滤波算法。它是基于无迹卡尔曼滤波&#xff08;Unscented Kalman Filter&…

Scala习题

姓名&#xff0c;语文&#xff0c;数学&#xff0c;英语 张伟&#xff0c;87&#xff0c;92&#xff0c;88 李娜&#xff0c;90&#xff0c;85&#xff0c;95 王强&#xff0c;78&#xff0c;90&#xff0c;82 赵敏&#xff0c;92&#xff0c;88&#xff0c;91 孙涛&#xff0c…

【rustdesk】客户端和服务端的安装和部署(自建服务器,docker,远程控制开源软件rustdesk)

【rustdesk】客户端和服务端的安装和部署&#xff08;自建服务器&#xff0c;docker&#xff09; 一、官方部署教程 https://rustdesk.com/docs/zh-cn/client/mac/ 官方服务端下载地址 https://github.com/rustdesk/rustdesk-server/releases 我用的docker感觉非常方便&am…

springboot配置https,并使用wss

学习链接 springboot如何将http转https SpringBoot配置HTTPS及开发调试 Tomcat8.5配置https和SpringBoot配置https 可借鉴的参考&#xff1a; springboot如何配置ssl支持httpsSpringBoot配置HTTPS及开发调试的操作方法springboot实现的https单向认证和双向认证(java生成证…

vscode的项目给gitlab上传

目录 一.创建gitlab帐号 二.在gitlab创建项目仓库 三.Windows电脑安装Git 四.vscode项目git上传 一.创建gitlab帐号 二.在gitlab创建项目仓库 图来自:Git-Gitlab中如何创建项目、创建Repository、以及如何删除项目_gitlab新建项目-CSDN博客&#xff09; 三.Windows电脑安…

C++设计模式(工厂模式)

一、介绍 1.动机 在软件系统中&#xff0c;经常面临着创建对象的工作&#xff0c;这些对象有可能是一系列相互依赖的对象&#xff1b;由于需求的变化&#xff0c;需要创建的对象的具体类型经常变化&#xff0c;同时也可能会有更多系列的对象需要被创建。 如何应对这种变化&a…

速度革命:esbuild如何改变前端构建游戏 (1)

什么是 esbuild&#xff1f; esbuild 是一款基于 Go 语言开发的 JavaScript 构建打包工具&#xff0c;以其卓越的性能著称。相比传统的构建工具&#xff08;如 Webpack&#xff09;&#xff0c;esbuild 在打包速度上有着显著的优势&#xff0c;能够将打包速度提升 10 到 100 倍…

java八股-分布式服务的接口幂等性如何设计?

文章目录 接口幂等token Redis分布式锁 原文视频链接&#xff1a;讲解的流程特别清晰&#xff0c;易懂&#xff0c;收获巨大 【新版Java面试专题视频教程&#xff0c;java八股文面试全套真题深度详解&#xff08;含大厂高频面试真题&#xff09;】 https://www.bilibili.com/…

C++:多态的原理

目录 一、多态的原理 1.虚函数表 2.多态的原理 二、单继承和多继承的虚函数表 1、单继承中的虚函数表 2、多继承中的虚函数表 一、多态的原理 1.虚函数表 首先我们创建一个使用了多态的类&#xff0c;创建一个对象来看其内部的内容&#xff1a; #include<iostre…

Local Changes不展示,DevEco Studio的git窗口中没有Local Changes

DevEco Studio的git窗口中&#xff0c;没有Local Changes&#xff0c;怎么设置可以调出&#xff1f; 进入File-->Settings-->Version Control&#xff0c;将Use non-modal commit interface前的勾选框取消勾选&#xff0c;点击OK即可在打开git窗口&#xff0c;就可以看到…

Windows Qtcreator不能debug 调试 qt5 程序

Windows下 Qt Creator 14.0.2 与Qt5.15.2 正常release打包都是没有问题的&#xff0c;就是不能debug&#xff0c;最后发现是两者不兼容导致的&#xff1b; 我使用的是 编译器是 MinGW8.1.0 &#xff0c;这个版本是有问题的&#xff0c;需要更新到最新&#xff0c;我更新的是Mi…