一、 前言
在现代企业级 Web 应用中,将页面(如报表、简历、合同)导出为 PDF 是一项高频需求。通常我们的首选方案是 html2canvas + jsPDF。
但在实践过程中,开发者往往会踩进两个深坑:
- 分页重叠:页面内容恰好被截断在分页处,导致文字一半在上一页,一半在下一页,甚至与底部的页码重叠。
- 浏览器崩溃/空白:当页面过长(如超过 10 页)时,生成的 PNG 图片体积巨大,导致浏览器内存溢出,最终导出的 PDF 是一片空白。
本文将分享一种**“Canvas 像素级裁剪”**的思路,通过数学计算强制预留安全边距,从物理上隔绝内容与页码的冲突。
二、 传统方案的局限性
很多同学尝试在 DOM 中动态插入“空白占位 Div”来顶掉分页点。但这种方式存在致命缺陷:
- 破坏布局:在 Flex 或 Grid 布局下,插入新节点会导致整个页面样式崩塌。
- 计算偏差:受浏览器缩放、DPI 以及滚动条影响,
offsetTop的计算往往不精准。
三、 核心算法:Canvas 像素裁剪
我们的新方案不再动 DOM,而是动图片。
1. 核心思路
- 将整个 DOM 渲染为一张超长的 Canvas。
- 定义 A4 纸张的比例,并计算出每一页 PDF 能够容纳的像素高度。
- 强制预留安全高度:在每一页 PDF 底部留出 20mm 的空白带(Buffer),只将内容渲染在上方区域。
- 使用 Canvas 的
drawImage方法,像切火腿肠一样,精准截取每一段像素切片。
2. 为什么选择 JPEG 而非 PNG?
在长图导出时,PNG 的体积是 JPEG 的 5-10 倍。使用 image/jpeg 配合 0.95 的质量压缩,既能保证肉眼不可见的清晰度损失,又能极大降低 Base64 字符串长度,彻底解决浏览器因内存限制导致的“空白页”Bug。
四、 完整代码实现
JavaScript
/**
* 完美分页 PDF 生成工具
* 核心原理:Canvas 像素裁剪 + 安全边距控制
*/
import html2Canvas from 'html2canvas'
import JsPDF from 'jspdf'
/**
* @param {String} title 下载的文件名
* @param {HTMLElement} html 要打印的 DOM 根节点
* @param {Boolean} isLandscape 是否横版 (true 为横版)
*/
async function htmlPdf(title, html, isLandscape = false) {
if (!html) {
console.error('未找到目标 DOM 节点')
return null
}
// 1. 定义 A4 纸张标准 (单位: mm)
const A4_WIDTH = isLandscape ? 297 : 210
const A4_HEIGHT = isLandscape ? 210 : 297
// 2. 定义内容打印区域 (留出边距,防止贴边和重叠)
const PADDING = 10 // 左右边距 10mm
const TOP_MARGIN = 10 // 上边距 10mm
const BOTTOM_MARGIN = 20 // 下边距 20mm (留给页码)
const CONTENT_WIDTH = A4_WIDTH - PADDING * 2
const CONTENT_HEIGHT = A4_HEIGHT - TOP_MARGIN - BOTTOM_MARGIN
try {
// 3. 生成完整 Canvas
const canvas = await html2Canvas(html, {
scale: 2, // 2倍清晰度,兼顾质量与性能
useCORS: true,
allowTaint: false,
backgroundColor: '#ffffff',
logging: false
})
const imgWidth = canvas.width
const imgHeight = canvas.height
// 计算 PDF 1mm 对应的像素点
const pxPerMm = imgWidth / CONTENT_WIDTH
// 计算 PDF 每一页能容纳的像素高度
const pagePxHeight = CONTENT_HEIGHT * pxPerMm
const pdf = new JsPDF(isLandscape ? 'l' : 'p', 'mm', 'a4')
let renderedHeight = 0
let pageCount = 0
// 4. 循环裁剪 Canvas 并分页添加
while (renderedHeight < imgHeight) {
pageCount++
// 当前页需要截取的像素高度
const currentChunkHeight = Math.min(pagePxHeight, imgHeight - renderedHeight)
// 创建临时页 Canvas
const pageCanvas = document.createElement('canvas')
pageCanvas.width = imgWidth
pageCanvas.height = currentChunkHeight
const ctx = pageCanvas.getContext('2d')
// 核心:从总 Canvas 中截取对应区域
ctx.drawImage(
canvas,
0, renderedHeight, imgWidth, currentChunkHeight, // 原图位置
0, 0, imgWidth, currentChunkHeight // 目标位置
)
// 如果不是第一页,先添加新页
if (renderedHeight > 0) {
pdf.addPage()
}
// 计算当前图片在 PDF 中的显示高度 (mm)
const pdfImgHeight = currentChunkHeight / pxPerMm
// 将裁剪后的图片放入 PDF,坐标为 (10, 10)
pdf.addImage(
pageCanvas.toDataURL('image/jpeg', 0.92), // 使用 JPEG 压缩减少 PDF 体积
'JPEG',
PADDING,
TOP_MARGIN,
CONTENT_WIDTH,
pdfImgHeight
)
// 5. 添加页码 (放在底部中间,距离页底 15mm)
pdf.setFontSize(10)
pdf.setTextColor('#666666')
const pageText = `- 第 ${pageCount} 页 -`
const textWidth = pdf.getTextWidth(pageText)
const x = (A4_WIDTH - textWidth) / 2
pdf.text(pageText, x, A4_HEIGHT - 10)
renderedHeight += currentChunkHeight
}
// 6. 输出处理
const pdfDataUri = pdf.output('datauristring')
const pdfFile = dataURLtoFile(pdfDataUri, `${title}.pdf`)
// 如果需要直接下载,可以解开下行注释
// pdf.save(`${title}.pdf`)
return {
url: pdfFile,
page: pageCount,
height: imgHeight / pxPerMm // 返回总高度 mm
}
} catch (error) {
console.error('PDF 生成失败:', error)
return null
}
}
/**
* 辅助函数:DataURL 转 File 对象
*/
function dataURLtoFile(dataurl, filename) {
const arr = dataurl.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new File([u8arr], filename, { type: mime })
}
export default htmlPdf
五、 方案优势总结
- 零侵入性:无需修改原有 DOM 结构,不影响 CSS 布局。
- 数学级避重叠:通过
PADDING_BOTTOM强制在底部留下“隔离带”,页码永远不会遮挡正文。 - 极佳稳定性:通过切片降低了单张图片的渲染压力,JPEG 压缩有效解决了移动端和高分辨率屏幕下的性能问题。
- 兼容性强:实测在 Chrome、Edge、Safari 等主流浏览器表现一致,无跨浏览器布局差异。
六、 结语
前端导出 PDF 虽是老生常谈,但细节决定成败。从“操作 DOM”转向“操作 Canvas 像素”,是解决分页截断问题的一条捷径。希望这个方案能帮你少走弯路!
如果这篇文章对你有帮助,欢迎点赞、收藏、评论!🚀