Table of Contents
前端页面导出为 PDF 有一个很简单的方法,直接 window.print()。但这种方法不优雅,并且如果只是页内部分元素需要导出就不好处理了。所以很多时候都是用 jspdf 结合 html2canvas 实现导出 PDF。但实际用起来还是有不少需要处理的难点,所以有了这篇文章。
1. 多页 PDF 导出
我们的页面是流式布局,没有具体的分页概念,而 PDF 是分页的,所以对于超过一页的内容,我们需要手动处理分页。
比如我们可以将元素整体生成 canvas,然后每一页设置不同的偏移。
html2canvas(el, {
scale,
useCORS: true,
allowTaint: true,
windowWidth: el.scrollWidth,
})
.then((canvas) => {
const imgData = canvas.toDataURL('image/jpeg', 0.98)
const doc = new jsPdf({
orientation: 'portrait',
unit: 'px',
format: 'a4',
})
const pageWidth = doc.internal.pageSize.getWidth()
const pageHeight = doc.internal.pageSize.getHeight()
const imgWidth = canvas.width
const imgHeight = canvas.height
const ratio = pageWidth / imgWidth
doc.addImage(imgData, 'JPEG', 0, 0, imgWidth * ratio, imgHeight * ratio)
// 将图片拆分为多页
const pages = Math.ceil((imgHeight * ratio) / pageHeight)
for (let i = 1; i < pages; i++) {
doc.addPage()
doc.addImage(
imgData,
'JPEG',
0,
-pageHeight * i,
imgWidth * ratio,
imgHeight * ratio,
)
}
doc.save('report.pdf')
})
如果我们的页面不是很长的话,这种方式就可以了。
2. 超出 canvas 限制的多页 PDF 导出
html2canvas 是把元素绘制到 canvas 上的,但 canvas 是有尺寸限制的,比如 chrome 就是 16384px。超出了要么报错,要么空白。
为了导出超限的元素,我们可以创建一个宽高固定的包裹元素,然后把要绘制的元素根据当前页面范围设置其绝对定位的位置。比如我把包裹元素按照 PDF 页面大小等比设置宽高(与要导出的元素等宽),然后一页一页的生成 canvas。
// 计算高度,避免过大导致浏览器限制
const maxHeight = el.clientHeight
// pdf 实例
const doc = new jsPdf({
orientation: 'portrait',
unit: 'px',
format: 'a4',
})
const cloned = cloneNode(el) as HTMLElement // 克隆目标元素
el.setAttribute('style', '')
const pageWidth = doc.internal.pageSize.getWidth()
const pageHeight = doc.internal.pageSize.getHeight()
const clipHeight = (pageHeight / pageWidth) * clipWidth
const pageCount = Math.ceil(maxHeight / clipHeight)
// 创建一个临时容器
const container = document.createElement('div')
container.style.width = `${clipWidth}px`
container.style.height = `${clipHeight}px`
container.style.overflow = 'hidden' // 隐藏超出部分
container.style.position = 'absolute' // 设置为绝对定位
container.style.top = '0'
container.style.left = '0'
container.style.zIndex = '-1' // 设置为最底层
cloned.style.position = 'absolute' // 设置为绝对定位
container.appendChild(cloned)
document.body.appendChild(container)
console.log('container', container)
for (let i = 0; i < pageCount; i++) {
const startY = i * clipHeight
cloned.style.top = `-${startY}px` // 设置克隆元素的顶部位置
const canvas = await html2canvas(container, {
scale,
useCORS: true,
allowTaint: true,
})
const imgData = canvas.toDataURL('image/jpeg', 0.98)
if (i) {
doc.addPage()
}
const imgWidth = canvas.width
const imgHeight = canvas.height
const ratio = pageWidth / imgWidth
doc.addImage(imgData, 'JPEG', 0, 0, imgWidth * ratio, imgHeight * ratio)
}
doc.save('report.pdf')
document.body.removeChild(container) // 移除临时容器
这种方式好理解,好操作,但性能不好。因为打印每一页,html2canvas 都需要处理完整的 el 元素。那么,可不可以通过其配置项 ignoreElements 方法忽略不在包裹容器可视范围的元素呢?我试了,但生成的都是空白页。所以,有了上面方法的进阶版本。
3. 超出 canvas 限制的多页 PDF 导出——进阶版
上面我设置的包裹容器的可视范围是根据 PDF 一页的大小来的,现在,换成根据 canvas 限制来。这样每次生成的 canvas 元素可以满足多页 PDF,提高了性能,代价就是……代码复杂度提升了,因为涉及到页面拼接的问题,比如上一个 canvas 剩余部分只能填充当前 PDF 页的一部分,下一部分需要新的 canvas 来填充。还有就是 html2canvas 缩放参数(scale)和 canvas 到 PDF 的缩放(ratio)在计算宽高和偏移等情况时需要注意区分。
/**
* 生成 pdf
* @description 该函数会将指定的元素转换为 pdf 格式,并返回 pdf 实例。该函数默认宽度为 A4,长度为元素的高度。该函数会将元素的内容绘制到 canvas 中,然后将 canvas 转换为 pdf 格式。该函数会自动处理分页和缩放问题。但该函数不会处理宽度超出 canvas 的问题,并且默认 canvas 最大高度在除以 scale 之后可以满足 pdf 的高度。
* @param el
* @param options
* @returns
*/
export default async function toPdf(
el: HTMLElement,
options: {
scale?: number
useCORS?: boolean
allowTaint?: boolean
} = {},
) {
// 缩放比例--最大为 2。更大的倍数导致 cnavas 每次可以绘制的 el 区域过小,生成次数过多,导致性能下降
const scale = Math.max(1, Math.min(options.scale || 2, 2))
const maxCanvasSize = Math.floor(getImageDataSizeLimit() / scale) // canvas 最大尺寸限制,用 ImageData 计算速度更快
// pdf 实例
const doc = new jsPDF({
orientation: 'portrait',
unit: 'px',
format: 'a4',
})
// 创建一个临时容器
const container = document.createElement('div')
container.style.overflow = 'hidden' // 隐藏超出部分
container.style.position = 'absolute' // 设置为绝对定位
container.style.top = '0'
container.style.left = '0'
container.style.zIndex = '-1' // 设置为最底层
el.style.position = 'absolute' // 设置为绝对定位
container.appendChild(el)
document.body.appendChild(container)
// 元素添加到 document 中才能获取到其宽高,所以宽高的计算在这里进行
const pageWidth = doc.internal.pageSize.getWidth()
const pageHeight = doc.internal.pageSize.getHeight()
const clipHeight = ((pageHeight / pageWidth) * el.clientWidth) >> 0
const pageCount = Math.ceil(el.clientHeight / clipHeight)
console.log('pageWidth', pageWidth)
console.log('pageHeight', pageHeight)
console.log('clipHeight', clipHeight)
console.log('el.clientWidth', el.clientWidth)
console.log('el.clientHeight', el.clientHeight)
console.log('pageCount', pageCount)
container.style.width = `${el.clientWidth}px`
container.style.height = `${maxCanvasSize}px`
// 创建一个 canvas 元素,用来存放 pdf 当前页面的内容
const canvas = document.createElement('canvas')
canvas.width = el.clientWidth * scale // 设置 canvas 的宽度
canvas.height = clipHeight * scale // 设置 canvas 的高度
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
}) as CanvasRenderingContext2D
// html2canvas 生成的 canvas
let htmlCanvas: HTMLCanvasElement = undefined
let elTop = 0 // 当前 canvas 对应的 el 的 top 值
// 生成 pdf
for (let i = 0; i < pageCount; i++) {
// 如果还没有 htmlCanvas,先生成一个
if (!htmlCanvas) {
el.style.top = `-${elTop}px`
htmlCanvas = await html2canvas(container, {
...options,
scale,
})
}
const startY = i * clipHeight // 当前页面的起始 Y 坐标
const startYInHtmlCanvas = Math.floor(startY * scale) % htmlCanvas.height // 当前页面在 html2canvas 中的 Y 开始坐标
const endYInHtmlCanvas = Math.floor(clipHeight * scale) + startYInHtmlCanvas // 当前页面在 html2canvas 中的 Y 结束坐标
ctx.clearRect(0, 0, canvas.width, canvas.height) // 清空 canvas
// 先将 htmlCanvas 中指定范围的内容绘制到 canvas 中
ctx.putImageData(
htmlCanvas
.getContext('2d')
.getImageData(
0,
startYInHtmlCanvas,
canvas.width,
endYInHtmlCanvas - startYInHtmlCanvas,
),
0,
0,
)
// 如果需要的内容超出当前 canvas 的范围,则需要生成新的 canvas,并补充剩余的内容
if (endYInHtmlCanvas > htmlCanvas.height) {
elTop -= maxCanvasSize // 更新 el 的 top 值
el.style.top = `${elTop}px` // 设置 el 的 top 值,移动到当前页面的起始位置
htmlCanvas = await html2canvas(container, {
...options,
scale,
})
// 将 htmlCanvas 中指定范围的内容绘制到 canvas 中。这里默认 canvas 是满足 pdf 一页的高度的,所以只需要绘制 htmlCanvas 的上半部分即可,否则事情会变得很复杂。
ctx.putImageData(
htmlCanvas
.getContext('2d')
.getImageData(
0,
0,
canvas.width,
endYInHtmlCanvas % htmlCanvas.height,
),
0,
htmlCanvas.height - startYInHtmlCanvas,
)
}
const image = canvas.toDataURL('image/jpeg', 0.95) // 将 canvas 转换为图片
if (i) {
// jsPDF 需要在每一页之间添加空白页
doc.addPage() // 添加新的一页
}
const ratio = pageWidth / canvas.width // 计算缩放比例
doc.addImage(
image,
'JPEG',
0,
0,
canvas.width * ratio,
canvas.height * ratio,
) // 将图片添加到 pdf 中
}
// 清理临时容器
document.body.removeChild(container) // 移除临时容器
el.style.position = '' // 恢复 el 的位置
return doc // 返回 pdf 实例
}
总之多 debug 一会还是可以弄出来的。