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 一会还是可以弄出来的。