VUE+elementUI前端导出解决方案,截断,清晰度,页边距,页眉页脚,富文本都处理了

pdfLoader.js

--------------------------

/*
* @Description: html转pdf 新版解决方案
* @Author: jeseven/wwl
* @Date: 2023-05-23 10:03:57
* @LastEditTime: 2023-05-23 10:23:22
* @LastEditors: jeseven/wwl
*/
import jsPDF from "jspdf";
import html2canvas from "html2canvas";
// http://raw.githack.com/MrRio/jsPDF/master/docs/jsPDF
import { Message } from "element-ui";

/**
* 生成pdf(处理多页pdf截断问题)
* @param {Object} param
* @param {HTMLElement} param.element - 需要转换的dom根节点
* @param {number} [param.contentWidth=550] - 一页pdf的内容宽度,0-592.28
* @param {string} [param.outputType='save'] - 生成pdf的数据类型,添加了'file'类型,其他支持的类型见http://raw.githack.com/MrRio/jsPDF/master/docs/jsPDF.html#output
* @param {number} [param.scale=window.devicePixelRatio * 2] - 清晰度控制,canvas放大倍数,默认像素比*2
* @param {string} [param.direction='p'] - 纸张方向,l横向,p竖向,默认A4纸张
* @param {string} [param.fileName='document.pdf'] - pdf文件名,当文件类型是file时候,文件名要以.pdf为后缀
* @param {number} param.x - pdf页内容距页面左边的高度,默认居中显示,为(A4宽度 - contentWidth) / 2)
* @param {number} param.y - pdf页内容距页面上边的高度,默认 15px
* @param {HTMLElement} param.header - 页眉dom元素
* @param {HTMLElement} param.footer - 页脚dom元素
* @param {string} [param.groupName='pdf-group'] - 给dom添加组标识的名字,分组代表要进行分页判断,当前组大于一页则新起一页,否则接着上一页
* @param {string} [param.itemName='pdf-group-item'] - 给dom添加元素标识的名字,设置了itemName代表此元素内容小于一页并且不希望被拆分,子元素也不需要遍历,即手动指定深度终点,优化性能
* @param {string} [param.editorName='pdf-editor'] - 富文本标识类
* @param {string} [param.groupName='el-table-row'] - 表格组件内部的深度节点
* @param {string} [param.splitName='pdf-split-page'] - 强制分页,某些情况下可能想不同元素单独起一页,可以设置这个类名
* @param {string} [param.isPageMessage=false] - 是否显示当前生成页数状态
* @returns {Promise} 根据outputType返回不同的数据类型
*/

export class PdfLoader {
constructor(element, param = {}) {
if (!(element instanceof HTMLElement)) {
throw new TypeError("element节点请传入dom节点");
}
console.log("[ param ] >", param);
this.element = element;
this.contentWidth = param.contentWidth || 550;
this.outputType = param.outputType || "save";
this.fileName = param.fileName || "导出的pdf文件";
this.scale = param.scale;
this.baseX = param.baseX;
this.baseY = param.baseY || 15;
this.header = param.header;
this.footer = param.footer;
this.isPageMessage = param.isPageMessage;
this.groupName = param.groupName || "pdf-group";
this.itemName = param.itemName || "pdf-group-item";
this.editorName = param.editorName || "pdf-editor";
this.tableSplitName = param.tableSplitName || "ant-table-row";
this.splitName = param.splitName || "pdf-split-page";
this.direction = param.direction || "p"; // 默认竖向,l横向
this.A4_WIDTH = 595; // a4纸的尺寸[595,842],单位像素
this.A4_HEIGHT = 842;
if (this.direction === "l") {
// 如果是横向,交换a4宽高参数
[this.A4_HEIGHT, this.A4_WIDTH] = [this.A4_WIDTH, this.A4_HEIGHT];
}
this.pdf = null;
this.rate = 1;
this.pages = [];
this.elementTop = 0 // 根元素距离可视区域高度
}

/**
* 将元素转化为canvas元素
* @param {HTMLElement} element - 当前要转换的元素
* @param {width} width - 内容宽度
* @returns
*/
async toCanvas(element, width) {
// canvas元素
const canvas = await html2canvas(element, {
allowTaint: true, // 允许渲染跨域图片
scale: this.scale || window.devicePixelRatio * 2, // 增加清晰度
useCORS: true, // 允许跨域
// onrendered: function (canvas) {
// document.body.appendChild(canvas);
// },
});
// 获取canavs转化后的宽度
const canvasWidth = canvas.width;
// 获取canvas转化后的高度
const canvasHeight = canvas.height;
// 高度转化为PDF的高度
const height = (width / canvasWidth) * canvasHeight;
// 转化成图片Data
const canvasData = canvas.toDataURL("image/jpeg", 1.0);
//console.log(canvasData)
return { width, height, data: canvasData };
}

/**
* 生成pdf方法,外面调用这个方法
* @returns {Promise} 返回一个promise
*/
getPdf() {
// 滚动置顶,防止顶部空白
window.pageYoffset = 0;
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
return new Promise(async (resolve, reject) => {
// jsPDFs实例
const pdf = new jsPDF({
unit: "pt",
format: "a4",
orientation: this.direction,
});

this.pdf = pdf;
let tfooterHeight = 0;
let theaderHeight = 0;

// 距离PDF左边的距离,/ 2 表示居中 ,,预留空间给左边, 右边,也就是左右页边距
const baseX = (this.A4_WIDTH - this.contentWidth) / 2;

// 距离PDF 页眉和页脚的间距, 留白留空
const baseY = this.baseY;

// 一页的高度, 转换宽度为一页元素的宽度
const { width, height, data } = await this.toCanvas(this.element, this.contentWidth);

// 页脚元素 经过转换后在PDF页面的高度
if (this.footer) {
tfooterHeight = (await this.toCanvas(this.footer, this.contentWidth)).height;
}

// 页眉元素 经过转换后在PDF的高度
if (this.header) {
theaderHeight = (await this.toCanvas(this.header, this.contentWidth)).height;
}

// 出去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
const originalPageHeight = this.A4_HEIGHT - tfooterHeight - theaderHeight - 2 * baseY;
this.originalPageHeight = originalPageHeight;

// 元素在网页页面的宽度
const elementWidth = this.element.scrollWidth;

// PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度 转化为 距离Canvas顶部的高度
const rate = this.contentWidth / elementWidth;
this.rate = rate;

// 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
this.elementTop = this.getElementTop(this.element)
// this.pages = [rate * this.getElementTop(this.element)];
this.pages = [0] // 要从0开始

// 深度遍历节点的方法
this.traversingNodes(this.element.childNodes);

const pages = this.pages;
console.log("%c [ pages ]-143", "font-size:13px; background:pink; color:#bf2c9f;", pages);

// 可能会存在遍历到底部元素为深度节点,可能存在最后一页位置未截取到的情况
if (pages[pages.length - 1] + originalPageHeight < height) {
pages.push(pages[pages.length - 1] + originalPageHeight);
}

// 根据分页位置 开始分页
for (let i = 0; i < pages.length; ++i) {
if (this.isPageMessage) {
Message.success(`共${pages.length}页, 生成第${i + 1}页`);
}
// 根据分页位置新增图片
this.addImage(baseX, baseY + theaderHeight - pages[i], pdf, data, width, height);
// 将 内容 与 页眉之间留空留白的部分进行遮白处理
this.addBlank(0, theaderHeight, this.A4_WIDTH, baseY, pdf);
// 将 内容 与 页脚之间留空留白的部分进行遮白处理
this.addBlank(0, this.A4_HEIGHT - baseY - tfooterHeight, this.A4_WIDTH, baseY, pdf);
// 对于除最后一页外,对 内容 的多余部分进行遮白处理
if (i < pages.length - 1) {
// 获取当前页面需要的内容部分高度
const imageHeight = pages[i + 1] - pages[i];
// 对多余的内容部分进行遮白
this.addBlank(0, baseY + imageHeight + theaderHeight, this.A4_WIDTH, this.A4_HEIGHT - imageHeight, pdf);
}
// 添加页眉
await this.addHeader(this.header, pdf, this.A4_WIDTH);
// 添加页脚
await this.addFooter(pages.length, i + 1, this.footer, pdf, this.A4_WIDTH);

// 若不是最后一页,则分页
if (i !== pages.length - 1) {
// 增加分页
pdf.addPage();
}
}

try {
const result = await this.getPdfBytype(pdf);
resolve(result);
} catch (error) {
reject("生成pdf出错", error);
}
});
}

// 根据类型获取pdf
getPdfBytype(pdf) {
let result = null;
switch (this.outputType) {
case "file":
result = new File([pdf.output("blob")], this.fileName, {
type: "application/pdf",
lastModified: Date.now(),
});
break;
case "save":
result = pdf.save(this.fileName);
break;
default:
result = pdf.output(this.outputType);
}
return result;
}

/**
* 遍历正常的元素节点
* @param {HTMLElement} nodes - 当前要遍历的节点数组
* @returns
*/
traversingNodes(nodes) {
for (let i = 0; i < nodes.length; ++i) {
const one = nodes[i];
// 需要判断跨页且内部存在跨页的元素,以分组类名区分
const isGround = one.classList && one.classList.contains(this.groupName);
// 小模块,并且内部不需要遍历了,作为深度终点
const isItem = one.classList && one.classList.contains(this.itemName);
// 强制分页的标记点
const isSplit = one.classList && one.classList.contains(this.splitName);

// 图片元素不需要继续深入,作为深度终点
const isIMG = one.tagName === "IMG";
// table的每一行元素也是深度终点
const isTableCol = one.classList && one.classList.contains(this.tableSplitName);
// 特殊的富文本元素
const isEditor = one.classList && one.classList.contains(this.editorName);
// 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
let { offsetHeight } = one;
// 计算出最终高度,要减去根元素顶部高度
let offsetTop = this.getElementTop(one) - this.elementTop

// dom转换后距离顶部的高度
// 转换成canvas高度
const top = this.rate * offsetTop;

if (isSplit) {
this.pages.push(top);
// 执行深度遍历操作
this.traversingNodes(one.childNodes);
}
// 对于需要进行分页且内部存在需要分页(即不属于深度终点)的元素进行处理
else if (isGround) {
// 执行位置更新操作
this.updatePos(this.rate * offsetHeight, top, one);
// 执行深度遍历操作
this.traversingNodes(one.childNodes);
}
// 对于深度终点元素进行处理
else if (isTableCol || isIMG || isItem) {
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
this.updatePos(this.rate * offsetHeight, top, one);
} else if (isEditor) {
// 执行位置更新操作
this.updatePos(this.rate * offsetHeight, top, one);
// 遍历富文本节点
this.traversingEditor(one.childNodes);
}
// 对于普通元素,则判断是否高度超过分页值,并且深入
else {
// 执行位置更新操作
this.updateNomalElPos(top);
// this.updatePos(this.rate * offsetHeight, top, one);
// 遍历子节点
this.traversingNodes(one.childNodes);
}
}
return;
}

/**
* 对于富文本元素,观察所得段落之间都是以<p> / <img> 元素相隔,因此不需要进行深度遍历
* (这个可以根据自己的富文本结构进行改动优化)
* @param {HTMLElement} nodes - 当前要遍历的节点数组
* @returns
*/
traversingEditor(nodes) {
// 遍历子节点
for (let i = 0; i < nodes.length; ++i) {
const one = nodes[i];
let { offsetHeight } = one;
let offsetTop = this.getElementTop(one) -this.elementTop
const top = this.rate * offsetTop;
this.updatePos(this.rate * offsetHeight, top, one);
}
}

/**
* 可能跨页元素位置更新的方法
* 需要考虑分页元素,则需要考虑两种情况
* 1. 普通达顶情况,如上
* 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
* @param {Number} eheight - 当前元素在pdf中的高度(经过比例转换的)
* @param {Number} top - 当前元素在pdf中距离顶部可视区域高度(经过比例转换)
* @returns
*/
updatePos(eheight, top) {
const pageH = this.pages.length > 0 ? this.pages[this.pages.length - 1] : 0;

// 如果高度已经超过当前页,则证明可以分页了
if (top - pageH >= this.originalPageHeight) {
this.pages.push(pageH + this.originalPageHeight);
}
// 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
// top!=pageH这个条件是防止多个元素嵌套情况下,他们top是一样的
else if (top + eheight - pageH > this.originalPageHeight && top != pageH) {
console.log("%c [ 分页了 ]: ", "color: #bf2c9f; background: pink; font-size: 13px;", "分页了");
this.pages.push(top);
}
}

/**
* 普通元素更新位置
* 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
* 这种判断,有可能出现截断,但是机率比较小,可以通过设置itemName进一步减少这种情况出现
* @param {Number} top - 当前元素在pdf中距离顶部可视区域高度(经过比例转换)
* @returns
*/
updateNomalElPos(top) {
const pageH = this.pages.length > 0 ? this.pages[this.pages.length - 1] : 0;
if (top - pageH >= this.originalPageHeight) {
this.pages.push(pageH + this.originalPageHeight);
return true;
}
}

/**
* 获取元素距离网页顶部的距离
* 通过遍历offsetParant获取距离顶端元素的高度值
* @param {HTMLElement} element - 需要计算的元素
* @returns
*/
getElementTop(element) {
let actualTop = element.offsetTop;
let current = element.offsetParent;

while (current && current !== null) {
actualTop += current.offsetTop;
current = current.offsetParent;
}

return actualTop;
}

/**
* 添加页眉
* @param {HTMLElement} header -页眉元素
* @param {Object} pdf - pdf实例
* @param {Number} contentWidth -在pdf中占据的宽度(默认占满)
* @returns
*/
async addHeader(header, pdf, contentWidth) {
if (!header || !(header instanceof HTMLElement)) {
return;
}
if (!this.__header) {
// 页头都是一样的,不需要每次都生成
this.__header = await this.toCanvas(header, contentWidth);
}

// 每页都从 0 0 开始?
// addImage(data,format,x,y,w,h)
const { height, data } = this.__header;
pdf.addImage(data, "JPEG", 0, 0, contentWidth, height);
}

/**
* 添加页脚
* @param {Number} pageSize -总页数
* @param {Number} pageNo -当前第几页
* @param {HTMLElement} footer -页脚元素
* @param {Object} pdf - pdf实例
* @param {Number} contentWidth - 在pdf中占据的宽度(默认占满)
* @returns
*/
async addFooter(pageSize, pageNo, footer, pdf, contentWidth) {
if (!footer || !(footer instanceof HTMLElement)) {
return;
}

// 页码元素,类名这里写死了
let pageNoDom = footer.querySelector(".pdf-footer-page");
let pageSizeDom = footer.querySelector(".pdf-footer-page-count");
if (pageNoDom) {
pageNoDom.innerText = pageNo;
}
if (pageSizeDom) {
pageSizeDom.innerText = pageSize;
}

// 如果设置了页码的才需要每次重新生成cavans
if (pageNoDom || !this.__footer) {
this.__footer = await this.toCanvas(footer, contentWidth);
}

const { height, data } = this.__footer;
// 高度位置计算:当前a4高度 - 页脚在pdf中的高度
pdf.addImage(data, "JPEG", 0, this.A4_HEIGHT - height, contentWidth, height);
}

// 截取图片
addImage(_x, _y, pdf, data, width, height) {
pdf.addImage(data, "JPEG", _x, _y, width, height);
}

/**
* 添加空白遮挡
* @param {Number} x - x 与页面左边缘的坐标(以 PDF 文档开始时声明的单位)
* @param {Number} y - y 与页面上边缘的坐标(以 PDF 文档开始时声明的单位)
* @param {Number} width - 填充宽度
* @param {Number} height -填充高度
* @param {Object} pdf - pdf实例
* @returns
*/
addBlank(x, y, width, height, pdf) {
pdf.setFillColor(255, 255, 255);
// rect(x, y, w, h, style) ->'F'填充方式,默认是描边方式
pdf.rect(x, y, Math.ceil(width), Math.ceil(height), "F");
}
}

----------------------

index.vue

<template>
<div class="ctn">
<div class="pdf-ctn">
<!-- 要导出的部分 -->
<div class="pdf-panel">
<div class="pdf-inside-panel">
<!-- <TableComponent v-for="(item ,index) in 2" :key="index" ></TableComponent> -->
<EleTable class="pdf-group"></EleTable>
<ImageComponent></ImageComponent>
<ChartComponent></ChartComponent>
<!-- <RichText v-for="(item, index) in 2" :key="index + 20"></RichText> -->
<!-- <RichText></RichText> -->
<pdfWord></pdfWord>
</div>
</div>

<!-- 页头页尾 -->
<div
class="pdf-header"
style="
font-weight: bold;
padding: 15px 8px;
width: 100%;
border-bottom: 1px solid rgba(0, 0, 0, 0.85);
color: rgba(0, 0, 0, 0.85);
position: fixed;
top: -100vh;
"
>
页头
</div>
<div
class="pdf-footer"
style="
font-weight: bold;
padding: 15px 8px;
width: 100%;
border-top: 1px solid rgba(0, 0, 0, 0.85);
position: fixed;
top: -100vh;
"
>
<div style="display: flex; justify-content: center; align-items: center; padding-top: 5px">我是页尾</div>
<div style="display: flex; justify-content: center; align-items: center; margin-top: 20px">

<div class="pdf-footer-page"></div>
页 / 共
<div class="pdf-footer-page-count"></div>

</div>
</div>
</div>
<div>
<el-button style="top: 100px; left: 0; position: fixed" @click="handleOutput2" type="primary">
测试导出2
</el-button>
</div>
</div>
</template>

<script>
import TableComponent from "../components/tableComponent.vue";
import ImageComponent from "../components/ImageComponent.vue";
import RichText from "../components/richText.vue";
import EleTable from "../components/table.vue";
import ChartComponent from "../components/echartComponent.vue";
import pdfWord from "../components/pdfWord.vue";

import { PdfLoader } from "../utils/pdfLoader";
export default {
name: "HelloWorld",
props: {
msg: String,
},
methods: {
handleOutput2() {
const loading = this.$loading({
lock: true,
text: "导出中",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.7)",
});

const element = document.querySelector(".pdf-panel");
const header = document.querySelector(".pdf-header");
const footer = document.querySelector(".pdf-footer");

const pdfLoader = new PdfLoader(element, {
footer: footer,
header: header,
// outputType:'file',
fileName: "自定义名字",
direction: "p",
isPageMessage:true
});
pdfLoader.getPdf().then((res) => {
console.log("[ 导出成功] >", res);
loading.close();
this.$message.success("导出成功");
});
},
},
components: {
TableComponent,
TableComponent,
ImageComponent,
RichText,
pdfWord,
EleTable,
ChartComponent,
},
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="less">
.ctn {
padding: 100px;
.pdf-ctn {
width: 1200px;
margin: 0 auto;
.pdf-panel {
position: relative;
}
}
}
</style>

来自<https://blog.csdn.net/weixin_45295262/article/details/117041018>

posted @ 2023-06-06 14:52  wangyb56  阅读(854)  评论(0编辑  收藏  举报