提供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.rhkb.cn/news/478378.html

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

相关文章

【C++动态规划 子集状态压缩】2002. 两个回文子序列长度的最大乘积|1869

本文涉及知识点 C动态规划 位运算、状态压缩、枚举子集汇总 LeetCode2002. 两个回文子序列长度的最大乘积 给你一个字符串 s &#xff0c;请你找到 s 中两个 不相交回文子序列 &#xff0c;使得它们长度的 乘积最大 。两个子序列在原字符串中如果没有任何相同下标的字符&…

鸿蒙NEXT开发案例:字数统计

【引言】 本文将通过一个具体的案例——“字数统计”组件&#xff0c;来探讨如何在鸿蒙NEXT框架下实现这一功能。此组件不仅能够统计用户输入文本中的汉字、中文标点、数字、以及英文字符的数量&#xff0c;还具有良好的用户界面设计&#xff0c;使用户能够直观地了解输入文本…

[极客大挑战 2019]BabySQL--详细解析

信息搜集 进入界面&#xff1a; 输入用户名为admin&#xff0c;密码随便输一个&#xff1a; 发现是GET传参&#xff0c;有username和password两个传参点。 我们测试一下password点位能不能注入&#xff1a; 单引号闭合报错&#xff0c;根据报错信息&#xff0c;我们可以判断…

C++《二叉搜索树》

在初阶数据结构中我学习了树基础的概念以及了解了顺序结构的二叉树——堆和链式结构二叉树该如何实现&#xff0c;那么接下来我们将进一步的学习二叉树&#xff0c;在此会先后学习到二叉搜索树、AVL树、红黑树&#xff1b;通过这些的学习将让我们更易于理解后面set、map、哈希等…

Apollo9.0源码部署(Nvidia显卡)

本文参照Apollo官方部署例程&#xff0c;进行修改。解决在部署期间遇到的网络不通、编译卡死、编译失败等问题。&#xff08;安装具有时效性&#xff0c;仅供参考&#xff09; 步骤1. 安装docker,显卡驱动、nvidia插件&#xff0c;此步骤可见专栏第一、二 节 步骤2. 拉取代…

第02章_MySQL环境搭建(基础)

1. MySQL 的卸载 1.1 步骤1&#xff1a;停止 MySQL 服务 在卸载之前&#xff0c;先停止 MySQL8.0 的服务。按键盘上的 “Ctrl Alt Delete” 组合键&#xff0c;打开“任务管理器”对话 框&#xff0c;可以在“服务”列表找到“MySQL8.0” 的服务&#xff0c;如果现在“正在…

【华为】配置VXLAN构建虚拟网络实现相同网段互通(静态方式)

微思网络 厦门微思网络 组网需求 企业已经建成比较成熟的园区网络&#xff0c;但是没有专用的数据中心网络&#xff0c;所有的服务器分布在不同的部门&#xff0c;并且不具备集中放置的条件。现在用户希望在已有园区网络上构建一个虚拟网络&#xff0c;需求如下&#xff1a; 将…

linux系统运维面试题(二)(Linux System Operations Interview Questions II)

&#x1f49d;&#x1f49d;&#x1f49d;欢迎来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 本人主要分享计算机核心技…

互联网直播/点播EasyDSS视频推拉流平台视频点播有哪些技术特点?

在数字化时代&#xff0c;视频点播应用已经成为我们生活中不可或缺的一部分。监控技术与视频点播的结合正悄然改变着我们获取和享受媒体内容的方式。这一变革不仅体现在技术层面的进步&#xff0c;更深刻地影响了我们。 EasyDSS视频直播点播平台是一款高性能流媒体服务软件。E…

最新SQL Server 2022保姆级安装教程【附安装包】

目录 一、安装包下载&#xff1a; 下载链接&#xff1a;https://pan.quark.cn/s/b1c0c63d61ec 二、安装SQL Server 1.下载安装包后解压出来&#xff0c;双击打开 2.等待加载安装程序 3.点击基本安装 4.点击接受 5.点击浏览 6.在D盘新建文件夹 7.命名为【Sql Server】&…

香港大带宽服务器:助力高效网络应用

随着全球化的加速和互联网流量的持续增长&#xff0c;大带宽服务器逐渐成为企业在全球范围内运营的关键设施。香港凭借其优越的地理位置、先进的网络基础设施和政策环境&#xff0c;成为部署大带宽服务器的重要节点之一。本文将全面探讨香港大带宽服务器的核心优势、应用场景及…

设计模式:责任链实现数据流风格的数据处理

数据流风格 数据流风格是软件架构中的一种风格&#xff0c;主要是面向数据&#xff0c;用于进行流式的数据处理&#xff1b;数据流风格的代表有管道-过滤器风格和批处理序列风格&#xff0c;这里主要是指管道-过滤器风格。 管道-过滤器风格就像其名字一样&#xff0c;是以一个…

PGSQL物化视图(Materialized View)

在 PostgreSQL 中&#xff0c;物化视图&#xff08;Materialized View&#xff09;是一种特殊的数据库对象&#xff0c;它存储了查询的结果集&#xff0c;并可以定期刷新以反映基础表中的数据变化。物化视图可以提高查询性能&#xff0c;因为它减少了每次查询时重新计算数据的需…

浏览器缓存与协商缓存

1. 强缓存&#xff08;Strong Cache&#xff09; 定义 强缓存是指在缓存的资源有效期内&#xff0c;浏览器会直接使用缓存中的数据&#xff0c;而不会发起网络请求。也就是说&#xff0c;浏览器会直接从本地缓存读取资源&#xff0c;不会与服务器进行任何交互。 如何控制强缓…

JS听到了替罪的回响

这篇还是继续写JS 这是有关函数的一些内容 函数 为什么需要函数 函数是被设计为执行指定任务的代码块 函数可以把具有相同或者相似逻辑的代码包裹起来&#xff0c;通过函数调用执行这些被包裹的代码逻辑&#xff0c;这样的优势是有利于精简代码方便复用 函数使用 这是函…

【优选算法】前缀和

目录 一、[【模板】前缀和](https://www.nowcoder.com/practice/acead2f4c28c401889915da98ecdc6bf?tpId230&tqId2021480&ru/exam/oj&qru/ta/dynamic-programming/question-ranking&sourceUrl%2Fexam%2Foj%3Fpage%3D1%26tab%3D%25E7%25AE%2597%25E6%25B3%2595…

SAP ME2L/ME2M/ME3M报表增强添加字段

SAP ME2L/ME2M/ME3M报表增强添加字段&#xff08;包含&#xff1a;LMEREPI02、SE18:ES_BADI_ME_REPORTING&#xff09; ME2L、ME2M、ME3M这三个报表的字段增强&#xff0c;核心点都在同一个结构里 SE11:MEREP_OUTTAB_PURCHDOC 在这里加字段&#xff0c;如果要加的字段是EKKO、…

破解天然气巡检挑战,构建智能运维体系

一、行业现状 天然气行业在能源领域地位举足轻重&#xff0c;其工作环境高风险&#xff0c;存在有毒有害、易爆气体及高温等情况&#xff0c;且需持续监控设备运行状态&#xff0c;人工巡检面临巨大挑战与风险。好在随着科技发展&#xff0c;防爆巡检机器人的应用为天然气管道…

TSmaster CAN/CANFD 诊断(Diagnostic_CAN)

文章目录 1、Diagnostic TP 参数配置1.1 传输层参数&#xff1a;1.2 服务层参数1.3 Seed&Key 2、基础诊断配置2.1 添加/删除 服务2.2 配置 BasicDiagnostic 服务参数 3、诊断控制台4、自动诊断流程4.1 流程用例管理4.2 配置诊断流程&#xff08;UDS Flow&#xff09;4.2.1 …

详解Servlet的使用

目录 Servlet 定义 动态页面 vs 静态页面 主要功能 Servlet的使用 创建Maven项目 引入依赖 创建目录 编写代码 打war包 部署程序 验证程序 Smart Tomcat 安装Smart Tomcat 配置Smart Tomcat插件 启动Tomcat 访问页面 路径对应关系 Servlet运行原理 Tomcat的…