基于NodeJS生成PDF文件

环境:NodeJS v8+

安装依赖

1
2
3
4
# jspdf 是主依赖,其它两个可根据情况选择;
# jspdf-autotable 用于绘制表格;
# jspdf-autotext 用于添加文本,实现自动换行等;
npm i --save jspdf jspdf-autotable jspdf-autotext

引入依赖和初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
// 推荐这种引入方式
import jsPDF from 'jspdf';
// 虽然官方仓库写的是这种,但实际使用过程中却遇到了一些问题,自己可以测试一下
// 比如提示某些方法不存在等
// import { jsPDF } from "jspdf";

// 初始化示例,这里用的是横向A4纸,并且开启压缩
// 构造参数说明如下
// orientation?: "p" | "portrait" | "l" | "landscape",
// unit?: "pt" | "px" | "in" | "mm" | "cm" | "ex" | "em" | "pc",
// format?: string | number[],
// compressPdf?: boolean
const pdfDoc = new jsPDF('l', 'mm', [ 210, 297 ], true);

添加文本

下面 jsPDF 默认添加文字的方法

1
2
3
4
5
6
7
8
9
10
11
12
// 参数:要绘制的文字,x轴坐标,y轴坐标
pdfDoc.text('Hello World!', 10, 10);

// 另一种写法
pdfDoc.text({
text: 'Hello World!',
x: 10,
y: 10
options: {
// 可以对文字追加一些样式等
}
});

这里推荐基于 jsPDF 的一个小插件 jspdf-autotext,目前主要是解决了自动换行问题,不用这个插件的话,需要自己去计算处理。

使用方法参考如下代码(确保在步骤一已经安装了这个依赖):

1
2
3
4
5
6
// 构造插件对象,参考文档:https://github.com/MarioJames/jsPDF-AutoText
// 这里传入的 pdfDoc 是前面初始化的 jsPDF 对象
const autoText = new jsPDFAutoText({ pdfFile: pdfDoc });

// 添加文本,自动换行
autoText.render('Hello World!');

这个小插件虽然解决了自动换行,但是还没解决文本居中问题,需要参考附录2:解决文字居中问题自行处理。

添加图片

1
2
3
4
5
6
7
8
9
10
11
12
13
// 读取图片文件,也可以用 Base64
const imageBuffer = await fse.readFileSync('/path/to/image')

// 根据需要调整不同参数
pdfDoc.addImage({
imageData: imageBuffer,
x: 30,
y: 30,
width: 50,
height: 50,
compression: "FAST",
format: "PNG"
})

添加表格

这里我们借助 jspdf-autotable 插件,下面是一个示例代码,列出可能用到的大部分属性,更详细的使用方式请参考官方仓库中的示例:examples.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pdfDoc.autoTable( {
theme: "grid",
startY: 10,
margin: { },
styles: { }, // 表格整体样式
tableWidth: 'wrap',
rowPageBreak: "avoid",
// head: options.head || [[]],
headStyles: { }, // 表头样式
body: [[]], // 表格主体数据
bodyStyles: { textColor: [51, 51, 51] },
columns: [], // 表格主体数据映射:字段名、列名
columnStyles: {}, // 表格列样式
foot: [], // 页脚数据
showFoot: 'lastPage',
footStyles: { }, // 页脚样式
willDrawPage: (data) => { },
didParseCell: (data) => { },
didDrawPage: (data) => { },
})

保存

1
2
// 把绘制好的 PDF 文件保存到指定目录下,注意目录权限问题
pdfDoc.save('/path/to/pdf')

附录1:解决中文乱码问题

首先需要找一款适合你的中文字体:建议不要体积太大,满足使用即可,否则生成的 PDF 文件也会比较大。

其次对字体进行转换:可以使用 jsPDF 官方仓库 /fontconverter 目录下的 fontconverter.html,或直接使用在线工具 /fontconverter/fontconverter.html

转换的目的是为了更好的引入 jsPDF,关于这一块儿的说明参考:#use-of-unicode-characters–utf-8

转换好之后,我们会得到一个 js 文件,里面包含了这个字体文件的 Base64 数据,下一步修改代码:

1
2
3
4
5
6
7
import { fontString } from "./pdf-font-hei";

// ....

pdfDoc.addFileToVFS("hei.ttf", fontString);
pdfDoc.addFont("hei.ttf", "hei", "normal");
pdfDoc.setFont('hei', 'normal');

附录2:解决文字居中问题

核心是设置好字体大小,通过一些内置方法计算出内容的宽度,然后添加缩进,并结合自动换行,具体代码参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const str = '要绘制的文字';

// 先设置一下要绘制文字的字体大小
pdfDoc.setFontSize(14);

// 计算页面可容纳内容的最大宽度
const pageSize = this.pdfDoc.internal.pageSize;
const pageWidth = pageSize.width ? pageSize.width : pageSize.getWidth;
const contentMaxWidth = pageWidth - (this.defaultStartX * 2);

// 调整一下插件的默认配置项
autoText.updateConfig({
INITIAL_POSITION_X: 5, // 每一行文字的起始 X 坐标,默认值 10
INITIAL_POSITION_Y: 10, // 每一行文字的起始 Y 坐标,默认值 10
TEXT_LINE_MAX_WIDTH: pageWidth - (5 * 2), // 每一行文字的最大宽度,超过换行,默认值 190
EVERY_INDENT_WIDTH: 1, // 每一个缩进的缩进量,默认值 5
});

// 绘制文字,通过 indent 控制缩进量;
// 如果计算的结果不准确,可以再次微调这个值,直到相对精准的居中
autoText.render({
text: str,
indent: Math.round((contentMaxWidth - pdfDoc.getTextWidth(str)) / 10)
});

附录3:完整代码参考

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
import jsPDF from 'jspdf';
import 'jspdf-autotable';
import { AutoText as jsPDFAutoText } from 'jspdf-autotext';
import { fontString } from "./pdf-font";

// 初始化
const pdfDoc = new jsPDF('l', 'mm', [ 210, 297 ], true);

// 处理中文乱码
pdfDoc.addFileToVFS("hei.ttf", fontString);
pdfDoc.addFont("hei.ttf", "hei", "normal");
pdfDoc.setFont('hei', 'normal');

// 添加一个标题
const autoText = new jsPDFAutoText({ pdfFile: pdfDoc });
autoText.render({ text: '这是一个标题', indent: 10 });

// 总页码占位符
const totalPagesExp = '{total_pages_count_string}';

// 添加一个表格
pdfDoc.autoTable( {
theme: "grid",
startY: 10,
margin: { },
styles: {
font: 'hei', // 这里指定处理中文乱码时添加的字体,否则表格绘制时也会出现乱码
fontSize: 8,
valign: 'middle',
lineColor: [0, 51, 51]
}, // 表格整体样式
tableWidth: 'wrap',
rowPageBreak: "avoid",
// head: options.head || [[]],
headStyles: {
valign: 'middle',
halign: 'center',
fontStyle: 'bold',
lineWidth: 0.1,
fillColor: [119, 119, 119]
}, // 表头样式
body: [
{ name: '小A', age: 22, address: '地址1' },
{ name: '小B', age: 25, address: '地址2' },
{ name: '小C', age: 27, address: '地址3' },
], // 表格主体数据
bodyStyles: { textColor: [51, 51, 51] },
columns: [
{ dataKey: 'name', header: '姓名' },
{ dataKey: 'age', header: '年龄' },
{ dataKey: 'address', header: '性别' }
], // 表格主体数据映射:字段名、列名
columnStyles: {
name: { halign: 'left', cellWidth: 100 },
age: { halign: 'center', cellWidth: 20 },
address: { halign: 'right', cellWidth: 200 },
}, // 表格列样式
foot: [
[
{ content: '合计', colSpan: 2 , styles: { halign: 'center' } },
'这是合计后面的表格,要显示的值'
]
], // 页脚数据
showFoot: 'lastPage',
footStyles: {
fillColor: [255, 255, 255],
textColor: [51, 51, 51],
fontStyle: 'bold',
lineWidth: 0.1,
lineColor: [0, 51, 51],
halign: 'right'
}, // 页脚样式
willDrawPage: (data) => { },
didParseCell: (data) => { },
didDrawPage: (data) => {
// 添加页码
let str = 'Page ' + pdfDoc.getNumberOfPages();
if (typeof doc.putTotalPages === 'function') {
str = str + ' of ' + totalPagesExp
}
pdfDoc.setFontSize(10)

// jsPDF 1.4+ uses getHeight, <1.4 uses .height
var pageSize = pdfDoc.internal.pageSize
var pageHeight = pageSize.height ? pageSize.height : pageSize.getHeight()
pdfDoc.text(str, 10, pageHeight - 10)
},
})

// 替换总页码
if (typeof doc.putTotalPages === 'function') {
pdfDoc.putTotalPages(totalPagesExp)
}

// 保存
pdfDoc.save('/home/dev/pdf/download/test.pdf');