jsPDF + html2canvas A4分页截断 完美解

业务需求

网页html生成A4大小分页的pdf,翻遍了整个互联网发现没有很系统的整理与分析,甚至对jsPDF的解析也没有几篇。遇到过几次,用的比较多,完成代码编写后特此整理分析,自我记录。

业务难点

  1. 存在图片/组件/文字被分割的现象,即分页处理
  2. 包括页头、页脚、上下安全间隔的情况
  3. 富文本分页情况

处理思路

通过深度搜索优先遍历,从顶部遍历需要转换的HTML节点, 并将节点分为三种情况进行处理(1. 普通节点。2. 需要进行分页处理并且内部可能包含也需要分页处理子节点的节点。3. 需要进行分页内部不包含需要分页处理的节点,即深度搜索的终点节点),通过从高到低遍历维护一个分页数组pages,该数组记录每一页的起始位置,如:pages[0] 对应 第一页起始位置pages[1] 对应 第二页起始位置

图解如下:

分页图解.png

通过深度遍历后得出每页起始位置的数组,遍历数组,通过jspdfaddImage接口对canvas进行画面截取,由于addImage只能固定位置的左上角起始点,不能进行非常精确的上下定位截取(下一节会详解addImage),会造成截取多余的内容(如上图页面1pages[1] 下方的内容会和 页面2pages[1] 下方的内容会一样(除长度外),而页面1pages[1] 下方的内容是多余的(是属于页面2的内容))因此需要对页面不需要的内容 使用jspdf的addBlank进行空白遮挡处理

jsPDF.addImage详解

官方文档链接addImage - Documentation (artskydj.github.io)

image.png

需要注意的点是坐标(x,y) 的取值, (x,y)对应的是添加图片的左上角取值,宽高则是根据转化成canvas的宽高取值,图解如下

image.png

因此在对一个长图片进行截取时,往往将y值设未负数,让需要截取图片的起始位置落于当前的pdf页面内,在当前案例下,每一页的图片摆放坐标y = -pages[i]

image.png

jsPDF.rect详解

文档链接 context2d - Documentation (artskydj.github.io)

image.png

该接口的参数 (x,y)坐标、宽高 与addImage接口的一致
当前pdf页需要的内容的高度为 pages[i] - pages[i-1], 除去顶部这个高度外以下的内容都是不需要的,因此得到每一页添加空白的y坐标值为- pages[i] - pages[i-1],高度h为一页pdf的高度(此处为A4页的高度) - pages[i] - pages[i-1],宽度为A4宽度,x为0, 图解如下:

image.png

深度优先遍历三种类型的节点

通过深度优先遍历操作,可以从高到低去遍历需要进行跨页判断的元素,检测是否跨页,并记录分页点,从而避免跨页问题。

1. 普通节点

当遍历到普通节点,即不需要进行分页判断的节点时,只需要进行 2步操作

  1. 当前节点距离顶部的高度 - pages最后一位元素的值(即上一页的分界点)得出的差值是否 大于 页面的高度 , 如果大于,则证明当前节点已经跨页,进行操作pages.push(pages[pages.length - 1] + 一页PDF的高度)
  2. 对子节点进行深度遍历

2. 需要进行跨页判断,且内部也含有 可能跨页/需要进行跨页判断 的节点

当元素进行到该类型的节点时, 需要进行3步操作

  1. 需要进行与普通节点第一步相同的判断
  2. (检测当前节点距离顶部的距离 + 节点自身的高度) 是否大于 (pages 最后一位元素(即当前页 顶部位置) + 一页PDF的高度(当前指A4的高度))

如果条件为真,则证明该节点属于跨页元素,距离页面顶部距离的值top 是分页点,往pages
push top

  1. 且由于内部还存在需要进行跨页检测的节点,因此需要对子节点进行深度遍历

3. 需要进行跨页判断,但内部不含有可能跨页/需要进行跨页判断 的节点, 即深度终点

该节点只需要进行 内部含有可能跨页/需要进行跨页判断 的节点 的第一第二步操作, 由于内部不再含有,因此不需要遍历子节点,为搜索的叶子节点。

html2Canvas生成图片模糊导致导出的PDF也模糊的问题

通过 scale 参数, 对canvas进行等比放大,可以使canvas生成的图片更清晰。

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element, width) {
// canvas元素
const canvas = await html2canvas(element, {
// allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 3 // 增加清晰度
});
// 获取canavs转化后的宽度
const canvasWidth = canvas.width;
// 获取canvas转化后的高度
const canvasHeight = canvas.height;
// 高度转化为PDF的高度
const height = (width / canvasWidth) * canvasHeight;
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
//console.log(canvasData)
return { width, height, data: canvasData };
}

样例及代码

gitee仓库: output_pdf_demo: jsPDF + html2canvas , 网页HTML导出A4格式PDF 处理分页切割问题 (gitee.com)

npm install & npm run serve 即可运行

image.png

分页效果:

富文本分页:

image.png

table行分页:

image.png

组件分页:

image.png

样例注意事项

样例比上述讲的情况内,引入了页眉、页脚、还有上下左右间距的情况,图解如下:

image.png

需要做的额外处理:

  1. 图片摆放的Y坐标由原来的-pages[i] 变成了 baseY + 页头元素高度 - pages[i]
  2. 中间实际内容部分与页眉/页脚之间的边距也需要进行遮白处理
  3. 内容的高度才为PDF页面的实际高度,判断分页的依据应该以内容高度为准
  4. 富文本文字的分页处理

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
js复制代码import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import { message } from 'ant-design-vue';
const A4_WIDTH = 592.28;
const A4_HEIGHT = 841.89;
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element, width) {
// canvas元素
const canvas = await html2canvas(element, {
// allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 2 // 增加清晰度
});
// 获取canavs转化后的宽度
const canvasWidth = canvas.width;
// 获取canvas转化后的高度
const canvasHeight = canvas.height;
// 高度转化为PDF的高度
const height = (width / canvasWidth) * canvasHeight;
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
//console.log(canvasData)
return { width, height, data: canvasData };
}
/**
* 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
* @param {Object} param
* @param {HTMLElement} param.element - 需要转换的dom根节点
* @param {number} [param.contentWidth=550] - 一页pdf的内容宽度,0-592.28
* @param {string} [param.filename='document.pdf'] - pdf文件名
* @param {HTMLElement} param.header - 页眉dom元素
* @param {HTMLElement} param.footer - 页脚dom元素
*/
export async function outputPDF({ element, contentWidth = 550,
footer, header, filename = "测试A4分页.pdf" }) {
if (!(element instanceof HTMLElement)) {
return;
}
// jsPDFs实例
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation: 'p',
});

// 一页的高度, 转换宽度为一页元素的宽度
const { width, height, data } = await toCanvas(element, contentWidth);

// 添加页脚
async function addHeader(header, pdf, contentWidth) {
const { height: headerHeight, data: headerData, width: hWidth } = await toCanvas(header, contentWidth);
pdf.addImage(headerData, 'JPEG', 0, 0, contentWidth, headerHeight);
}

// 添加页眉
async function addFooter(pageNum, now, footer, pdf, contentWidth) {
const newFooter = footer.cloneNode(true);
newFooter.querySelector('.pdf-footer-page').innerText = now;
newFooter.querySelector('.pdf-footer-page-count').innerText = pageNum;
document.documentElement.append(newFooter);
const { height: footerHeight, data: footerData, width: fWidth } = await toCanvas(newFooter, contentWidth);
pdf.addImage(footerData, 'JPEG', 0, A4_HEIGHT - footerHeight, contentWidth, footerHeight)

}

// 添加
function addImage(_x, _y, pdf, data, width, height) {
pdf.addImage(data, 'JPEG', _x, _y, width, height);
}

// 增加空白遮挡
function addBlank(x, y, width, height, pdf) {
pdf.setFillColor(255, 255, 255);
pdf.rect(x, y, Math.ceil(width), Math.ceil(height), 'F');
};

// 页脚元素 经过转换后在PDF页面的高度
const { height: tfooterHeight } = await toCanvas(footer, contentWidth)

// 页眉元素 经过转换后在PDF的高度
const { height: theaderHeight } = await toCanvas(header, contentWidth);

// 距离PDF左边的距离,/ 2 表示居中
const baseX = (A4_WIDTH - contentWidth) / 2; // 预留空间给左边
// 距离PDF 页眉和页脚的间距, 留白留空
const baseY = 15;

// 出去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
const originalPageHeight = (A4_HEIGHT - tfooterHeight - theaderHeight - 2 * baseY);

// 元素在网页页面的宽度
const elementWidth = element.offsetWidth;

// PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度 转化为 距离Canvas顶部的高度
const rate = contentWidth / elementWidth

// 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
const pages = [rate * getElementTop(element)];

// 获取元素距离网页顶部的距离
// 通过遍历offsetParant获取距离顶端元素的高度值
function getElementTop(element) {
let actualTop = element.offsetTop;
let current = element.offsetParent;

while (current && current !== null) {
actualTop += current.offsetTop;
current = current.offsetParent;
}
return actualTop;
}



// 遍历正常的元素节点
function traversingNodes(nodes) {
for (let i = 0; i < nodes.length; ++i) {
const one = nodes[i];
// 需要判断跨页且内部存在跨页的元素
const isDivideInside = one.classList && one.classList.contains('divide-inside');
// 图片元素不需要继续深入,作为深度终点
const isIMG = one.tagName === 'IMG';
// table的每一行元素也是深度终点
const isTableCol = one.classList && ((one.classList.contains('ant-table-row')));
// 特殊的富文本元素
const isEditor = one.classList && (one.classList.contains('editor'));
// 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
let { offsetHeight } = one;
// 计算出最终高度
let offsetTop = getElementTop(one);

// dom转换后距离顶部的高度
// 转换成canvas高度
const top = rate * (offsetTop)

// 对于需要进行分页且内部存在需要分页(即不属于深度终点)的元素进行处理
if (isDivideInside) {
// 执行位置更新操作
updatePos(rate * offsetHeight, top, one);
// 执行深度遍历操作
traversingNodes(one.childNodes);
}
// 对于深度终点元素进行处理
else if (isTableCol || isIMG) {
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
updatePos(rate * offsetHeight, top, one);
}
else if (isEditor) {
// 执行位置更新操作
updatePos(rate * offsetHeight, top, one);
// 遍历富文本节点
traversingEditor(one.childNodes)
}
// 对于普通元素,则判断是否高度超过分页值,并且深入
else {
// 执行位置更新操作
updateNomalElPos(top)
// 遍历子节点
traversingNodes(one.childNodes);
}
}
return;
}

// 对于富文本元素,观察所得段落之间都是以<p> / <img> 元素相隔,因此不需要进行深度遍历 (仅针对个人遇到的情况)
function traversingEditor(nodes) {
// 遍历子节点
for (let i = 0; i < nodes.length; ++i) {
const one = nodes[i];
let { offsetHeight } = one;
let offsetTop = getElementTop(one);
const top = contentWidth / elementWidth * (offsetTop)
updatePos(contentWidth / elementWidth * offsetHeight, top, one);
}
}

// 普通元素更新位置的方法
// 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
function updateNomalElPos(top) {
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight);
}
}

// 可能跨页元素位置更新的方法
// 需要考虑分页元素,则需要考虑两种情况
// 1. 普通达顶情况,如上
// 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
function updatePos(eheight, top) {
// 如果高度已经超过当前页,则证明可以分页了
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight);
}
// 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
else if ((top + eheight - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight) && (top != (pages.length > 0 ? pages[pages.length - 1] : 0))) {
pages.push(top);
}
}

// 深度遍历节点的方法
traversingNodes(element.childNodes);
// 可能会存在遍历到底部元素为深度节点,可能存在最后一页位置未截取到的情况
if (pages[pages.length - 1] + originalPageHeight < height) {
pages.push(pages[pages.length - 1] + originalPageHeight);
}
//console.log({ pages, contentWidth, width,height })



// 根据分页位置 开始分页
for (let i = 0; i < pages.length; ++i) {
message.success(`共${pages.length}页, 生成第${i + 1}页`)
// 根据分页位置新增图片
addImage(baseX, baseY + theaderHeight - pages[i], pdf, data, width, height);
// 将 内容 与 页眉之间留空留白的部分进行遮白处理
addBlank(0, theaderHeight, A4_WIDTH, baseY, pdf);
// 将 内容 与 页脚之间留空留白的部分进行遮白处理
addBlank(0, A4_HEIGHT - baseY - tfooterHeight, A4_WIDTH, baseY, pdf);
// 对于除最后一页外,对 内容 的多余部分进行遮白处理
if (i < pages.length - 1) {
// 获取当前页面需要的内容部分高度
const imageHeight = pages[i + 1] - pages[i];
// 对多余的内容部分进行遮白
addBlank(0, baseY + imageHeight + theaderHeight, A4_WIDTH, A4_HEIGHT - (imageHeight), pdf);
}
// 添加页眉
await addHeader(header, pdf, A4_WIDTH)
// 添加页脚
await addFooter(pages.length, i + 1, footer, pdf, A4_WIDTH);

// 若不是最后一页,则分页
if (i !== pages.length - 1) {
// 增加分页
pdf.addPage();
}
}
return pdf.save(filename)
}

参考文档及博文

jsPDF - Documentation (artskydj.github.io)

配置型 | HTML2CANVAS 中文文档 (allenchinese.github.io)

【原创】jspdf+html2canvas生成多页pdf防截断处理 - 简书 (jianshu.com)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%