首先,分析原始需求:点击导出为 PDF 文件时,弹出一个 Modal 框,预览生成的 PDF 文件,然后点击生成后下载 PDF 文件。

# 1. 预览时的文件是图片 or PDF or iFrame?

感觉预览图片会简单一点,还可以放大。那就用 dom-to-image 对 DOM 元素进行截图吧,然后上传图片 url 到 oss,在预览窗口预览图片。

# 2. 下载时如何进行分页下载?

思路是将 DOM 图片按固定尺寸分批次调用 pdf.addPage () 和 pdf.addImage () 方法生成 pdf 页面并插入剪切后的图片,如此循环操作,直至将整张图片遍历完毕后结束,最后调用 pdf.save () 方法进行保存

# 3. 分页截断怎么处理?

jspdf 分页有个比较不好的地方就内容过长的时候虽然会虽然能做到分页,但是会把内容给截断,解决思路是给每个可能会被截断元素加上类,然后动态的计算该元素的位置是否在下一页和上一页之间,如果在的话就添加一个空白元素把这个元素给挤下去,这样就能实现

# 4. 对于 HTML 生成 PDF 的前后端方案的分析

纯前端方案:

纯前端的方案,存在着浏览器环境依赖或一定的局限性, 一定程度上难以做到多端导出统一(也许可以,但是过于繁琐)。 以下是尝试过的方案:

# 1. printjs/window.print()

通过调取浏览器原生的打印功能进行打印,缺点: 对于自定义页眉页脚的自定义不友好。 需要用户手动打印 / 进行微调,对用户不够友好。

# 2. jsPDF + dom-to-image

实现是通过 dom-to-image 将 HTML 元素 转化 canvas 再转化成 JPED/PNG, 再通过 jsPDF 生成 PDF 文件。

优点:生成符合完整符合样式的 PDF,所见即所得,页头页尾自定义化高, 水印可以通过 fixed 布局元素生成。

缺点:需要手动计算分页点。 由于是通过转化成图片来生成 PDF。 可以通过分割 HTML 元素来规避这个问题,但是操作会更加繁复。

最终选用 jsPDF + dom-to-image 的方案,和产品沟通后预览样式为 dom(简单了很多)。html2Canvas 也可以实现相同的效果,我这边用的是 dom-to-image

实现原理:

动态计算每页 dom 元素的高度(元素 margin 会导致高度计算不准确,建议使用 padding),将确保不被分割的 dom 元素 加上特定的 tag 标签(我这里用的是 class='whole-node'),判断此 dom 元素的最上面和最下面是否在同一页中,如果不在说明不处理就会被截断,怎么处理,不能被截断的元素上方插入对应的空白块占位,已达到将当前元素放到下一页的目的(当前页面高度 - dom 元素最上方位置 = 需要插入空白块的高度)

import { jsPDF } from 'jspdf';
import { message } from 'antd';
import domtoimage from 'dom-to-image';
import moment from 'moment';

const convertPdf = (name: string, setLoading: any) => {
  try {
    const title = '文件名称';
    const A4_WIDTH = 592.28;
    const A4_HEIGHT = 880;
    // 要生成的dom
    const printDom: any = document.querySelector('#pdf_page');

    const pageHeight = (printDom.offsetWidth / A4_WIDTH) * A4_HEIGHT;
    const wholeNodes = document.querySelectorAll('.whole-node');

    // 在不能被截断的元素上方插入对应的空白块占位
    wholeNodes.forEach((node: any) => {
      const topPageNum = Math.ceil(node.offsetTop / pageHeight);
      const bottomPageNum = Math.ceil((node.offsetTop + node.offsetHeight) / pageHeight);

      if (topPageNum !== bottomPageNum) {
        // 说明dom会被截断
        const divParent = node.parentNode;
        const newBlock = document.createElement('div');
        newBlock.className = 'emptyDiv';
        const _H = topPageNum * pageHeight - node.offsetTop;
        // 空白块高度
        newBlock.style.height = _H + 60 + 'px';
        divParent.insertBefore(newBlock, node);
      }
    });

    domtoimage
      .toPng(printDom)
      .then((dataUrl) => {
        const emptyDivs = document.querySelectorAll('.emptyDiv');
        //dom 已经转换为canvas 对象,删除插入的空白块
        emptyDivs.forEach((div: any) => div.parentNode.removeChild(div));

        const img = new Image();
        img.src = dataUrl;
        img.onload = () => {
          const canvas = document.createElement('canvas');
          const ctx: any = canvas.getContext('2d');
          canvas.width = img.width;
          canvas.height = img.height;
          ctx.drawImage(img, 0, 0);

          const contentWidth = canvas.width;
          const contentHeight = canvas.height;
          const imgWidth = A4_WIDTH;
          const imgHeight = (A4_WIDTH / contentWidth) * contentHeight;
          const pageData = canvas.toDataURL('image/jpeg', 1.0);
          const PDF = new jsPDF('portrait', 'pt', 'a4');

          let position = 0;
          let leftHeight = contentHeight;

          while (leftHeight > 0) {
            PDF.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight);
            leftHeight -= pageHeight;
            position -= A4_HEIGHT;

            if (leftHeight > 0) {
              PDF.addPage();
            }
          }

          PDF.save(`${title}.pdf`);
          message.success('保存成功');
          setLoading(false);
        };
      })
      .catch((error) => {
        console.error('Error generating PDF:', error);
        setLoading(false);
        message.error('保存失败');
      });
  } catch (error) {
    console.error('Error generating PDF:', error);
    setLoading(false);
    message.error('保存失败');
  }
};

export default convertPdf;

参考文档

jspdf+html2canvas 生成多页 pdf 防截断处理

jsPDF+html2canvasA4 分页截断完美解决方案