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
|2026年3月19日8 分で読める1.8k 語数|

PDFKit による PDF 生成: 請求書、レポート、証明書

PDF 生成は、実装を始めるまでは簡単そうに見える機能の 1 つです。ページを適切にオーバーフローさせる動的テーブル、アラビア語 RTL サポートを備えた多言語テキスト、適切なページ番号付け、ページ全体で一貫したヘッダーとフッター、埋め込みロゴ - これらすべてには、複雑さを処理するレイアウト エンジンが必要です。 PDFKit は、あらゆる要素を正確に制御できる、低レベルながら強力な Node.js PDF 生成ライブラリです。

このガイドでは、請求書テンプレート、ページネーションを含むデータ テーブル、複数列レイアウト、カスタム フォント、画像埋め込み、ストリーミング配信など、NestJS で本番用 PDF を生成するための PDFKit パターンを、生成された PDF を添付ファイルとして電子メールで送信する方法まで説明します。

重要なポイント

  • PDFKit は Node.js でプログラム的に PDF を生成します — ブラウザー、Puppeteer、HTML から PDF への変換は必要ありません
  • 送信前に常に PDF を完全にバッファリングします。不完全な PDF をクライアントにストリーミングしないでください。
  • 一貫したクロスプラットフォームのレンダリングのために registerFont() 経由でフォントを埋め込みます。システム フォントに依存しないでください。
  • 複数ページのドキュメントには手動の改ページ検出が必要です: doc.y の位置を doc.page.height と比較して確認してください
  • アラビア語および RTL テキストの場合は、適切なアラビア語フォント (Noto Sans Arabic) を使用し、テキストの方向を手動で処理します。
  • PDF を AWS S3 にストリーミングし、署名済みの URL を返します。API を介して大きなバッファを渡さないでください。
  • テストには doc.pipe(fs.createWriteStream()) を使用します。 doc.pipe(res) HTTP ストリーミングの場合。電子メール添付ファイル用のバッファ
  • アーカイブ準拠のための PDF/A メタデータの追加: TitleAuthorCreatorProducer フィールド

インストール

pnpm add pdfkit
pnpm add -D @types/pdfkit

画像サポート (PNG、JPEG) とフォントのサブセットについては、追加のパッケージは必要ありません。PDFKit はそれらをネイティブに処理します。


基本的な請求書の構造

// 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 コントローラーの統合

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

ページ番号付きの複数ページのレポート

// 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 へのストリーミング

大規模なレポートの場合は、メモリにバッファリングするのではなく、S3 に直接ストリーミングします。

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

証明書テンプレート

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

よくある質問

PDF 生成には PDFKit と Puppeteer — どちらを使用する必要がありますか?

構造化ドキュメント用の PDFKit: 請求書、レポート、証明書、手紙。高速で (ブラウザの起動は不要)、メモリ使用量は最小限で、レイアウトをピクセル単位で完璧に制御できます。 HTML から PDF への変換のための Puppeteer — すでに HTML テンプレートがあり、複雑な CSS、Web フォント、動的 JavaScript などをそのままレンダリングする必要がある場合に最適です。 Puppeteer は 5 ~ 10 倍遅く、ヘッドレス Chrome プロセスが必要ですが、Web 時代のレイアウトをより適切に処理します。請求書やビジネス文書には、PDFKit が最適です。

PDFKit で動的コンテンツの改ページを処理するにはどうすればよいですか?

PDFKit does not auto-paginate — you must check doc.y against doc.page.height - margin before rendering each element.パターン: if (doc.y > doc.page.height - minSpaceNeeded) { doc.addPage(); renderPageHeader(doc); }。 For table rows, calculate the minimum space needed for one row and check before each row. For multi-line text, estimate height as fontSize * lineCount * 1.2.

PDFKit にカスタム フォントを埋め込むにはどうすればよいですか?

フォントを使用する前に doc.registerFont('FontName', '/path/to/font.ttf') に登録してください。これをサービス コンストラクターまたはセットアップ関数で呼び出します。 PDFKit ships with the 14 standard PDF fonts (Helvetica, Times, Courier and their Bold/Italic variants) — these do not need to be registered. For Unicode support (Arabic, Chinese, Japanese), you must register a Unicode-compatible TTF/OTF font; the standard PDF fonts only cover Latin characters.

アラビア語またはその他の RTL 言語の PDF を生成するにはどうすればよいですか?

PDFKit supports Unicode but does not natively handle bidirectional text reordering. For Arabic, register Noto Sans Arabic or Amiri font. Use a bidi algorithm library (like bidi-js) to reorder the text before passing it to PDFKit. Reverse the layout manually for RTL documents (right-aligned text, mirrored table columns). Test thoroughly — Arabic PDF generation requires careful handling of text joining, diacritics, and number direction.

How do I add digital signatures or PDF/A compliance?

PDFKit supports basic PDF metadata for archival (Title, Author, Subject, Keywords) via the info option. For digital signatures, use the node-signpdf library with PDFKit — it modifies the PDF stream to embed a PKCS#7 signature. PDF/A 準拠 (長期アーカイブ) の場合は、埋め込みフォント、透明度なし、および適切な色空間定義を確認してください。 PDFKit v0.13 以降では、これらの機能のサポートが強化されていますが、複雑なコンプライアンス要件には、pdf-lib のような専用ライブラリの恩恵を受ける可能性があります。


次のステップ

PDFKit を使用すると、Node.js バックエンドで直接プログラマチックな PDF を生成できます。ブラウザーのオーバーヘッド、外部サービス、ページごとの価格設定は必要ありません。 Invoices, reports, certificates, and statements become first-class features of your API.

ECOSIRE は、すべての NestJS プロジェクトの完全な請求パイプラインの一部として、PDF 請求書の生成、自動電子メール配信、S3 アーカイブを実装しています。 Explore our backend engineering services to learn how we build production-grade invoice and reporting systems.

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット