引言

最近在业务上遇到了一个问题是要将页面打印pdf文件,产品的预期是希望点击一个按钮,就能够将页面数据写在一个pdf上,并下载下来,需要保证pdf的内容具有很好的可读性。

评估下来这个需求的本质是要实现一个能够将HTML页面转为PDF并实现下载的功能。通过技术调研,我最终的方案是采购html2canvas + jspdf这两个库来实现,通过使用html2canvas将使用canvas将页面转为base64图片流,然后将其插入jspdf插件中,实现保存并下载pdf。

这个方案其实也是目前比较常用的一个方案,但是在实践过程中,遇到的最大问题就是分页截断的问题。当html页面超过一页A4纸的时候,页面就是出现被截断,影响了pdf的可读性。

由于网上关于分页截断的解决思路比较少,所以特意将此次的解决方案记录下来,也作为一次个人的技术成长实践。

使用 JSPDF 和 html2canvas 创建简单的 PDF文件

首先,我们开始使用 JSPDF 和 html2canvas 创建一个简单的 PDF。

创建一个 JSPDF 实例

创建一个 JSPDF 实例,设置页面的大小、方向和其他参数。参考官网可以写一个很简单的实例:https://artskydj.github.io/jsPDF/docs/index.html

1
2
3
4
5
6
7
8
var doc = new jsPDF({
orientation: 'landscape',
unit: 'in',
format: [4, 2]
})

doc.text('Hello world!', 1, 1)
doc.save('two-by-four.pdf')

生成一个pdf文件,并且在文件中写入一定内容,其实JSPDF 这个库就能做到。

但是很多场景下,我们的目标pdf中的内容会更复杂,而且还要考虑排版,所以最好的方式是引入html2canvas这个库,将页面元素转换成base64数据,然后贴在pdf中(使用addImage方法),这样就能保证页面的内容。引入了html2canvas库后,我们更多关注就是利用现成组件库、或者原生html和css实现更复杂的页面内容。

引入 html2canvas

官方文档:https://html2canvas.hertzen.com/getting-started

使用 html2canvas 捕捉 HTML 内容或特定的 HTML 元素,并将其转换为 Canvas。下面是一个简单的demo, 可以可以看到html2canvas能够将dom元素转化为一张base64图片,尝试选择页面元素可以感受到图片和文字的不同。

https://codepen.io/janice143/pen/RwvpQbZ

将html2canvas转化的图片放到pdf中

这一步我们需要使用JSPDF 的addImage方法,其语法如下:

1
addImage(imageData, format, x, y, width, height, alias, compression)
  • imageData - 要添加的图像数据。可以是图像的 URL、图像的 base64 编码字符串或图像的二进制数据
  • format - 图像的格式。可以是 “JPEG”、”PNG” 或 “TIFF”。
  • x - 图像在 PDF 文档中的 x 坐标。
  • y - 图像在 PDF 文档中的 y 坐标。
  • width - 图像的宽度。
  • height - 图像的高度。
  • alias - 图像的别名。此别名可用于在 PDF 文档中引用图像。
  • compression - 图像的压缩级别。可以是 “NONE”、”FAST” 或 “SLOW”。

下面是一串示例代码:

1
2
3
4
var doc = new jsPDF();
doc.addImage('image.jpg', 'JPEG', 10, 10, 100, 100);
doc.save('output.pdf');

此示例将在 PDF 文档的 (10, 10) 处添加一个名为 “image.jpg” 的 JPEG 图像。图像的宽度和高度将分别为 100 和 100 像素。

JSPDF 和 html2canvas结合起来用

知道上面的关键三个点,接下来我们将这三个步骤串联起来,实现一个基本的html→pdf的方案。大致步骤如下:

  1. 写一个基本html页面
  2. 创建jspdf实例
  3. 获取页面的dom节点,使用html2canvas将其转化为base64数据流
  4. 将base64数据流装载到jspdf的addImage方法中
  5. 保存pdf

基于这5个步骤,可以实现基本的单页打印。

多页:比例缩放+循环移位

但是,在你实践中,你会发现2个问题:

  • 页面的内容超出了pdf页面
  • 打印的pdf只有一页,没有展示全部的html信息

这篇博客给了一个非常好的解决方案,其原理是:

  • 通过比例缩放,实现等比展示在pdf中
  • 通过循环迭代,不停移动base64的图片位置,也就是调整X,Y参数(x - 图像在 PDF 文档中的 x 坐标;y - 图像在 PDF 文档中的 y 坐标),移动使用jspdf的addPage方法新加一个pdf。

Vue前端实现HTML转PDF并导出 - 掘金

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 当内容未超过pdf一页显示的范围,无需分页
if (leftHeight < pageHeight) {
// addImage(pageData, 'JPEG', 左,上,宽度,高度)设置
PDF.addImage(pageData, 'JPEG', 0, 0, imgWidth, imgHeight)
} else {
// 超过一页时,分页打印(每页高度841.89)
while (leftHeight > 0) {
PDF.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
leftHeight -= pageHeight
position -= 841.89
if (leftHeight > 0) {
PDF.addPage()
}
}
}

分页截断的挑战

尽管 JSPDF 和 html2canvas 是功能强大的工具,但是 他们也有很多槽点,比如得手动分页,手动处理分页截断的问题。等你实践到这一步,就开始面临分页截断的问题,类似的问题也有网友在Github上提出,但是底下依然没有很好的解决思路:https://github.com/parallax/jsPDF/issues/1517

好在掘金上有人分享了一个不错的方法:

jsPDF + html2canvas A4分页截断 完美解决方案(含代码 + 案例) - 掘金

概括一下,其处理分页截断的原理就是在使用addImage之前,将html进行分页,通过维护一个高度位置数据,来记录每次循环迭代addImage的位置。

从高到低遍历维护一个分页数组pages,该数组记录每一页的起始位置,如:pages[0] 对应 第一页起始位置, pages[1] 对应 第二页起始位置

Untitled

接下来我们重点讨论如何将html进行切割,然后生成Pages这个数组。

假设html高度是1500,pdf宽高是[500, 900],如果不用处理分页截断的问题,我们可以想到第一页(0-900)是用来承载html从高度为0到900的信息;第二页(900-1800)是用来承载html从高度900到1500的,所以Pages数组为[0, 900]。

如果要处理分页截断呢,这时候就需要计算html元素的距离打印起始位置的高度h1,以及该元素的内部高度h2,通过这两个高度来判断这个元素要不要放在下一页,防止截断。

Untitled

如果h1 + h2 > 页面高度, 这时候说明这个元素不处理的就会被分页截断,所以应该要把这个元素放到第二页去渲染,这就以为这Pages记录的数据要变化,示意图如下,可以看到Pages[1]我们往上调整了,比第二页pdf的起始位置更高。

Untitled

说明渲染第二页pdf的时候,要从h1开始渲染,pages数组为[0, h1]。解释为第一页pdf渲染html高度区域为0-900, 第二页pdf渲染html高度区域为h1-1500。注意到第一页渲染的时候到尾部的时候,会有部分内容和第二页头部内容重合。

为了解决这个问题,我们可以使用jspdfrectsetFillColor方法,把重合的区域遮百处理。

1
2
pdf.setFillColor(255, 255, 255);
pdf.rect(x, y, Math.ceil(_width), Math.ceil(_height), 'F');

如何获得h1和h2

上面我们谈到了h1h2,其中h1是元素盒子的上边距到打印区域的高度(比例缩放后的高度),h2是元素盒子的内部高度。

计算h1: [getBoundingClientRect方法](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect)

1
2
3
const rect = contentElement.getBoundingClientRect() || {};
const topDistance = rect.top;
return topDistance;

Untitled

计算h2offsetHeight方法

Untitled

值得注意的是,因为打印区域的html元素不一定是从窗口顶部开始,所以为了计算实际的h1(元素到打印区域的顶部距离),可以采用这样的方法:

  • getBoundingClientRect方法计算元素到窗口顶部的距离
  • 循环打印之前将pages信息针对第一个元素进行一个高度校准。
1
2
3
// 对pages进行一个值的修正,因为pages生成是根据根元素来的,根元素并不是我们实际要打印的元素,而是element,
// 所以要把它修正,让其值是以真实的打印元素顶部节点为准
const newPages = pages.map((item) => item - pages[0]);

源代码

文章的最后,我基于这篇博客的思路,另写了一份demo,源代码如下:https://github.com/janice143/pdf-demo。与现有文章不同的是,本仓库的代码特点在于:

  • 支持设置pdf打印的方向,比如横向
  • 修正了高度计算问题,解决了多出一个空白页问题。掘金那篇文章计算元素高度时候没有减去容器距离顶部高度,所以导致很多新手使用那份代码的时候,会发现自己的页面顶部被裁剪到了,原因就是这个
  • 支持自定义页眉页脚
  • 支持扩展自定义分页方法,如果遇到复杂的组件,可以自定扩展逻辑计算高度

Untitled

Untitled