هذا المقال يتطلب مستوى: متوسط
اعمل خدمة توليد فواتير PDF من HTML بـ Node.js و Puppeteer
هتخرج من المقال ده بخدمة حقيقية بتحوّل أي فاتورة HTML لملف PDF جاهز للتحميل، بنفس تنسيق صفحتك بالظبط: خط عربي، اتجاه RTL، وجدول أسعار مضبوط. الشغل كله في أقل من 80 سطر.
الافتراض إن عندك Node.js 18 أو أحدث، وحجم الحِمل ≤ 20 فاتورة في الثانية على سيرفر صغير. فوق كده محتاج طابور، وهنقولك امتى بالظبط.
المشكلة باختصار
توليد PDF من الصفر بمكتبة رسم — يعني ترسم كل سطر بإحداثيات x و y — بيتحوّل لكابوس أول ما الفاتورة يكون فيها جدول، شعار، أو نص عربي. الطريقة دي بتفشل بسرعة لأنك بتعيد اختراع محرك تنسيق موجود أصلاً في متصفحك.
البديل: تكتب الفاتورة HTML و CSS عادي — اللي انت شاطر فيه أصلاً — وتخلّي المتصفح هو اللي يطبعها PDF. نفس المحرك اللي بيعرض صفحتك بيولّد الملف، فاللي بتشوفه هو اللي بينزل.
الفكرة: متصفح بلا واجهة
تخيّل مطبعة كاملة في أوضة خلفية: فيها كل الماكينات والحبر والورق، بس من غير شاشة عرض قدامها. بتبعتلها ورقة تصميم، وبترجّعلك نسخة مطبوعة، وانت مش شايف الأوضة أصلاً. ده بالظبط المتصفح بلا واجهة (Headless Browser).
علميًا: Puppeteer بيشغّل نسخة حقيقية من Chromium من غير واجهة رسومية. نفس محرك التنسيق (Blink) اللي في كروم العادي بيقرأ الـ DOM، يحسب التخطيط والخطوط والألوان، وبعدين يصدّر الصفحة PDF عبر أمر printToPDF في بروتوكول DevTools. فالنتيجة مطابقة للعرض على الشاشة، مش تقريب ليه.
ابنيها خطوة بخطوة
- جهّز المشروع وثبّت المكتبات. النتيجة المتوقعة: مجلد فيه Express و Puppeteer، ومعاه نسخة Chromium متحمّلة.
Puppeteer بيحمّل نسخته من Chromium أول مرة (حوالي 170 ميجابايت)، فالتثبيت الأول بياخد شوية وقت. ده طبيعي.
mkdir invoice-pdf && cd invoice-pdf npm init -y npm install express puppeteer - اكتب قالب الفاتورة HTML. ملف يبني الـ HTML من بياناتك، باتجاه RTL ودالة تهرّب النص. ليه نهرّب النص؟ لو حطّيت اسم العميل في الـ HTML على طول من غير تهذيب، أي عميل اسمه فيه رموز زي علامة أصغر من ممكن يكسر فاتورتك أو يحقن كود. زي ما بتفلتر أي مدخل مستخدم، هنا بنحوّل الرموز الخاصة لصيغتها الآمنة.
النتيجة المتوقعة: دالة بترجّع نص HTML كامل من أي كائن بيانات.
// template.js const esc = (s) => String(s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); function renderInvoice(data) { const rows = data.items.map(it => `<tr><td>${esc(it.name)}</td><td>${it.qty}</td><td>${it.price.toFixed(2)}</td></tr>` ).join(''); const total = data.items.reduce((s, it) => s + it.qty * it.price, 0); return `<!doctype html> <html lang="ar" dir="rtl"><head><meta charset="utf-8"> <style> body { font-family: 'Cairo', sans-serif; padding: 40px; color:#1e293b; } h1 { color:#4338ca; } table { width:100%; border-collapse:collapse; margin-top:16px; } td, th { border:1px solid #e2e8f0; padding:8px; text-align:right; } .total { font-size:20px; font-weight:bold; margin-top:20px; } </style></head><body> <h1>فاتورة #${esc(data.id)}</h1> <p>العميل: ${esc(data.customer)}</p> <table> <tr><th>الصنف</th><th>الكمية</th><th>السعر</th></tr> ${rows} </table> <div class="total">الإجمالي: ${total.toFixed(2)} ج.م</div> </body></html>`; } module.exports = { renderInvoice };