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);
}
相关推荐
驴肉板烧凤梨牛肉堡1 分钟前
七牛的单个大文件上传的组件封装,vue3+elment-plus+hooks
前端
gnip5 分钟前
webpack环境注入模块到全局
前端
楠目24 分钟前
JS语言基础
开发语言·前端·javascript
树上有只程序猿33 分钟前
杂七杂八系列----浅谈.NET微服务架构的演变
前端
快乐就是哈哈哈33 分钟前
做了个渐变边框的input输入框,领导和客户很满意!
前端
纯阳阳34 分钟前
如何用echart绘制圆柱
前端·echarts
橘黄的猫34 分钟前
JavaScript vs Java 大数迷局:1929790778458951682 的奇幻漂流
前端·后端
3Katrina35 分钟前
一文了解JS的NaN与Symbol
前端·javascript
前端付豪36 分钟前
网易灰度发布系统揭秘:一天300次上线是怎么实现的?
前端·后端·架构
小old弟37 分钟前
啥时候dependencies❓啥时候devDependencies❓
前端