使用jsPDF导出PDF文件实践分享

这篇文章发布于 2023年06月4日,星期日,15:07,归类于 JS实例。 阅读 48629 次, 今日 29 次 25 条评论

 

PDF封面图

一、jsPDF项目简介

最近遇到个需要将网页中特定内容转为PDF的需求,所以有机会试用了下jsPDF,也遇到了一些问题,这里分享给大家了。

首先,项目地址:https://github.com/parallax/jsPDF

目前2.6万的star数,可以说是Github上Top级别的项目了,也是Web导PDF的首选解决方案。

Star数目示意

官方的使用示意也很简单,构造,内容和保持。

import { jsPDF } from "jspdf";

// 默认是 a4 纸张尺寸,纵向,单位是mm
const doc = new jsPDF();

doc.text("Hello world!", 10, 10);
doc.save("a4.pdf");

好,接下来,就是把页面中的DOM内容作为PDF内容就可以了,理论上如此,但实际使用却令人深思。

二、内置html()方法惊为天人

jsPDF内置一个名为html()的方法,可以直接让HTML元素作为PDF的内容。

var doc = new jsPDF();
// element就是需要转变为pdf的DOM元素
doc.html(element, {
   callback: function (doc) {
     doc.save();
   },
   x: 10,
   y: 10
});

结果我试了下,效果惊为天人。

内置html方法的惊人效果

研究一番,发现是要导入完整的中文字体,而一个中文字体,少说5-6M,在Web这种场景下就不太合适。

于是改变策略,决定先用html2canvas将内容转成图片,再以图片的方式,一页一页地插入到PDF中,实现成本会低很多,不足就是文字无法选择。

三、借助html2canvas

html2canvas这个项目之前也有提过,接近3万 Star,也是Github上的顶级开源项目。

项目地址:https://github.com/niklasvh/html2canvas

极简使用示意

这里快速演示下如何使用html2canvas和jsPDF生成PDF文件。

假设页面上有个布局元素,id属性值是element,效果如下图所示:

示意布局图

则下面的这段代码就可以让这段布局内容变成PDF文件下载下来。

<script src="./html2canvas.min.js"></script>
<script src="./jspdf.umd.min.js"></script>
<script>
var pdf = new jspdf.jsPDF();
html2canvas(target).then(function(canvas) {
    pdf.addImage(canvas.toDataURL('image/jpeg'), 10, 10);
    pdf.save('mybook.pdf');
});
</script>

此时的PDF打开效果就是这样的:

PDF效果示意

眼见为实,您可以狠狠地点击这里:html2canvs与jspdf生成PDF简易demo

然而,真实的项目开发要比demo页面麻烦的多。

几乎大多数人一定会遇到的问题,那就是如果html2canvas生成的图片过长,该如何在PDF中分页。

以及,图片跨域了,又当如何处理?

四、图片跨域和分页的问题

canvas与图片跨域的问题,我之前专门撰文讲解过,传送门地址:“解决canvas图片getImageData,toDataURL跨域问题

里面的方法虽多,根据我多年的实践,还是服务器设置Access-Control-Allow-Origin运行访问,前端fetch或XMLHttpRequest获取图片数据的策略最好用。

以fetch举例,想要获得一个图片地址是 imgUrl 的图像数据,可以这么处理:

fetch(imgUrl).then(res => res.blob()).then(blob => {
  var reader = new FileReader() ;
  reader.onload = function () {
    // this.result 就是图片的base64地址
  };
  reader.readAsDataURL(blob) ;
})

为何需要转成base64呢?因为根据我的实践,html2canvas内容中的图片地址,需要转换成base64地址,才能真正可用。

分页的问题

分页的问题可以使用代码搞定,设置好每一页PDF的高度,然后canvas的高度一页一页剪掉再分别添加即可。

这里就有一些需要提前知道的,关于尺寸的知识。

当我们构造一个 pdf 实例的时候,如果不设置任何参数,则其尺寸是 A4 纸的尺寸,210毫米×297毫米。

根据我的实践,这个尺寸有些小了,生成的PDF内容模糊,阅读体验极为不佳。

所以,有必要对PDF尺寸进行自定义,也就是设置得大一些,虽然大尺寸让PDF文件占据空间也大了,但现在是大屏高清时代,流量不值钱,此不足不值一提。

具体而言,我是这么处理的。

首先确定好页面中容器元素的宽度,假设是700px,则PDF的尺寸可以设置为2倍,也就是1400px,而竖版PDF的高宽比是根号二,也就是1.414,所以PDF的高度就是1400*1.414=1979.6像素。

此时,再配合简单的数学计算,我们就可以将canvas图像分隔成一页一页的,分别塞在PDF中。

具体代码参见下一节封装的代码示意。

五、封装后的的方法

为了方便遇到类似需求的同学可以快速完成对应的开发。

我将上面的图像处理和分页封装成了可复用的方法,代码如下所示(支持jspdf和html2canvas src直连和 npm install 安装两种使用形式):

// 导出 pdf 封装方法
// by zhangxinxu(.com)
// 访问 https://www.zhangxinxu.com/wordpress/?p=10854 了解更新信息
export async function exportPdf (element, filename = '未命名', callback = () => {}) {
  if (!element) {
    callback();
    return;
  }

  // 尺寸的确定
  const originWidth = element.offsetWidth || 700;

  // 创建一个容器,用于克隆元素
  const container = document.createElement('div');
  // 16px是为了生成的PDF有安全边距
  container.style.cssText = `position:fixed;left: ${-2 * originWidth}px; top:0;padding:16px;width:${originWidth}px;box-sizing:content-box;`;
  // 插入到body中
  document.body.appendChild(container);
  // 克隆元素
  container.appendChild(element.cloneNode(true));

  // 依赖的库
  var jsPDF;

  if (typeof html2canvas == 'undefined') {
    html2canvas = await import('html2canvas').then(module => module.default);
  }

  if (typeof jspdf == 'undefined') {
    jsPDF = await import('jspdf').then(module => module.jsPDF);
  } else {
    jsPDF = jspdf.jsPDF;
  }

  // 为了保证显示质量,2倍PDF尺寸
  const scale = 2;
  const width = originWidth + 32;

  const PDF_WIDTH = width * scale;
  const PDF_HEIGHT = width * 1.414 * scale;

  // 渲染方法
  const render = function () {
    // 渲染为图片并下载
    html2canvas(container, {
      scale: scale
    }).then(function(canvas) {
      const contentWidth = canvas.width;
      const contentHeight = canvas.height;

      // 一页pdf显示html页面生成的canvas高度
      const pageHeight = contentWidth / PDF_WIDTH * PDF_HEIGHT;

      // canvas图像在画布上的尺寸
      const imgWidth = PDF_WIDTH;
      const imgHeight = PDF_WIDTH / contentWidth * contentHeight;

      let leftHeight = contentHeight;
      let position = 0;

      const doc = new jsPDF('p', 'px', [PDF_WIDTH, PDF_HEIGHT]);

      // 不足一页
      if (leftHeight < pageHeight) {
        doc.addImage(canvas, 'PNG', 0, 0, imgWidth, imgHeight);
      } else {
        // 多页
        while (leftHeight > 0) {
          doc.addImage(canvas, 'PNG', 0, position, imgWidth, imgHeight)
          leftHeight -= pageHeight;
          position -= PDF_HEIGHT;
          //避免添加空白页
          if (leftHeight > 0) {
            doc.addPage();
          }
        }
      }

      doc.save(filename + '.pdf');

      // 移除创建的元素
      container.remove();

      // 隐藏全局loading提示
      callback();
    });
  }

  // 图像地址替换成base64地址
  const eleImgs = container.querySelectorAll('img');
  const length = eleImgs.length;
  let start = 0;
  container.querySelectorAll('img').forEach(ele => {
    let src = ele.src;

    if (!src) {
      return;
    }

    // 事件处理,必须成功或失败
    ele.onload = function () {
      if (!/^http/.test(ele.src)) {
        start++;
        if (start == length) {
          render();
        }
      }
    };

    // 请求图片并转为base64地址
    fetch(src).then(res => res.blob()).then(blob => {
      var reader = new FileReader() ;
      reader.onload = function () {
        ele.src = this.result;
      };
      reader.readAsDataURL(blob) ;
    }).catch(() => {
      // 请求异常处理
      start++;
      if (start == length) {
        render();
      }
    });
  });
}

可以自动将容器元素中的图片Base64,同时内容分布在每一页的PDF上并下载。

实践出真知

为了验证封装的方法的效果,我特意做了个演示页面。

//zxx: 演示页面采用的是直联调用

您可以狠狠地点击这里:exportPdf封装方法与跨域图片PDF导出demo

打击可以点击下图所示的“PDF生成”按钮,菊花转几圈之后,就可以看到PDF下载的提示了(看你浏览器设置,也可能是直接保持到本地)。

导出PDF示意

下图是生成的PDF文件的缩略图,可以看到图片和排版都是完全符合预期的。
导出PDF示意图

调用这块的JS代码参考:

<script src="./html2canvas.min.js"></script>
<script src="./jspdf.umd.min.js"></script>
<script type="module">
import { exportPdf } from './exportPdf.js';
// 点击按钮执行PDF导出
button.addEventListener('click', () => {
    const article = document.querySelector('article');
    // 显示loading
    button.loading = true;
    // 由于导出PDF是异步的,所以需要在导出完成后隐藏loading
    exportPdf(article, '最终章 极北大迷宫', () => {
        button.loading = false;
    });
});
</script>

少了很多处理细节,是不是实现起来简单多了。

六、跨行内联背景色的渲染问题

实际使用中,还遇到了比较棘手的问题。

就是内联元素,如果有背景色,且这个背景色换行了,则生成的PDF的这部分色块会覆盖部分内容,导致异常。

例如页面渲染是这样的:
原始布局效果示意

PDF效果却是这样的:

内联色块bug

我查了下html2canvas的issues,有多个类似反馈,但是都没有进行处理。

按照我对html2canvas底层实现方式的理解,这个问题确实不太好处理。

但是,并不表示没有方法。

可以将整块的内联元素,分隔成一个一个独立的内联元素,这样就可以正常渲染了。

也就是将这个结构:

<span class="bgcolor">CSS新世界</span>

转换成这样子的(实际开发不能换行,这里是为了方便大家阅读刻意处理的):

<span>
    <span class="bgcolor">C</span>
    <span class="bgcolor">S</span>
    <span class="bgcolor">S</span>
    <span class="bgcolor">新</span>
    <span class="bgcolor">世</span>
    <span class="bgcolor">届</span>
</span>

来看下最终的效果。

您可以狠狠地点击这里:解决html2canvas span inline background色块问题demo

点击下面这个按钮,JS会对原来的DOM结构进行处理(实际开发可以克隆该元素再处理,以避免DOM结构变化会带来潜在风险):

修复按钮点击示意

此时,生成的PDF效果就和原始布局样式效果保持一致了:

生成效果保持一致了

七、其他点点点

本文示意页面所使用的JS文件都是script直连。

实际开发,多是走前端框架。

由于这两个JS都是体积比较大的JS,因此,可以使用动态加载的方式来实现。

import('html2canvas').then(module => module.default).then(...)
import('jspdf').then(module => module.jsPDF).then(...)

盼星星盼月亮

《CSS选择器世界 第2版》的签字版也已经可以购买啦,打开手机淘宝,扫下图左下角的码就可以了。

书籍购买码

//zxx: 包邮,另外,我这里还有三张极客时间14天畅学卡,先购买的优先赠送之。

OK,其他就没什么好说的,希望本文的内容可以帮到遇到类似需求的小伙伴。

❤️ ? ? ? ? ?

(本篇完)

分享到:


发表评论(目前25 条评论)

  1. channg说道:

    示例的小说成功勾起了阅读兴趣2333

  2. Echo说道:

    html2canvas触发时会重新加载页面的所有静态资源,用时比较长,这个有办法解决吗

  3. jarvis说道:

    讲真的,这个只能很粗糙的处理一些要求不高的PDF,如果对字体,排版有要求的,这玩意跑不通。 真正的方案是 pdfmake 做排版,如果需要填写表单,合并PDF,修改已有PDF文件可以搭配 pdflib 一起。当然中级方案是 jsPDF

  4. 说道:

    如果分页超出15页,基本会失败,包含图片很多时,插件会出现绘制失败,如果是嵌套webview时性能很差。这个需要解决了。

  5. Evan说道:

    如果不强制要求前端处理的话, 后端使用Puppeteer, 将页面转成PDF, 应该是目前最好的pdf方案. 这个相当于浏览器打印中的另存为PDF.

  6. 代码如诗如画说道:

    旭哥,如何才能让导出的PDF里面的文本支持被复制

  7. 未及说道:

    有个开箱即用的也是基于html2canvas+jspdf封装的 “html2pdf.js”,可以试试挺方便的省去了自己写,哈哈

  8. gino说道:

    图片被切断了

  9. zjz1993说道:

    导出来的pdf的图片没有了,而且字被分隔页分割开了

  10. demo说道:

    这里的分页效果不是很智能,有些粗暴,文字可能会被截成两半,一张图片分为上下两页,这种情况是不是没办法避免

  11. na1tez说道:

    demo挂了 看了一下发现点按钮后卡在了loading 打开控制台看果然是报错了

  12. 李鹏坤说道:

    字体可以使用字体子集化的方案做,将要打印的内容的文字获取后,丢给基于 https://github.com/ecomfe/fontmin 的 node 服务,就可以得到几百 KB 的字体文件了。

  13. zerotower说道:

    鑫哥,你的第二个示例导出PDF失败了。报“ReferenceError: article is not defined”

  14. codehz说道:

    其实可以试试丢iframe里,然后调用print方法打印(

  15. Corazon说道:

    有没有考虑过jsPDF做成服务,通过网络请求发送DOM调用的方法,做成服务可以提前下好各种字体是不是就可以不担心下载字体的问题了?类似的方法还有使用 puppteer的pdf导出

  16. xin说道:

    前两周刚做了这个,页面过长的时候,canvas不行了。

  17. joiner说道:

    大佬之前分享过canvas中实现文字换行的功能,如果纯文字分页感觉也没问题,但文档中存在表格图片代码块这些内容时,分页就不知道乍处理了,一些在线的文档不知道如何是进行分页的,大佬有相关了解嘛?

  18. thetbw说道:

    倒数第二个演示有个报错
    js-utils-jspdf-page-image-demo.php:96 Uncaught ReferenceError: article is not defined

  19. lif3ng说道:

    DOM->img->pdf 方式对于分页时文字、表格被截断的问题是不是没有什么解决方法,如果想在浏览器前端低成本生成分页式不截断内容的PDF,有什么方法吗?

    • 张 鑫旭说道:

      我想到的是使用 columns 布局,就是不知道 html2canvas 支不支持。

      • ln说道:

        从当前页分割后的canvas对象最后一行开始遍历, 如果有预定的文字像素或者表格像素, 然后直接往上挪动1px直到没有,然后当前页的截取就减去挪动的高度。