前言
最近公司有个系统要做一个质量报告导出为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仓库地址