51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

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


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 &lt; pageHeight) {
    doc.addImage(canvas, 'PNG', 0, 0, imgWidth, imgHeight);
  } else {
    // 多页
    while (leftHeight &gt; 0) {
      doc.addImage(canvas, 'PNG', 0, position, imgWidth, imgHeight)
      leftHeight -= pageHeight;
      position -= PDF_HEIGHT;
      //避免添加空白页
      if (leftHeight &gt; 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 =&gt; res.blob()).then(blob =&gt; {
  var reader = new FileReader() ;
  reader.onload = function () {
    ele.src = this.result;
  };
  reader.readAsDataURL(blob) ;
}).catch(() =&gt; {
  // 请求异常处理
  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,其他就没什么好说的,希望本文的内容可以帮到遇到类似需求的小伙伴。

❤️ ? ? ? ? ?

(本篇完)

赞(0)
未经允许不得转载:工具盒子 » 使用jsPDF导出PDF文件实践分享