前端导出PDF相关

839 阅读6分钟

记录一下纯前端导出PDF时遇到的坑和解决方式

前置

目前在开发垂类的ai应用,区别于普通的大模型提供的聊天功能,垂类即在某个领域内的继续深度挖掘,在此过程中就接到了很多特色的需求,如:

image.png 上面的这些应用都是训练大模型之后,由大模型返回字符串数据流,再由前端处理成各式各样的花里胡哨的功能。

例如 AI-数字活页教材

  • 大模型返回的格式是这样的(一开始只有正常的字符串,这是经过训练后的)
const str = `[0:《物流概论》] [1:项目一 物流基础知识] [2:任务一 物流的概念与发展] [3:任务背景] [6:物流作为现代经济的重要组成部分,对企业和社会的发展具有重要意义。] [3:任务要求] [6:要求学生了解物流的基本概念和发展历程。] [3:学习目标] [4:知识目标] [5:(1)掌握物流的定义和内涵。] [5:(2)熟悉物流的发展阶段和主要特点。] [4:技能目标] [5:(1)能够简要阐述物流的发展历程。] [5:(2)能够分析物流在现代经济中的作用。] [4:素质目标] [5:(1)培养学生对物流领域的兴趣和探索精神。] [5:(2)培养学生的宏观思维和综合分析能力。] [3:知识讲解] [4:一、物流的定义和范围] [5:(一)物流的概念] [6:明确物流的基本含义和涵盖的领域。] [5:(二)物流的范围] [6:包括采购物流、生产物流、销售物流等。] [4:二、物流的发展历程] [5:(一)传统物流阶段] [6:特点和主要形式。] [5:(二)现代物流阶段] [6:新技术和管理理念的应用。] [4:三、物流的分类] [5:(一)按照物流活动的主体分类] [6:1.企业自营物流] [6:2.第三方物流] [6:3.第四方物流] [5:(二)按照物流活动的地域范围分类]`

  • 产品想要的是这样的:

image.png

  • 所以只能先将字符串转成数组对象:
const parseContentToKeyValueArray = (scheduleString) => {
   if (!scheduleString) return
   if (scheduleString.match(/\[[^\]]*$/)) {
      scheduleString += ']'
   }
   const pairs = scheduleString.match(/\[(.*?)]/gs) || []
   return pairs.map(pair => {
      const [key, value] = pair.slice(1, -1).split(':').map(str => str.trim())
      return { key, value, id: uuId() }
   }).filter(item => item)
};

再一通xjb编辑之后就成了一本活页教材,该导出PDF

tip: 在导出pdf时,如果有多页,且页面的内容长度不固定时,那么恭喜你:终于掉坑了!

内容被拦腰截断这是很正常的,深呼吸就好!

用到的库:

npm install html2canvas --save

npm install jspdf --save

进入正题吧:

前端导出的时候只是根据你要导出的html结构帮你生成pdf,但是它并不管你其他,该断页的时候他就强硬断,不管你内容是否受到影响,所以我们只能是说在生成之前我们自己分好页,计算出哪里会被截断,然后添加新的空白元素将要截断的元素往下一页挤。

  • 所以为了方便计算,我们应该尽可能的将每一个元素拆成最小的单位,也就是初始数据不要包含父子级关系,页面布局的时候除了最外层,里面也尽可能的不要有父子关系的嵌套,大家都是兄弟关系,从上往下排列

image.png

例如我上面的结构,都是同级关系,都是最小的计算单位,那么在计算的时候就更加方便。

当然,即使你有树形结构或者有父子级关系也不大,只是要多做相应的处理就可以了

页面布局

image.png

计算断页并添加空白
setTimeout(() => {
    const fileList = document.getElementsByClassName('pdfRef')
    if (fileList.length > 0) {
        const pageHeight = 1122; //计算pdf高度
        let currentHeight = 0; // 当前元素的高度
        let yushu = 0
        for (let i = 0; i < fileList.length; i++) {
            // 当前页用了多少高度
            yushu = currentHeight % pageHeight;
            const domH = parseFloat(window.getComputedStyle(fileList[i]).height);
            // 获取该div的父节点
            const divParent = fileList[i].parentNode;
            if (fileList[i].classList.contains('bookname')) {
                currentHeight += domH;
            } else if (fileList[i].classList.contains('project')) {
                if (!yushu) {
                    divParent.insertBefore(getFooterElement(80), fileList[i + 1]);
                    currentHeight = currentHeight + domH + 80;
                } else {
                    const newNode = getFooterElement(pageHeight - yushu);
                    divParent.insertBefore(newNode, fileList[i]);
                    divParent.insertBefore(getFooterElement(80), fileList[i + 1]);
                    currentHeight = currentHeight + domH + pageHeight - yushu + 80;
                }
            } else if (fileList[i].classList.contains('text')) {
                if (yushu + domH > pageHeight - 30) {
                    const newNode = getFooterElement(pageHeight - yushu + 60);
                    divParent.insertBefore(newNode, fileList[i - 1]);
                    currentHeight = currentHeight + domH + pageHeight - yushu + 60;
                } else {
                    currentHeight += domH;
                }
            } else if (yushu + domH > pageHeight - 30) {
                const newNode = getFooterElement(pageHeight - yushu + 140);
                divParent.insertBefore(newNode, fileList[i - 1]);
                currentHeight = currentHeight + domH + pageHeight - yushu + 140;
            } else {
                currentHeight += domH;
            }
        }
        yushu = currentHeight % pageHeight;
        console.log('🚀 yushu 🚀======>', yushu)
        if (yushu) {
            const divParent = fileList[fileList.length -1].parentNode;
            const newNode = getFooterElement(pageHeight - yushu);
            divParent.insertBefore(newNode, null);
        }
    }
}, 1000)
// pdf截断需要一个空白位置来补充
const getFooterElement = (remainingHeight, fillingHeight = 0) => {
    const newNode = document.createElement("div");
    newNode.style.width = "calc(100% + 8px)";
    newNode.classList.add("divRemove");
    newNode.style.height = remainingHeight + fillingHeight + "px";
    return newNode;
}; 
上面的代码是根据我的需求特殊改的,通过判断是否为某些特殊元素来决定是否添加空白,但原理都是一样的,计算该元素的高度,再对比当前页剩余的高度,如果不够摆,那就添加空白挤到下一页去。这里只是给个参考,需求不同不能直接复用!

下面给一个大概率能复用到的整个流程代码

import html2Canvas from "html2canvas";
import JsPDF from "jspdf";

export const htmlPdf = (title, html, fileList, type) => {
  // type传有效值pdf则为横版
  if (fileList.length > 0) {
    const pageHeight = 1122; //手动测得的 pdf 高度
    let currentHeight = 0; 
    for (let i = 0; i < fileList.length; i++) {
      // 循环获取的元素高度
      const domH = parseFloat(window.getComputedStyle(fileList[i]).height);
      if (currentHeight != 0) {
        // 取余得到 pdf 当前页渲染到了多少高度
        const yushu = currentHeight % pageHeight;
        // 如果继续渲染当前元素会超出截断 + 直接加空白
        if (yushu + domH > pageHeight - 30) {
          const divParent = fileList[i].parentNode; // 获取该div的父节点
          // 因为我的PDF有封面要占满整个单页,做特殊处理
          if (domH == 1122) {
            const newNode = getFooterElement(pageHeight - yushu);
            divParent.insertBefore(newNode, fileList[i]);
            currentHeight = currentHeight + domH + pageHeight - yushu;
          } else {
            // 这里加上30是为了留出上下边距
            const newNode = getFooterElement(pageHeight - yushu + 30);
            divParent.insertBefore(newNode, fileList[i]);
            currentHeight = currentHeight + domH + pageHeight - yushu + 30;
          }

        } else {
          currentHeight += domH;
        }
      } else {
        currentHeight += domH;
      }
    }
  }
  html2Canvas(html, {
    allowTaint: false,
    taintTest: false,
    logging: false,
    useCORS: true,
    dpi: window.devicePixelRatio * 1,
    scale: 1, // 按比例增加分辨率
  }).then((canvas) => {
    var pdf = new JsPDF("p", "mm", "a4"); // A4纸,纵向
    var ctx = canvas.getContext("2d");
    var a4w = type ? 297 : 210;
    var a4h = type ? 210 : 297;
    var imgHeight = Math.floor((a4h * canvas.width) / a4w); // 按A4显示比例换算一页图像的像素高度
    var renderedHeight = 0;
    while (renderedHeight < canvas.height) {
      var page = document.createElement("canvas");
      page.width = canvas.width;
      page.height = Math.min(imgHeight, canvas.height - renderedHeight); // 可能内容不足一页

      // 用getImageData剪裁指定区域,并画到前面创建的canvas对象中
      page
        .getContext("2d")
        .putImageData(
          ctx.getImageData(
            0,
            renderedHeight,
            canvas.width,
            Math.min(imgHeight, canvas.height - renderedHeight)
          ),
          0,
          0
        );
      pdf.addImage(
        page.toDataURL("image/jpeg", 1.0),
        "JPEG",
        0,
        0,
        a4w,
        Math.min(a4h, (a4w * page.height) / page.width)
      ); // 添加图像到页面,保留10mm边距

      renderedHeight += imgHeight;
      if (renderedHeight < canvas.height) {
        pdf.addPage(); // 如果后面还有内容,添加一个空页
      }
    }
    // 保存文件
    pdf.save(title + ".pdf");
  });
};
// pdf截断需要一个空白位置来补充
const getFooterElement = (remainingHeight, fillingHeight = 0) => {
  const newNode = document.createElement("div");
  newNode.style.background = "#ffffff";
  newNode.style.width = "100%";
  newNode.classList.add("divRemove");
  newNode.style.height = remainingHeight + fillingHeight + "px";
  return newNode;
};
页面使用
import { htmlPdf } from "@/utils/htmlToPDF.js"
const handleExport = () => {
    var fileName = '前端牛马PDF'
    
    // 找出所有要计算的元素
    const fileList = document.getElementsByClassName('pdfRef')
    
    // #pdfRef 为要导出的整个结构
    htmlPdf(fileName, document.querySelector('#pdfRef'), fileList)
}

搞完收工💥