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 小时前
Node.js v26.1.0 深度解读:FFI、后量子密码与调试器的进化
前端·后端·node.js
测试员周周1 小时前
【Appium 系列】第06节-页面对象实现 — LoginPage 实战
开发语言·前端·人工智能·python·功能测试·appium·测试用例
西洼工作室2 小时前
前端直传OSS服务端签名(Policy+Signature)/STS临时凭证
前端·文件上传·oss
你很易烊千玺3 小时前
日常练习-数组 字符串常用的场景
前端·javascript·字符串·数组
weixin199701080163 小时前
[特殊字符] RESTful API 接口规范详解:构建高效、可扩展的 Web 服务(附 Python 源码)
前端·python·restful
存在的五月雨4 小时前
Vue3项目一些语法
前端·javascript·react.js
nashane4 小时前
HarmonyOS 6学习:Web组件同层渲染事件处理与智能长截图实现
前端·学习·harmonyos·harmonyos 5
大家的林语冰4 小时前
Node 2026 发布,JS 三大新功能上线,最后一个奇偶版本
前端·javascript·node.js
nashane4 小时前
HarmonyOS 6学习:Web组件同层渲染触摸事件与长截图拼接实战
前端·学习·harmonyos·harmonyos 5