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);
}
相关推荐
北海-cherish2 小时前
vue中的 watchEffect、watchAsyncEffect、watchPostEffect的区别
前端·javascript·vue.js
2501_915909063 小时前
HTML5 与 HTTPS,页面能力、必要性、常见问题与实战排查
前端·ios·小程序·https·uni-app·iphone·html5
white-persist4 小时前
Python实例方法与Python类的构造方法全解析
开发语言·前端·python·原型模式
新中地GIS开发老师5 小时前
Cesium 军事标绘入门:用 Cesium-Plot-JS 快速实现标绘功能
前端·javascript·arcgis·cesium·gis开发·地理信息科学
Superxpang5 小时前
前端性能优化
前端·javascript·vue.js·性能优化
Rysxt_5 小时前
Element Plus 入门教程:从零开始构建 Vue 3 界面
前端·javascript·vue.js
隐含5 小时前
对于el-table中自定义表头中添加el-popover会弹出两个的解决方案,分别针对固定列和非固定列来隐藏最后一个浮框。
前端·javascript·vue.js
大鱼前端5 小时前
Turbopack vs Webpack vs Vite:前端构建工具三分天下,谁将胜出?
前端·webpack·turbopack
你的人类朋友5 小时前
先用js快速开发,后续引入ts是否是一个好的实践?
前端·javascript·后端