HTML 导出 pdf 文件

整体思路

htmldom > Canvas > image > pdf > save

第三方库

html2pdf.js

实现功能及缺陷

我当前的业务就是把一个 md 渲染出来的 dom 文档导出来。 文档中没有图片、没有大段文本、也不会特别长。

功能

  1. 分页
  2. 页头
  3. 页尾

缺陷

  1. 大段文本分页问题
  2. 内容过多会有问题
  3. pdf 不能编辑
  4. pdf 容易失真

代码

分页的思路

遍历要导出的dom找到所有行级标签, 然后计算每个行级便签距离上一个分页符的距离,当dom上边沿距离上一个分页符大于 0 并且 小于一个行高的时候在当前 dom 前插入一个分页符,当dom下边沿距离上一个分页符大于 0 并且 小于一个行高的时候在当前 dom 后插入一个分页符

分页符: <div class="page-break"></div>

ini 复制代码
import PdfIndex from "./PdfIndex.vue";
import { createVNode, cloneVNode, render as vueRender } from "vue";
import { SubjectBase } from "~/common/SubjectBase";
import html2pdf from "html2pdf.js";

const pageMargin: [number, number, number, number] = [18, 15, 13, 15]; // 上、右、下、左
const a4PageHeight = 297;
const a4PageWidth = 210;
// 获取图片数据
function generateImg(url: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = url;
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error("Failed to load image"));
  });
}

function createIndexPage(
  name: string,
  pageHeight: number = 297,
  pageWidth: number = 210,
  margin: [number, number, number, number] = [18, 15, 13, 15],
): Node {
  const indexPage = document.createElement("div");
  indexPage.style.width = `${pageWidth - margin[1] - margin[3]}mm`;
  indexPage.style.height = `${pageHeight - margin[0] - margin[2] - 6}mm`;
  let template = createVNode(PdfIndex);
  vueRender(
    cloneVNode(template, {
      title: name,
    }),
    indexPage,
  );
  return indexPage;
}

function traverseDOM(node: HTMLElement, callback: (node: HTMLElement, tagName: string) => void) {
  let tagNames = ["P", "H1", "H2", "H3", "H4", "H5", "H6", "IMG", "BR", "LI", "DIV", "TR", "PRE"];
  if (node.children.length > 0) {
    Array.from(node.children).forEach((child) => {
      traverseDOM(child as HTMLElement, callback);
    });
  }
  if (tagNames.includes(node.tagName)) {
    callback(node, node.tagName);
  }
}

let pxPerMm: number | undefined;
// 将px转换为mm
function pxToMm(px: number): number {
  if (pxPerMm) return px / pxPerMm;
  const tmpNode = document.createElement("div");
  tmpNode.style.cssText =
    "width:1mm; position:absolute; left:0; top:0; z-index:99; visibility:hidden";
  document.body.appendChild(tmpNode);
  pxPerMm = tmpNode.getBoundingClientRect().width;
  tmpNode.parentNode?.removeChild(tmpNode);
  return px / pxPerMm;
}

function getPreviousElement(node: HTMLElement): HTMLElement | null {
  const previousElement = node.previousElementSibling;
  if (previousElement) {
    return previousElement as HTMLElement;
  } else {
    return null;
  }
}

function createPageBreakNode(
  node: HTMLElement,
  position: "beforebegin" | "afterend",
  tagName?: string,
) {
  let pageBreak = document.createElement("div");
  if (tagName === "TR") {
    pageBreak = document.createElement("tr");
  }
  pageBreak.classList.add("page-break");
  node.insertAdjacentElement(position, pageBreak);
  return pageBreak;
}

function getDistanceToTop(element: HTMLElement) {
  // 获取元素相对于视口顶部的距离
  const rect = element.getBoundingClientRect();
  // 获取页面垂直滚动距离
  const scrollTop = window.scrollY || document.documentElement.scrollTop;
  // 计算元素到页面顶部的总距离
  return rect.top + scrollTop;
}

function setPageBreak(
  element: HTMLElement,
  params: { pageHeight: number; pageWidth: number; margin: [number, number, number, number] },
) {
  let float = pxToMm(34);
  let docPage = document.createElement("div");
  docPage.style.width = `${params.pageWidth - params.margin[1] - params.margin[3]}mm`;
  docPage.style.overflow = "hidden";
  docPage.style.padding = "0px";
  docPage.style.position = "absolute";
  docPage.style.top = "100000px";
  docPage.style.left = "0px";
  docPage.style.zIndex = "1000";
  docPage.appendChild(element);
  document.body.appendChild(docPage);
  let pageHeight = params.pageHeight - params.margin[0] - params.margin[2];
  let pageBreak: HTMLElement | null = null;
  let parentElement = docPage.querySelector(".markdown-body") as HTMLElement;
  traverseDOM(element, (node, tagName) => {
    // 完整的一页开始的 offsetTop
    let startOffsetTop = pxToMm(getDistanceToTop(element));
    if (pageBreak) {
      startOffsetTop = pxToMm(getDistanceToTop(pageBreak));
    }

    const isLastDom = !node.nextElementSibling;
    if (isLastDom) {
      return;
    }
    // 当前节点上边距离完整一页开始的 offsetTop
    let pageY = pxToMm(getDistanceToTop(node)) - startOffsetTop; // 当前节点上边距离完整一页开始的 offsetTop
    let pageY2 = pxToMm(getDistanceToTop(node) + node.offsetHeight) - startOffsetTop; // 当前节点下边距离完整一页开始的 offsetTop
    // 插入分页符的位置必须是页面下边界上面,
    if (pageHeight - pageY > 0 && pageHeight - pageY <= float) {
      pageBreak = createPageBreakNode(node, "beforebegin", tagName);
    } else if (pageHeight - pageY2 > 0 && pageHeight - pageY2 <= float) {
      pageBreak = createPageBreakNode(node, "afterend", tagName);
    } else if (pageY2 > pageHeight) {
      pageBreak = createPageBreakNode(node, "beforebegin", tagName);
    } else if (pageY > pageHeight) {
      let previousElement = getPreviousElement(node);
      if (previousElement) {
        pageBreak = createPageBreakNode(previousElement, "afterend", tagName);
      } else {
        pageBreak = createPageBreakNode(node, "beforebegin", tagName);
      }
    }
  });

  return docPage;
}

export async function generatePDF(element: HTMLElement, title: string, filename: string) {
  if (!element) return;
  element = element.cloneNode(true) as HTMLDivElement;
  if (element.children[0].tagName === "P") {
    element.removeChild(element.children[0] as HTMLElement);
  }
  let pageDoc = setPageBreak(element, {
    pageHeight: a4PageHeight,
    pageWidth: a4PageWidth,
    margin: pageMargin,
  });
  const indexPage = createIndexPage(title, a4PageHeight, a4PageWidth, pageMargin);
  element.insertBefore(indexPage, element.childNodes[0]);
  // 测试代码,在页面中添加一个固定位置的红色div,用于测试分页
  // let test_dom = element.cloneNode(true);
  // let docPage = document.createElement("div");
  // docPage.style.width = a4PageWidth - pageMargin[1] - pageMargin[3] + "mm";
  // docPage.style.height = a4PageHeight - pageMargin[0] - pageMargin[2] + "mm";
  // docPage.style.position = "fixed";
  // docPage.style.top = "10px";
  // docPage.style.left = "10px";
  // docPage.style.zIndex = "1000";
  // docPage.style.backgroundColor = "red";
  // docPage.style.overflow = "auto";
  // docPage.appendChild(test_dom);
  // document.body.appendChild(docPage);

  const options = {
    margin: pageMargin, // 上、右、下、左
    filename: `${filename}.pdf`,
    image: { type: "jpeg", quality: 0.98 },
    html2canvas: { scale: 2, logging: true },
    jsPDF: { unit: "mm", format: "a4", orientation: "portrait" },
    pagebreak: {
      // mode: ["avoid-all", "css"], // 禁用 legacy 模式
      // mode: ["avoid-all", "css", "legacy"], // 尽可能避免分页
      before: ".page-break", // 指定分页元素
    },
  };
  const t = new Date().toLocaleString("sv").slice(0, 16).replace(":", "");
  await html2pdf()
    .set(options)
    .from(element)
    .toPdf()
    .get("pdf")
    .then(async (pdf: any) => {
      const totalPages = pdf.internal.getNumberOfPages();
      // 遍历每一页添加页头页脚
      for (let page = 1; page <= totalPages; page++) {
        pdf.setPage(page);
        const pageSize = pdf.internal.pageSize;
        const pageHeight = pageSize.height;
        const pageWidth = pageSize.width;
        // 添加页头文本
        if (page !== 1) {
          pdf.setLineWidth(0.1);
          pdf.setDrawColor(216, 216, 216);
          pdf.line(10, 15, pageSize.width - 10, 15);
          let imgContent = await generateImg("/images/login-logo.png");
          pdf.addImage(imgContent, "png", pageWidth - 10 - 32, 5, 32, 7.5);
        }

        // 添加页脚页码
        if (page !== 1) {
          pdf.setLineWidth(0.1);
          pdf.setDrawColor(216, 216, 216);
          pdf.line(10, pageHeight - 12, pageSize.width - 10, pageHeight - 12);
          pdf.setFontSize(10);
          pdf.text(` -- ${page} / ${totalPages} --`, pageWidth / 2 - 10, pageHeight - 7);
        }
      }
    })
    .save(`${SubjectBase.userInfo?.preferred_username}-${filename}-${t}`);

  setTimeout(() => {
    pageDoc?.remove();
  }, 1000);
}

export function printElement(element: HTMLElement) {
  let ifr: any = document.createElement("iframe");
  ifr.style = "height: 0px; width: 0px; position: absolute";
  document.body.appendChild(ifr);
  ifr.contentDocument.body.appendChild(element.cloneNode(true));
  ifr.contentWindow.print();

  ifr.parentElement.removeChild(ifr);
}
相关推荐
xump13 分钟前
如何在DevTools选中调试一个实时交互才能显示的元素样式
前端·javascript·css
折翅嘀皇虫14 分钟前
fastdds.type_propagation 详解
java·服务器·前端
Front_Yue15 分钟前
深入探究跨域请求及其解决方案
前端·javascript
wordbaby17 分钟前
React Native 进阶实战:基于 Server-Driven UI 的动态表单架构设计
前端·react native·react.js
抱琴_17 分钟前
【Vue3】我用 Vue 封装了个 ECharts Hooks,同事看了直接拿去复用
前端·vue.js
风止何安啊19 分钟前
JS 里的 “变量租房记”:闭包是咋把变量 “扣” 下来的?
前端·javascript·node.js
Danny_FD24 分钟前
用 ECharts markLine 标注节假日
前端·echarts
程序员西西25 分钟前
SpringBoot无感刷新Token实战指南
java·开发语言·前端·后端·计算机·程序员
烛阴25 分钟前
Luban集成CocosCreator完整教程
前端·typescript·cocos creator
有点笨的蛋25 分钟前
深入理解 JavaScript 原型机制:构造函数、原型对象与原型链
前端·javascript