React-PDF + Next.js 15: إنشاء ملفات PDF احترافية وفواتير وتقارير باستخدام TypeScript

Noqta Team
بواسطة Noqta Team ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

أنشئ ملفات PDF مثالية باستخدام مكونات React. تتيح لك مكتبة @react-pdf/renderer بناء مستندات PDF باستخدام صيغة JSX المألوفة — بدون متصفحات بدون واجهة، وبدون حيل تحويل HTML إلى PDF. في هذا الدليل، ستبني نظام إنشاء فواتير متكامل مع Next.js 15 وServer Actions ومستندات PDF قابلة للتحميل.

ما ستتعلمه

بنهاية هذا الدليل، ستتمكن من:

  • إعداد @react-pdf/renderer في مشروع Next.js 15 مع TypeScript
  • بناء مكونات PDF قابلة لإعادة الاستخدام باستخدام عناصر React-PDF الأساسية (Document، Page، View، Text)
  • إنشاء قالب فاتورة احترافي مع جداول ورؤوس وتذييلات
  • توليد ملفات PDF على جانب الخادم باستخدام مسارات API في Next.js وServer Actions
  • إضافة بيانات ديناميكية من النماذج لإنتاج مستندات مخصصة
  • تنفيذ نقطة نهاية للتحميل تبث ملفات PDF إلى المتصفح
  • دعم الخطوط المخصصة واللغات من اليمين إلى اليسار (العربية) في مستنداتك
  • نشر خط أنابيب إنشاء PDF جاهز للإنتاج

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت (node --version)
  • خبرة في TypeScript (الأنواع، الواجهات، async/await)
  • معرفة بـ Next.js (App Router، Server Components، مسارات API)
  • معرفة بـ React (المكونات، الخصائص، JSX)
  • محرر أكواد — يُنصح باستخدام VS Code أو Cursor

لماذا @react-pdf/renderer؟

هناك عدة طرق لإنشاء ملفات PDF في JavaScript. إليك مقارنة بينها:

الطريقةجاهز للـ Serverlessالتحكم في التخطيطحجم الحزمةالتعقيد
@react-pdf/rendererنعمكامل (Flexbox)~500 كيلوبايتمنخفض
Puppeteerلا (يحتاج Chrome)كامل (HTML/CSS)~300 ميغابايتعالي
jsPDFنعمإحداثيات يدوية~300 كيلوبايتمتوسط
PDFKitنعمإحداثيات يدوية~1 ميغابايتمتوسط
html-pdfلا (مهمل)مجموعة فرعية من HTML/CSSمتغيرمتوسط

@react-pdf/renderer هو الخيار الأفضل لمشاريع React/Next.js لأن:

  1. أنت تعرف الواجهة البرمجية بالفعل — إنها JSX مع مكونات React
  2. لا حاجة لمتصفح بدون واجهة — تولد ملفات PDF أصلياً، تعمل في بيئة serverless
  3. تخطيط Flexbox — ضع العناصر بشكل طبيعي، ليس بإحداثيات x/y
  4. دعم البث — أنشئ وابث ملفات PDF كبيرة بكفاءة
  5. خطوط مخصصة — سجل أي خط TTF/OTF، بما في ذلك الخطوط العربية

الخطوة 1: إعداد المشروع

أنشئ مشروع Next.js 15 جديد مع TypeScript:

npx create-next-app@latest pdf-invoice-app --typescript --tailwind --app --src-dir
cd pdf-invoice-app

ثبت التبعيات المطلوبة:

npm install @react-pdf/renderer

هذه هي التبعية الوحيدة التي تحتاجها لإنشاء PDF. توفر المكتبة كل شيء: هيكل المستند، والتنسيق، والخطوط، والعرض.

هيكل المشروع سيكون كالتالي:

pdf-invoice-app/
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   └── invoice/
│   │   │       └── route.ts          # نقطة نهاية إنشاء PDF
│   │   ├── invoice/
│   │   │   └── page.tsx              # صفحة نموذج الفاتورة
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── components/
│   │   └── pdf/
│   │       ├── InvoiceDocument.tsx    # مستند PDF الرئيسي
│   │       ├── InvoiceHeader.tsx      # مكون الرأس
│   │       ├── InvoiceTable.tsx       # جدول العناصر
│   │       ├── InvoiceFooter.tsx      # التذييل مع الإجماليات
│   │       └── styles.ts             # أنماط PDF المشتركة
│   └── lib/
│       └── types.ts                  # أنواع TypeScript للفاتورة
├── public/
│   └── fonts/                        # خطوط مخصصة (اختياري)
├── package.json
└── tsconfig.json

الخطوة 2: تعريف أنواع الفاتورة

ابدأ بتعريف أنواع TypeScript لبيانات الفاتورة. أنشئ ملف الأنواع:

// src/lib/types.ts
 
export interface InvoiceItem {
  description: string;
  quantity: number;
  unitPrice: number;
  total: number;
}
 
export interface InvoiceData {
  invoiceNumber: string;
  date: string;
  dueDate: string;
  // المرسل
  company: {
    name: string;
    address: string;
    city: string;
    country: string;
    email: string;
    phone: string;
    taxId?: string;
  };
  // المستلم
  client: {
    name: string;
    address: string;
    city: string;
    country: string;
    email: string;
  };
  // عناصر السطر
  items: InvoiceItem[];
  // البيانات المالية
  subtotal: number;
  taxRate: number;
  taxAmount: number;
  total: number;
  // اختياري
  notes?: string;
  currency: string;
}

ستتم مشاركة هذه الأنواع بين النموذج ومسار API ومكونات PDF — مما يمنحك أماناً كاملاً للأنواع من البداية إلى النهاية.


الخطوة 3: إنشاء أنماط PDF

يستخدم React-PDF واجهة StyleSheet مشابهة لـ React Native. عرف الأنماط المشتركة:

// src/components/pdf/styles.ts
 
import { StyleSheet } from "@react-pdf/renderer";
 
export const colors = {
  primary: "#1a1a2e",
  secondary: "#16213e",
  accent: "#0f3460",
  text: "#333333",
  lightText: "#666666",
  border: "#e0e0e0",
  background: "#f8f9fa",
  white: "#ffffff",
};
 
export const styles = StyleSheet.create({
  page: {
    padding: 40,
    fontSize: 10,
    fontFamily: "Helvetica",
    color: colors.text,
    backgroundColor: colors.white,
  },
  header: {
    flexDirection: "row",
    justifyContent: "space-between",
    marginBottom: 30,
    paddingBottom: 20,
    borderBottomWidth: 2,
    borderBottomColor: colors.primary,
  },
  companyName: {
    fontSize: 22,
    fontFamily: "Helvetica-Bold",
    color: colors.primary,
    marginBottom: 4,
  },
  invoiceTitle: {
    fontSize: 28,
    fontFamily: "Helvetica-Bold",
    color: colors.accent,
    textAlign: "right",
  },
  invoiceMeta: {
    textAlign: "right",
    fontSize: 10,
    color: colors.lightText,
    marginTop: 4,
  },
  section: {
    marginBottom: 20,
  },
  sectionTitle: {
    fontSize: 11,
    fontFamily: "Helvetica-Bold",
    color: colors.primary,
    marginBottom: 6,
    textTransform: "uppercase",
    letterSpacing: 1,
  },
  row: {
    flexDirection: "row",
  },
  col: {
    flex: 1,
  },
  // أنماط الجدول
  table: {
    marginTop: 10,
    marginBottom: 20,
  },
  tableHeader: {
    flexDirection: "row",
    backgroundColor: colors.primary,
    padding: 8,
    borderRadius: 4,
  },
  tableHeaderCell: {
    color: colors.white,
    fontSize: 9,
    fontFamily: "Helvetica-Bold",
    textTransform: "uppercase",
    letterSpacing: 0.5,
  },
  tableRow: {
    flexDirection: "row",
    padding: 8,
    borderBottomWidth: 1,
    borderBottomColor: colors.border,
  },
  tableRowAlt: {
    flexDirection: "row",
    padding: 8,
    backgroundColor: colors.background,
    borderBottomWidth: 1,
    borderBottomColor: colors.border,
  },
  tableCell: {
    fontSize: 10,
    color: colors.text,
  },
  descriptionCol: { width: "45%" },
  quantityCol: { width: "15%", textAlign: "center" },
  priceCol: { width: "20%", textAlign: "right" },
  totalCol: { width: "20%", textAlign: "right" },
  // الإجماليات
  totalsContainer: {
    flexDirection: "row",
    justifyContent: "flex-end",
    marginTop: 10,
  },
  totalsBox: {
    width: 250,
    padding: 12,
    backgroundColor: colors.background,
    borderRadius: 4,
  },
  totalsRow: {
    flexDirection: "row",
    justifyContent: "space-between",
    marginBottom: 6,
  },
  totalsFinal: {
    flexDirection: "row",
    justifyContent: "space-between",
    paddingTop: 8,
    borderTopWidth: 2,
    borderTopColor: colors.primary,
    marginTop: 4,
  },
  totalsFinalText: {
    fontSize: 14,
    fontFamily: "Helvetica-Bold",
    color: colors.primary,
  },
  // التذييل
  footer: {
    position: "absolute",
    bottom: 30,
    left: 40,
    right: 40,
    textAlign: "center",
    fontSize: 8,
    color: colors.lightText,
    borderTopWidth: 1,
    borderTopColor: colors.border,
    paddingTop: 10,
  },
  notes: {
    marginTop: 20,
    padding: 12,
    backgroundColor: colors.background,
    borderRadius: 4,
    borderLeftWidth: 3,
    borderLeftColor: colors.accent,
  },
  notesTitle: {
    fontSize: 10,
    fontFamily: "Helvetica-Bold",
    color: colors.primary,
    marginBottom: 4,
  },
  notesText: {
    fontSize: 9,
    color: colors.lightText,
    lineHeight: 1.5,
  },
});

الفروقات الرئيسية عن CSS العادي:

  • لا توجد خصائص مختصرة — استخدم paddingTop بدلاً من padding: "10 0"
  • Flexbox هو الافتراضي — كل View هو حاوية flex
  • الوحدات هي نقاط — 1pt = 1/72 بوصة (وحدة PDF القياسية)
  • خصائص محدودة — مجموعة فرعية فقط من CSS مدعومة (لا grid، لا float)

الخطوة 4: بناء رأس الفاتورة

أنشئ مكون الرأس الذي يعرض معلومات الشركة وبيانات الفاتورة التعريفية:

// src/components/pdf/InvoiceHeader.tsx
 
import { View, Text } from "@react-pdf/renderer";
import { styles } from "./styles";
import type { InvoiceData } from "@/lib/types";
 
interface InvoiceHeaderProps {
  data: InvoiceData;
}
 
export function InvoiceHeader({ data }: InvoiceHeaderProps) {
  return (
    <View style={styles.header}>
      {/* اليسار: معلومات الشركة */}
      <View style={styles.col}>
        <Text style={styles.companyName}>{data.company.name}</Text>
        <Text>{data.company.address}</Text>
        <Text>
          {data.company.city}, {data.company.country}
        </Text>
        <Text>{data.company.email}</Text>
        <Text>{data.company.phone}</Text>
        {data.company.taxId && (
          <Text style={{ marginTop: 4, fontSize: 9 }}>
            الرقم الضريبي: {data.company.taxId}
          </Text>
        )}
      </View>
 
      {/* اليمين: بيانات الفاتورة */}
      <View>
        <Text style={styles.invoiceTitle}>فاتورة</Text>
        <Text style={styles.invoiceMeta}>#{data.invoiceNumber}</Text>
        <Text style={styles.invoiceMeta}>التاريخ: {data.date}</Text>
        <Text style={styles.invoiceMeta}>تاريخ الاستحقاق: {data.dueDate}</Text>
      </View>
    </View>
  );
}

الخطوة 5: بناء جدول العناصر

مكون الجدول يعرض كل عنصر في الفاتورة مع ألوان صفوف متناوبة:

// src/components/pdf/InvoiceTable.tsx
 
import { View, Text } from "@react-pdf/renderer";
import { styles } from "./styles";
import type { InvoiceItem } from "@/lib/types";
 
interface InvoiceTableProps {
  items: InvoiceItem[];
  currency: string;
}
 
function formatCurrency(amount: number, currency: string): string {
  return `${currency} ${amount.toFixed(2)}`;
}
 
export function InvoiceTable({ items, currency }: InvoiceTableProps) {
  return (
    <View style={styles.table}>
      {/* رأس الجدول */}
      <View style={styles.tableHeader}>
        <Text style={[styles.tableHeaderCell, styles.descriptionCol]}>
          الوصف
        </Text>
        <Text style={[styles.tableHeaderCell, styles.quantityCol]}>الكمية</Text>
        <Text style={[styles.tableHeaderCell, styles.priceCol]}>
          سعر الوحدة
        </Text>
        <Text style={[styles.tableHeaderCell, styles.totalCol]}>المجموع</Text>
      </View>
 
      {/* صفوف الجدول */}
      {items.map((item, index) => (
        <View
          key={index}
          style={index % 2 === 0 ? styles.tableRow : styles.tableRowAlt}
        >
          <Text style={[styles.tableCell, styles.descriptionCol]}>
            {item.description}
          </Text>
          <Text style={[styles.tableCell, styles.quantityCol]}>
            {item.quantity}
          </Text>
          <Text style={[styles.tableCell, styles.priceCol]}>
            {formatCurrency(item.unitPrice, currency)}
          </Text>
          <Text style={[styles.tableCell, styles.totalCol]}>
            {formatCurrency(item.total, currency)}
          </Text>
        </View>
      ))}
    </View>
  );
}

الخطوة 6: بناء تذييل الفاتورة مع الإجماليات

يعرض التذييل المجموع الفرعي والضريبة والمبلغ النهائي:

// src/components/pdf/InvoiceFooter.tsx
 
import { View, Text } from "@react-pdf/renderer";
import { styles } from "./styles";
import type { InvoiceData } from "@/lib/types";
 
interface InvoiceFooterProps {
  data: InvoiceData;
}
 
function formatCurrency(amount: number, currency: string): string {
  return `${currency} ${amount.toFixed(2)}`;
}
 
export function InvoiceFooter({ data }: InvoiceFooterProps) {
  return (
    <>
      {/* الإجماليات */}
      <View style={styles.totalsContainer}>
        <View style={styles.totalsBox}>
          <View style={styles.totalsRow}>
            <Text>المجموع الفرعي</Text>
            <Text>{formatCurrency(data.subtotal, data.currency)}</Text>
          </View>
          <View style={styles.totalsRow}>
            <Text>الضريبة ({data.taxRate}%)</Text>
            <Text>{formatCurrency(data.taxAmount, data.currency)}</Text>
          </View>
          <View style={styles.totalsFinal}>
            <Text style={styles.totalsFinalText}>الإجمالي</Text>
            <Text style={styles.totalsFinalText}>
              {formatCurrency(data.total, data.currency)}
            </Text>
          </View>
        </View>
      </View>
 
      {/* الملاحظات */}
      {data.notes && (
        <View style={styles.notes}>
          <Text style={styles.notesTitle}>ملاحظات</Text>
          <Text style={styles.notesText}>{data.notes}</Text>
        </View>
      )}
 
      {/* تذييل الصفحة */}
      <View style={styles.footer} fixed>
        <Text>
          {data.company.name} | {data.company.email} | {data.company.phone}
        </Text>
        <Text style={{ marginTop: 2 }}>
          شكراً لتعاملكم معنا
        </Text>
      </View>
    </>
  );
}

خاصية fixed على View التذييل تعني أنه سيظهر في كل صفحة إذا امتدت الفاتورة لعدة صفحات.


الخطوة 7: تجميع مستند الفاتورة الكامل

الآن اجمع كل المكونات في المستند الرئيسي:

// src/components/pdf/InvoiceDocument.tsx
 
import { Document, Page, View, Text } from "@react-pdf/renderer";
import { styles } from "./styles";
import { InvoiceHeader } from "./InvoiceHeader";
import { InvoiceTable } from "./InvoiceTable";
import { InvoiceFooter } from "./InvoiceFooter";
import type { InvoiceData } from "@/lib/types";
 
interface InvoiceDocumentProps {
  data: InvoiceData;
}
 
export function InvoiceDocument({ data }: InvoiceDocumentProps) {
  return (
    <Document
      title={`فاتورة ${data.invoiceNumber}`}
      author={data.company.name}
      subject={`فاتورة لـ ${data.client.name}`}
      creator="PDF Invoice App"
    >
      <Page size="A4" style={styles.page}>
        {/* الرأس مع معلومات الشركة وبيانات الفاتورة */}
        <InvoiceHeader data={data} />
 
        {/* قسم الفوترة */}
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>فاتورة إلى</Text>
          <Text style={{ fontFamily: "Helvetica-Bold", marginBottom: 2 }}>
            {data.client.name}
          </Text>
          <Text>{data.client.address}</Text>
          <Text>
            {data.client.city}, {data.client.country}
          </Text>
          <Text>{data.client.email}</Text>
        </View>
 
        {/* جدول العناصر */}
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>العناصر</Text>
          <InvoiceTable items={data.items} currency={data.currency} />
        </View>
 
        {/* التذييل مع الإجماليات والملاحظات */}
        <InvoiceFooter data={data} />
      </Page>
    </Document>
  );
}

يقبل مكون Document خصائص البيانات الوصفية (title، author، subject) التي يتم تضمينها في ملف PDF — وتظهر في لوحات معلومات قارئ PDF.


الخطوة 8: إنشاء مسار API لتوليد PDF

هنا يحدث السحر. أنشئ مسار API يقبل بيانات الفاتورة ويعيد تدفق PDF:

// src/app/api/invoice/route.ts
 
import { NextRequest, NextResponse } from "next/server";
import { renderToBuffer } from "@react-pdf/renderer";
import { InvoiceDocument } from "@/components/pdf/InvoiceDocument";
import type { InvoiceData } from "@/lib/types";
 
export async function POST(request: NextRequest) {
  try {
    const data: InvoiceData = await request.json();
 
    // التحقق من الحقول المطلوبة
    if (!data.invoiceNumber || !data.items?.length) {
      return NextResponse.json(
        { error: "رقم الفاتورة وعنصر واحد على الأقل مطلوبان" },
        { status: 400 }
      );
    }
 
    // توليد مخزن PDF المؤقت
    const pdfBuffer = await renderToBuffer(
      <InvoiceDocument data={data} />
    );
 
    // إرجاع PDF كملف قابل للتحميل
    return new NextResponse(pdfBuffer, {
      status: 200,
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": `attachment; filename="invoice-${data.invoiceNumber}.pdf"`,
        "Content-Length": pdfBuffer.length.toString(),
      },
    });
  } catch (error) {
    console.error("فشل توليد PDF:", error);
    return NextResponse.json(
      { error: "فشل في توليد PDF" },
      { status: 500 }
    );
  }
}

النقاط الرئيسية حول نقطة النهاية هذه:

  • renderToBuffer تولد PDF بالكامل في الذاكرة — لا حاجة لنظام ملفات
  • Content-Disposition مع attachment يفعل تحميل المتصفح
  • Content-Type مضبوط على application/pdf ليتعامل المتصفح معه بشكل صحيح
  • يعمل على جانب الخادم في Node.js — يعمل على Vercel أو Railway أو أي مستضيف Node

الخطوة 9: بناء واجهة نموذج الفاتورة

أنشئ نموذج الواجهة الأمامية حيث يدخل المستخدمون بيانات الفاتورة:

// src/app/invoice/page.tsx
 
"use client";
 
import { useState } from "react";
import type { InvoiceData, InvoiceItem } from "@/lib/types";
 
const defaultItem: InvoiceItem = {
  description: "",
  quantity: 1,
  unitPrice: 0,
  total: 0,
};
 
const defaultInvoice: InvoiceData = {
  invoiceNumber: `INV-${Date.now().toString(36).toUpperCase()}`,
  date: new Date().toISOString().split("T")[0],
  dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
    .toISOString()
    .split("T")[0],
  company: {
    name: "نقطة ديجيتال",
    address: "123 شارع التكنولوجيا",
    city: "تونس",
    country: "تونس",
    email: "billing@noqta.tn",
    phone: "+216 71 000 000",
    taxId: "TN-12345678",
  },
  client: {
    name: "",
    address: "",
    city: "",
    country: "",
    email: "",
  },
  items: [{ ...defaultItem }],
  subtotal: 0,
  taxRate: 19,
  taxAmount: 0,
  total: 0,
  currency: "TND",
  notes: "الدفع مستحق خلال 30 يوماً. شكراً لتعاملكم معنا!",
};
 
export default function InvoicePage() {
  const [invoice, setInvoice] = useState<InvoiceData>(defaultInvoice);
  const [isGenerating, setIsGenerating] = useState(false);
 
  function updateItem(index: number, field: keyof InvoiceItem, value: string | number) {
    const updatedItems = [...invoice.items];
    updatedItems[index] = {
      ...updatedItems[index],
      [field]: value,
    };
 
    // إعادة حساب إجمالي العنصر
    updatedItems[index].total =
      updatedItems[index].quantity * updatedItems[index].unitPrice;
 
    // إعادة حساب إجماليات الفاتورة
    const subtotal = updatedItems.reduce((sum, item) => sum + item.total, 0);
    const taxAmount = subtotal * (invoice.taxRate / 100);
 
    setInvoice({
      ...invoice,
      items: updatedItems,
      subtotal,
      taxAmount,
      total: subtotal + taxAmount,
    });
  }
 
  function addItem() {
    setInvoice({
      ...invoice,
      items: [...invoice.items, { ...defaultItem }],
    });
  }
 
  function removeItem(index: number) {
    const updatedItems = invoice.items.filter((_, i) => i !== index);
    const subtotal = updatedItems.reduce((sum, item) => sum + item.total, 0);
    const taxAmount = subtotal * (invoice.taxRate / 100);
 
    setInvoice({
      ...invoice,
      items: updatedItems,
      subtotal,
      taxAmount,
      total: subtotal + taxAmount,
    });
  }
 
  async function generatePDF() {
    setIsGenerating(true);
    try {
      const response = await fetch("/api/invoice", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(invoice),
      });
 
      if (!response.ok) {
        throw new Error("فشل توليد PDF");
      }
 
      // تحميل PDF
      const blob = await response.blob();
      const url = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = `invoice-${invoice.invoiceNumber}.pdf`;
      link.click();
      URL.revokeObjectURL(url);
    } catch (error) {
      console.error("خطأ:", error);
      alert("فشل في توليد PDF. يرجى المحاولة مرة أخرى.");
    } finally {
      setIsGenerating(false);
    }
  }
 
  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-8">مولد الفواتير</h1>
 
      {/* معلومات العميل */}
      <section className="mb-8 p-6 border rounded-lg">
        <h2 className="text-xl font-semibold mb-4">معلومات العميل</h2>
        <div className="grid grid-cols-2 gap-4">
          <input
            type="text"
            placeholder="اسم العميل"
            className="border rounded px-3 py-2"
            value={invoice.client.name}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, name: e.target.value },
              })
            }
          />
          <input
            type="email"
            placeholder="البريد الإلكتروني للعميل"
            className="border rounded px-3 py-2"
            value={invoice.client.email}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, email: e.target.value },
              })
            }
          />
          <input
            type="text"
            placeholder="العنوان"
            className="border rounded px-3 py-2"
            value={invoice.client.address}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, address: e.target.value },
              })
            }
          />
          <input
            type="text"
            placeholder="المدينة"
            className="border rounded px-3 py-2"
            value={invoice.client.city}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, city: e.target.value },
              })
            }
          />
          <input
            type="text"
            placeholder="البلد"
            className="border rounded px-3 py-2"
            value={invoice.client.country}
            onChange={(e) =>
              setInvoice({
                ...invoice,
                client: { ...invoice.client, country: e.target.value },
              })
            }
          />
        </div>
      </section>
 
      {/* عناصر السطر */}
      <section className="mb-8 p-6 border rounded-lg">
        <h2 className="text-xl font-semibold mb-4">عناصر الفاتورة</h2>
        <div className="space-y-3">
          {invoice.items.map((item, index) => (
            <div key={index} className="flex gap-3 items-center">
              <input
                type="text"
                placeholder="الوصف"
                className="border rounded px-3 py-2 flex-1"
                value={item.description}
                onChange={(e) =>
                  updateItem(index, "description", e.target.value)
                }
              />
              <input
                type="number"
                placeholder="الكمية"
                className="border rounded px-3 py-2 w-20"
                value={item.quantity}
                onChange={(e) =>
                  updateItem(index, "quantity", Number(e.target.value))
                }
              />
              <input
                type="number"
                placeholder="سعر الوحدة"
                className="border rounded px-3 py-2 w-32"
                value={item.unitPrice}
                onChange={(e) =>
                  updateItem(index, "unitPrice", Number(e.target.value))
                }
              />
              <span className="w-28 text-right font-mono">
                {invoice.currency} {item.total.toFixed(2)}
              </span>
              <button
                type="button"
                onClick={() => removeItem(index)}
                className="text-red-500 hover:text-red-700 px-2"
              >
                حذف
              </button>
            </div>
          ))}
        </div>
        <button
          type="button"
          onClick={addItem}
          className="mt-4 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded"
        >
          + إضافة عنصر
        </button>
      </section>
 
      {/* الملخص */}
      <section className="mb-8 p-6 border rounded-lg bg-gray-50">
        <div className="flex justify-between mb-2">
          <span>المجموع الفرعي</span>
          <span>
            {invoice.currency} {invoice.subtotal.toFixed(2)}
          </span>
        </div>
        <div className="flex justify-between mb-2">
          <span>الضريبة ({invoice.taxRate}%)</span>
          <span>
            {invoice.currency} {invoice.taxAmount.toFixed(2)}
          </span>
        </div>
        <div className="flex justify-between font-bold text-lg border-t pt-2">
          <span>الإجمالي</span>
          <span>
            {invoice.currency} {invoice.total.toFixed(2)}
          </span>
        </div>
      </section>
 
      {/* زر التوليد */}
      <button
        type="button"
        onClick={generatePDF}
        disabled={isGenerating}
        className="w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {isGenerating ? "جاري توليد PDF..." : "تحميل فاتورة PDF"}
      </button>
    </div>
  );
}

الخطوة 10: إضافة دعم الخطوط المخصصة

الخطوط المضمنة (Helvetica، Courier، Times-Roman) تعمل للمحتوى الإنجليزي. للعربية والخطوط المخصصة أو الطباعة ذات العلامة التجارية، سجل خطوط TTF:

// src/lib/register-fonts.ts
 
import { Font } from "@react-pdf/renderer";
 
// تسجيل عائلة خط مخصصة
Font.register({
  family: "Inter",
  fonts: [
    {
      src: "https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.ttf",
      fontWeight: 400,
    },
    {
      src: "https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.ttf",
      fontWeight: 700,
    },
  ],
});
 
// تسجيل خط يدعم العربية
Font.register({
  family: "Noto Sans Arabic",
  src: "https://fonts.gstatic.com/s/notosansarabic/v28/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyG2vu3CBFQLaig.ttf",
});

استورد هذا الملف في أعلى مسار API لضمان تسجيل الخطوط قبل العرض:

// src/app/api/invoice/route.ts
import "@/lib/register-fonts";
// ... باقي المسار

ثم استخدم الخط المخصص في أنماطك:

page: {
  fontFamily: "Inter",
  // ... أنماط أخرى
},

الخطوة 11: إضافة دعم RTL (العربية)

إحدى الميزات القوية لـ @react-pdf/renderer هي قدرتها على التعامل مع النص من اليمين إلى اليسار. هذا ضروري للفواتير العربية:

// src/components/pdf/InvoiceDocumentArabic.tsx
 
import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer";
import type { InvoiceData } from "@/lib/types";
 
const rtlStyles = StyleSheet.create({
  page: {
    padding: 40,
    fontSize: 10,
    fontFamily: "Noto Sans Arabic",
    direction: "rtl",
  },
  header: {
    flexDirection: "row-reverse",
    justifyContent: "space-between",
    marginBottom: 30,
  },
  text: {
    textAlign: "right",
  },
  tableHeader: {
    flexDirection: "row-reverse",
    backgroundColor: "#1a1a2e",
    padding: 8,
  },
  tableRow: {
    flexDirection: "row-reverse",
    padding: 8,
    borderBottomWidth: 1,
    borderBottomColor: "#e0e0e0",
  },
});
 
export function InvoiceDocumentArabic({ data }: { data: InvoiceData }) {
  return (
    <Document title={`فاتورة ${data.invoiceNumber}`}>
      <Page size="A4" style={rtlStyles.page}>
        <View style={rtlStyles.header}>
          <View>
            <Text style={{ fontSize: 28, textAlign: "right" }}>فاتورة</Text>
            <Text style={rtlStyles.text}>#{data.invoiceNumber}</Text>
          </View>
          <View>
            <Text style={{ fontSize: 22 }}>{data.company.name}</Text>
            <Text>{data.company.address}</Text>
          </View>
        </View>
 
        {/* جدول بالاتجاه المعكوس */}
        <View style={rtlStyles.tableHeader}>
          <Text style={{ color: "#fff", width: "20%" }}>المجموع</Text>
          <Text style={{ color: "#fff", width: "20%" }}>السعر</Text>
          <Text style={{ color: "#fff", width: "15%" }}>الكمية</Text>
          <Text style={{ color: "#fff", width: "45%" }}>الوصف</Text>
        </View>
        {data.items.map((item, i) => (
          <View key={i} style={rtlStyles.tableRow}>
            <Text style={{ width: "20%" }}>
              {data.currency} {item.total.toFixed(2)}
            </Text>
            <Text style={{ width: "20%" }}>
              {data.currency} {item.unitPrice.toFixed(2)}
            </Text>
            <Text style={{ width: "15%", textAlign: "center" }}>
              {item.quantity}
            </Text>
            <Text style={{ width: "45%" }}>{item.description}</Text>
          </View>
        ))}
      </Page>
    </Document>
  );
}

التقنيات الرئيسية لـ RTL:

  • direction: "rtl" على نمط الصفحة
  • flexDirection: "row-reverse" لعكس اتجاه التخطيط
  • textAlign: "right" لمحاذاة النص
  • عائلة خط عربي مسجلة مع Font.register()

الخطوة 12: توليد PDF باستخدام Server Actions

لنهج أكثر تكاملاً، يمكنك استخدام Server Actions في Next.js بدلاً من مسار API منفصل:

// src/app/invoice/actions.ts
 
"use server";
 
import { renderToBuffer } from "@react-pdf/renderer";
import { InvoiceDocument } from "@/components/pdf/InvoiceDocument";
import type { InvoiceData } from "@/lib/types";
 
export async function generateInvoicePDF(
  data: InvoiceData
): Promise<{ pdf: string; filename: string }> {
  const pdfBuffer = await renderToBuffer(
    <InvoiceDocument data={data} />
  );
 
  // تحويل المخزن المؤقت إلى base64 لنقله إلى العميل
  const base64 = Buffer.from(pdfBuffer).toString("base64");
 
  return {
    pdf: base64,
    filename: `invoice-${data.invoiceNumber}.pdf`,
  };
}

استخدم Server Action على العميل:

// في مكون العميل الخاص بك
import { generateInvoicePDF } from "./actions";
 
async function handleGenerate() {
  const { pdf, filename } = await generateInvoicePDF(invoice);
 
  // فك ترميز base64 وتفعيل التحميل
  const bytes = Uint8Array.from(atob(pdf), (c) => c.charCodeAt(0));
  const blob = new Blob([bytes], { type: "application/pdf" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  link.click();
  URL.revokeObjectURL(url);
}

Server Actions مريحة للحالات البسيطة، لكن نهج مسار API أفضل عندما تحتاج لبث ملفات PDF كبيرة أو التكامل مع خدمات خارجية.


الخطوة 13: متقدم - معاينة PDF في المتصفح

لتجربة مستخدم أفضل، يمكنك عرض معاينة مباشرة قبل التحميل. استخدم pdf() لتوليد رابط blob:

// src/components/pdf/PDFPreview.tsx
 
"use client";
 
import { useState, useEffect } from "react";
import { pdf } from "@react-pdf/renderer";
import { InvoiceDocument } from "./InvoiceDocument";
import type { InvoiceData } from "@/lib/types";
 
interface PDFPreviewProps {
  data: InvoiceData;
}
 
export function PDFPreview({ data }: PDFPreviewProps) {
  const [url, setUrl] = useState<string | null>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    async function generatePreview() {
      const blob = await pdf(<InvoiceDocument data={data} />).toBlob();
      if (!cancelled) {
        const objectUrl = URL.createObjectURL(blob);
        setUrl(objectUrl);
      }
    }
 
    generatePreview();
 
    return () => {
      cancelled = true;
      if (url) URL.revokeObjectURL(url);
    };
  }, [data]);
 
  if (!url) {
    return (
      <div className="flex items-center justify-center h-[600px] bg-gray-100 rounded-lg">
        <p className="text-gray-500">جاري إنشاء المعاينة...</p>
      </div>
    );
  }
 
  return (
    <iframe
      src={url}
      className="w-full h-[600px] rounded-lg border"
      title="معاينة الفاتورة"
    />
  );
}

مهم: دالة pdf() من @react-pdf/renderer تعمل بالكامل في المتصفح. هذا يعني أن المعاينة تعمل على جانب العميل بدون الوصول إلى خادمك — ممتاز للحصول على ردود فعل فورية أثناء ملء النموذج.


الخطوة 14: توليد PDF دفعي

لتوليد فواتير متعددة مرة واحدة (فوترة شهرية، تقارير)، أنشئ نقطة نهاية دفعية:

// src/app/api/invoices/batch/route.ts
 
import { NextRequest, NextResponse } from "next/server";
import { renderToBuffer } from "@react-pdf/renderer";
import { InvoiceDocument } from "@/components/pdf/InvoiceDocument";
import type { InvoiceData } from "@/lib/types";
import archiver from "archiver";
 
export async function POST(request: NextRequest) {
  const { invoices }: { invoices: InvoiceData[] } = await request.json();
 
  if (!invoices?.length) {
    return NextResponse.json(
      { error: "لم يتم تقديم فواتير" },
      { status: 400 }
    );
  }
 
  // توليد جميع ملفات PDF بشكل متزامن
  const pdfPromises = invoices.map(async (invoice) => {
    const buffer = await renderToBuffer(
      <InvoiceDocument data={invoice} />
    );
    return {
      filename: `invoice-${invoice.invoiceNumber}.pdf`,
      buffer,
    };
  });
 
  const pdfs = await Promise.all(pdfPromises);
 
  // إنشاء أرشيف ZIP
  const archive = archiver("zip", { zlib: { level: 9 } });
  const chunks: Uint8Array[] = [];
 
  archive.on("data", (chunk: Uint8Array) => chunks.push(chunk));
 
  for (const { filename, buffer } of pdfs) {
    archive.append(Buffer.from(buffer), { name: filename });
  }
 
  await archive.finalize();
  const zipBuffer = Buffer.concat(chunks);
 
  return new NextResponse(zipBuffer, {
    headers: {
      "Content-Type": "application/zip",
      "Content-Disposition": `attachment; filename="invoices-${Date.now()}.zip"`,
    },
  });
}

ثبت تبعية archiver:

npm install archiver
npm install -D @types/archiver

اختبار التنفيذ

شغل خادم التطوير واختبر مولد الفواتير:

npm run dev

انتقل إلى http://localhost:3000/invoice ثم:

  1. املأ تفاصيل العميل — أدخل الاسم والبريد الإلكتروني والعنوان
  2. أضف عناصر — أضف عناصر متعددة بكميات وأسعار
  3. تحقق من الحسابات — تأكد من صحة المجموع الفرعي والضريبة والإجمالي
  4. ولد PDF — انقر على زر التحميل
  5. افتح ملف PDF — تحقق من أن التخطيط والخطوط والبيانات مطابقة

يمكنك أيضاً اختبار API مباشرة باستخدام curl:

curl -X POST http://localhost:3000/api/invoice \
  -H "Content-Type: application/json" \
  -d '{
    "invoiceNumber": "INV-001",
    "date": "2026-04-10",
    "dueDate": "2026-05-10",
    "company": {
      "name": "نقطة ديجيتال",
      "address": "123 شارع التكنولوجيا",
      "city": "تونس",
      "country": "تونس",
      "email": "billing@noqta.tn",
      "phone": "+216 71 000 000"
    },
    "client": {
      "name": "شركة أكمي",
      "address": "456 شارع الأعمال",
      "city": "دبي",
      "country": "الإمارات",
      "email": "accounts@acme.com"
    },
    "items": [
      {"description": "تطوير ويب", "quantity": 40, "unitPrice": 45, "total": 1800},
      {"description": "تصميم UI/UX", "quantity": 20, "unitPrice": 50, "total": 1000}
    ],
    "subtotal": 2800,
    "taxRate": 19,
    "taxAmount": 532,
    "total": 3332,
    "currency": "TND",
    "notes": "الدفع مستحق خلال 30 يوماً."
  }' \
  --output invoice.pdf

استكشاف الأخطاء وإصلاحها

المشاكل الشائعة والحلول

"Cannot find module @react-pdf/renderer" يحدث هذا عادة عندما يحاول Next.js تجميع React-PDF للعميل. تأكد من أنك تستورد renderToBuffer فقط في ملفات جانب الخادم (مسارات API، Server Actions). استخدم الاستيراد الديناميكي إذا لزم الأمر:

const { renderToBuffer } = await import("@react-pdf/renderer");

أخطاء "Invalid hook call" مكونات React-PDF ليست مكونات React DOM. لا تعرضها أبداً داخل شجرة مكونات React عادية. يمكن فقط تمريرها إلى renderToBuffer أو renderToStream أو pdf().

الخطوط المخصصة لا تحمل يجب أن تكون روابط الخطوط قابلة للوصول في وقت البناء/العرض. للخطوط المستضافة ذاتياً، ضعها في مجلد public/ واستخدم روابط مطلقة:

Font.register({
  family: "MyFont",
  src: "/fonts/MyFont-Regular.ttf",
});

ملف PDF فارغ أو خالي تأكد من أن بياناتك ليست undefined. أضف console logs في مسار API للتحقق من وصول البيانات إلى دالة العرض. تأكد أيضاً من أن محتوى النص ملفوف في مكونات <Text> — النصوص المجردة داخل <View> لن تُعرض.

ملفات PDF الكبيرة بطيئة للمستندات ذات الصفحات الكثيرة، استخدم renderToStream بدلاً من renderToBuffer لتجنب تحميل ملف PDF بالكامل في الذاكرة:

import { renderToStream } from "@react-pdf/renderer";
 
const stream = await renderToStream(<InvoiceDocument data={data} />);
return new NextResponse(stream as any, {
  headers: { "Content-Type": "application/pdf" },
});

نصائح الأداء

  1. خزن الخطوط مؤقتاً — سجل الخطوط مرة واحدة على مستوى الوحدة، ليس لكل طلب
  2. استخدم renderToStream للمستندات الأكبر من 50 صفحة
  3. قلل إعادة العرض في مكون المعاينة باستخدام useMemo
  4. احسب الإجماليات مسبقاً على الخادم بدلاً من مكونات PDF
  5. اضغط الصور قبل تضمينها في ملفات PDF (استخدم WebP أو PNG مضغوط)

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

الآن بعد أن أصبح لديك نظام إنشاء PDF يعمل، فكر في هذه التحسينات:

  • تكامل البريد الإلكتروني — أرسل الفواتير كمرفقات بريد إلكتروني مع Resend
  • تخزين قاعدة البيانات — احفظ بيانات الفاتورة مع Drizzle ORM أو Prisma
  • المصادقة — احمِ نقطة نهاية الفاتورة مع Better Auth
  • التقارير المجدولة — أنشئ تقارير شهرية مع Trigger.dev
  • القوالب — أنشئ قوالب للتقارير والإيصالات والشهادات
  • التوقيعات الرقمية — أضف توقيع PDF للامتثال القانوني

الخلاصة

لقد بنيت نظام إنشاء PDF متكامل باستخدام @react-pdf/renderer و Next.js 15. الإعداد متوافق مع serverless، وآمن الأنواع، ويستخدم أنماط مكونات React المألوفة.

النقاط الرئيسية هي:

  • @react-pdf/renderer تولد ملفات PDF بمكونات React — لا حاجة لمتصفح بدون واجهة
  • تخطيط Flexbox يجعل التموضع طبيعياً ومتوقعاً
  • مسارات API توفر نقطة نهاية نظيفة لتوليد وتحميل PDF
  • Server Actions تقدم بديلاً أبسط لإنشاء PDF داخل التطبيق
  • الخطوط المخصصة ودعم RTL تجعلها مناسبة للمستندات متعددة اللغات
  • البث يتيح توليد المستندات الكبيرة بكفاءة

ملفات PDF هي متطلب أساسي لتطبيقات الأعمال — الفواتير والتقارير والعقود والشهادات. مع هذا الأساس، يمكنك بناء أي قالب مستند يحتاجه تطبيقك.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على إتقان الإحصاء: من الأساسيات الوصفية إلى الانحدار المتقدم واختبار الفرضيات.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة