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 Mart 202610 dk okuma2.1k Kelime|

PDFKit ile PDF Oluşturma: Faturalar, Raporlar ve Sertifikalar

PDF oluşturma, uygulamaya başlayana kadar basit görünen özelliklerden biridir. Sayfaları doğru bir şekilde taşan dinamik tablolar, Arapça RTL destekli çok dilli metin, doğru sayfa numaralandırma, sayfalar arasında tutarlı üstbilgiler ve altbilgiler, gömülü logolar — bunların tümü, karmaşıklığı sizin için halledecek bir düzen motoru gerektirir. PDFKit, size her öğe üzerinde hassas kontrol sağlayan, düşük seviyeli ancak güçlü bir Node.js PDF oluşturma kitaplığıdır.

Bu kılavuz, NestJS'de üretim PDF oluşturmaya yönelik PDFKit modellerini kapsar: fatura şablonları, sayfalandırmalı veri tabloları, çok sütunlu düzenler, özel yazı tipleri, görüntü yerleştirme ve akışlı teslimat - oluşturulan PDF'nin ek olarak e-postayla gönderilmesine kadar.

Önemli Çıkarımlar

  • PDFKit, Node.js'de programlı olarak PDF'ler oluşturur — tarayıcı yok, Puppeteer yok, HTML'den PDF'ye dönüştürme yok
  • Göndermeden önce her zaman PDF'yi tamamlanana kadar arabelleğe alın — tamamlanmamış bir PDF'yi istemciye aktarmayın
  • Tutarlı platformlar arası işleme için yazı tiplerini registerFont() yoluyla ekleyin; asla sistem yazı tiplerine güvenmeyin
  • Çok sayfalı belgeler manuel sayfa sonu algılaması gerektirir: doc.y konumunu doc.page.height ile karşılaştırın
  • Arapça ve RTL metin için uygun bir Arapça yazı tipi (Noto Sans Arabic) kullanın ve metin yönünü manuel olarak yönetin
  • PDF'leri AWS S3'e aktarın ve önceden imzalanmış bir URL döndürün; API'niz üzerinden büyük arabellekler geçirmeyin
  • Test için doc.pipe(fs.createWriteStream()) kullanın; HTTP akışı için doc.pipe(res); e-posta ekleri için arabellek
  • Arşiv uyumluluğu için PDF/A meta verileri ekleyin: Title, Author, Creator, Producer alanları

Kurulum

pnpm add pdfkit
pnpm add -D @types/pdfkit

Görüntü desteği (PNG, JPEG) ve yazı tipi alt kümeleme için ek paketlere gerek yoktur; PDFKit bunları yerel olarak yönetir.


Temel Fatura Yapısı

// 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 Denetleyici Entegrasyonu

// 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 };
  }
}

Sayfa Numaralı Çok Sayfalı Rapor

// 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();
  });
}

S3'e akış

Büyük raporlar için bellekte ara belleğe almak yerine doğrudan S3'e akış yapın:

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}`;
}

Sertifika Şablonu

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();
  });
}

Sıkça Sorulan Sorular

PDF oluşturmak için PDFKit vs Puppeteer — hangisini kullanmalıyım?

Yapılandırılmış belgeler için PDFKit: faturalar, raporlar, sertifikalar, mektuplar. Hızlıdır (tarayıcı başlatılmaz), minimum düzeyde bellek kullanır ve düzen üzerinde piksel açısından mükemmel kontrol sağlar. HTML'den PDF'ye dönüştürme için Puppeteer — zaten bir HTML şablonunuz olduğunda ve onun karmaşık CSS, web yazı tipleri ve dinamik JavaScript dahil olduğu gibi oluşturulmasına ihtiyaç duyduğunuzda en iyisidir. Puppeteer 5-10 kat daha yavaştır ve başsız bir Chrome işlemi gerektirir, ancak web çağı düzenlerini daha iyi işler. Faturalar ve iş belgeleri için PDFKit doğru seçimdir.

PDFKit'te dinamik içerik için sayfa sonlarını nasıl yönetirim?

PDFKit otomatik olarak sayfalandırma yapmaz; her öğeyi oluşturmadan önce doc.y ile doc.page.height - margin'i kontrol etmeniz gerekir. Desen: if (doc.y > doc.page.height - minSpaceNeeded) { doc.addPage(); renderPageHeader(doc); }. Tablo satırlarında, bir satır için gereken minimum alanı hesaplayın ve her satırdan önce kontrol edin. Çok satırlı metin için yüksekliği fontSize * lineCount * 1.2 olarak tahmin edin.

Özel yazı tiplerini PDFKit'e nasıl gömebilirim?

Fontları kullanmadan önce doc.registerFont('FontName', '/path/to/font.ttf') ile kaydedin. Bunu hizmet yapıcınızda veya bir kurulum işlevinde arayın. PDFKit, 14 standart PDF yazı tipiyle (Helvetica, Times, Courier ve bunların Kalın/İtalik çeşitleri) birlikte gelir; bunların kaydedilmesine gerek yoktur. Unicode desteği (Arapça, Çince, Japonca) için Unicode uyumlu bir TTF/OTF yazı tipini kaydetmeniz gerekir; standart PDF yazı tipleri yalnızca Latin karakterlerini kapsar.

Arapça veya diğer RTL dilleri için nasıl PDF oluşturabilirim?

PDFKit, Unicode'u destekler ancak çift yönlü metin yeniden sıralamasını yerel olarak işlemez. Arapça için Noto Sans Arabic veya Amiri yazı tipini kaydedin. Metni PDFKit'e aktarmadan önce yeniden sıralamak için bir bidi algoritma kitaplığı (bidi-js gibi) kullanın. RTL belgeleri için düzeni manuel olarak tersine çevirin (sağa hizalanmış metin, yansıtılmış tablo sütunları). Kapsamlı bir şekilde test edin — Arapça PDF oluşturma, metin birleştirme, aksan işaretleri ve sayı yönünün dikkatli bir şekilde ele alınmasını gerektirir.

Dijital imzaları veya PDF/A uyumluluğunu nasıl eklerim?

PDFKit, info seçeneği aracılığıyla arşivleme için temel PDF meta verilerini (Başlık, Yazar, Konu, Anahtar Kelimeler) destekler. Dijital imzalar için PDFKit ile birlikte node-signpdf kitaplığını kullanın; bu kitaplık, PKCS#7 imzasını gömmek için PDF akışını değiştirir. PDF/A uyumluluğu (uzun vadeli arşivleme) için gömülü yazı tiplerinden, şeffaflık olmadığından ve uygun renk alanı tanımlarından emin olun. PDFKit v0.13+, bu özellikler için geliştirilmiş desteğe sahiptir, ancak karmaşık uyumluluk gereksinimleri, pdf-lib gibi özel bir kitaplıktan yararlanabilir.


Sonraki Adımlar

PDFKit, doğrudan Node.js arka ucunuzda profesyonel, programatik PDF oluşturma olanağı sağlar; tarayıcı yükü yok, harici hizmet yok, sayfa başına fiyatlandırma yok. Faturalar, raporlar, sertifikalar ve ekstreler API'nizin birinci sınıf özellikleri haline gelir.

ECOSIRE, her NestJS projesinde eksiksiz faturalandırma hattının bir parçası olarak PDF fatura oluşturma, otomatik e-posta teslimi ve S3 arşivlemeyi uygular. Üretim düzeyinde fatura ve raporlama sistemlerini nasıl oluşturduğumuzu öğrenmek için arka uç mühendislik hizmetlerimizi keşfedin.

E

Yazan

ECOSIRE Research and Development Team

ECOSIRE'da kurumsal düzeyde dijital ürünler geliştiriyor. Odoo entegrasyonları, e-ticaret otomasyonu ve yapay zeka destekli iş çözümleri hakkında içgörüler paylaşıyor.

WhatsApp'ta Sohbet Et