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 مارس 202610 دقائق قراءة2.2k كلمات|

إنشاء ملفات PDF باستخدام PDFKit: الفواتير والتقارير والشهادات

يعد إنشاء PDF أحد تلك الميزات التي تبدو بسيطة حتى تبدأ في تنفيذها. الجداول الديناميكية التي تتخطى الصفحات بشكل صحيح، والنص متعدد اللغات مع دعم اللغة العربية من اليمين إلى اليسار، والترقيم المناسب للصفحات، والرؤوس والتذييلات المتسقة عبر الصفحات، والشعارات المضمنة - كل هذا يتطلب محرك تخطيط يتعامل مع التعقيد نيابةً عنك. PDFKit عبارة عن مكتبة Node.js PDF منخفضة المستوى ولكنها قوية تمنحك تحكمًا دقيقًا في كل عنصر.

يغطي هذا الدليل أنماط PDFKit لإنشاء ملفات PDF للإنتاج في NestJS: قوالب الفواتير، وجداول البيانات مع ترقيم الصفحات، والتخطيطات متعددة الأعمدة، والخطوط المخصصة، وتضمين الصور، والتسليم المتدفق - وصولاً إلى إرسال ملف PDF الذي تم إنشاؤه كمرفق عبر البريد الإلكتروني.

الوجبات الرئيسية

  • يقوم PDFKit بإنشاء ملفات PDF برمجياً في Node.js - لا يوجد متصفح، ولا محرك الدمى، ولا يوجد تحويل من HTML إلى PDF
  • قم دائمًا بتخزين ملف PDF مؤقتًا حتى اكتماله قبل الإرسال - لا تقم بدفق ملف PDF غير مكتمل إلى العميل
  • تضمين الخطوط عبر registerFont() للحصول على عرض متسق عبر الأنظمة الأساسية - لا تعتمد أبدًا على خطوط النظام
  • تتطلب المستندات متعددة الصفحات اكتشافًا يدويًا لفاصل الصفحات: تحقق من موضع doc.y مقابل doc.page.height
  • بالنسبة للنص العربي وRTL، استخدم خطًا عربيًا مناسبًا (Noto Sans Arab) وتعامل مع اتجاه النص يدويًا
  • دفق ملفات PDF إلى AWS S3 وإرجاع عنوان URL موقّع مسبقًا - لا تمرر مخازن مؤقتة كبيرة عبر واجهة برمجة التطبيقات (API) الخاصة بك
  • استخدم doc.pipe(fs.createWriteStream()) للاختبار؛ doc.pipe(res) لتدفق HTTP؛ المخزن المؤقت لمرفقات البريد الإلكتروني
  • إضافة بيانات تعريف PDF/A للامتثال الأرشيفي: Title، Author، Creator، Producer الحقول

التثبيت

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

الأسئلة المتداولة

PDFKit vs Puppeteer لإنشاء PDF - ما الذي يجب أن أستخدمه؟

PDFKit للمستندات المنظمة: الفواتير والتقارير والشهادات والخطابات. إنه سريع (بدون بدء تشغيل المتصفح)، ويستخدم الحد الأدنى من الذاكرة، ويمنح تحكمًا مثاليًا بالبكسل في التخطيط. محرك الدمى لتحويل HTML إلى PDF - الأفضل عندما يكون لديك بالفعل قالب HTML وتحتاج إلى عرضه كما هو، بما في ذلك CSS المعقدة وخطوط الويب وجافا سكريبت الديناميكية. محرك الدمى أبطأ بمقدار 5 إلى 10 مرات ويتطلب عملية Chrome بدون رأس، ولكنه يتعامل مع تخطيطات عصر الويب بشكل أفضل. بالنسبة للفواتير ومستندات الأعمال، فإن PDFKit هو الخيار الصحيح.

كيف أتعامل مع فواصل الصفحات للمحتوى الديناميكي في PDFKit؟

لا يقوم PDFKit بترقيم الصفحات تلقائيًا — يجب عليك التحقق من doc.y مقابل doc.page.height - margin قبل عرض كل عنصر. النمط: if (doc.y > doc.page.height - minSpaceNeeded) { doc.addPage(); renderPageHeader(doc); }. بالنسبة لصفوف الجدول، احسب الحد الأدنى للمساحة اللازمة لصف واحد وتحقق قبل كل صف. بالنسبة للنص متعدد الأسطر، قم بتقدير الارتفاع بـ fontSize * lineCount * 1.2.

كيف أقوم بتضمين خطوط مخصصة في PDFKit؟

قم بتسجيل الخطوط بـ doc.registerFont('FontName', '/path/to/font.ttf') قبل استخدامها. اتصل بهذا في مُنشئ الخدمة أو وظيفة الإعداد. يأتي PDFKit مزودًا بـ 14 خطًا قياسيًا لملفات PDF (Helvetica وTimes وCourier ومتغيراتها Bold/Italic) - ولا يلزم تسجيلها. للحصول على دعم Unicode (العربية والصينية واليابانية)، يجب عليك تسجيل خط TTF/OTF متوافق مع Unicode؛ تغطي خطوط PDF القياسية الأحرف اللاتينية فقط.

كيف أقوم بإنشاء ملفات PDF للغة العربية أو لغات RTL الأخرى؟

يدعم PDFKit Unicode ولكنه لا يتعامل أصلاً مع إعادة ترتيب النص ثنائي الاتجاه. للغة العربية، قم بتسجيل الخط Noto Sans Arab أو Amiri. استخدم مكتبة خوارزمية ثنائية الاتجاه (مثل bidi-js) لإعادة ترتيب النص قبل تمريره إلى PDFKit. قم بعكس التخطيط يدويًا لمستندات RTL (نص محاذ إلى اليمين، أعمدة جدول معكوسة). اختبار شامل - يتطلب إنشاء ملف PDF باللغة العربية معالجة دقيقة لربط النص وعلامات التشكيل واتجاه الأرقام.

كيف يمكنني إضافة التوقيعات الرقمية أو التوافق مع PDF/A؟

يدعم PDFKit بيانات تعريف PDF الأساسية للأرشفة (العنوان، المؤلف، الموضوع، الكلمات الرئيسية) عبر خيار info. بالنسبة للتوقيعات الرقمية، استخدم مكتبة node-signpdf مع PDFKit - فهي تعدل تدفق PDF لتضمين توقيع PKCS#7. بالنسبة للتوافق مع PDF/A (الأرشفة طويلة المدى)، تأكد من وجود خطوط مضمنة، وعدم الشفافية، وتعريفات مساحة الألوان المناسبة. قام PDFKit v0.13+ بتحسين الدعم لهذه الميزات، ولكن قد تستفيد متطلبات التوافق المعقدة من مكتبة مخصصة مثل pdf-lib.


الخطوات التالية

يتيح PDFKit إمكانية إنشاء ملفات PDF احترافية وبرمجية مباشرة في الواجهة الخلفية لـ Node.js - دون الحاجة إلى تحميل المتصفح، أو خدمات خارجية، أو تسعير لكل صفحة. تصبح الفواتير والتقارير والشهادات والكشوف ميزات من الدرجة الأولى لواجهة برمجة التطبيقات (API) الخاصة بك.

تنفذ ECOSIRE إنشاء فاتورة بتنسيق PDF وتسليم البريد الإلكتروني تلقائيًا وأرشفة S3 كجزء من مسار الفوترة الكامل في كل مشروع من مشاريع NestJS. استكشف خدماتنا الهندسية الخلفية للتعرف على كيفية إنشاء أنظمة إعداد التقارير والفواتير على مستوى الإنتاج.

مشاركة:
E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب