Skip to content

html 转 pdf 的前端方案

简介

本文主要介绍 html 转 pdf 的前端方案, 首先会先介绍 jspdf 的一些基本使用, 然后会解决一些常见问题,例如:分页截断、添加页头页脚

jspdf 的一些基本使用

安装

shell
npm install jspdf

文字生成 PDF

js
// 默认a4大小,竖直方向,mm单位的PDF
const doc = new jsPDF();

// 添加文本
doc.text("Hello world!", 10, 10);

doc.save("a4.pdf");

图片生成 PDF

官方文档链接addImage

js
const doc = new jsPDF();

doc.addImage(
  "https://www.icodehub.top/seeking-wd/test.png",
  "JPEG",
  40,
  10,
  100,
  100
);

doc.save("a4.pdf");

html 转 pdf 的方案

将 html 转 pdf 的纯前端解决方案通常是 jspdf + html2canvas

遇到的问题

①、html2Canvas 生成图片模糊导致导出的 PDF 也模糊的问题 通过  scale  参数, 对 canvas 进行等比放大,可以使 canvas 生成的图片更清晰

②、分页截断 通过深度搜索优先遍历,从顶部遍历需要转换的 HTML 节点, 并将节点分为三种情况进行处理

  • 普通节点
  • 需要进行分页处理并且内部可能包含也需要分页处理子节点的节点
  • 需要进行分页内部不包含需要分页处理的节点,即深度搜索的终点节点

通过从高到低遍历维护一个分页数组 pages,该数组记录每一页的起始位置

多余内容, 可通过空白遮挡

相关代码

js
/* eslint-disable no-debugger */
/* eslint-disable no-unused-vars */
import jsPDF from "jspdf";
import html2canvas from "html2canvas";
import { message } from "ant-design-vue";
const A4_WIDTH = 592.28;
const A4_HEIGHT = 841.89;
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element, width) {
  // canvas元素
  const canvas = await html2canvas(element, {
    // allowTaint: true, // 允许渲染跨域图片
    scale: window.devicePixelRatio * 2, // 增加清晰度
    useCORS: true, // 允许跨域
    onrendered: function (canvas) {
      document.body.appendChild(canvas);
    },
  });
  // 获取canavs转化后的宽度
  const canvasWidth = canvas.width;
  // 获取canvas转化后的高度
  const canvasHeight = canvas.height;
  // 高度转化为PDF的高度
  const height = (width / canvasWidth) * canvasHeight;
  // 转化成图片Data
  const canvasData = canvas.toDataURL("image/jpeg", 1.0);
  //console.log(canvasData)
  return { width, height, data: canvasData };
}
/**
 * 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
 * @param {Object} param
 * @param {HTMLElement} param.element - 需要转换的dom根节点
 * @param {number} [param.contentWidth=550] - 一页pdf的内容宽度,0-592.28
 * @param {string} [param.filename='document.pdf'] - pdf文件名
 * @param {HTMLElement} param.header - 页眉dom元素
 * @param {HTMLElement} param.footer - 页脚dom元素
 */
export async function outputPDF({
  element,
  contentWidth = 550,
  footer,
  header,
  filename = "测试A4分页.pdf",
}) {
  if (!(element instanceof HTMLElement)) {
    return;
  }
  // jsPDFs实例
  const pdf = new jsPDF({
    unit: "pt",
    format: "a4",
    orientation: "p",
  });

  // 一页的高度, 转换宽度为一页元素的宽度
  const { width, height, data } = await toCanvas(element, contentWidth);

  // 添加页脚
  async function addHeader(header, pdf, contentWidth) {
    const {
      height: headerHeight,
      data: headerData,
      width: hWidth,
    } = await toCanvas(header, contentWidth);
    pdf.addImage(headerData, "JPEG", 0, 0, contentWidth, headerHeight);
  }

  // 添加页眉
  async function addFooter(pageNum, now, footer, pdf, contentWidth) {
    const newFooter = footer.cloneNode(true);
    newFooter.querySelector(".pdf-footer-page").innerText = now;
    newFooter.querySelector(".pdf-footer-page-count").innerText = pageNum;
    document.documentElement.append(newFooter);
    const {
      height: footerHeight,
      data: footerData,
      width: fWidth,
    } = await toCanvas(newFooter, contentWidth);
    pdf.addImage(
      footerData,
      "JPEG",
      0,
      A4_HEIGHT - footerHeight,
      contentWidth,
      footerHeight
    );
  }

  // 添加
  function addImage(_x, _y, pdf, data, width, height) {
    pdf.addImage(data, "JPEG", _x, _y, width, height);
  }

  // 增加空白遮挡
  function addBlank(x, y, width, height, pdf) {
    pdf.setFillColor(255, 255, 255);
    pdf.rect(x, y, Math.ceil(width), Math.ceil(height), "F");
  }

  // 页脚元素 经过转换后在PDF页面的高度
  const { height: tfooterHeight } = await toCanvas(footer, contentWidth);

  // 页眉元素 经过转换后在PDF的高度
  const { height: theaderHeight } = await toCanvas(header, contentWidth);

  // 距离PDF左边的距离,/ 2 表示居中
  const baseX = (A4_WIDTH - contentWidth) / 2; // 预留空间给左边
  // 距离PDF 页眉和页脚的间距, 留白留空
  const baseY = 15;

  // 出去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
  const originalPageHeight =
    A4_HEIGHT - tfooterHeight - theaderHeight - 2 * baseY;

  // 元素在网页页面的宽度
  const elementWidth = element.offsetWidth;

  // PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度  转化为 距离Canvas顶部的高度
  const rate = contentWidth / elementWidth;
  console.log("contentWidth:", contentWidth);
  console.log("elementWidth:", elementWidth);
  console.log("rate:", rate);

  // 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
  const pages = [rate * getElementTop(element)];

  // 获取元素距离网页顶部的距离
  // 通过遍历offsetParent获取距离顶端元素的高度值
  function getElementTop(element) {
    let actualTop = element.offsetTop;
    let current = element.offsetParent;

    while (current && current !== null) {
      actualTop += current.offsetTop;
      current = current.offsetParent;
    }
    return actualTop;
  }

  // 遍历正常的元素节点
  function traversingNodes(nodes) {
    for (let i = 0; i < nodes.length; ++i) {
      const one = nodes[i];
      // 需要判断跨页且内部存在跨页的元素
      const isDivideInside =
        one.classList && one.classList.contains("divide-inside");
      // 图片元素不需要继续深入,作为深度终点
      const isIMG = one.tagName === "IMG";
      // table的每一行元素也是深度终点
      const isTableCol =
        one.classList && one.classList.contains("ant-table-row");
      // 特殊的富文本元素
      const isEditor = one.classList && one.classList.contains("editor");
      // 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
      let { offsetHeight } = one;
      // 计算出最终高度
      let offsetTop = getElementTop(one);

      // dom转换后距离顶部的高度
      // 转换成canvas高度
      const top = rate * offsetTop;

      // 对于需要进行分页且内部存在需要分页(即不属于深度终点)的元素进行处理
      if (isDivideInside) {
        // 执行位置更新操作
        updatePos(rate * offsetHeight, top, one);
        // 执行深度遍历操作
        traversingNodes(one.childNodes);
      }
      // 对于深度终点元素进行处理
      else if (isTableCol || isIMG) {
        // dom高度转换成生成pdf的实际高度
        // 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
        updatePos(rate * offsetHeight, top, one);
      } else if (isEditor) {
        // 执行位置更新操作
        updatePos(rate * offsetHeight, top, one);
        // 遍历富文本节点
        traversingEditor(one.childNodes);
      }
      // 对于普通元素,则判断是否高度超过分页值,并且深入
      else {
        // 执行位置更新操作
        updateNomalElPos(top);
        // 遍历子节点
        traversingNodes(one.childNodes);
      }
    }
    return;
  }

  // 对于富文本元素,观察所得段落之间都是以<p> / <img> 元素相隔,因此不需要进行深度遍历 (仅针对个人遇到的情况)
  function traversingEditor(nodes) {
    // 遍历子节点
    for (let i = 0; i < nodes.length; ++i) {
      const one = nodes[i];
      let { offsetHeight } = one;
      let offsetTop = getElementTop(one);
      const top = (contentWidth / elementWidth) * offsetTop;
      updatePos((contentWidth / elementWidth) * offsetHeight, top, one);
    }
  }

  // 普通元素更新位置的方法
  // 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的top高度 大于 正常一页的高度,则需要载入分页点
  function updateNomalElPos(top) {
    if (
      top - (pages.length > 0 ? pages[pages.length - 1] : 0) >
      originalPageHeight
    ) {
      pages.push(
        (pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight
      );
    }
  }

  // 可能跨页元素位置更新的方法
  // 需要考虑分页元素,则需要考虑两种情况
  // 1. 普通达顶情况,如上
  // 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
  function updatePos(eheight, top) {
    // 如果高度已经超过当前页,则证明可以分页了
    if (
      top - (pages.length > 0 ? pages[pages.length - 1] : 0) >=
      originalPageHeight
    ) {
      pages.push(
        (pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight
      );
    }
    // 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
    else if (
      top + eheight - (pages.length > 0 ? pages[pages.length - 1] : 0) >
        originalPageHeight &&
      top != (pages.length > 0 ? pages[pages.length - 1] : 0)
    ) {
      pages.push(top);
    }
  }

  // 深度遍历节点的方法
  traversingNodes(element.childNodes);
  // 可能会存在遍历到底部元素为深度节点,可能存在最后一页位置未截取到的情况
  if (pages[pages.length - 1] + originalPageHeight < height) {
    pages.push(pages[pages.length - 1] + originalPageHeight);
  }

  // 根据分页位置 开始分页
  for (let i = 0; i < pages.length; ++i) {
    message.success(`共${pages.length}页, 生成第${i + 1}页`);
    // 根据分页位置新增图片
    addImage(baseX, baseY + theaderHeight - pages[i], pdf, data, width, height);
    // 将 内容 与 页眉之间留空留白的部分进行遮白处理
    addBlank(0, theaderHeight, A4_WIDTH, baseY, pdf);
    // 将 内容 与 页脚之间留空留白的部分进行遮白处理
    addBlank(0, A4_HEIGHT - baseY - tfooterHeight, A4_WIDTH, baseY, pdf);
    // 对于除最后一页外,对 内容 的多余部分进行遮白处理
    if (i < pages.length - 1) {
      // 获取当前页面需要的内容部分高度
      const imageHeight = pages[i + 1] - pages[i];
      // 对多余的内容部分进行遮白
      addBlank(
        0,
        baseY + imageHeight + theaderHeight,
        A4_WIDTH,
        A4_HEIGHT - imageHeight,
        pdf
      );
    }
    // 添加页眉
    await addHeader(header, pdf, A4_WIDTH);
    // 添加页脚
    await addFooter(pages.length, i + 1, footer, pdf, A4_WIDTH);

    // 若不是最后一页,则分页
    if (i !== pages.length - 2) {
      // 增加分页
      pdf.addPage();
    }
  }
  return pdf.save(filename);
}


⭐️⭐️⭐️ 好啦!!!本文章到这里就结束啦。⭐️⭐️⭐️

✿✿ ヽ(°▽°)ノ ✿

撒花 🌸🌸🌸🌸🌸🌸

上次更新于: