将html转pdf的纯前端解决方案通常是jspdf+html2canvas,在保持网页的宽高比以及pdf每页的大小(通常为A4)的情况下,难免会出现网页内容较多需要生成多页pdf的情况,如果按常规的方法生成的pdf很容易出现一个dom元素被截断分散在两页pdf里,因此本文针对此情况提出一些思考和实现,完整代码附在文章结尾供参考。
实现思路
- 每页按固定宽高布局
- 以不被截断的最小固定高度布局
- 不依赖布局,动态计算每页应放置的dom元素
从页面布局的角度考虑,如果生成的网页按照与pdf固定的比例刚好是不会被截断的效果就直接解决问题了。因此最直接的方法为明确知道要生成几页pdf,网页按每页pdf的宽高映射一个固定的宽高,然后按照这个固定的宽高放置不超过该大小的dom。另一种方式则是以不被截断的最小固定高度布局,即每一块dom的高度固定,并且一页的高度刚好为该高度的整数倍,则不管怎样,最后生成的多页pdf都不会有截断的情况。由于生成的页面按照固定的pdf每页大小进行分割自然不会截断,生成pdf的代码只需要进行正常的多页pdf生成即可。
如果网页无法进行固定大小的布局,在生成pdf的时候则需要计算每页pdf放置的dom达到刚好不被截断的边界情况。考虑到dom可能嵌套层级较多,并且对一些属性节点、文本节点不好计算高度,可以给dom元素添加标识来表示是否需要计算高度。
此外,html2canvas将html生成canvas对象的过程比较慢,但生成多页pdf又需要将页面做拆分,因此可以只生成一个canvas对象,通过在添加canvas到pdf时设置图片定位达到截断的效果,如果页面需要有内边距,还需要在内边距的地方用空白遮挡多余的canvas内容。
以下就每种实现思路以示例和代码做更详细的说明。
每页按固定宽高布局
适用场景:dom元素是确定的,每页可以按固定的宽高来布局,如固定格式的报表之类的。
html结构示例如下:
<style>
.page {
width: 1000px;
height: 1600px;
}
</style>
<div class="container">
<div class="page"></div>
<div class="page"></div>
<div class="page"></div>
</div>
<script>
outputPdf({
element: document.querySelector('.content'),
contentWidth: 500, // 0-592.28
contentHeight: 800, // 0-841.89
})
</script>
每个page元素内可以添加自定义的元素,但高度不应超过page的高度。
contentWidth与contentHeight为一页pdf(A4大小)中放置的内容大小,page元素的宽高比必须等于 contentWidth/contentHeight 。
生成多页pdf的主要代码如下:
const pageNum = Math.ceil(height / contentHeight); // 总页数
const arr = Array.from({ length: pageNum }).map((_, i) => i);
for await (const i of arr) {
addImage(baseX, baseY - i * contentHeight);
const isFirst = i === 0;
const isLast = i === arr.length - 1;
if (!isFirst) {
// 用空白遮挡顶部需要隐藏的部分
pdf.addBlank(0, 0, A4_WIDTH, baseY);
}
if (!isLast) {
// 用空白遮挡底部需要隐藏的部分
pdf.addBlank(0, baseY + contentHeight, A4_WIDTH, A4_HEIGHT - (baseY + contentHeight));
}
await addHeader(isFirst);
await addFooter(isLast);
if (!isLast) {
pdf.addPage();
}
}
以不被截断的最小固定高度布局
适用场景:dom元素是确定的,页面中每一块可以按固定的宽高来布局,如循环生成的列表数据,每一行高度固定,但列表个数是不固定的。
html结构示例如下:
<style>
.item{
width: 1000px;
height: 400px;
}
</style>
<div class="container">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
<script>
outputPdf({
element: document.querySelector('.content'),
contentWidth: 500, // 0-592.28
contentHeight: 800, // 0-841.89
})
</script>
每个item元素内可以添加自定义的元素,但高度不应超过item的高度。
contentWidth与contentHeight为一页pdf(A4大小)中放置的内容大小,实际dom每页的宽高比依然要等于 contentWidth/contentHeight,且一页的高度应为item的整数倍,如item的高度可取800、400、320、200等。
该方式与第一种生成的dom元素本身不会出现截断,所以生成多页pdf的代码完全一样。进一步可以将第一种方式看成第二种方式的特殊情况,即item的高度直接为每页的高度。
不依赖布局,动态计算每页应放置的dom元素
适用场景:dom结构的大小不固定(需手动给dom元素添加标识)
html结构示例如下:
<style>
.block{
height: 323px;
}
.small-block {
height: 155px;
}
</style>
<div class="container">
<div data-item class="small-block"></div>
<div data-item class="block"></div>
<div data-group>
<div data-item class="small-block"></div>
<div data-item class="small-block"></div>
<div data-group>
<div data-item class="small-block"></div>
<div data-item class="small-block"></div>
<div data-item class="small-block"></div>
<div data-item class="small-block"></div>
<div data-item class="block"></div>
</div>
</div>
<div data-item class="block"></div>
<div data-item>
<div class="small-block"></div>
<div class="small-block"></div>
<div class="small-block"></div>
<div class="small-block"></div>
</div>
<div data-item class="block"></div>
</div>
<script>
outputPdf({
element: document.querySelector('.content'),
contentWidth: 500, // 0-592.28
contentHeight: 800, // 0-841.89
})
</script>
由于dom结构的大小不固定,这里使用data-item标识不被截断的元素,如果有嵌套的元素,则使用data-group标识,根据该标识遍历子节点中标识为data-item的元素。根据打了标识的元素计算出每页应放置的dom元素的高度,最后生成多页pdf,代码如下:
// 从根节点遍历dom,计算出每页应放置的内容高度以保证不被截断
const splitElement = () => {
const res = [];
let pos = 0;
const elementWidth = element.offsetWidth;
function updatePos (height) {
if (pos + height <= contentHeight) {
pos += height;
return;
}
res.push(pos);
pos = height;
}
function traversingNodes (nodes) {
if (nodes.length === 0) return;
nodes.forEach(one => {
if (one.nodeType !== 1) return;
const { [itemName]: item, [groupName]: group } = one.dataset;
if (item != null) {
const { offsetHeight } = one;
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
updatePos(contentWidth / elementWidth * offsetHeight);
} else if (group != null) {
traversingNodes(one.childNodes);
}
});
}
traversingNodes(element.childNodes);
res.push(pos);
return res;
};
const elements = splitElement();
let accumulationHeight = 0;
let currentPage = 0;
for await (const elementHeight of elements) {
addImage(baseX, baseY - accumulationHeight);
accumulationHeight += elementHeight;
const isFirst = currentPage === 0;
const isLast = currentPage === elements.length - 1;
if (!isFirst) {
pdf.addBlank(0, 0, A4_WIDTH, baseY);
}
if (!isLast) {
pdf.addBlank(0, baseY + elementHeight, A4_WIDTH, A4_HEIGHT - (baseY + elementHeight));
}
await addHeader(isFirst);
await addFooter(isLast);
if (!isLast) {
pdf.addPage();
}
currentPage++;
}
生成pdf的完整代码
以下为生成pdf的完整代码,添加了页眉、页脚功能,附上代码便于直接使用。
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
const A4_WIDTH = 592.28;
const A4_HEIGHT = 841.89;
jsPDF.API.output2 = function (outputType = 'save', filename = 'document.pdf') {
let result = null;
switch (outputType) {
case 'file':
result = new File([this.output('blob')], filename, {
type: 'application/pdf',
lastModified: Date.now(),
});
break;
case 'save':
result = this.save(filename);
break;
default:
result = this.output(outputType);
}
return result;
};
jsPDF.API.addBlank = function (x, y, width, height) {
this.setFillColor(255, 255, 255);
this.rect(x, y, Math.ceil(width), Math.ceil(height), 'F');
};
jsPDF.API.toCanvas = async function (element, width) {
const canvas = await html2canvas(element);
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const height = (width / canvasWidth) * canvasHeight;
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
return { width, height, data: canvasData };
};
jsPDF.API.addHeader = async function (x, width, header) {
if (!(header instanceof HTMLElement)) return;
let __header;
if (this.__header) {
__header = this.__header;
} else {
__header = await this.toCanvas(header, width);
this.__header = __header;
}
const { height, data } = __header;
this.addImage(data, 'JPEG', x, 0, width, height);
};
jsPDF.API.addFooter = async function (x, width, footer) {
if (!(footer instanceof HTMLElement)) return;
let __footer;
if (this.__footer) {
__footer = this.__footer;
} else {
__footer = await this.toCanvas(footer, width);
this.__footer = __footer;
}
const { height, data } = __footer;
this.addImage(data, 'JPEG', x, A4_HEIGHT - height, width, height);
};
/**
* 生成pdf(处理多页pdf截断问题)
* @param {Object} param
* @param {HTMLElement} param.element - 需要转换的dom根节点
* @param {number} [param.contentWidth=550] - 一页pdf的内容宽度,0-592.28
* @param {number} [param.contentHeight=800] - 一页pdf的内容高度,0-841.89
* @param {string} [param.outputType='save'] - 生成pdf的数据类型,添加了'file'类型,其他支持的类型见http://raw.githack.com/MrRio/jsPDF/master/docs/jsPDF.html#output
* @param {string} [param.filename='document.pdf'] - pdf文件名
* @param {number} param.x - pdf页内容距页面左边的高度,默认居中显示,为(A4宽度 - contentWidth) / 2)
* @param {number} param.y - pdf页内容距页面上边的高度,默认居中显示,为(A4高度 - contentHeight) / 2)
* @param {HTMLElement} param.header - 页眉dom元素
* @param {HTMLElement} param.footer - 页脚dom元素
* @param {boolean} [param.headerOnlyFirst=true] - 是否只在第一页添加页眉
* @param {boolean} [param.footerOnlyLast=true] - 是否只在最后一页添加页脚
* @param {string} [param.mode='adaptive'] - 生成pdf的模式,支持'adaptive'、'fixed','adaptive'需给dom添加标识,'fixed'需固定布局。
* @param {string} [param.itemName='item'] - 给dom添加元素标识的名字,'adaptive'模式需在dom中设置
* @param {string} [param.groupName='group'] - 给dom添加组标识的名字,'adaptive'模式需在dom中设置
* @returns {Promise} 根据outputType返回不同的数据类型
*/
async function outputPdf ({
element, contentWidth = 550, contentHeight = 800,
outputType = 'save', filename = 'document.pdf', x, y,
header, footer, headerOnlyFirst = true, footerOnlyLast = true,
mode = 'adaptive', itemName = 'item', groupName = 'group',
}) {
if (!(element instanceof HTMLElement)) {
throw new Error('The root element must be HTMLElement.');
}
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation: 'p',
});
const { width, height, data } = await pdf.toCanvas(element, contentWidth);
const baseX = x == null ? (A4_WIDTH - contentWidth) / 2 : x;
const baseY = y == null ? (A4_HEIGHT - contentHeight) / 2 : y;
async function addHeader (isFirst) {
if (isFirst || !headerOnlyFirst) {
await pdf.addHeader(baseX, contentWidth, header);
}
}
async function addFooter (isLast) {
if (isLast || !footerOnlyLast) {
await pdf.addFooter(baseX, contentWidth, footer);
}
}
function addImage (_x, _y) {
pdf.addImage(data, 'JPEG', _x, _y, width, height);
}
const params = {
element, contentWidth, contentHeight, itemName, groupName,
pdf, baseX, baseY, width, height, addImage, addHeader, addFooter,
};
switch (mode) {
case 'adaptive':
await outputWithAdaptive(params);
break;
case 'fixed':
default:
await outputWithFixedSize(params);
}
return pdf.output2(outputType, filename);
}
async function outputWithFixedSize ({
pdf, baseX, baseY, height, addImage, addHeader, addFooter, contentHeight,
}) {
const pageNum = Math.ceil(height / contentHeight); // 总页数
const arr = Array.from({ length: pageNum }).map((_, i) => i);
for await (const i of arr) {
addImage(baseX, baseY - i * contentHeight);
const isFirst = i === 0;
const isLast = i === arr.length - 1;
if (!isFirst) {
// 用空白遮挡顶部需要隐藏的部分
pdf.addBlank(0, 0, A4_WIDTH, baseY);
}
if (!isLast) {
// 用空白遮挡底部需要隐藏的部分
pdf.addBlank(0, baseY + contentHeight, A4_WIDTH, A4_HEIGHT - (baseY + contentHeight));
}
await addHeader(isFirst);
await addFooter(isLast);
if (!isLast) {
pdf.addPage();
}
}
}
async function outputWithAdaptive ({
element, contentWidth, itemName, groupName,
pdf, baseX, baseY, addImage, addHeader, addFooter, contentHeight,
}) {
// 从根节点遍历dom,计算出每页应放置的内容高度以保证不被截断
const splitElement = () => {
const res = [];
let pos = 0;
const elementWidth = element.offsetWidth;
function updatePos (height) {
if (pos + height <= contentHeight) {
pos += height;
return;
}
res.push(pos);
pos = height;
}
function traversingNodes (nodes) {
if (nodes.length === 0) return;
nodes.forEach(one => {
if (one.nodeType !== 1) return;
const { [itemName]: item, [groupName]: group } = one.dataset;
if (item != null) {
const { offsetHeight } = one;
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
updatePos(contentWidth / elementWidth * offsetHeight);
} else if (group != null) {
traversingNodes(one.childNodes);
}
});
}
traversingNodes(element.childNodes);
res.push(pos);
return res;
};
const elements = splitElement();
let accumulationHeight = 0;
let currentPage = 0;
for await (const elementHeight of elements) {
addImage(baseX, baseY - accumulationHeight);
accumulationHeight += elementHeight;
const isFirst = currentPage === 0;
const isLast = currentPage === elements.length - 1;
if (!isFirst) {
pdf.addBlank(0, 0, A4_WIDTH, baseY);
}
if (!isLast) {
pdf.addBlank(0, baseY + elementHeight, A4_WIDTH, A4_HEIGHT - (baseY + elementHeight));
}
await addHeader(isFirst);
await addFooter(isLast);
if (!isLast) {
pdf.addPage();
}
currentPage++;
}
}
export default outputPdf;