هذا المقال يتطلب مستوى: متوسط
اعمل خدمة توليد فواتير 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 }; - شغّل متصفحًا واحدًا وأعد استخدامه. ده أهم سطر في الأداء. متفتحش متصفح جديد لكل طلب — افتح واحد وخليه عايش، وافتح صفحة جديدة بس لكل فاتورة.
النتيجة المتوقعة: دالة بترجّع Buffer فيه بايتات الـ PDF.
// pdf.js const puppeteer = require('puppeteer'); const { renderInvoice } = require('./template'); let browserPromise; const getBrowser = () => (browserPromise ??= puppeteer.launch({ headless: 'new', args: ['--no-sandbox'], })); async function invoiceToPdf(data) { const browser = await getBrowser(); const page = await browser.newPage(); try { await page.setContent(renderInvoice(data), { waitUntil: 'networkidle0' }); return await page.pdf({ format: 'A4', printBackground: true, margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' }, }); } finally { await page.close(); // اقفل الصفحة، مش المتصفح } } module.exports = { invoiceToPdf }; - اربطها بمسار Express يبثّ الملف.
النتيجة المتوقعة:
// server.js const express = require('express'); const { invoiceToPdf } = require('./pdf'); const app = express(); // بيانات وهمية — بدّلها باستعلام قاعدة بياناتك const getInvoice = (id) => ({ id, customer: 'شركة النور', items: [ { name: 'استضافة سنوية', qty: 1, price: 1200 }, { name: 'دومين', qty: 1, price: 50 }, ], }); app.get('/invoice/:id', async (req, res) => { try { const pdf = await invoiceToPdf(getInvoice(req.params.id)); res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="invoice-${req.params.id}.pdf"`, }); res.send(pdf); } catch (e) { console.error(e); res.status(500).send('failed to render pdf'); } }); app.listen(3000, () => console.log('on http://localhost:3000'));GET /invoice/42بيرجّع ملف PDF جاهز. - اضبط تنسيق الطباعة. المتصفح بيطبع بوسائط
printمشscreen. استخدم@pageللتحكم في الحجم والهوامش، و-webkit-print-color-adjustعشان ألوان الخلفيات متضيعش في الملف.@page { size: A4; margin: 0; } @media print { body { -webkit-print-color-adjust: exact; } }
التحقق من أنه يعمل
- شغّل
node server.js. - افتح
http://localhost:3000/invoice/42في المتصفح — المفروض تشوف الـ PDF جوّه الصفحة. - من التيرمينال:
curl -o test.pdf http://localhost:3000/invoice/42ثم افتحtest.pdf. لو فتح وفيه الجدول والعربي مظبوط في اتجاهه، تمام كله شغّال.
الأرقام والمقايضات
على سيرفر صغير (2 vCPU)، فتح متصفح جديد لكل طلب بيضيف حوالي 800 مللي ثانية إلى 1.5 ثانية لكل فاتورة. لما تعيد استخدام نفس المتصفح وتفتح صفحة جديدة بس، وقت توليد الفاتورة الواحدة بينزل لحوالي 120 إلى 300 مللي ثانية. يعني تحسّن 4 إلى 8 أضعاف من قرار واحد: إنك متقفلش المتصفح بعد كل طلب.
المقايضة صريحة: Puppeteer بيجرّ Chromium كامل معاه. بتكسب وفاء تنسيق 100% (Flexbox، خطوط ويب، اتجاه RTL عربي جاهز من غير مجهود)؛ وبتخسر حوالي 150 إلى 250 ميجابايت رام لكل نسخة متصفح، و170 ميجابايت مساحة قرص، وشوية تعقيد في إعداد الـ Docker (محتاج مكتبات النظام اللي Chromium بيعتمد عليها). الافتراض إن عندك ≥ 1 جيجابايت رام فاضية مخصّصة للخدمة دي.
متى لا تستخدم هذه الطريقة
- حجم ضخم جدًا (آلاف الملفات في الثانية): متصفح واحد مش هيكفي. محتاج مجموعة متصفحات (browser pool) وطابور معالجة، أو خدمة توليد PDF مخصّصة.
- إيصالات نصية بسيطة من غير تنسيق أو جداول: مكتبة خفيفة زي
pdfkitأرخص بكثير في الرام ووقت التشغيل. - بيئات Serverless بميزانية بدء ضيّقة: تشغيل Chromium بيتقّل الـ cold start. استخدم بناء مخصّص زي
@sparticuz/chromium، أو خدمة PDF مستضافة تنادي عليها بـ API.
الخطوة التالية
خُد ملف pdf.js فوق كما هو، بدّل دالة getInvoice باستعلام قاعدة بياناتك الحقيقي، وشغّل curl -o out.pdf http://localhost:3000/invoice/<رقم فاتورة حقيقي>. لو الـ PDF طلع بخط عربي مكسور أو حروف منفصلة، ضيف خط ويب عربي (زي Cairo) داخل الـ <head> وأعد التوليد — دي أشهر مشكلة وحلها سطر واحد.
المصادر
- توثيق Puppeteer الرسمي — دالة
page.pdfوخياراتها: pptr.dev/api/puppeteer.page.pdf - Chrome for Developers — وضع المتصفح بلا واجهة (Headless): developer.chrome.com/docs/chromium/headless
- MDN — قاعدة
@pageوأنماط الطباعة: developer.mozilla.org/.../CSS/@page - Express — إرسال الردود (
res.sendوres.set): expressjs.com/en/api.html - @sparticuz/chromium — تشغيل Chromium في بيئات Serverless: github.com/Sparticuz/chromium