PDF-Generierung mit PDFKit: Rechnungen, Berichte und Zertifikate
Die PDF-Generierung ist eine dieser Funktionen, die einfach aussieht, bis Sie mit der Implementierung beginnen. Dynamische Tabellen mit korrektem Seitenüberlauf, mehrsprachiger Text mit arabischer RTL-Unterstützung, korrekte Seitennummerierung, konsistente Kopf- und Fußzeilen auf allen Seiten, eingebettete Logos – all dies erfordert eine Layout-Engine, die die Komplexität für Sie bewältigt. PDFKit ist eine einfache, aber leistungsstarke Node.js-PDF-Generierungsbibliothek, die Ihnen eine präzise Kontrolle über jedes Element ermöglicht.
In diesem Leitfaden werden PDFKit-Muster für die Generierung von Produktions-PDFs in NestJS behandelt: Rechnungsvorlagen, Datentabellen mit Paginierung, mehrspaltige Layouts, benutzerdefinierte Schriftarten, Bildeinbettung und Streaming-Zustellung – bis hin zum Versenden der generierten PDF-Datei als Anhang per E-Mail.
Wichtige Erkenntnisse
- PDFKit generiert PDFs programmgesteuert in Node.js – kein Browser, kein Puppeteer, keine HTML-zu-PDF-Konvertierung
- Puffern Sie die PDF-Datei vor dem Senden immer bis zur Fertigstellung – streamen Sie keine unvollständige PDF-Datei an den Client
- Betten Sie Schriftarten über
registerFont()ein, um ein konsistentes plattformübergreifendes Rendering zu gewährleisten – verlassen Sie sich niemals auf Systemschriftarten- Mehrseitige Dokumente erfordern eine manuelle Seitenumbrucherkennung: Überprüfen Sie die Position
doc.yanhand vondoc.page.height– Für arabischen und RTL-Text verwenden Sie eine geeignete arabische Schriftart (Noto Sans Arabic) und legen Sie die Textrichtung manuell fest – Streamen Sie PDFs an AWS S3 und geben Sie eine vorsignierte URL zurück – übergeben Sie keine großen Puffer über Ihre API- Verwenden Sie
doc.pipe(fs.createWriteStream())zum Testen;doc.pipe(res)für HTTP-Streaming; Puffer für E-Mail-Anhänge- Fügen Sie PDF/A-Metadaten für Archivierungskonformität hinzu: Felder
Title,Author,Creator,Producer
Installation
pnpm add pdfkit
pnpm add -D @types/pdfkit
Für die Bildunterstützung (PNG, JPEG) und die Unterteilung von Schriftarten sind keine zusätzlichen Pakete erforderlich – PDFKit verarbeitet sie nativ.
Grundlegende Rechnungsstruktur
// 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);
}
}
NestJS-Controller-Integration
// 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 };
}
}
Mehrseitiger Bericht mit Seitenzahlen
// 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();
});
}
Streaming auf S3
Streamen Sie bei großen Berichten direkt an S3, anstatt sie im Speicher zu puffern:
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}`;
}
Zertifikatvorlage
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();
});
}
Häufig gestellte Fragen
PDFKit vs. Puppeteer für die PDF-Generierung – welches sollte ich verwenden?
PDFKit für strukturierte Dokumente: Rechnungen, Berichte, Zertifikate, Briefe. Es ist schnell (kein Browserstart erforderlich), benötigt nur minimalen Speicher und ermöglicht eine pixelgenaue Kontrolle über das Layout. Puppeteer für die HTML-zu-PDF-Konvertierung – am besten, wenn Sie bereits über eine HTML-Vorlage verfügen und diese unverändert gerendert benötigen, einschließlich komplexem CSS, Web-Schriftarten und dynamischem JavaScript. Puppeteer ist 5-10x langsamer und erfordert einen Headless-Chrome-Prozess, kommt aber besser mit Layouts aus der Web-Ära zurecht. Für Rechnungen und Geschäftsdokumente ist PDFKit die richtige Wahl.
Wie gehe ich mit Seitenumbrüchen für dynamische Inhalte in PDFKit um?
PDFKit führt keine automatische Paginierung durch – Sie müssen doc.y mit doc.page.height - margin vergleichen, bevor Sie jedes Element rendern. Das Muster: if (doc.y > doc.page.height - minSpaceNeeded) { doc.addPage(); renderPageHeader(doc); }. Berechnen Sie für Tabellenzeilen den Mindestplatzbedarf für eine Zeile und überprüfen Sie ihn vor jeder Zeile. Bei mehrzeiligem Text schätzen Sie die Höhe auf fontSize * lineCount * 1.2.
Wie bette ich benutzerdefinierte Schriftarten in PDFKit ein?
Registrieren Sie Schriftarten mit doc.registerFont('FontName', '/path/to/font.ttf'), bevor Sie sie verwenden. Rufen Sie dies in Ihrem Service-Konstruktor oder einer Setup-Funktion auf. PDFKit wird mit den 14 Standard-PDF-Schriftarten (Helvetica, Times, Courier und deren Varianten Bold/Italic) ausgeliefert – diese müssen nicht registriert werden. Für die Unicode-Unterstützung (Arabisch, Chinesisch, Japanisch) müssen Sie eine Unicode-kompatible TTF/OTF-Schriftart registrieren; Die Standard-PDF-Schriftarten decken nur lateinische Zeichen ab.
Wie erstelle ich PDFs für Arabisch oder andere RTL-Sprachen?
PDFKit unterstützt Unicode, unterstützt jedoch nicht nativ die bidirektionale Neuordnung von Text. Registrieren Sie für Arabisch die Schriftart Noto Sans Arabic oder Amiri. Verwenden Sie eine Bidi-Algorithmus-Bibliothek (wie bidi-js), um den Text neu anzuordnen, bevor Sie ihn an PDFKit übergeben. Kehren Sie das Layout für RTL-Dokumente manuell um (rechtsbündiger Text, gespiegelte Tabellenspalten). Gründlich testen – Die arabische PDF-Erstellung erfordert eine sorgfältige Handhabung der Textverbindung, der diakritischen Zeichen und der Zahlenrichtung.
Wie füge ich digitale Signaturen oder PDF/A-Konformität hinzu?
PDFKit unterstützt grundlegende PDF-Metadaten für die Archivierung (Titel, Autor, Betreff, Schlüsselwörter) über die Option info. Verwenden Sie für digitale Signaturen die node-signpdf-Bibliothek mit PDFKit – sie ändert den PDF-Stream, um eine PKCS#7-Signatur einzubetten. Stellen Sie für die PDF/A-Konformität (Langzeitarchivierung) sicher, dass eingebettete Schriftarten, keine Transparenz und korrekte Farbraumdefinitionen vorhanden sind. PDFKit v0.13+ bietet eine verbesserte Unterstützung für diese Funktionen, komplexe Compliance-Anforderungen können jedoch von einer dedizierten Bibliothek wie pdf-lib profitieren.
Nächste Schritte
PDFKit ermöglicht die professionelle, programmatische PDF-Generierung direkt in Ihrem Node.js-Backend – kein Browser-Overhead, keine externen Dienste, keine Preise pro Seite. Rechnungen, Berichte, Zertifikate und Kontoauszüge werden zu erstklassigen Funktionen Ihrer API.
ECOSIRE implementiert die Erstellung von PDF-Rechnungen, die automatisierte E-Mail-Zustellung und die S3-Archivierung als Teil der vollständigen Abrechnungspipeline für jedes NestJS-Projekt. [Entdecken Sie unsere Backend-Engineering-Dienstleistungen] (/services), um zu erfahren, wie wir Rechnungs- und Berichtssysteme in Produktionsqualität erstellen.
Geschrieben von
ECOSIRE Research and Development Team
Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.