为了账号安全,请及时绑定邮箱和手机立即绑定

在 NodeJs/NestJs 中动态生成 PDF 文件

在我的实习期间,我被分配了一个任务,需要解决如何在后端动态生成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 {}

简单的说明一下正在发生的一切:

  1. 我们手动管理指针位置。
  2. 虽然我已将边距设为固定值,但你可以很容易地使它们动态化。
  3. 当客户端请求 GET /pdf 路由时,会从数据库中提取组织详情并生成报告。
  4. 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):

await this.pdfService.addNewLine\(\);

目录自动生成,页码也加对了。

我们也可以通过jsPDF的this.doc.internal.pageSize.getHeight()和getWidth()方法来动态更改大小。

最后的话。

我认为这还算不错,但远非最佳。我首先想到的一个问题是,我们最初为目录留了一整页,如果放不下怎么办?我还没这么做,因为目前这满足了我的需求,但如果要做这个改动也不难。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消