在网页端ERP应用内,打印页面是一个很常规的操作,包括打印单据、打印表单这些,而除了打印以外,导出PDF的功能也很常见,很多企业会选择将一些重要的单据保存为PDF进行电子留档,于是网页应用导出PDF功能也被很多网页ERP进行开发了出来。今天这篇文章,就分享一下如何通过在浏览器里面导出PDF。
很多时候,开发出一个新功能并不是一次性就到位,而是通过很多次的迭代开发,结合实际应用场景慢慢优化之后,才能够得到最优的体验,今天也通过明源工作流导出PDF功能的几个迭代开发,跟大家分享一下导出PDF功能是如何实现并进行迭代优化。
①
/导出PDF V1.0/
当明源工作流第一次拿到的导出PDF功能时,需求只有一个要求“客户希望能够将当前流程导出PDF,作为电子留档,因为浏览器自带的导出PDF操作比较繁琐,所以希望页面内有一个导出PDF的按钮,可以一键导出PDF”。
在产品经理规划好需求,并在需求迭代上传达之后,从开发的角度来讲,就得到了一个信息:“这个方案由前端实现,只需要将当前页面内容保存为PDF文件就可以”。所以很快就有了第一版导出PDF的功能,这个时候第一版的实现方案很简单,就是将当前浏览器的内容保存为一个PDF文件,并可以选择保存在本地。
在说明技术实现方案,首先需要明白一个概念,浏览器本身没有导出PDF的功能,其次JS本身也不存在导出PDF的相关API供调用,包括像“window.print()”的接口调用,也只不过是唤起浏览器的打印窗口,所以想通过一行代码实现一键导出PDF,是没有办法的。
那么导出PDF只能通过其他方案来实现,而作为整个系统里面不是很重要,也不能作为产品本身能够直接变现的一个功能,通过完全自主开发来实现导出PDF,不太现实,所以最终通过参考相关资料之后,决定采用成熟的前端插件来实现。
所采用的插件分别是“html2canvas.js"以及“jspdf.js”,这两个插件的功能是独立的,html2canvas可以将网页内指定dom通过canvas生成为一张base64图片,jspdf可以将指定图片转换为一个PDF文件流并触发浏览器文件下载,所以两个插件需要结合使用。
大致实现代码如下:
//调用html2canvas将指定区域生成为imghtml2canvas($('.pdfPrint')[0], { logging: false }).then(function (canvas) { var img = Canvas2Image.convertToImage(canvas, canvas.width, canvas.height); //调用jsPDF将图片生成为PDF文件 var pdf = new jsPDF('', 'pt', 'a4', true);//调用jsPDF将图片生成为PDF文件 pdf.addImage(img, 'JPG', 10, 10, imgWidth, imgHeight, '', 'FAST'); //调用pdf插件,保存pdf文件到本机 pdf.save($("#js-titleText").text().trim() + '.pdf');});
实现思路,是将指定区域的dom生成为图片,然后再将这个图片保存为PDF文件,实际上,当我们百度“js导出PDF”后,会得到很多类似的文章推荐,进去之后也基本上都是你抄我,我抄你的内容,代码本身没有问题,但实际上有不少坑在里面,而且这些坑在百度里面是找不到解决方案的。▼

②
/导出PDF V2.0/
在第一个版本开发测试上线之后,也稳定运行了一段时间,很长一段时间没有用户反馈这个问题,一个是并没有直接宣传这个功能,也没有作为卖点功能主推,但只要有按钮,就会有用户点击,终于在有客户发现这个按钮,并选择使用之后,终于用出不是这个方案开发初衷的其他功能:“我们在导出这个PDF之后,用浏览器打印出来,发现只能打印一页A4纸,而且内容很小,希望你们可以解决”。
这个功能开发的初衷只是给提出需求的客户,解决电子留档的问题,但没想到有客户用导出的PDF用来作为打印用途,这明显违背了这个功能的初衷,但客户就是上帝,如果不解决就面临产品被投诉,所以我们进行了二次优化。
实际上,出现客户所说的问题,也是因为我们在开发的时候,没有考虑到还存在打印需求,直接将整张网页保存为了一页PDF,而PDF的机制是,不管你一页内容有多少内容,在打印的时候,始终只会将这一页内容放在一张A4纸里面打印出来,不会二次帮你分页,如下图所示。▼

当网页内容越长,保存的PDF一页内容也会越长,打印出来的A4内容也就越小。所以解决这个问题的内容关键是前端在保存PDF的时候,对PDF进行分页。而刚好,JSPDF插件提供了分页机制,我们只需要在版本1里面做一些分页就可以。▼
//调用html2canvas将指定区域生成为imghtml2canvas($('.pdfPrint')[0], { logging: false }).then(function (canvas) { var img = Canvas2Image.convertToImage(canvas, canvas.width, canvas.height); //调用jsPDF将图片生成为PDF文件 var pdf = new jsPDF('', 'pt', 'a4', true);//调用jsPDF将图片生成为PDF文件 pdf.addImage(img, 'JPG', 10, 10, imgWidth, imgHeight, '', 'FAST'); //添加分页逻辑,对生成的图片进行分页处理 if (leftHeight pdf.addImage(img, 'JPG', 10, 10, imgWidth, imgHeight, '', 'FAST'); } else { while (leftHeight > 0) { pdf.addImage(img, 'JPG', 10, position, imgWidth, imgHeight, '', 'FAST') leftHeight -= pageHeight; position -= 841.89; //避免添加空白页 if (leftHeight > 0) { pdf.addPage(); } } } //保存为PDF文件 pdf.save($("#js-titleText").text().trim() + '.pdf'); });
在加入分页之后,开发测试之后,第二个版本上线。
③
/导出PDF V3.0/
第二个版本上线之后,又稳定运行了一个时间,而将我们这个导出PDF功能用来导出后再进行打印的客户也越来越多了(说好的只是想用来电子存档呢)。当然了,用的人越多,发现的问题也会越多,终于有一家客户发现了问题,并给出了反馈“总感觉你们导出的这个PDF打印出来好模糊,希望可以优化一下”。
实际上,造成打印出来模糊的问题,我们开发自己包括测试一直都没注意到,在跟网页自带的打印效果对比之后,发现真的很模糊。▼

在分析代码,并且查找了相关文档之后,发现依然找不到问题原因。后来突发灵感,将我们导出功能的PDF和网页内容放进PS里面进行了对比,发现导出的PDF内容实际比网页内容缩放了,而正是这个缩放,导致了导出的PDF模糊,打印后更模糊了。▼

在查找不到任何相关文档并且检查插件使用代码也没问题之后(实际上这个过程花费了很长时间,包括做不同的数据验证),最后了一个关键信息,就是jspdf的生成PDF实例接口传入参数有误。▼
var pdf = new jsPDF('', 'pt', 'a4', true);//调用jsPDF将图片生成为PDF文件
在jsPDF方法内,第3个参数,官方文档为传入’a4’,生成的PDF会是A4纸张的尺寸,在分析了插件源码之后发现,实际上’a4’对应了一个默认宽高尺寸“[595.28,841.89]”,这个尺寸会让导出的PDF有轻微的缩放,而canvas生成的图片,是跟网页dom尺寸一模一样的,问题关键点就出现在了我们不能使用默认的’a4’配置,而是应该传入一个更为精确的尺寸。在一番尝试之后,验证出了如果想要打印出的A4清晰,传入的尺寸应该是“[576,840]”,这个尺寸与官方文档的尺寸不一致,但打印验证之后,发现确实清晰了很多,最终导出的效果就很清晰了。▼

三版开发测试上线。
④
/导出PDF V4.0/
在解决导出PDF清晰度之后,又稳定运行了很长一段时间,这个时候已经大面积客户发现,并将这个导出PDF功能作为打印功能用到了极致,我们甚至已经忘了这个功能初衷是给客户用来作为电子留档的。直到遇到了大数据客户,打印了一张超大表单卡死之后,给我们提出了反馈“你们这个导出PDF按钮一点,浏览器就卡死了,能不能帮忙看看是什么问题”。
到目前为止,使用的“html2canvas.js"以及“jspdf.js”其实本身是没有太大问题的,但实际性能瓶颈还是存在,当页面长度有几千甚至几万个像素长度之后(很多用户超大表单),将生成的图片转换为PDF会让浏览器会扛不住这么大的算法,直接卡到崩溃。针对这个问题,继续让前端来解决性能瓶颈,已经没有可操作的余地了,这个时候经过内部分析,决定采用前端+后端实现的方案。
优化后的大致开发逻辑:前端将生成的图片传递给后端,再由后端生成一个PDF文档后,返回给前端一个文件流地址,前端再触发这个文档流的下载。也就是我们在这个版本内,舍弃了jspdf插件,将转换PDF的压力给到服务器来解决,关键的实现代码如下所示。▼
window.utility.ajax({ loading: true, type: 'POST', data: JSON.stringify({ base64Text: $(img).attr("src"), width: pdfWidth,//图片宽度 height: pdfHeight,//图片高度 pdfWidth: _pdfWidth,//PDF宽度 pdfHeight: _pdfHeight//PDF高度 }), url: "/***/***", dataType: "json", contentType: "application/json", cache: false, success: function (result) { data = result.data; if (data == "") { mysoft.layer.alertTip({ text: "下载文件的 URL 不能为空!" }); return; } var docName = tplData.ProcessInfo.Title + ".pdf"; var downUrl = "/Download/" + docName + "?filepath=" + data; //下载后端给的PDF文档流 window.open(downUrl); }});
⑤
/导出PDF V5.0/
4.0版本其实还不够应付更大的性能瓶颈,因为没稳定多久,又有客户做出了更大的数据,导致canvas生成图片也卡死,并且在IE下,生成图片支持的最大内容高度也要小很多,这个时候考虑用前端的方案进行优化。
优化主要是前端进行图片的分页,但前端没有直接操作图片分割的API,于是采用了一个变通方案,优化思路如下:将需要生成图片的dom复制多份,过定位算出每个dom的偏移量,最终通过循环生成这些dom的图片后,将分割后的图片,传给后端进行下载。
⑥
/导出PDF 终极版本V6.0/
实际上,第5个版本体验不太好,循环复制大批量dom到浏览器内,本身就会增加性能问题,其次循环生成canvas也会导致浏览器卡顿,上线之后的反馈并不太好。最终做了一个折中方案,限制不同浏览器可导出PDF的最大高度,如果超出性能瓶颈,则提示使用浏览器默认的导出PDF功能,前端则还是生成一张图片给后端,由后端对图片进行分割后,再提供PDF文件给前端进行下载。
方案改动不太大,最终的这个方案,主要生成PDF的逻辑在于后端,同样的,后端生成PDF也会存在导出的PDF模糊问题,这个时候同样只需要控制生成的尺寸就可以保证导出的PDF清晰度。而作为控制导出的PDF尺寸一个小技巧,将每页PDF的宽高属性由前端传入,这样通过前端修改尺寸属性,可以快速找到最合适的导出尺寸。
后端生成PDF的功能和实现逻辑大致如下:
代码实现:
private static List CutImageByBase64(string base64Str,int width, int height){ var result = new List(); //临时文件存放文件夹 var temp_path = FileHelper.GetPath("\\TempFiles\\ImportPDF\\"); if (!Directory.Exists(temp_path)) { Directory.CreateDirectory(temp_path); } byte[] bit = Convert.FromBase64String(base64Str); using (MemoryStream memStream = new MemoryStream(bit)) { using (var bmp = new System.Drawing.Bitmap(memStream)) { var imgCount = Math.Ceiling(bmp.Height * 1.0 / height); for (int i = 0; i < imgCount; i++) { //根据指定大小生成图片 using (var img = new System.Drawing.Bitmap(width, height)) { //根据原图切割,宽度固定,只会根据高度来切割 using (var g = System.Drawing.Graphics.FromImage(img)) { g.PageUnit = System.Drawing.GraphicsUnit.Pixel; g.Clear(System.Drawing.Color.White); g.DrawImage(bmp, new System.Drawing.RectangleF(0, 0, width, height), new System.Drawing.RectangleF(0, height * i, width, height), System.Drawing.GraphicsUnit.Pixel); g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; } string savePath = $@"{temp_path}{Guid.NewGuid()}.jpg"; img.Save(savePath, System.Drawing.Imaging.ImageFormat.Jpeg); result.Add(savePath); } } } } return result;}
/写在最后/
经过几轮的迭代开发,这个在18年由客户提出并实现开发的功能,经过了接近两年的大面积使用验证,现在总算是比较稳定下来,但目前也依然面临一个问题,由于生成的PDF实际上是网页的截图,而浏览器导出的PDF是矢量文件,清晰度依然没有原生的导出PDF完美,如果你有更好的解决方案,欢迎文章底部评论留言。
文:@徐龙昌 @曾斌
来源:明源技术团队