PDF Generation with PDFKit: Invoices, Reports, and Certificates

Generate professional PDFs with PDFKit in Node.js. Covers invoice templates, tables, headers, footers, fonts, images, streaming, and NestJS integration for production PDF generation.

E
ECOSIRE Research and Development Team
|19 de março de 202610 min de leitura2.3k Palavras|

Geração de PDF com PDFKit: faturas, relatórios e certificados

A geração de PDF é um daqueles recursos que parece simples até você começar a implementá-lo. Tabelas dinâmicas que transbordam as páginas corretamente, texto multilíngue com suporte RTL em árabe, numeração de páginas adequada, cabeçalhos e rodapés consistentes nas páginas, logotipos incorporados – tudo isso requer um mecanismo de layout que lide com a complexidade para você. PDFKit é uma biblioteca de geração de PDF Node.js de baixo nível, mas poderosa, que oferece controle preciso sobre cada elemento.

Este guia cobre padrões PDFKit para geração de PDF de produção no NestJS: modelos de faturas, tabelas de dados com paginação, layouts de várias colunas, fontes personalizadas, incorporação de imagens e entrega de streaming - até o envio por e-mail do PDF gerado como anexo.

Principais conclusões

  • PDFKit gera PDFs programaticamente em Node.js — sem navegador, sem Puppeteer, sem conversão de HTML para PDF
  • Sempre armazene o PDF em buffer até a conclusão antes de enviá-lo — não transmita um PDF incompleto para o cliente
  • Incorpore fontes via registerFont() para renderização consistente entre plataformas — nunca confie em fontes do sistema
  • Documentos com várias páginas exigem detecção manual de quebra de página: verifique a posição doc.y em relação a doc.page.height
  • Para texto árabe e RTL, use uma fonte árabe adequada (Noto Sans Arabic) e administre a direção do texto manualmente
  • Transmita PDFs para AWS S3 e retorne um URL pré-assinado — não passe buffers grandes por meio de sua API
  • Use doc.pipe(fs.createWriteStream()) para teste; doc.pipe(res) para streaming HTTP; buffer para anexos de e-mail
  • Adicione metadados PDF/A para conformidade de arquivamento: campos Title, Author, Creator, Producer

Instalação

pnpm add pdfkit
pnpm add -D @types/pdfkit

Para suporte de imagens (PNG, JPEG) e subconjuntos de fontes, nenhum pacote adicional é necessário – o PDFKit lida com eles nativamente.


Estrutura básica da fatura

// src/modules/billing/pdf/invoice.generator.ts
import PDFDocument from 'pdfkit';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';

interface InvoiceData {
  invoiceNumber: string;
  date: Date;
  dueDate: Date;
  customer: {
    name: string;
    email: string;
    address: string;
    city: string;
    country: string;
  };
  items: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
    total: number;
  }>;
  subtotal: number;
  tax: number;
  total: number;
  currency: string;
  notes?: string;
}

@Injectable()
export class InvoiceGenerator {
  private readonly primaryColor = '#0f172a';
  private readonly accentColor  = '#f59e0b';
  private readonly mutedColor   = '#64748b';
  private readonly borderColor  = '#e2e8f0';

  constructor(private config: ConfigService) {}

  async generate(data: InvoiceData): Promise<Buffer> {
    return new Promise((resolve, reject) => {
      const buffers: Buffer[] = [];
      const doc = new PDFDocument({
        size: 'A4',
        margin: 50,
        info: {
          Title:    `Invoice ${data.invoiceNumber}`,
          Author:   'ECOSIRE Private Limited',
          Creator:  'ECOSIRE Billing System',
          Producer: 'PDFKit',
        },
      });

      // Collect chunks into buffer (do not stream incomplete PDFs)
      doc.on('data', (chunk: Buffer) => buffers.push(chunk));
      doc.on('end', () => resolve(Buffer.concat(buffers)));
      doc.on('error', reject);

      this.renderHeader(doc, data);
      this.renderCustomerSection(doc, data);
      this.renderItemsTable(doc, data);
      this.renderTotals(doc, data);
      if (data.notes) this.renderNotes(doc, data.notes);
      this.renderFooter(doc, data);

      doc.end();
    });
  }

  private renderHeader(doc: PDFKit.PDFDocument, data: InvoiceData): void {
    // Company logo
    const logoPath = path.join(process.cwd(), 'public', 'logo-dark.png');
    if (fs.existsSync(logoPath)) {
      doc.image(logoPath, 50, 45, { width: 120 });
    } else {
      doc
        .fontSize(18)
        .fillColor(this.primaryColor)
        .font('Helvetica-Bold')
        .text('ECOSIRE', 50, 50);
    }

    // Invoice label and number
    doc
      .fontSize(28)
      .fillColor(this.accentColor)
      .font('Helvetica-Bold')
      .text('INVOICE', 0, 50, { align: 'right' });

    doc
      .fontSize(10)
      .fillColor(this.mutedColor)
      .font('Helvetica')
      .text(`#${data.invoiceNumber}`, 0, 82, { align: 'right' });

    // Divider line
    doc
      .moveTo(50, 110)
      .lineTo(545, 110)
      .lineWidth(1)
      .strokeColor(this.borderColor)
      .stroke();

    doc.moveDown(2);
  }

  private renderCustomerSection(doc: PDFKit.PDFDocument, data: InvoiceData): void {
    const startY = 130;

    // Billed To
    doc
      .fontSize(8)
      .fillColor(this.mutedColor)
      .font('Helvetica-Bold')
      .text('BILLED TO', 50, startY)
      .fontSize(11)
      .fillColor(this.primaryColor)
      .text(data.customer.name, 50, startY + 16)
      .fontSize(9)
      .fillColor(this.mutedColor)
      .font('Helvetica')
      .text(data.customer.email, 50, startY + 32)
      .text(data.customer.address, 50, startY + 46)
      .text(`${data.customer.city}, ${data.customer.country}`, 50, startY + 60);

    // Invoice dates (right column)
    const dateRows = [
      ['Invoice Date:', this.formatDate(data.date)],
      ['Due Date:',     this.formatDate(data.dueDate)],
      ['Currency:',     data.currency],
    ];

    let dateY = startY;
    for (const [label, value] of dateRows) {
      doc
        .fontSize(9)
        .fillColor(this.mutedColor)
        .font('Helvetica')
        .text(label, 350, dateY, { width: 90 });
      doc
        .fontSize(9)
        .fillColor(this.primaryColor)
        .font('Helvetica-Bold')
        .text(value, 445, dateY, { width: 100, align: 'right' });
      dateY += 18;
    }

    doc.moveDown(5);
  }

  private renderItemsTable(doc: PDFKit.PDFDocument, data: InvoiceData): void {
    const tableTop = 265;
    const colWidths = { desc: 230, qty: 60, unitPrice: 90, total: 90 };
    const startX = 50;

    // Table header background
    doc
      .rect(startX, tableTop, 495, 22)
      .fillColor(this.primaryColor)
      .fill();

    // Header text
    doc
      .fontSize(9)
      .fillColor('#ffffff')
      .font('Helvetica-Bold')
      .text('DESCRIPTION', startX + 8, tableTop + 6, { width: colWidths.desc })
      .text('QTY', startX + colWidths.desc + 8, tableTop + 6, { width: colWidths.qty, align: 'center' })
      .text('UNIT PRICE', startX + colWidths.desc + colWidths.qty + 8, tableTop + 6, { width: colWidths.unitPrice, align: 'right' })
      .text('TOTAL', startX + colWidths.desc + colWidths.qty + colWidths.unitPrice + 8, tableTop + 6, { width: colWidths.total, align: 'right' });

    let rowY = tableTop + 28;
    const fmt = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: data.currency,
    });

    for (const [index, item] of data.items.entries()) {
      // Alternating row background
      if (index % 2 === 0) {
        doc
          .rect(startX, rowY - 4, 495, 24)
          .fillColor('#f8fafc')
          .fill();
      }

      doc
        .fontSize(9)
        .fillColor(this.primaryColor)
        .font('Helvetica')
        .text(item.description, startX + 8, rowY, { width: colWidths.desc })
        .text(String(item.quantity), startX + colWidths.desc + 8, rowY, { width: colWidths.qty, align: 'center' })
        .text(fmt.format(item.unitPrice), startX + colWidths.desc + colWidths.qty + 8, rowY, { width: colWidths.unitPrice, align: 'right' })
        .text(fmt.format(item.total), startX + colWidths.desc + colWidths.qty + colWidths.unitPrice + 8, rowY, { width: colWidths.total, align: 'right' });

      rowY += 24;

      // Page break check
      if (rowY > doc.page.height - 150) {
        doc.addPage();
        rowY = 50;
      }
    }

    // Table bottom border
    doc
      .moveTo(startX, rowY + 4)
      .lineTo(545, rowY + 4)
      .lineWidth(1)
      .strokeColor(this.borderColor)
      .stroke();

    doc.y = rowY + 20;
  }

  private renderTotals(doc: PDFKit.PDFDocument, data: InvoiceData): void {
    const fmt = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: data.currency,
    });

    const totalsX = 350;
    let currentY = doc.y + 10;

    const rows = [
      ['Subtotal', fmt.format(data.subtotal), false],
      [`Tax (${((data.tax / data.subtotal) * 100).toFixed(0)}%)`, fmt.format(data.tax), false],
      ['Total Due', fmt.format(data.total), true],
    ];

    for (const [label, value, isTotal] of rows) {
      if (isTotal) {
        doc
          .rect(totalsX, currentY - 4, 195, 28)
          .fillColor(this.accentColor)
          .fill();
        doc
          .fontSize(11)
          .fillColor('#000000')
          .font('Helvetica-Bold')
          .text(label as string, totalsX + 8, currentY + 2, { width: 90 })
          .text(value as string, totalsX + 98, currentY + 2, { width: 89, align: 'right' });
      } else {
        doc
          .fontSize(9)
          .fillColor(this.mutedColor)
          .font('Helvetica')
          .text(label as string, totalsX + 8, currentY, { width: 90 })
          .fillColor(this.primaryColor)
          .text(value as string, totalsX + 98, currentY, { width: 89, align: 'right' });
      }
      currentY += 28;
    }

    doc.y = currentY + 10;
  }

  private renderNotes(doc: PDFKit.PDFDocument, notes: string): void {
    doc.moveDown(2);
    doc
      .fontSize(9)
      .fillColor(this.mutedColor)
      .font('Helvetica-Bold')
      .text('NOTES', 50, doc.y);
    doc
      .fontSize(9)
      .fillColor(this.primaryColor)
      .font('Helvetica')
      .text(notes, 50, doc.y + 12, { width: 495 });
  }

  private renderFooter(doc: PDFKit.PDFDocument, _data: InvoiceData): void {
    const footerY = doc.page.height - 60;

    doc
      .moveTo(50, footerY - 10)
      .lineTo(545, footerY - 10)
      .lineWidth(1)
      .strokeColor(this.borderColor)
      .stroke();

    doc
      .fontSize(8)
      .fillColor(this.mutedColor)
      .font('Helvetica')
      .text(
        'ECOSIRE Private Limited  |  [email protected]  |  ecosire.com',
        50,
        footerY,
        { align: 'center', width: 495 }
      );
  }

  private formatDate(date: Date): string {
    return new Intl.DateTimeFormat('en-US', {
      year: 'numeric', month: 'long', day: 'numeric',
    }).format(date);
  }
}

Integração do controlador NestJS

// src/modules/billing/billing.controller.ts
@Controller('billing')
export class BillingController {
  constructor(
    private readonly billingService: BillingService,
    private readonly invoiceGenerator: InvoiceGenerator,
    private readonly emailService: EmailService,
  ) {}

  @Get('invoices/:id/pdf')
  async downloadInvoice(
    @Param('id') id: string,
    @Req() req: AuthenticatedRequest,
    @Res() res: Response
  ): Promise<void> {
    const invoice = await this.billingService.getInvoice(id, req.user.organizationId);

    if (!invoice) {
      throw new NotFoundException('Invoice not found');
    }

    const pdfBuffer = await this.invoiceGenerator.generate({
      invoiceNumber: invoice.number,
      date: invoice.createdAt,
      dueDate: invoice.dueDate,
      customer: {
        name: req.user.name,
        email: req.user.email,
        address: invoice.billingAddress,
        city: invoice.billingCity,
        country: invoice.billingCountry,
      },
      items: invoice.lineItems,
      subtotal: invoice.subtotal,
      tax: invoice.tax,
      total: invoice.total,
      currency: invoice.currency,
    });

    res.set({
      'Content-Type':        'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
      'Content-Length':      pdfBuffer.length,
      'Cache-Control':       'private, no-cache',
    });

    res.send(pdfBuffer);
  }

  @Post('invoices/:id/email')
  async emailInvoice(
    @Param('id') id: string,
    @Req() req: AuthenticatedRequest
  ) {
    const invoice = await this.billingService.getInvoice(id, req.user.organizationId);
    const pdfBuffer = await this.invoiceGenerator.generate({
      invoiceNumber: invoice.number,
      // ... populate from invoice data
    } as InvoiceData);

    // Non-blocking email send
    this.emailService.sendInvoiceEmail(
      req.user.email,
      req.user.name,
      invoice.number,
      pdfBuffer
    ).catch((err) =>
      this.logger.warn(`Invoice email failed: ${err.message}`)
    );

    return { message: 'Invoice sent to ' + req.user.email };
  }
}

Relatório de várias páginas com números de página

// For reports spanning multiple pages
async generateReport(data: ReportData): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const buffers: Buffer[] = [];
    const doc = new PDFDocument({ size: 'A4', margin: 50 });

    doc.on('data', (chunk: Buffer) => buffers.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(buffers)));
    doc.on('error', reject);

    const pageCount = Math.ceil(data.rows.length / 30); // 30 rows per page
    let currentPage = 0;

    // Add page numbers to every page on finalize
    doc.on('pageAdded', () => {
      currentPage++;
      doc
        .fontSize(8)
        .fillColor('#94a3b8')
        .text(
          `Page ${currentPage}`,
          0,
          doc.page.height - 30,
          { align: 'center', width: doc.page.width }
        );
    });

    // Render content
    this.renderReportHeader(doc, data);

    let rowIndex = 0;
    for (const row of data.rows) {
      if (doc.y > doc.page.height - 100) {
        doc.addPage(); // Triggers 'pageAdded' for footer
        this.renderReportHeader(doc, data); // Re-render header on new page
      }
      this.renderTableRow(doc, row, rowIndex++);
    }

    doc.end();
  });
}

Transmitindo para S3

Para relatórios grandes, transmita diretamente para o S3 em vez de armazenar em buffer na memória:

import { PassThrough } from 'stream';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

async function generateAndUploadToS3(
  invoiceData: InvoiceData,
  s3: S3Client,
  bucket: string,
  key: string
): Promise<string> {
  const passThrough = new PassThrough();

  const doc = new PDFDocument({ size: 'A4', margin: 50 });
  doc.pipe(passThrough);

  // Render invoice content
  renderInvoice(doc, invoiceData);
  doc.end();

  // Upload stream to S3
  await s3.send(new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    Body: passThrough,
    ContentType: 'application/pdf',
    Metadata: {
      'invoice-number': invoiceData.invoiceNumber,
    },
  }));

  return `s3://${bucket}/${key}`;
}

Modelo de certificado

async generateCertificate(data: CertificateData): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const buffers: Buffer[] = [];
    const doc = new PDFDocument({
      size: [792, 612], // Letter landscape
      margin: 60,
      layout: 'landscape',
    });

    doc.on('data', (chunk: Buffer) => buffers.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(buffers)));
    doc.on('error', reject);

    // Background
    doc.rect(0, 0, 792, 612).fillColor('#fefce8').fill();

    // Gold border (double line)
    doc
      .rect(20, 20, 752, 572)
      .lineWidth(3)
      .strokeColor('#f59e0b')
      .stroke();
    doc
      .rect(28, 28, 736, 556)
      .lineWidth(1)
      .strokeColor('#f59e0b')
      .stroke();

    // Title
    doc
      .fontSize(36)
      .fillColor('#0f172a')
      .font('Helvetica-Bold')
      .text('CERTIFICATE OF COMPLETION', 60, 100, { align: 'center' });

    // Recipient name
    doc
      .fontSize(28)
      .fillColor('#f59e0b')
      .text(data.recipientName, 60, 200, { align: 'center' });

    // Course name
    doc
      .fontSize(16)
      .fillColor('#0f172a')
      .font('Helvetica')
      .text(`has successfully completed`, 60, 260, { align: 'center' })
      .fontSize(20)
      .font('Helvetica-Bold')
      .text(data.courseName, 60, 290, { align: 'center' });

    // Date and signature
    doc
      .fontSize(11)
      .fillColor('#64748b')
      .font('Helvetica')
      .text(`Issued on ${this.formatDate(data.issuedDate)}`, 60, 400, { align: 'center' });

    doc.end();
  });
}

Perguntas frequentes

PDFKit vs Puppeteer para geração de PDF — qual devo usar?

PDFKit para documentos estruturados: faturas, relatórios, certidões, cartas. É rápido (sem inicialização do navegador), usa memória mínima e oferece controle perfeito sobre o layout. Puppeteer para conversão de HTML para PDF — melhor quando você já tem um modelo HTML e precisa dele renderizado como está, incluindo CSS complexo, fontes da web e JavaScript dinâmico. O Puppeteer é 5 a 10 vezes mais lento e requer um processo do Chrome sem cabeça, mas lida melhor com layouts da era da web. Para faturas e documentos comerciais, PDFKit é a escolha certa.

Como lidar com quebras de página para conteúdo dinâmico no PDFKit?

O PDFKit não faz paginação automática — você deve verificar doc.y em relação a doc.page.height - margin antes de renderizar cada elemento. O padrão: if (doc.y > doc.page.height - minSpaceNeeded) { doc.addPage(); renderPageHeader(doc); }. Para linhas da tabela, calcule o espaço mínimo necessário para uma linha e verifique antes de cada linha. Para texto com várias linhas, estime a altura como fontSize * lineCount * 1.2.

Como incorporo fontes personalizadas no PDFKit?

Registre as fontes com doc.registerFont('FontName', '/path/to/font.ttf') antes de usá-las. Chame isso em seu construtor de serviço ou em uma função de configuração. O PDFKit vem com 14 fontes PDF padrão (Helvetica, Times, Courier e suas variantes Negrito/Itálico) — elas não precisam ser registradas. Para suporte a Unicode (árabe, chinês, japonês), você deve registrar uma fonte TTF/OTF compatível com Unicode; as fontes PDF padrão cobrem apenas caracteres latinos.

Como posso gerar PDFs para árabe ou outros idiomas RTL?

PDFKit oferece suporte a Unicode, mas não lida nativamente com reordenação de texto bidirecional. Para árabe, registre a fonte Noto Sans Arabic ou Amiri. Use uma biblioteca de algoritmos bidi (como bidi-js) para reordenar o texto antes de passá-lo para o PDFKit. Inverta o layout manualmente para documentos RTL (texto alinhado à direita, colunas de tabela espelhadas). Teste minuciosamente - a geração de PDF em árabe requer um tratamento cuidadoso da junção de texto, diacríticos e direção de números.

Como adiciono assinaturas digitais ou conformidade com PDF/A?

PDFKit suporta metadados básicos de PDF para arquivamento (Título, Autor, Assunto, Palavras-chave) por meio da opção info. Para assinaturas digitais, use a biblioteca node-signpdf com PDFKit — ela modifica o fluxo de PDF para incorporar uma assinatura PKCS#7. Para conformidade com PDF/A (arquivamento de longo prazo), garanta fontes incorporadas, sem transparência e definições de espaço de cores adequadas. PDFKit v0.13+ melhorou o suporte para esses recursos, mas requisitos complexos de conformidade podem se beneficiar de uma biblioteca dedicada como pdf-lib.


Próximas etapas

O PDFKit permite a geração profissional e programática de PDF diretamente no back-end do Node.js - sem sobrecarga do navegador, sem serviços externos, sem preços por página. Faturas, relatórios, certificados e extratos tornam-se recursos de primeira classe da sua API.

ECOSIRE implementa geração de faturas em PDF, entrega automatizada de e-mail e arquivamento S3 como parte do pipeline completo de faturamento em cada projeto NestJS. Explore nossos serviços de engenharia de back-end para saber como criamos sistemas de faturas e relatórios de nível de produção.

Compartilhar:
E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp