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 TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Erweitern Sie Ihr Geschäft mit ECOSIRE
Unternehmenslösungen in den Bereichen ERP, E-Commerce, KI, Analyse und Automatisierung.