在我的实习期间,我被分配了一个任务,需要解决如何在后端动态生成PDF的方法。我听说前端团队已经尝试过了,但没有成功。我的第一想法是这似乎是一个很常见的需求,应该有很多现成的库可用。但看起来我失算了。
以下是在PDF中需要动态生成的内容:
-> 图表
-> 数据表
-> 插入图片
-> 生成目录和自动页码
我试用了多个PDF生成库(如pdfkit等)。它们都没有提供你期待的PDF生成库应有的最基本功能。
我的最终选择是jsPDF,最终选择它的原因是两点:
1. 一个人并不会仅仅在PDF里写文字(因为其他库功能不足)
2. 它有一个叫做jspdf-autotable的扩展库,可以让用户生成表格(而是直接生成表格,而不是插入图片)
用yarn或npm添加下面这些库。
yarn add jspdf (@types/jspdf 为 jspdf 的类型定义文件) tjspdf-autotable canvas // 这些是用于 Node.js 环境中的包名,常用于生成 PDF 和处理 canvas 渲染的 Web 应用程序。注意,"yarn add" 是使用 Yarn 包管理器添加依赖的命令。
如果安装 CANVAS 失败:
安装过程中,canvas 出现了一些错误。我在网上找到了一些信息,了解到 canvas 与 Apple M 系列芯片存在一些兼容性问题。解决方法:从源代码构建安装。查看如何从源代码构建的 文档。另外,canvas 无法正确识别 puppeteer 的路径,所以我还需要对其进行一些调整。我在 Docker 中进行了一些调试后,能够像这样从源代码构建:
...
# 为画布编译所需的系统依赖项
RUN apt-get update && \
apt-get install -y \
build-essential \
libcairo2-dev \
libpango1.0-dev \
libjpeg-dev \
libgif-dev \
librsvg2-dev \
python3 \
python3-pip \
chromium \
git && \
rm -rf /var/lib/apt/lists/*
# 检查Python的setuptools是否已安装
RUN pip3 show setuptools || true
# 若未安装setuptools,则安装之
RUN apt-get install python3-setuptools
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
ENV CHROMIUM_PATH /usr/bin/chromium
...
这是一段 NestJs 的代码,应该可以在任何 NodeJs 环境中轻松运行。
// pdf.module.ts
import { Module } from '@nestjs/common';
import { PdfService } from './pdf.service';
@Module({
providers: [PdfService],
exports: [PdfService],
})
export class PdfModule {}
简单的说明一下正在发生的一切:
- 我们手动管理指针位置。
- 虽然我已将边距设为固定值,但你可以很容易地使它们动态化。
- 当客户端请求 GET /pdf 路由时,会从数据库中提取组织详情并生成报告。
- PDF 会被生成、保存、发送给客户端,然后从内存中清理。
// pdf.interface.ts
import { TextOptionsLight } from 'jspdf';
import { UserOptions } from 'jspdf-autotable';
export interface index {
Index: string;
Page: number;
}
export interface TableOptions extends UserOptions {
ignoreFields?: string[];
tableName: string;
addToIndex?: boolean;
}
export interface TextOptions extends TextOptionsLight {
x?: number;
y?: number;
addToIndex?: boolean;
}
这里定义了一些功能和实例变量:
-> xMargin, yMargin:分别表示水平和垂直间距
-> indexData:保存要添加到目录的信息
-> x, y:当前指针在二维表格中的位置
- updatePointer(): 在添加每项内容(如文本、图片等)之后,我们将指针移动到新的正确位置(即新的 x 和 y 坐标)。
- addNewPage() :在 PDF 中添加新页面并将指针移至页面的起始位置。
- addImage(imageData: Buffer, options?: any): 接收一个图片的缓冲区并将其添加到 PDF 中。可以通过选项调整图片尺寸,默认为填充整个页面。也可以通过选项的 options.x 和 options.y 指定图片的具体位置,默认为当前指针位置。
- addGenericTable <T>(dataArr: T[], options: TableOptions): 接收一个对象数组并生成表格。TableOptions 包括三个新的选项:
ignoreFields? :不希望在表格中显示的字段。例如,我正在打印一个用户表。我有一个 User 对象数组:User[],但是用户对象中还有一个密码字段,我不希望它出现在表格中,因此我在此处传入 ['password']。
tableName :表格的名称
addToIndex? :是否希望该表格出现在目录页中。 - addText(text: string, options?: TextOptions) :添加文本,选项中有一个 addToIndex? 字段,与 addGenericTable() 类似。
- addNewLine() : 用于换行,可能用于分段。
- render() : 完成 PDF 的编写并进行渲染。
// pdf.service.ts
import { Injectable } from '@nestjs/common';
import { TextOptionsLight, jsPDF } from 'jspdf';
import autoTable, { UserOptions } from 'jspdf-autotable';
import { TableOptions, TextOptions, index } from './pdf.interface';
@Injectable()
export class PdfService {
private doc: jsPDF;
private readonly filePath = './output.pdf';
private readonly xMargin = 20;
private readonly yMargin = 30;
private indexData: index[] = [];
private x: number;
private y: number;
private defaultTableOptions: TableOptions = {
tableName: '默认表格名称',
ignoreFields: [],
addToIndex: false,
};
constructor() {
this.doc = new jsPDF({ orientation: 'p', unit: 'pt', format: 'a4' });
this.resetXandY();
this.updatePointer();
}
private resetXandY() {
this.x = this.xMargin;
this.y = this.yMargin;
}
private updatePointer() {
this.doc.moveTo(this.x, this.y);
}
async addNewPage() {
this.doc.addPage();
this.resetXandY();
this.updatePointer();
}
// 在位置 (x, y) 添加图像,宽度和高度分别为默认值或指定值
async addImage(imageData: Buffer, options?: any) {
this.doc.addImage(
imageData,
'JPEG',
options?.x || this.x,
options?.y || this.y,
options?.width || this.doc.internal.pageSize.getWidth(),
options?.height || this.doc.internal.pageSize.getHeight(),
);
this.y = options?.height || this.doc.internal.pageSize.getHeight() + this.doc.getLineHeight();
this.updatePointer();
}
async addGenericTable<T>(dataArr: T[], options: TableOptions) {
if (dataArr.length === 0) {
console.error('数据数组为空,无法继续');
return;
}
const mergedOptions: TableOptions = {
...this.defaultTableOptions,
...options,
startY: this.y + this.doc.getLineHeight(),
};
this.addText(`${mergedOptions.tableName}`);
if (mergedOptions.addToIndex) {
this.indexData.push({
Index: mergedOptions.tableName,
Page: this.doc.getCurrentPageInfo().pageNumber,
});
}
const headers = Object.keys(dataArr[0]).filter(
(key) => !mergedOptions.ignoreFields?.includes(key),
);
const transformedData = dataArr.map((item: any, index) =>
headers.map((key: string) =>
item[key] instanceof Date ? item[key].toISOString() : item[key],
),
);
autoTable(this.doc, {
head: [headers],
body: transformedData,
didDrawCell: (data) => {},
...mergedOptions,
});
this.y = (this.doc as any).lastAutoTable.finalY + this.doc.getLineHeight();
this.updatePointer();
}
async addText(text: string, options?: TextOptions) {
const lines = this.doc.splitTextToSize(
text,
this.doc.internal.pageSize.width - this.xMargin * 2,
);
if (options?.addToIndex) {
this.indexData.push({
Index: text,
Page: this.doc.getCurrentPageInfo().pageNumber,
});
}
console.log(`写入文本 '${text}' 前的位置是 ${this.x} & ${this.y}`);
this.doc.text(
lines,
options?.x || this.x,
options?.y || this.y,
);
this.y = this.doc.getTextDimensions(lines).h + this.doc.getLineHeight();
this.updatePointer();
}
async addNewLine() {
this.y += this.doc.getLineHeight();
this.x = this.xMargin;
this.updatePointer();
}
async render(): Promise<string> {
await this.addPageNumbers();
await this.index();
return new Promise<string>((resolve, reject) => {
this.doc.save(this.filePath);
resolve(this.filePath);
});
}
private async addPageNumbers() {
const pageCount = (this.doc as any).internal.getNumberOfPages(); // 总页数
for (let i = 0; i < pageCount; i++) {
this.doc.setPage(i);
const pageCurrent = (this.doc as any).internal.getCurrentPageInfo()
.pageNumber; // 当前页
this.doc.setFontSize(12);
this.doc.text(
'页码: ' + pageCurrent + '/' + pageCount,
this.xMargin,
this.doc.internal.pageSize.height - this.yMargin / 2,
);
}
}
private async index() {
this.doc.setPage(2);
this.resetXandY();
this.updatePointer();
await this.addGenericTable(this.indexData, {
tableName: `目录表`,
theme: 'grid',
});
}
}
// org.controller.ts (仅展示部分内容)
@ApiTags('Organization')
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth('jwt')
@Controller('组织')
export class OrgController {
constructor(private readonly orgService: OrgService) {}
@Get('pdf')
@ApiOperation({ summary: '发送邮件报告' })
async generatePdf(@GetOrg() org: Org, @Res() res: Response) {
let filePath: any;
try {
filePath = await this.orgService.createPdfInOneFile(org.id);
res.setHeader('Content-disposition', 'attachment; filename=output.pdf'); // 设置下载文件名
res.setHeader('Content-type', 'application/pdf'); // 设置内容类型为PDF
res.sendFile(filePath, { root: process.cwd() }, (err) => {
if (err) {
console.error(err);
}
fs.unlinkSync(filePath); // 删除临时PDF文件
});
} catch (err) {
console.log('生成PDF出错:', err);
throw err;
}
}
}
//org.service.ts (仅显示部分内容)
@Injectable()
export class OrgService {
constructor(
@InjectRepository(Org)
private orgRepository: Repository<Org>,
private userService: UserService,
private pdfService: PdfService,
private visualizationService: VisualizationService,
) {}
async createPdfInOneFile(orgId: string): Promise<any> {
const usersData = await this.findAllUsersOfOrg(orgId);
const chart = await this.visualizationService.createChart();
await this.pdfService.addText(`Heading`);
await this.pdfService.addNewLine(); // 留空一行
await this.pdfService.addText(`副标题:`);
await this.pdfService.addNewLine();
// 留一张空白页作为目录
await this.pdfService.addNewPage();
await this.pdfService.addNewPage();
await this.pdfService.addGenericTable(usersData, {
ignoreFields: ['password', 'otp', 'otpCreatedAt', 'lastPasswordUpdateAt'],
tableName: '用户表格',
addToIndex: true, // 添加到目录
theme: 'grid',
});
// 更改忽略字段,表格的大小会自动调整。请参考pdf中的图片
await this.pdfService.addGenericTable(usersData, {
ignoreFields: ['password', 'otp', 'otpCreatedAt', 'lastPasswordUpdateAt', 'createdAt'],
tableName: '用户2表格',
addToIndex: true, // 添加到目录
theme: 'grid',
});
await this.pdfService.addNewPage();
await this.pdfService.addText(`结尾页`, {
align: 'center',
});
this.pdfService.addImage(chart, { width: 200, height: 200 });
return await this.pdfService.render();
}
}
关于PDF生成的部分就是这样。现在我们来探讨一下如何生成图表作为图像并将其添加到PDF中。我为此编写了一个可视化服务。它使用canvas,然后是pupeeter。工作原理是启动一个pupeeter实例来生成图表并截图。虽然这听起来可能有点笨拙,但这是目前唯一的可行方法。
import { Injectable } from '@nestjs/common';
import { createCanvas } from 'canvas';
import * as echarts from 'echarts';
import puppeteer from 'puppeteer';
@Injectable()
export class VisualizationService {
// 目前我生成图表时并未使用任何实际数据,只是采用了固定的数值。
async createChart(data?): Promise<Buffer> {
const echartOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: [
{
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
axisTick: {
alignWithLabel: '与标签对齐',
},
},
],
yAxis: [
{
type: 'value',
},
],
series: [
{
name: 'Direct',
type: 'bar',
barWidth: '条形宽度',
data: [10, 52, 200, 334, 390, 330, 220],
},
],
};
const canvas = createCanvas(600, 600);
const chart = echarts.init(canvas as any);
chart.setOption(echartOption);
return canvas.toBuffer('image/png');
}
}
示例图片(来自生成的PDF):
目录自动生成,页码也加对了。
我们也可以通过jsPDF的this.doc.internal.pageSize.getHeight()和getWidth()方法来动态更改大小。
最后的话。
我认为这还算不错,但远非最佳。我首先想到的一个问题是,我们最初为目录留了一整页,如果放不下怎么办?我还没这么做,因为目前这满足了我的需求,但如果要做这个改动也不难。
共同学习,写下你的评论
评论加载中...
作者其他优质文章