wujingjing
2024-12-30 f12fd0b36f027cbbae0fb322b237abe857a32e2b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
const A4_WIDTH = 592.28;
const A4_HEIGHT = 841.89;
// NOTE: 使用最新版本html2canvas@1.4.1+tailwind会导致文字往下偏移;
// 但是退回到不偏移的版本html2canvas@1.0.0-alpha.12+tailwind,带动画的图片会有偏移
 
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
// 需要分页的内容,包含以下 class: divide-inside、ant-table-row、row
async function toCanvas(element, width) {
    // canvas元素
    const canvas = await html2canvas(element, {
        // allowTaint: true, // 允许渲染跨域图片
        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(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
 * @param {Object} param
 * @param {HTMLElement} param.element - 需要转换的dom根节点
 * @param {number} [param.contentWidth=550] - 一页pdf的内容宽度,0-592.28
 * @param {string} [param.filename='document.pdf'] - pdf文件名
 * @param {HTMLElement} param.header - 页眉dom元素
 * @param {HTMLElement} param.footer - 页脚dom元素
 */
export async function outputPDF({ element, contentWidth = 550, footer, header, filename = '测试A4分页.pdf' }) {
    if (!(element instanceof HTMLElement)) {
        return;
    }
    // jsPDFs实例
    const pdf = new jsPDF({
        unit: 'pt',
        format: 'a4',
        orientation: 'p',
    });
 
    // 一页的高度, 转换宽度为一页元素的宽度
    const { width, height, data } = await toCanvas(element, contentWidth);
 
    // 添加页脚
    async function addHeader(header, pdf, contentWidth) {
        const { height: headerHeight, data: headerData, width: hWidth } = await toCanvas(header, contentWidth);
        pdf.addImage(headerData, 'JPEG', 0, 0, contentWidth, headerHeight);
    }
 
    // 添加页眉
    async function addFooter(pageNum, now, footer, pdf, contentWidth) {
        const newFooter = footer.cloneNode(true);
        newFooter.querySelector('.pdf-footer-page').innerText = now;
        newFooter.querySelector('.pdf-footer-page-count').innerText = pageNum;
        document.documentElement.append(newFooter);
        const { height: footerHeight, data: footerData, width: fWidth } = await toCanvas(newFooter, contentWidth);
        pdf.addImage(footerData, 'JPEG', 0, A4_HEIGHT - footerHeight, contentWidth, footerHeight);
    }
 
    // 添加
    function addImage(_x, _y, pdf, data, width, height) {
        pdf.addImage(data, 'JPEG', _x, _y, width, height);
    }
 
    // 增加空白遮挡
    function addBlank(x, y, width, height, pdf) {
        pdf.setFillColor(255, 255, 255);
        pdf.rect(x, y, Math.ceil(width), Math.ceil(height), 'F');
    }
    let tfooterHeight = 0;
    let theaderHeight = 0;
 
    if (footer) {
        // 页脚元素 经过转换后在PDF页面的高度
        tfooterHeight = (await toCanvas(footer, contentWidth)).height;
    }
 
    if (header) {
        // 页眉元素 经过转换后在PDF的高度
        theaderHeight = (await toCanvas(header, contentWidth)).height;
    }
 
    // 距离PDF左边的距离,/ 2 表示居中
    const baseX = (A4_WIDTH - contentWidth) / 2; // 预留空间给左边
    // 距离PDF 页眉和页脚的间距, 留白留空
    const baseY = 15;
 
    // 出去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
    const originalPageHeight = A4_HEIGHT - tfooterHeight - theaderHeight - 2 * baseY;
 
    // 元素在网页页面的宽度
    const elementWidth = element.offsetWidth;
 
    // PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度  转化为 距离Canvas顶部的高度
    const rate = contentWidth / elementWidth;
 
    // 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
    const pages = [rate * getElementTop(element)];
 
    // 获取元素距离网页顶部的距离
    // 通过遍历offsetParant获取距离顶端元素的高度值
    function getElementTop(element) {
        let actualTop = element.offsetTop;
        let current = element.offsetParent;
 
        while (current && current !== null) {
            actualTop += current.offsetTop;
            current = current.offsetParent;
        }
        return actualTop;
    }
 
    // 遍历正常的元素节点
    function traversingNodes(nodes) {
        for (let i = 0; i < nodes.length; ++i) {
            const one = nodes[i];
            // 需要判断跨页且内部存在跨页的元素
            const isDivideInside = one.classList && one.classList.contains('divide-inside');
            // 图片元素不需要继续深入,作为深度终点
            const isIMG = one.tagName === 'IMG';
            // table的每一行元素也是深度终点
            const isTableCol = one.classList && one.classList.contains('ant-table-row');
            // 特殊的富文本元素
            const isEditor = one.classList && one.classList.contains('editor');
            // 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
            let { offsetHeight } = one;
            // 计算出最终高度
            let offsetTop = getElementTop(one);
 
            // dom转换后距离顶部的高度
            // 转换成canvas高度
            const top = rate * offsetTop;
 
            // 对于需要进行分页且内部存在需要分页(即不属于深度终点)的元素进行处理
            if (isDivideInside) {
                // 执行位置更新操作
                updatePos(rate * offsetHeight, top, one);
                // 执行深度遍历操作
                traversingNodes(one.childNodes);
            }
            // 对于深度终点元素进行处理
            else if (isTableCol || isIMG) {
                // dom高度转换成生成pdf的实际高度
                // 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
                updatePos(rate * offsetHeight, top, one);
            } else if (isEditor) {
                // 执行位置更新操作
                updatePos(rate * offsetHeight, top, one);
                // 遍历富文本节点
                traversingEditor(one.childNodes);
            }
            // 对于普通元素,则判断是否高度超过分页值,并且深入
            else {
                // 执行位置更新操作
                updateNomalElPos(top);
                // 遍历子节点
                traversingNodes(one.childNodes);
            }
        }
        return;
    }
 
    // 对于富文本元素,观察所得段落之间都是以<p> / <img> 元素相隔,因此不需要进行深度遍历 (仅针对个人遇到的情况)
    function traversingEditor(nodes) {
        // 遍历子节点
        for (let i = 0; i < nodes.length; ++i) {
            const one = nodes[i];
            let { offsetHeight } = one;
            let offsetTop = getElementTop(one);
            const top = (contentWidth / elementWidth) * offsetTop;
            updatePos((contentWidth / elementWidth) * offsetHeight, top, one);
        }
    }
 
    // 普通元素更新位置的方法
    // 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
    function updateNomalElPos(top) {
        if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight) {
            pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight);
        }
    }
 
    // 可能跨页元素位置更新的方法
    // 需要考虑分页元素,则需要考虑两种情况
    // 1. 普通达顶情况,如上
    // 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
    function updatePos(eheight, top, ele) {
        // 如果高度已经超过当前页,则证明可以分页了
        if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
            pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight);
        }
        // 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
        else if (
            top + eheight - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight &&
            top != (pages.length > 0 ? pages[pages.length - 1] : 0)
        ) {
            pages.push(top);
        }
    }
 
    // 深度遍历节点的方法
    traversingNodes(element.childNodes);
    // 可能会存在遍历到底部元素为深度节点,可能存在最后一页位置未截取到的情况
    if (pages[pages.length - 1] + originalPageHeight < height) {
        pages.push(pages[pages.length - 1] + originalPageHeight);
    }
    //console.log({ pages, contentWidth, width,height })
    const firstTop = pages[0] + 1;
    // 根据分页位置 开始分页
    for (let i = 0; i < pages.length; ++i) {
        // ElMessage.success(`共${pages.length}页, 生成第${i + 1}页`);
        // 根据分页位置新增图片
        addImage(baseX, baseY + theaderHeight - pages[i] + firstTop, pdf, data, width, height);
        // 将 内容 与 页眉之间留空留白的部分进行遮白处理
        addBlank(0, theaderHeight, A4_WIDTH, baseY, pdf);
        // 将 内容 与 页脚之间留空留白的部分进行遮白处理
        addBlank(0, A4_HEIGHT - baseY - tfooterHeight, A4_WIDTH, baseY, pdf);
        // 对于除最后一页外,对 内容 的多余部分进行遮白处理
        if (i < pages.length - 1) {
            // 获取当前页面需要的内容部分高度
            const imageHeight = pages[i + 1] - pages[i];
            // 对多余的内容部分进行遮白
            addBlank(0, baseY + imageHeight + theaderHeight, A4_WIDTH, A4_HEIGHT - imageHeight, pdf);
        }
        // 添加页眉
        header && (await addHeader(header, pdf, A4_WIDTH));
        // 添加页脚
        footer && (await addFooter(pages.length, i + 1, footer, pdf, A4_WIDTH));
 
        // 若不是最后一页,则分页
        if (i !== pages.length - 1) {
            // 增加分页
            pdf.addPage();
        }
    }
    const pdfInstance = pdf.save(filename);
    return pdfInstance;
}