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.yem relação adoc.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.
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.