יצירת PDF ב-Node.js: עשר שיטות והשוואה ביניהן

Puppeteer, Playwright, Gotenberg, PDFKit, pdf-lib, react-pdf, jsPDF, Typst, LibreOffice ושירותי API. איך כל שיטה עובדת, כמה היא שוקלת, ואיך היא מתנהגת עם עברית ו-RTL.

Node.jsPDF

כל מערכת שמוציאה מסמך ללקוח מגיעה לאותה נקודה: צריך PDF, והוא צריך להיראות נכון. חשבונית, דוח, כרטיס כניסה, חוזה. ב-Node יש בערך עשר דרכים לעשות את זה, והן נבדלות בשלושה דברים בלבד.

הראשון הוא מי מצייר את הדף. או שדפדפן מריץ HTML ו-CSS ומדפיס את התוצאה, או שספרייה מציירת טקסט וקווים בקואורדינטות. הראשון נותן עיצוב מוכר במחיר של 300 מגה-בייט Chromium בכל קונטיינר; השני שוקל מגה-בייטים בודדים ודורש לחשב מיקום של כל שורה.

השני הוא איפה זה רץ. Lambda עם 250MB אחרי פריקה, קונטיינר Docker בלי הגבלה, או תהליך Node אחד קטן שגם מגיש בקשות HTTP.

השלישי, ובישראל הוא הקובע: עברית. טקסט דו-כיווני מחייב שני דברים נפרדים, שאפשר לבלבל ביניהם. הראשון הוא shaping, כלומר בחירת הצורות הנכונות של הגליפים מתוך הפונט. השני הוא bidi, אלגוריתם UAX#9 שקובע באיזה סדר להציב אותם כשמעורבים בשורה עברית, ספרות ואנגלית. דפדפן עושה את שניהם. רוב ספריות ה-PDF עושות רק את הראשון, ולפעמים גם אותו לא.

Puppeteer

הדרך הנפוצה ביותר. Puppeteer מריץ Chromium, טוען HTML, ומבקש ממנו את אותה פונקציית הדפסה שיושבת מאחורי Ctrl+P.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();

await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
  format: 'A4',
  printBackground: true,
  margin: { top: '18mm', bottom: '18mm', left: '15mm', right: '15mm' },
  displayHeaderFooter: true,
  headerTemplate: '<div></div>',
  footerTemplate: '<div style="font-size:9px;width:100%;text-align:center">' +
    '<span class="pageNumber"></span> / <span class="totalPages"></span></div>',
});

await browser.close();

היתרון הוא שכל מה שאתם כבר יודעים על CSS עובד: Flexbox, Grid, @media print, page-break-inside: avoid על שורת טבלה, position: fixed לכותרת רצה, direction: rtl על ה-html. עברית עובדת מהקופסה, כולל מספרים בתוך משפט עברי, כי מנוע הטקסט של Chromium מריץ bidi מלא.

החסרונות ידועים. ההתקנה מורידה Chromium, ותמונת Docker קטנה הופכת לגדולה. כל המרה פותחת דף, כלומר תהליך רנדור, וזיכרון. ההרצה כ-root ב-Docker דורשת --no-sandbox, ומרגע שהעברתם HTML שמקורו במשתמש, ההגנה הזאת חשובה יותר ממה שנדמה.

שתי טעויות חוזרות. הראשונה היא waitUntil: 'networkidle0' על HTML עם פונט מ-Google Fonts: אם הרשת בקונטיינר חסומה, הדף ממתין עד ה-timeout ואז מודפס בפונט ברירת מחדל, כלומר בלי עברית תקינה. הכניסו את הפונטים כקבצים מקומיים או כ-base64 בתוך @font-face. השנייה היא הפעלת דפדפן חדש לכל בקשה. שמרו מופע אחד חי ופתחו ממנו דפים, או עדיף, הקשרים מבודדים:

const context = await browser.createBrowserContext();
const page = await context.newPage();
// ...
await context.close();

Playwright

אותה גישה בדיוק, ארגז כלים בוגר יותר. playwright install --with-deps chromium מביא גם את ספריות המערכת שחסרות בתמונות Debian רזות, וזה חוסך את סבב הניחושים של libnss3, libatk ו-libgbm.

import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();

await page.goto(`${APP_URL}/invoices/1042/print`, { waitUntil: 'networkidle' });
await page.emulateMedia({ media: 'print' });
const pdf = await page.pdf({ format: 'A4', printBackground: true });

await browser.close();

page.pdf() קיים ב-Chromium בלבד, גם ב-Playwright. אם המסמך נבנה מדף מוגן, browser.newContext({ storageState }) מזריק עוגיות התחברות בלי לעבור מסך לוגין בכל הרצה. יש גם תמונת Docker רשמית עם הדפדפנים והפונטים בפנים, מה שהופך את ההבדל מ-Puppeteer לעניין של תפעול יותר מאשר של API.

Gotenberg

במקום להריץ דפדפן בתוך תהליך ה-Node שלכם, מריצים אותו כשירות נפרד ושולחים אליו קבצים ב-HTTP. Gotenberg עוטף Chromium וגם LibreOffice.

const form = new FormData();
form.append('files', new File([html], 'index.html', { type: 'text/html' }));
form.append('files', new File([logo], 'logo.png'));
form.append('paperWidth', '8.27');
form.append('paperHeight', '11.7');

const res = await fetch('http://gotenberg:3000/forms/chromium/convert/html', {
  method: 'POST',
  body: form,
});
const pdf = Buffer.from(await res.arrayBuffer());

מה שזה קונה: האפליקציה נשארת קלה, הדפדפן מקבל קונטיינר משלו עם מגבלת זיכרון משלו, וקריסה בהמרה לא מפילה את שרת ה-API. אפשר גם להוסיף מכונות המרה בלי לגעת באפליקציה. המחיר הוא רכיב תשתית נוסף שצריך לנטר, ו-round trip ברשת על כל מסמך. אם המסמכים כבדים, זה מורגש.

הפונטים יושבים בתמונה של Gotenberg, לא בשלכם. תמונה שנבנתה בלי פונט עברי תחזיר ריבועים, וזה הבאג הראשון שכולם פוגשים כאן.

PDFKit

הצד השני של המפה. אין דפדפן, אין HTML. אתם מציירים.

import PDFDocument from 'pdfkit';

const doc = new PDFDocument({ size: 'A4', margin: 50 });
const chunks = [];
doc.on('data', (c) => chunks.push(c));

doc.registerFont('body', 'assets/Rubik-Regular.ttf');
doc.font('body').fontSize(20).text('חשבונית מס 1042', { align: 'right' });
doc.moveDown().fontSize(11).text('סה"כ לתשלום: 1,240.00', { align: 'right' });

doc.end();
await new Promise((resolve) => doc.on('end', resolve));
const pdf = Buffer.concat(chunks);

הספרייה שוקלת מעט, רצה בכל מקום, ומייצרת מסמך תוך מילישניות בודדות. היא מתאימה כשהפריסה קבועה וידועה מראש, למשל תווית משלוח או קבלה בגודל אחיד.

עברית היא הבעיה. PDFKit משתמש ב-fontkit ולכן קורא פונטים של OpenType, אבל הוא לא מריץ אלגוריתם דו-כיווניות. שורה שמערבת עברית, ספרות וסימנים תצא בסדר שגוי, ותיקון ידני על ידי היפוך מחרוזות נשבר ברגע שנכנס לשורה מספר או מילה באנגלית. אם חייבים, מריצים את הטקסט דרך bidi-js לפני הציור ומקבלים תוצאה סבירה, אבל כל תיבה ותיבה היא עכשיו קוד שלכם.

חסרון שני: אין פריסה אוטומטית. טבלה עם עמודות ברוחב משתנה, שורה שנשברת לשני עמודים, כותרת שחוזרת בעמוד הבא, כל אלה נכתבים ביד.

pdf-lib

pdf-lib אינו מנוע פריסה. הוא ספרייה שקוראת וכותבת את מבנה הקובץ עצמו, וזה בדיוק הכוח שלו: הוא היחיד ברשימה שעובד היטב על PDF קיים.

import { PDFDocument } from 'pdf-lib';

const pdf = await PDFDocument.load(templateBytes);
const form = pdf.getForm();
form.getTextField('customer').setText('חברת דוגמה בע"מ');
form.getTextField('total').setText('1,240.00');
form.flatten();

const merged = await PDFDocument.create();
for (const src of [pdf, await PDFDocument.load(termsBytes)]) {
  const pages = await merged.copyPages(src, src.getPageIndices());
  pages.forEach((p) => merged.addPage(p));
}

const bytes = await merged.save();

מילוי טפסים, מיזוג, פיצול, סיבוב, חותמת על עמוד קיים, הוספת מטא-דאטה. הכל בלי תלויות מקומיות ובלי דפדפן, כולל בדפדפן עצמו. לפונט לא סטנדרטי צריך @pdf-lib/fontkit שנרשם במפורש, ובעברית חוזרת אותה מגבלה: אין bidi, ואת סדר התווים אתם קובעים.

השילוב שעובד בפרקטיקה הוא Chromium שמייצר את הדף, ואז pdf-lib שמוסיף עליו עמוד נספח או חתימה. שתי הספריות עושות דברים שונים ולא מתחרות זו בזו.

react-pdf

אם ה-UI כתוב ב-React, @react-pdf/renderer נותן לתאר מסמך באותה שפה, עם מנוע פריסה משלו שמכיר Flexbox.

import { Document, Page, Text, View, Font, renderToBuffer } from '@react-pdf/renderer';

Font.register({ family: 'Rubik', src: 'assets/Rubik-Regular.ttf' });

const Invoice = ({ order }) => (
  <Document>
    <Page size="A4" style={{ fontFamily: 'Rubik', padding: 40, direction: 'rtl' }}>
      <Text style={{ fontSize: 20 }}>חשבונית מס {order.id}</Text>
      {order.lines.map((line) => (
        <View key={line.id} style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
          <Text>{line.name}</Text>
          <Text>{line.total}</Text>
        </View>
      ))}
    </Page>
  </Document>
);

const pdf = await renderToBuffer(<Invoice order={order} />);

היתרון הוא רכיבים: אותו <InvoiceRow /> שמוצג במסך יכול לעטוף את אותו מודל נתונים. אין Chromium, והפלט דטרמיניסטי.

היתרון הזה נגמר מהר כשמנסים להעתיק עיצוב קיים. תת-הקבוצה של CSS שנתמכת קטנה, אין Grid, position מוגבל, ושליטה בשבירת עמודים מסתכמת ב-break ו-wrap. התמיכה ב-RTL קיימת אבל חלקית, ושורה מעורבת של עברית ואנגלית מחייבת בדיקה בעיניים ולא הנחה. בנוסף, JSX בצד השרת דורש שלב בנייה, וזו הוצאה שלא הייתה קיימת קודם.

jsPDF

נכתב במקור לדפדפן, ורץ גם ב-Node. מתאים כשההפקה צריכה לקרות אצל המשתמש, בלי לשלוח את הנתונים לשרת בכלל.

import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';

const doc = new jsPDF({ unit: 'mm', format: 'a4' });
doc.addFileToVFS('Rubik.ttf', rubikBase64);
doc.addFont('Rubik.ttf', 'Rubik', 'normal');
doc.setFont('Rubik').setR2L(true);

doc.text('דוח מכירות', 190, 20, { align: 'right' });
autoTable(doc, { head: [['סכום', 'לקוח']], body: rows, styles: { font: 'Rubik', halign: 'right' } });

const blob = doc.output('blob');

setR2L(true) הופך את סדר התווים בשורה, וזה פותר את המקרה הפשוט של עברית בלבד. ברגע שיש מספר או מילה לטינית באמצע, התוצאה שוב לא נכונה, כי היפוך אינו bidi. הוספת פונט דורשת המרה ל-base64 מראש, מה שמנפח את חבילת ה-JS. jspdf-autotable פותר את הטבלאות והוא הסיבה שרוב האנשים נשארים עם הספרייה.

הכלל הפשוט: jsPDF שווה את זה כשהדרישה היא הפקה בצד הלקוח. בשרת יש אפשרויות טובות יותר.

Typst, LaTeX ו-Pandoc

כשהמסמך ארוך, מובנה וטיפוגרפית תובעני, כלי הקלדה אמיתי עושה עבודה טובה יותר מדפדפן. Node רק מכין קובץ קלט ומריץ תהליך.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

await promisify(execFile)('typst', [
  'compile',
  'templates/report.typ',
  '/tmp/report.pdf',
  '--input', `total=${total}`,
]);

Typst מהיר, השפה קריאה, וההידור לוקח עשיריות שנייה. LaTeX עם XeLaTeX או LuaLaTeX נותן שליטה טיפוגרפית מלאה, כולל עברית דרך polyglossia, במחיר של התקנה שמשקלה גיגה-בייטים ושל שפה שאף אחד בצוות לא רוצה לתחזק. Pandoc מתאים כשהמקור הוא Markdown והפלט נדרש בכמה פורמטים.

בכל המקרים האלה אתם מריצים תהליך חיצוני. אם התבנית מקבלת קלט ממשתמש, בדקו אותו: מהדר טקסט הוא מנוע ביצוע, ו-LaTeX בפרט יודע לקרוא ולכתוב קבצים אם לא סוגרים אותו.

LibreOffice ותבניות DOCX

תרחיש נפוץ שקל לפספס: רואה החשבון או המחלקה המשפטית מחזיקים תבנית Word ורוצים לערוך אותה בעצמם. במקום לשכתב אותה ב-HTML, ממלאים אותה וממירים.

import Docxtemplater from 'docxtemplater';
import PizZip from 'pizzip';

const doc = new Docxtemplater(new PizZip(templateBuffer), { linebreaks: true });
doc.render({ customer: 'חברת דוגמה בע"מ', total: '1,240.00' });
const docx = doc.getZip().generate({ type: 'nodebuffer' });

// soffice --headless --convert-to pdf --outdir /tmp /tmp/filled.docx

התבנית נשארת בבעלות מי שכתב אותה, והעיצוב נשמר. המחיר הוא LibreOffice בקונטיינר, המרה שלוקחת שניות ולא מילישניות, ותהליך שלא תמיד מסתיים בשקט ודורש timeout ו-kill. Gotenberg חושף בדיוק את המסלול הזה כ-endpoint, וזו בדרך כלל הדרך השפויה להריץ אותו.

שירותי API מנוהלים

DocRaptor, PDFShift, Api2Pdf, Browserless, PDFMonkey. אתם שולחים HTML או URL ומקבלים קובץ.

const res = await fetch('https://api.example-pdf.com/v1/convert', {
  method: 'POST',
  headers: { 'content-type': 'application/json', authorization: `Bearer ${KEY}` },
  body: JSON.stringify({ source: html, format: 'A4' }),
});

זה מוריד מהצוות את כל נושא הדפדפן בפרודקשן: אין תמונה לתחזק, אין דליפות זיכרון, אין פונטים חסרים. DocRaptor רץ על PrinceXML, שמטפל בשבירת עמודים ובכותרות טבלה חוזרות טוב יותר מ-Chromium.

בתמורה, כל מסמך עולה כסף ועובר דרך שרת של צד שלישי. למסמך שמכיל שם לקוח, סכומים ומספר עוסק, זה שיקול פרטיות ולא רק שיקול תקציב, והוא צריך להופיע בהסכם עיבוד המידע שלכם.

טבלת השוואה

שיטה תלות עברית ו-RTL מתי מתאים
Puppeteer Chromium, כ-300MB מלא, כולל שורה מעורבת מסמך מעוצב שכבר קיים כ-HTML
Playwright Chromium, תמונה רשמית מלא אותו דבר, כשההרצה ב-Docker חשובה
Gotenberg קונטיינר נפרד מלא, אם יש פונט בתמונה נפח גבוה, הפרדה בין השירותים
PDFKit ספרייה בלבד shaping בלבד, בלי bidi פריסה קבועה, תווית או קבלה
pdf-lib ספרייה בלבד בלי bidi עריכה, מיזוג ומילוי טפסים
react-pdf ספרייה ושלב בנייה חלקי, דורש בדיקה קוד React משותף למסך ולמסמך
jsPDF ספרייה בלבד setR2L בלבד הפקה בדפדפן, בלי שרת
Typst או LaTeX מהדר חיצוני טוב, בהגדרה נכונה דוחות ארוכים, טיפוגרפיה
LibreOffice חבילה כבדה טוב תבנית Word שמישהו אחר מתחזק
שירות API חשבון וקישוריות מלא להוציא את הנושא מהצוות

עברית: מה באמת נשבר

הבעיה הכי נפוצה היא לא קוד, אלא פונט חסר. תמונת node:22-slim מגיעה בלי פונטים כמעט, ו-Chromium שלא מוצא פונט עברי מצייר ריבועים. fonts-noto-core ו-fonts-noto-hebrew פותרים את זה, או פשוט העתקת קובץ ה-TTF שלכם לתוך התמונה:

COPY assets/Rubik-Regular.ttf /usr/share/fonts/truetype/rubik/
RUN fc-cache -f

הבעיה השנייה היא סדר. direction: rtl על אלמנט ב-HTML מפעיל את bidi ופותר גם את המקרה של סה"כ 1,240.00 ש"ח. בספריות ציור אין דבר כזה, ומי שמדביק text.split('').reverse().join('') יגלה שהסכום התהפך יחד עם המילים.

הבעיה השלישית עדינה: ניקוד, גרשיים וסימני פיסוק בקצה שורה. בדקו על טקסט אמיתי מהמערכת, לא על שלום עולם.

הרצה בפרודקשן

חמישה דברים ששווים יותר מבחירת הספרייה.

הריצו את ההמרה מחוץ למחזור הבקשה. תור עבודות עם worker נפרד מונע מדף כבד אחד לתפוס את השרת, ומאפשר retry בלי שהמשתמש יראה שגיאה.

הגבילו זיכרון וזמן. --max-old-space-size על Node לא מגן על תהליך הדפדפן, שהוא זה שיתפח; המגבלה צריכה להיות ברמת הקונטיינר, ולכל המרה צריך timeout שמוודא שהדף נסגר גם כשההמרה נכשלה.

מחזרו מופע דפדפן אחד, לא לכל בקשה, אבל אתחלו אותו כל כמה מאות המרות. דליפות זיכרון ב-Chromium ארוך-חיים הן עובדה, לא תיאוריה.

ב-Lambda השתמשו ב-@sparticuz/chromium יחד עם puppeteer-core. חבילת ה-puppeteer המלאה לא נכנסת למגבלת הגודל, ו-/tmp הוא המקום היחיד שאפשר לכתוב אליו.

עקבו אחרי גודל הפלט. קפיצה פתאומית של פי חמישה בגודל הקובץ מעידה בדרך כלל על תמונה שנכנסה ברזולוציה מלאה במקום ממוזערת, וזה הדבר שהמשתמשים ירגישו לפני שהניטור שלכם ירגיש.

איך בוחרים

אם המסמך כבר קיים כדף באפליקציה, Playwright או Puppeteer, ושדרוג ל-Gotenberg כשהנפח מצדיק שירות נפרד. אם הפריסה קבועה וקטנה ואתם רוצים מסמך ללא דפדפן, PDFKit. אם צריך לגעת בקובץ קיים, pdf-lib, בלי קשר למה שיצר אותו. אם התבנית שייכת למישהו שכותב ב-Word, LibreOffice. ואם אף אחד בצוות לא רוצה להיות אחראי על דפדפן בפרודקשן, שירות מנוהל, בתנאי שתוכן המסמך מתיר לו לצאת החוצה.