reportgen
Back to all posts

Multi-Language PDF Generation: From English to العربية Without Breaking Everything 🌍

May 2, 2025

Your app just got its first user from Dubai. They generate a PDF invoice.

All the Arabic text renders as boxes. □□□□□□.

Or worse - right-to-left text flows left-to-right, making it completely unreadable.

Welcome to the nightmare of international PDF generation.

But it doesn't have to be this way. Here's how to generate PDFs in any language without breaking everything.


The Multi-Language Challenge

Generating PDFs in English? Easy. Add non-Latin scripts? Everything breaks.

Common Problems:

Missing fonts - Characters render as boxes (□) ❌ Wrong text direction - Arabic/Hebrew flow left-to-right instead of RTL ❌ Character encoding issues - Mojibake (文字化け) ❌ Font fallbacks failing - Mixed languages don't render ❌ Locale formatting - Dates/numbers display incorrectly

Let's fix all of them.


Step 1: Font Support for All Scripts

The Problem: Font Coverage

Most fonts only support Latin characters. Add Arabic, Chinese, or Thai, and you get boxes.

<!-- ❌ This will show boxes for non-Latin text -->
<style>
  body { font-family: 'Arial', sans-serif; }
</style>
<p>مرحبا بك</p> <!-- Renders as: □□□□ -->

The Solution: Use Google Noto Fonts

Noto (No Tofu, where "tofu" = □) covers every script.

<!-- ✅ Universal font support -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&family=Noto+Sans+Arabic:wght@400;700&family=Noto+Sans+SC:wght@400;700&display=swap" rel="stylesheet">
 
<style>
  body {
    font-family: 'Noto Sans', 'Noto Sans Arabic', 'Noto Sans SC', sans-serif;
  }
</style>

Language-Specific Font Stacks

<style>
  /* Latin + Cyrillic + Greek */
  .western {
    font-family: 'Inter', 'Roboto', -apple-system, sans-serif;
  }
 
  /* Arabic */
  .arabic {
    font-family: 'Noto Sans Arabic', 'Arabic Typesetting', sans-serif;
    direction: rtl;
  }
 
  /* Hebrew */
  .hebrew {
    font-family: 'Noto Sans Hebrew', 'David', sans-serif;
    direction: rtl;
  }
 
  /* Chinese Simplified */
  .chinese-simplified {
    font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  }
 
  /* Chinese Traditional */
  .chinese-traditional {
    font-family: 'Noto Sans TC', 'PingFang TC', 'Microsoft JhengHei', sans-serif;
  }
 
  /* Japanese */
  .japanese {
    font-family: 'Noto Sans JP', 'Hiragino Kaku Gothic Pro', 'Yu Gothic', sans-serif;
  }
 
  /* Korean */
  .korean {
    font-family: 'Noto Sans KR', 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
  }
 
  /* Thai */
  .thai {
    font-family: 'Noto Sans Thai', 'Leelawadee', sans-serif;
  }
 
  /* Devanagari (Hindi, Marathi, Nepali) */
  .devanagari {
    font-family: 'Noto Sans Devanagari', 'Mangal', sans-serif;
  }
</style>

Dynamic Font Loading

function getFont(languageCode) {
  const fontMap = {
    'ar': 'Noto Sans Arabic',
    'he': 'Noto Sans Hebrew',
    'zh-CN': 'Noto Sans SC',
    'zh-TW': 'Noto Sans TC',
    'ja': 'Noto Sans JP',
    'ko': 'Noto Sans KR',
    'th': 'Noto Sans Thai',
    'hi': 'Noto Sans Devanagari',
    'default': 'Noto Sans'
  };
 
  return fontMap[languageCode] || fontMap.default;
}
 
// In your template
const html = `
<style>
  body {
    font-family: '${getFont(user.language)}', sans-serif;
  }
</style>
`;

Step 2: Right-to-Left (RTL) Support

The Problem: Text Direction

Arabic and Hebrew read right-to-left. English templates flow left-to-right.

<!-- ❌ Arabic text flows the wrong direction -->
<p>مرحباً بك في التقرير</p>
<!-- Renders backward: ريرقتلا يف كب ابحرم -->

The Solution: Set dir="rtl"

<!-- ✅ Correct RTL rendering -->
<html dir="rtl" lang="ar">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: 'Noto Sans Arabic', sans-serif;
      direction: rtl;
      text-align: right;
    }
  </style>
</head>
<body>
  <p>مرحباً بك في التقرير</p>
</body>
</html>

Mixed Direction Content

<style>
  .rtl {
    direction: rtl;
    text-align: right;
  }
 
  .ltr {
    direction: ltr;
    text-align: left;
  }
 
  /* For embedded LTR text in RTL context */
  .embed-ltr {
    display: inline-block;
    direction: ltr;
  }
</style>
 
<div class="rtl">
  <p>السعر الإجمالي: <span class="embed-ltr">$1,234.56</span></p>
  <!-- Price renders correctly in LTR within RTL text -->
</div>

RTL-Aware Table Layouts

<style>
  /* LTR Table */
  table.ltr {
    direction: ltr;
  }
 
  /* RTL Table */
  table.rtl {
    direction: rtl;
  }
 
  table.rtl th:first-child,
  table.rtl td:first-child {
    text-align: right;
  }
</style>
 
<table class="rtl">
  <thead>
    <tr>
      <th>المنتج</th>
      <th>الكمية</th>
      <th>السعر</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>كمبيوتر محمول</td>
      <td>2</td>
      <td>$2,000</td>
    </tr>
  </tbody>
</table>

Step 3: Character Encoding

Always Use UTF-8

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <!-- ☝️ CRITICAL: Always specify UTF-8 -->
</head>
<body>
  <p>こんにちは世界</p> <!-- Japanese -->
  <p>你好世界</p> <!-- Chinese -->
  <p>مرحبا بالعالم</p> <!-- Arabic -->
</body>
</html>

Server-Side Encoding

// Ensure your server sends UTF-8
app.use((req, res, next) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  next();
});
 
// When generating PDFs
const html = Buffer.from(template, 'utf8').toString();
 
const response = await fetch('https://reportgen.io/api/v1/generate-pdf-sync', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json; charset=utf-8',
    'X-API-Key': apiKey
  },
  body: JSON.stringify({
    html_template: html,
    data: data,
    engine: 'handlebars'
  })
});

Step 4: Locale-Aware Formatting

Dates and Times

function formatDate(date, locale) {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }).format(date);
}
 
const examples = {
  'en-US': formatDate(new Date(), 'en-US'), // "May 2, 2025"
  'ar-SA': formatDate(new Date(), 'ar-SA'), // "٢ مايو ٢٠٢٥"
  'zh-CN': formatDate(new Date(), 'zh-CN'), // "2025年5月2日"
  'ja-JP': formatDate(new Date(), 'ja-JP'), // "2025年5月2日"
};
 
// In template
const html = `<p>{{formatted_date}}</p>`;
const data = { formatted_date: formatDate(invoice.date, user.locale) };

Numbers and Currency

function formatCurrency(amount, locale, currency) {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency
  }).format(amount);
}
 
const examples = {
  'en-US': formatCurrency(1234.56, 'en-US', 'USD'), // "$1,234.56"
  'ar-SA': formatCurrency(1234.56, 'ar-SA', 'SAR'), // "١٬٢٣٤٫٥٦ ر.س."
  'de-DE': formatCurrency(1234.56, 'de-DE', 'EUR'), // "1.234,56 €"
  'ja-JP': formatCurrency(1234.56, 'ja-JP', 'JPY'), // "¥1,235"
};

Percentages and Decimals

function formatNumber(number, locale) {
  return new Intl.NumberFormat(locale).format(number);
}
 
// Arabic uses different decimal separators
formatNumber(1234.56, 'ar-SA'); // "١٬٢٣٤٫٥٦"
 
// German uses comma for decimal
formatNumber(1234.56, 'de-DE'); // "1.234,56"

Step 5: Template Localization

Multi-Language Template Strategy

Option 1: Separate Templates per Language

const templates = {
  'en': './templates/invoice-en.html',
  'ar': './templates/invoice-ar.html',
  'zh': './templates/invoice-zh.html'
};
 
function getTemplate(locale) {
  return fs.readFileSync(templates[locale] || templates['en'], 'utf8');
}

Option 2: Single Template with i18n

import i18n from 'i18n';
 
i18n.configure({
  locales: ['en', 'ar', 'zh', 'ja'],
  directory: './locales',
  defaultLocale: 'en'
});
 
// Translation file: locales/ar.json
{
  "invoice.title": "فاتورة",
  "invoice.date": "التاريخ",
  "invoice.total": "المجموع",
  "invoice.customer": "العميل"
}
 
// In template
i18n.setLocale(user.locale);
 
const html = `
<html dir="${i18n.__('text.direction')}" lang="${user.locale}">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: '${getFont(user.locale)}', sans-serif;
      direction: ${i18n.__('text.direction')};
    }
  </style>
</head>
<body>
  <h1>${i18n.__('invoice.title')}</h1>
  <p>${i18n.__('invoice.customer')}: {{customer_name}}</p>
  <p>${i18n.__('invoice.total')}: {{total}}</p>
</body>
</html>
`;

Step 6: Complete Multi-Language Invoice Example

import i18n from 'i18n';
import fetch from 'node-fetch';
 
// Configure i18n
i18n.configure({
  locales: ['en', 'ar', 'zh', 'ja', 'de', 'es'],
  directory: './locales',
  defaultLocale: 'en'
});
 
function getFont(locale) {
  const fonts = {
    'ar': 'Noto Sans Arabic',
    'he': 'Noto Sans Hebrew',
    'zh': 'Noto Sans SC',
    'ja': 'Noto Sans JP',
    'ko': 'Noto Sans KR',
    'th': 'Noto Sans Thai',
    'default': 'Noto Sans'
  };
  return fonts[locale] || fonts.default;
}
 
function getDirection(locale) {
  return ['ar', 'he', 'fa', 'ur'].includes(locale) ? 'rtl' : 'ltr';
}
 
async function generateInvoice(invoiceData, locale) {
  i18n.setLocale(locale);
 
  const direction = getDirection(locale);
  const font = getFont(locale);
 
  const html = `
<!DOCTYPE html>
<html dir="${direction}" lang="${locale}">
<head>
  <meta charset="UTF-8">
  <link href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(font)}:wght@400;700&display=swap" rel="stylesheet">
  <style>
    body {
      font-family: '${font}', sans-serif;
      direction: ${direction};
      text-align: ${direction === 'rtl' ? 'right' : 'left'};
      padding: 40px;
    }
 
    h1 {
      color: #2563eb;
      margin-bottom: 20px;
    }
 
    table {
      width: 100%;
      border-collapse: collapse;
      margin: 20px 0;
    }
 
    th, td {
      padding: 12px;
      border-bottom: 1px solid #e5e7eb;
      text-align: ${direction === 'rtl' ? 'right' : 'left'};
    }
 
    th {
      background: #2563eb;
      color: white;
    }
 
    .total {
      font-weight: bold;
      font-size: 18px;
    }
  </style>
</head>
<body>
  <h1>${i18n.__('invoice.title')} #{{invoice_number}}</h1>
 
  <p><strong>${i18n.__('invoice.date')}:</strong> {{formatted_date}}</p>
  <p><strong>${i18n.__('invoice.customer')}:</strong> {{customer_name}}</p>
 
  <table>
    <thead>
      <tr>
        <th>${i18n.__('invoice.item')}</th>
        <th>${i18n.__('invoice.quantity')}</th>
        <th>${i18n.__('invoice.price')}</th>
        <th>${i18n.__('invoice.total')}</th>
      </tr>
    </thead>
    <tbody>
      {{#each items}}
      <tr>
        <td>{{description}}</td>
        <td>{{quantity}}</td>
        <td>{{formatted_price}}</td>
        <td>{{formatted_total}}</td>
      </tr>
      {{/each}}
      <tr class="total">
        <td colspan="3">${i18n.__('invoice.grand_total')}</td>
        <td>{{formatted_grand_total}}</td>
      </tr>
    </tbody>
  </table>
</body>
</html>
`;
 
  // Format data according to locale
  const formattedData = {
    invoice_number: invoiceData.number,
    customer_name: invoiceData.customer,
    formatted_date: new Intl.DateTimeFormat(locale, {
      year: 'numeric',
      month: 'long',
      day: 'numeric'
    }).format(invoiceData.date),
    items: invoiceData.items.map(item => ({
      description: item.description,
      quantity: item.quantity,
      formatted_price: new Intl.NumberFormat(locale, {
        style: 'currency',
        currency: invoiceData.currency
      }).format(item.price),
      formatted_total: new Intl.NumberFormat(locale, {
        style: 'currency',
        currency: invoiceData.currency
      }).format(item.quantity * item.price)
    })),
    formatted_grand_total: new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: invoiceData.currency
    }).format(invoiceData.total)
  };
 
  // Generate PDF
  const response = await fetch('https://reportgen.io/api/v1/generate-pdf-sync', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'X-API-Key': process.env.REPORTGEN_API_KEY
    },
    body: JSON.stringify({
      html_template: html,
      data: formattedData,
      engine: 'handlebars'
    })
  });
 
  return await response.arrayBuffer();
}
 
// Usage
const invoiceData = {
  number: 'INV-001',
  customer: 'عبدالله محمد',
  date: new Date(),
  currency: 'SAR',
  total: 1500,
  items: [
    { description: 'كمبيوتر محمول', quantity: 1, price: 1200 },
    { description: 'فأرة لاسلكية', quantity: 2, price: 150 }
  ]
};
 
const pdfBuffer = await generateInvoice(invoiceData, 'ar');
fs.writeFileSync('invoice-ar.pdf', Buffer.from(pdfBuffer));

Testing Multi-Language PDFs

Test Checklist

  • All characters render correctly (no boxes)
  • RTL text flows right-to-left
  • Mixed LTR/RTL content displays properly
  • Numbers and dates use locale formatting
  • Currency symbols appear in correct position
  • Font weights (bold, etc.) work correctly
  • Tables align properly for RTL/LTR
  • Page breaks don't split characters

Testing Script

async function testLanguages() {
  const languages = ['en', 'ar', 'zh', 'ja', 'he', 'th'];
 
  for (const lang of languages) {
    const pdf = await generateInvoice(testData, lang);
    fs.writeFileSync(`test-invoice-${lang}.pdf`, Buffer.from(pdf));
    console.log(`✓ Generated PDF for ${lang}`);
  }
}
 
testLanguages();

Common Pitfalls and Fixes

❌ Pitfall 1: Hardcoded Text Direction

<!-- Bad: Hardcoded LTR -->
<div style="text-align: left;">{{content}}</div>
 
<!-- Good: Dynamic direction -->
<div style="text-align: {{text_align}};">{{content}}</div>

❌ Pitfall 2: Mixing Font Files

<!-- Bad: English font for all languages -->
<style>
  body { font-family: 'Roboto', sans-serif; }
</style>
 
<!-- Good: Language-specific fonts with fallbacks -->
<style>
  body { font-family: 'Noto Sans Arabic', 'Noto Sans', sans-serif; }
</style>

❌ Pitfall 3: Locale-Unaware Formatting

// Bad: Hardcoded format
const formattedPrice = `$${price.toFixed(2)}`;
 
// Good: Locale-aware
const formattedPrice = new Intl.NumberFormat(locale, {
  style: 'currency',
  currency: currency
}).format(price);

Final Thoughts

Multi-language PDF generation seems hard. It's not. You just need:

Proper fonts (Use Noto fonts for coverage) ✅ Correct text direction (RTL for Arabic/Hebrew) ✅ UTF-8 encoding (Always) ✅ Locale-aware formatting (Dates, numbers, currency) ✅ i18n infrastructure (Translation management)

Follow this guide, and your PDFs will work in any language.

Need multi-language PDF support? Try reportgen.io - UTF-8 and RTL support built-in.