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);
}
相关推荐
薛定谔的猫19829 分钟前
gradio学习代码部分
java·前端·javascript
yqcoder19 分钟前
React 深度解析:类组件 (Class) vs 函数组件 (Function)
前端·javascript·react.js
HwJack2024 分钟前
HarmonyOS 开发中Web 组件渲染进程崩溃后的“起死回生”
前端·华为·harmonyos
HyaCinth24 分钟前
一人一周,用 Codex 渐进式迁移重构了一个材料学组件库
前端·javascript·css
心.c34 分钟前
大厂高频手写题
开发语言·前端·javascript
神の愛9 小时前
左连接查询数据 left join
java·服务器·前端
小码哥_常10 小时前
解锁Android嵌入式照片选择器,让你的App体验丝滑起飞
前端
郑寿昌11 小时前
IIoT本体迁移的领域扩展机制
服务器·前端·microsoft
深海鱼在掘金11 小时前
Next.js从入门到实战保姆级教程(第十一章):错误处理与加载状态
前端·typescript·next.js
深海鱼在掘金11 小时前
Next.js从入门到实战保姆级教程(第十二章):认证鉴权与中间件
前端·typescript·next.js