导出PDF的实践

导出PDF的实践

本篇主要介绍3种pdf导出的方法和优缺点

直奔实现方式:

方案一:jsPDF

方案二:html2canvas + jsPDF

方案三:pdfkit + svg-to-pdfkit + voilab-pdf-table


方案一:jsPDF

常规的由前端实现html导出为PDF文件,jsPDF即可,该插件提供了文本导出,图片导出,html导出,图形元素导出。我看到很多其他的文章中都提到改插件不支持中文编码,下面会给出解决的案例。

//以导出a4格式的PDF文件为例
//let pdf = new jsPDF('', 'pt', 'a4');  
//文本导出
pdf.text('hello world !', 10, 10);
//图片导出,可以选择其他的图片格式,我这里给出的例子是jpeg格式,尝试过png,导出的文件非常大
//此处的canvas来源于html2canvas
let contentWidth = canvas.width;
let contentHeight = canvas.height;
let pageData = canvas.toDataURL('image/jpeg', 1.0);  //1.0表示图片清晰质量,0~1
const SIZE = [595.28,841.89];  //a4宽高
const WGUTTER = 20;  //横向页边距
var imgWidth = SIZE[0]- WGUTTER*2;  //图片在pdf中的宽
var imgHeight = imgWidth / contentWidth * contentHeight;  //图片在pdf中的高
pdf.addImage(pageData, 'JPEG', WGUTTER, position, imgWidth, imgHeight );
//html导出
pdf.fromHTML(document.getElementById("id"), 10, 10);
//图形元素导出
pdf.line(20, 20, 60, 20);  //直线
pdf.rect(20, 20, 10, 10);  //矩形
//svg的导出,借助插件svg-to-pdfkit
SVGtoPDF(doc, svg, 0, 0);
SVGtoPDF(doc, svg, 0, 0, { fontCallback: () => fonturl });

关于jsPDF更多的例子请参考[版本有点旧]:

着重提一下关于中文乱码的问题

//text中文乱码,不做font编码处理直接这样写就会乱码
pdf.text('我很西瓜的瓜!', 10, 10);
//html中如果存在中文编码的话也会乱码
pdf.fromHTML(document.getElementById("id"), 10, 10);
//我这边是用到了一个叫做jsPDF-CustomFonts-support的jsPDF的支持库需要引入的文件如下
<script src="https://cdn.bootcss.com/jspdf/1.3.5/jspdf.min.js"></script>
<script language="javascript" type="text/javascript" src="../jspdf.customfonts.min.js"></script> 
<script language="javascript" type="text/javascript" src="../default_vfs.js"></script> 
var pdf = new jsPDF('', 'pt', 'a4');
pdf.addFont("msyh.ttf", 'yh', 'normal');
pdf.setFont("yh");
pdf.text("我很西瓜的瓜",10,10);
pdf.save('content.pdf');

npm install jsPDF-CustomFonts-support里面没有default_vfs.js文件,需要根据自己的需要生成,我这里添加的是微软雅黑,请参考:

点评:中文的支持度不高,上面提到的中文乱码解决方法支持到pdf.text(),还不支持pdf.fromHTML(),而且pdf.addFont()会降低pdf导出的性能。


方案二:html2canvas + jsPDF

各自的习惯不同,有的同学喜欢局部图表的导出,即单张单张图表生成图片然后添加到pdf文件对象里,我建议生成一张图片然后添加到pdf文件对象里的方式,这样布局更好控制。生成图片的优点有二,避开中文乱码的坑,布局可控。


缺点1:html2canvas将html转化为canvas并不能100%还原dom结构

html2canvas通过遍历页面DOM结构,收集所有元素信息及对应样式,渲染出canvas image。它只能将它能处理的元素和样式还原生成canvas image,对于不支持的样式,渲染还原效果不佳,不能100%还原dom结构。

//例1
dom结构很深的情况下,有的时候会发现连最基础的文本都无法生成,如果遇到这种情况,尝试把<div>标签改成<span>标签,
如果可以显示文本,再添加样式{display:inline-block;width:100%;}
一些高级的样式,比如
display: -webkit-box;
-webkit-box-flex: 3;
类似上面这种高级的样式canvas是不支持的,如果有用到这种样式需要找替代的方式处理。

关于支持性,这是官方给出的支持报告:


缺点2:需要手动计算来分页

当我们使用html2canvas插件将一个元素渲染成一张图片并将它添加到pdf对象中时,就带来了分页的问题,pdf对象的分页并非自动添加,而是需要我们通过计算图片的高度手动执行addPage()方法,即便如此,也还是存在分页异常的问题,比如说图表被截断,表格,甚至文字被截断,我实现的方式如下:

const SIZE = [595.28,841.89];  //a4宽高
let node = document.getElementById("pdf_content");
let nodeH = node.clientHeight;
let nodeW = node.clientWidth;
let pageH = nodeW / SIZE[0] * SIZE[1];
let modules = node.childNodes;
let pageFooterH = 50;  //50为页尾的高度
this.addCover(node.childNodes[0],pageH);  //添加封面
this.addPageFooter(node.childNodes[1],1,0);  //添加页尾
this.addPageHeader(node.childNodes[2]);  //添加页头
for(let i = 0,len = modules.length;i < len;i++){
   let item = modules[i];
   //div距离body的高度是pageH的倍数x,但是加上自身高度之后是pageH的倍数x+1
   let beforeH = item.offsetTop + pageFooterH;
   let afterH = beforeH + item.clientHeight + pageFooterH;  
   let currentPage = parseInt(beforeH/pageH);
   if(currentPage != parseInt(afterH/pageH)){
      //上一个元素底部距离body的高度
      let lastItemAftarH = modules[i-1].offsetTop + modules[i-1].clientHeight; 
      let diff = pageH - lastItemAftarH%pageH - pageFooterH;  
      //加页尾
      this.addPageFooter(item,currentPage+1,diff);
      //加页头
      this.addPageHeader(item);
    if(i == len-1){
      let diff = pageH - afterH%pageH + 20;  //50为页尾的高度
      //加页尾
      this.addPageFooter(item,currentPage+1,diff,true);
}

说明:node对象下的每一个一级子元素都被当作一个不可被分割的整体,遍历这些子元素,如果某个元素距离body的高度是pageH的倍数x,当加上该元素自身的高度时是pageH的倍数x+1,那么说明x这一页放不下该元素,该元素需要被放在x+1页上,利用marginTop设置一个留白区域即可。

将node元素生成pdf文件

const WGUTTER = 20;  //横向页边距
html2canvas(node).then(function(canvas) {
    var contentWidth = canvas.width;
    var contentHeight = canvas.height;
    //一页pdf显示html页面生成的canvas高度;
    var pageHeight = contentWidth / SIZE[0] * SIZE[1];
    //未生成pdf的html页面高度
    var leftHeight = contentHeight;
    //html页面生成的canvas在pdf中图片的宽高
    var imgWidth = SIZE[0]- WGUTTER*2;
    var imgHeight = imgWidth / contentWidth * contentHeight;
    var pageData = canvas.toDataURL('image/jpeg', 1.0);
    var pdf = new jsPDF('', 'pt', 'a4', true);
    //pdf页面竖向偏移
    var position = 0;
    if (leftHeight < pageHeight) {
        pdf.addImage(pageData, 'JPEG', WGUTTER, position, imgWidth, imgHeight, 'FAST' );
    } else {
        while(leftHeight > 0) {
            pdf.addImage(pageData, 'JPEG', WGUTTER, position, imgWidth, imgHeight)
            leftHeight -= pageHeight;
            position -= SIZE[1];
            //避免添加空白页
            if(leftHeight > 0) {
                pdf.addPage();
    pdf.save('content.pdf');
}

在合理分页的基础上,我们可以添加封面,页头,页尾。

添加封面:

addCover = (node,pageH) => {
    let cover = document.createElement("div");
    cover.className = "c-page-cover";
    cover.style.height = (pageH-50)+"px";
    cover.innerHTML = `<img src="./img/logo.png" />
                      <table>
                        <tbody>
                          <tr><td>pdf name</td></tr>   
                          <tr><td>pdf报告生成时间</td></tr>                                
                        </tbody>
                      </table>`;
    node.parentNode.insertBefore(cover,node);
  }

添加页头:

addPageHeader = (item) => {
    let pageHeader = document.createElement("div");
    pageHeader.className = "c-page-head";
    pageHeader.innerHTML = "页头内容";
    item.parentNode.insertBefore(pageHeader,item);
  }

添加页尾:

addPageFooter = (item,currentPage,diff,isLastest) => {
    let pageFooter = document.createElement("div");
    pageFooter.className = "c-page-foot";
    pageFooter.innerHTML = "第 "+ currentPage +" 页 ";
    isLastest?item.parentNode.insertBefore(pageFooter,null):item.parentNode.insertBefore(pageFooter,item);
    pageFooter.style.marginTop = diff+"px";
    pageFooter.style.marginBottom = "10px";
  }


缺点3:生成的pdf文件比较大,一页将近1MB

顺便提一下将生成的pdf上传到服务器而不是下载到本地的做法:

var datauri = pdf.output('dataurlstring');
然后把这个字符串送到后台解密
后台主要用BASE64Decoder解密,放到文件流里,生成文件,下次直接访问

上传到服务端,不直接在客户端导出,但是生成的文件都比较大,不建议上传到服务端,浏览器上传文件1M以上就容易崩溃,上传到服务端只要改写最后一句即可,后端支持下,把base64编码解码存入文件流生成文件:


方案三:pdfkit + svg-to-pdfkit + voilab-pdf-table

该方案既适用于前端也适用于后端,支持中文

前端:

//需要引入的js文件
<script src="../pdfkit.js"></script>
<script src="../blobstream.js"></script>
<script src="../svg-to-pdfkit.js"></script>
<script src="../voilab-pdf-table.js"></script>
const SIZE = [595.28,841.89];  //a4宽高
let pdf = new PDFDocument({compress: false});
pdf.font('./pdfRelay/msyh.ttf');
//添加svg 
let svgs = node.getElementsByTagName("svg");
for (let i = 0; i < svgs.length; i++) {
    SVGtoPDF(pdf, svgs[i].outerHTML || svgs[i], 0, 0);
    if (i !== svgs.length - 1) {
       pdf.addPage();
//添加表格
let keys = ["k1","k2","k3"];
let columns = [];
let width = (SIZE[0]-60*2)/keys.length;
for(var i=0,len=keys.length;i<len;i++){
    columns.push({
        id: keys[i],
        header: keys[i],
        width:width,
let table = new pdfTable(pdf, {bottomMargin: 30}); 
table.setColumnsDefaults({
    headerBorder: ['L','T','B','R'],
    headerPadding: [5, 0, 5, 0],
    border: ['L','T','B','R'],
    align: 'center',
    fontSize: "10px",
    padding: [5, 0, 5, 0],
.addColumns(columns)
.onPageAdded(function (tb) {
    tb.addHeader();
table.addBody(data);
//生成文档
let stream =pdf.pipe(blobStream());
stream.on('finish', function() {
    let blob = stream.toBlob('application/pdf');
    var url = URL.createObjectURL(blob);
    var a = document.createElement('a');
    a.download = 'content.pdf';
    a.href = url;
    a.click();
    URL.revokeObjectURL(url);

node的实现方式:

const PDF = require("pdfkit");
const SVGtoPDF = require('svg-to-pdfkit');  
const pdfTable = require('voilab-pdf-table'); 
const fs = require('fs'); 
const fonturl = './ttf/msyh.ttf';
let pdf = new PDF(); 
pdf.font(fonturl);   
let stream = fs.createWriteStream('./content.pdf');    
pdf.pipe(stream); 
pdf.addPage();
pdf.image("./img/logo.png",100,100,{width:100});
pdf.addPage();
pdf.text(data,0,0);
//svg,处理中文乱码
pdf.addPage();
SVGtoPDF(pdf, svg对象或者html, 0, 0, { fontCallback: () => fonturl });
//table
pdf.addPage();
let table = new pdfTable(pdf, {bottomMargin: 30}); 
table.setColumnsDefaults({
    headerBorder: ['L','T','B','R'],
    headerPadding: [5, 0, 5, 0],
    border: ['L','T','B','R'],
    align: 'center',
    fontSize: "10px",
    padding: [5, 0, 5, 0],
.addColumns(columns)