整体思路
htmldom > Canvas > image > pdf > save
第三方库
实现功能及缺陷
我当前的业务就是把一个 md 渲染出来的 dom 文档导出来。 文档中没有图片、没有大段文本、也不会特别长。
功能
- 分页
- 页头
- 页尾
缺陷
- 大段文本分页问题
- 内容过多会有问题
- pdf 不能编辑
- 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);
}