彻底告别分页截断与重叠:一种基于 Canvas 坐标裁剪的完美 PDF 导出方案

0 阅读4分钟

一、 前言

在现代企业级 Web 应用中,将页面(如报表、简历、合同)导出为 PDF 是一项高频需求。通常我们的首选方案是 html2canvas + jsPDF

但在实践过程中,开发者往往会踩进两个深坑:

  1. 分页重叠:页面内容恰好被截断在分页处,导致文字一半在上一页,一半在下一页,甚至与底部的页码重叠。
  2. 浏览器崩溃/空白:当页面过长(如超过 10 页)时,生成的 PNG 图片体积巨大,导致浏览器内存溢出,最终导出的 PDF 是一片空白。

本文将分享一种**“Canvas 像素级裁剪”**的思路,通过数学计算强制预留安全边距,从物理上隔绝内容与页码的冲突。


二、 传统方案的局限性

很多同学尝试在 DOM 中动态插入“空白占位 Div”来顶掉分页点。但这种方式存在致命缺陷:

  • 破坏布局:在 Flex 或 Grid 布局下,插入新节点会导致整个页面样式崩塌。
  • 计算偏差:受浏览器缩放、DPI 以及滚动条影响,offsetTop 的计算往往不精准。

三、 核心算法:Canvas 像素裁剪

我们的新方案不再动 DOM,而是动图片

1. 核心思路

  1. 将整个 DOM 渲染为一张超长的 Canvas。
  2. 定义 A4 纸张的比例,并计算出每一页 PDF 能够容纳的像素高度
  3. 强制预留安全高度:在每一页 PDF 底部留出 20mm 的空白带(Buffer),只将内容渲染在上方区域。
  4. 使用 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

五、 方案优势总结

  1. 零侵入性:无需修改原有 DOM 结构,不影响 CSS 布局。
  2. 数学级避重叠:通过 PADDING_BOTTOM 强制在底部留下“隔离带”,页码永远不会遮挡正文。
  3. 极佳稳定性:通过切片降低了单张图片的渲染压力,JPEG 压缩有效解决了移动端和高分辨率屏幕下的性能问题。
  4. 兼容性强:实测在 Chrome、Edge、Safari 等主流浏览器表现一致,无跨浏览器布局差异。

六、 结语

前端导出 PDF 虽是老生常谈,但细节决定成败。从“操作 DOM”转向“操作 Canvas 像素”,是解决分页截断问题的一条捷径。希望这个方案能帮你少走弯路!


如果这篇文章对你有帮助,欢迎点赞、收藏、评论!🚀