前置条件

在当前的Web应用开发中,将页面渲染的内容导出为PDF文件,供用户下载与查看,已成为一项常见且复杂的需求

尤其是当项目规模较大、用户群体庞大时,后端渲染PDF并提供下载链接的方式虽可行,但将消耗大量的服务器资源

因此,寻找一种纯前端实现PDF渲染的解决方案成为了开发中的一大挑战

本文旨在探讨在Vue3框架下,如何利用前端技术实现高效且用户友好的PDF导出功能

项目需求概述

在本项目中,我们面对如下需求:

  1. 技术栈

    使用Vue3构建的Web应用

  2. 用户需求

    用户需求能够在页面上查看各类图表,并提供一键导出功能,将图表渲染为PDF文件,以便用户下载和查看

挑战

  1. 不增加后端负担

    考虑到项目的庞大规模,要求PDF渲染工作完全在前端完成,避免由后端渲染PDF而增加服务器的计算与资源开销

  2. 前端实现

    需要对前端技术方案进行调研,寻找合适的工具与库来实现HTML元素到PDF的转换,同时确保功能的完整性(包括下载、分页及完整节点渲染等)


技术方案

在进行技术选型和调研过程中,我们决定采用html2canvaspdf.js这两个库作为主要技术方案

以下是具体的实施步骤:

  1. 使用html2canvas捕获页面内容

    html2canvas`是一个强大的JavaScript库,它能够将HTML元素“截图”并以Canvas的形式返回

    这一过程完全基于前端完成,无需服务器参与

    使用该库,我们可以将Vue组件渲染的各类图表转换为Canvas对象

    1
    2
    3
    html2canvas(document.querySelector("#chart")).then(canvas => {
    // 操作canvas
    });
  2. 使用pdf.js生成PDF文件

    随后,我们利用pdf.js库将得到的Canvas内容插入到PDF文件中,并实现分页、排版等功能,最终生成供用户下载的PDF文件

    pdf.js`提供了丰富的API来处理PDF生成的各种细节,包括分页控制、内容排版等

    1
    2
    3
    4
    5
    const imgData = canvas.toDataURL('image/png');
    const pdf = new jsPDF();

    pdf.addImage(imgData, 'PNG', 0, 0);
    pdf.save('download.pdf');
  3. 全面展示

    对于那些未直接在浏览器窗口中展现的内容,我们通过适当的DOM操作和排版逻辑,确保所有相关节点都能在最终的PDF文件中得到准确、完整的展示


代码实现

实现步骤

  1. 导入库

    1
    2
    import html2canvas from "html2canvas";
    import jsPDF from "jspdf";
    • html2canvas

      这个库用于将HTML元素渲染为Canvas

      Canvas是一种HTML元素,可以用来绘制图形和图像

    • jsPDF

      这个库用于生成PDF文件

      它提供了多种方法来操作PDF文档,比如添加文本、图像等

  2. 定义常量

    1
    2
    const A4_HEIGHT = 841.89; // A4纸高度
    const A4_WIDTH = 592.28; // A4纸宽度
    • A4_HEIGHTA4_WIDTH

      定义了A4纸张的标准尺寸,以点(pt)为单位

      这些常量将在后续代码中用于计算和设置PDF页面的尺寸

  3. 将HTML元素转换为Canvas

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const toCanvas = async (element, width) => {
    const canvas = await html2canvas(element, {
    scale: window.devicePixelRatio * 3,
    });
    const canvasWidth = canvas.width;
    const canvasHeight = canvas.height;
    const height = (width / canvasWidth) * canvasHeight;
    const canvasData = canvas.toDataURL('image/jpeg', 1.0);
    return { width, height, data: canvasData };
    };
    • element:需要转换的HTML元素
    • width:目标Canvas的宽度
    1. html2canvas(element, { scale: window.devicePixelRatio * 3 }):将HTML元素转换为Canvas。scale参数提高了图像的清晰度
    2. canvas.widthcanvas.height:获取Canvas的宽高
    3. height = (width / canvasWidth) * canvasHeight:计算目标Canvas的高度,以保持宽高比
    4. canvas.toDataURL(‘image/jpeg’, 1.0):将Canvas转换为JPEG格式的Base64字符串
    5. return { width, height, data: canvasData }:返回包含Canvas数据和尺寸的对象
  4. 计算分页信息

    参数

    • nodes:需要计算的HTML节点列表
    • rate:节点尺寸与目标宽度的比例
    • originalPageHeight:页面原始高度
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const getPages = (nodes, rate, originalPageHeight) => {
    let totalHeight = 0;
    const pages = [0];
    nodes.forEach((node, index) => {
    const top = rate * node.offsetTop;
    const height = rate * node.offsetHeight;
    const marginTop = index === 0 ? Math.max(top, 0) : 0;

    if ((top + height - marginTop) > (totalHeight + originalPageHeight)) {
    pages.push(top - marginTop);
    totalHeight += (top - marginTop);
    }
    });
    return pages;
    };

    内部逻辑

    • totalHeight:记录当前页的总高度
    • pages:记录每页的起始位置,初始值为0
    • nodes.forEach((node, index) => { … }):遍历每个节点
      • top:节点的顶部位置,按比例缩放
      • height:节点的高度,按比例缩放
      • marginTop:第一个节点的顶部边距
    • if ((top + height - marginTop) > (totalHeight + originalPageHeight)):如果节点超出了当前页面,添加新的页面
    • pages.push(top - marginTop):记录新页的起始位置
    • totalHeight += (top - marginTop):更新当前页的总高度
  5. 导出PDF

    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
    export async function outputPDF(element, title, contentWidth = 550) {
    if (!(element instanceof HTMLElement)) return;

    const pdf = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'p' });
    const { width, height, data } = await toCanvas(element, contentWidth);
    const baseX = (A4_WIDTH - contentWidth) / 2;
    const baseY = 20;
    const originalPageHeight = A4_HEIGHT - (baseY * 2);
    const rate = contentWidth / element.offsetWidth;
    const pages = getPages(element.childNodes, rate, originalPageHeight);

    pages.forEach((page, i) => {
    pdf.addImage(data, 'JPEG', baseX, baseY - page, width, height);
    pdf.setFillColor(255, 255, 255);
    pdf.rect(0, 0, Math.ceil(A4_WIDTH), Math.ceil(baseY), 'F');

    if (i < pages.length - 1) {
    const imageHeight = pages[i + 1] - page;
    pdf.rect(0, baseY + imageHeight, Math.ceil(A4_WIDTH), Math.ceil(A4_HEIGHT - imageHeight), 'F');
    pdf.addPage();
    }
    });

    return pdf.save(`${title}.pdf`);
    }
    • if (!(element instanceof HTMLElement)) return;:首先检查传入的元素是否是一个有效的HTML元素,如果不是则直接返回
    • const pdf = new jsPDF({ unit: ‘pt’, format: ‘a4’, orientation: ‘p’ });:初始化一个新的PDF文档
      • unit:单位设置为点(pt)
      • format:页面格式设置为A4
      • orientation:页面方向设置为纵向(portrait)
    • const { width, height, data } = await toCanvas(element, contentWidth);:调用toCanvas函数,将HTML元素转换为Canvas,并获取其数据和尺寸
    • const baseX = (A4_WIDTH - contentWidth) / 2;:计算PDF内容的起始X坐标,以使内容居中。
    • const baseY = 20;:设置PDF内容的起始Y坐标
    • const originalPageHeight = A4_HEIGHT - (baseY * 2);:计算页面的可用高度,减去上下边距
    • const rate = contentWidth / element.offsetWidth;:计算节点尺寸与目标宽度的比例
    • const pages = getPages(element.childNodes, rate, originalPageHeight);:调用getPages函数,获取分页信息
  6. 处理每个页面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    pages.forEach((page, i) => {
    pdf.addImage(data, 'JPEG', baseX, baseY - page, width, height);
    pdf.setFillColor(255, 255, 255);
    pdf.rect(0, 0, Math.ceil(A4_WIDTH), Math.ceil(baseY), 'F');

    if (i < pages.length - 1) {
    const imageHeight = pages[i + 1] - page;
    pdf.rect(0, baseY + imageHeight, Math.ceil(A4_WIDTH), Math.ceil(A4_HEIGHT - imageHeight), 'F');
    pdf.addPage();
    }
    });
    • pages.forEach((page, i) => { … }):遍历每个页面
      • pdf.addImage(data, ‘JPEG’, baseX, baseY - page, width, height);:将Canvas图像添加到PDF的当前页面
        • data:Canvas图像的Base64数据
        • baseX:图像的X坐标
        • baseY - page:图像的Y坐标,减去当前页的起始位置
        • width:图像的宽度
        • height:图像的高度
      • pdf.setFillColor(255, 255, 255);:设置填充颜色为白色
      • pdf.rect(0, 0, Math.ceil(A4_WIDTH), Math.ceil(baseY), ‘F’);:在页面顶部绘制一个白色矩形,作为页眉
      • if (i < pages.length - 1):如果不是最后一页,处理下一页
        • const imageHeight = pages[i + 1] - page;:计算当前页的图像高度
        • pdf.rect(0, baseY + imageHeight, Math.ceil(A4_WIDTH), Math.ceil(A4_HEIGHT - imageHeight), ‘F’);:在页面底部绘制一个白色矩形,作为页脚
        • pdf.addPage();:添加新的一页
  7. 保存PDF

    1
    return pdf.save(`${title}.pdf`);
    • pdf.save(${title}.pdf);:将PDF文档保存为指定标题的文件

源码

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
/**
* 导入html2canvas库,用于将HTML元素转换为canvas
* 导入jsPDF库,用于创建PDF文档
*/
import html2canvas from "html2canvas";
import jsPDF from "jspdf";

// 定义A4纸张的标准宽高
const A4_HEIGHT = 841.89; // A4纸高度
const A4_WIDTH = 592.28; // A4纸宽度

/**
* 将给定的HTML元素转换为canvas,并调整其尺寸以适应指定宽度
* @param {HTMLElement} element - 需要转换的HTML元素
* @param {number} width - 输出canvas的目标宽度
* @returns {Promise<{width: number, height: number, data: string}>} - 包含canvas数据和尺寸的对象
*/
const toCanvas = async (element, width) => {
// 使用window.devicePixelRatio提高图像清晰度
const canvas = await html2canvas(element, {
scale: window.devicePixelRatio * 3,
});
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const height = (width / canvasWidth) * canvasHeight;
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
return { width, height, data: canvasData };
};

/**
* 计算HTML节点在特定比例下的分页信息
* @param {NodeList} nodes - 需要计算的HTML节点列表
* @param {number} rate - 节点尺寸与目标宽度的比例
* @param {number} originalPageHeight - 页面原始高度
* @returns {number[]} - 各页顶部位置的数组
*/
const getPages = (nodes, rate, originalPageHeight) => {
let totalHeight = 0;
const pages = [0];
nodes.forEach((node, index) => {
const top = rate * node.offsetTop;
const height = rate * node.offsetHeight;
const marginTop = index === 0 ? Math.max(top, 0) : 0;

// 如果节点超出了当前页面,添加新的页面
if ((top + height - marginTop) > (totalHeight + originalPageHeight)) {
pages.push(top - marginTop);
totalHeight += (top - marginTop);
}
});
return pages;
};

/**
* 将指定的HTML元素导出为PDF文件
* @param {HTMLElement} element - 需要转换为PDF的HTML元素
* @param {string} title - PDF文件的标题
* @param {number} [contentWidth=550] - PDF内容区域的宽度,默认为550
* @returns {Promise<void>} - 保存PDF文件的异步操作
*/
export async function outputPDF(element, title, contentWidth = 550) {
if (!(element instanceof HTMLElement)) return;

// 初始化PDF文档
const pdf = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'p' });
const { width, height, data } = await toCanvas(element, contentWidth);
const baseX = (A4_WIDTH - contentWidth) / 2; // PDF内容的起始X坐标
const baseY = 20; // PDF内容的起始Y坐标
const originalPageHeight = A4_HEIGHT - (baseY * 2); // 页面可用高度
const rate = contentWidth / element.offsetWidth; // 节点尺寸与目标宽度的比例
const pages = getPages(element.childNodes, rate, originalPageHeight); // 获取分页信息

// 处理每个页面
pages.forEach((page, i) => {
// 添加图片到PDF
pdf.addImage(data, 'JPEG', baseX, baseY - page, width, height);
// 添加页眉(白色背景)
pdf.setFillColor(255, 255, 255);
pdf.rect(0, 0, Math.ceil(A4_WIDTH), Math.ceil(baseY), 'F');

// 如果不是最后一页,处理下一页
if (i < pages.length - 1) {
const imageHeight = pages[i + 1] - page;
pdf.rect(0, baseY + imageHeight, Math.ceil(A4_WIDTH), Math.ceil(A4_HEIGHT - imageHeight), 'F');
pdf.addPage();
}
});

// 保存PDF文件
return pdf.save(`${title}.pdf`);
}

思路讲解

  1. 导入必要的库:使用 html2canvas 将 HTML 元素转换为 Canvas,使用 jsPDF 生成 PDF 文件
  2. 定义A4纸张的标准尺寸:设置 A4 纸张的宽度和高度
  3. 将HTML元素转换为Canvas:使用 toCanvas 函数将 HTML 元素转换为 Canvas,并获取其数据和尺寸
  4. 计算分页信息:使用 getPages 函数计算内容在 PDF 中的分页信息
  5. 生成PDF文件
    • 初始化一个新的 PDF 文档
    • 遍历每一页,将 Canvas 图像添加到 PDF 中
    • 为每一页添加页眉和页脚
    • 保存生成的 PDF 文件