← Back to Blog

Flutter PDF Localization: Generate Multilingual Documents and Reports

flutterpdfdocumentsinvoicesreportsrtllocalization

Flutter PDF Localization: Generate Multilingual Documents and Reports

Generating localized PDF documents is essential for business apps that create invoices, reports, contracts, and receipts. This guide shows you how to create professional multilingual PDFs in Flutter with proper text direction, fonts, and formatting for any language.

Why PDF Localization Matters

PDFs are often legal documents or official records that require:

  • Accurate translations - Business and legal terminology must be precise
  • Proper formatting - Numbers, dates, and currencies vary by locale
  • RTL support - Arabic, Hebrew, and Persian documents read right-to-left
  • Unicode fonts - Asian, Arabic, and special characters need proper fonts
  • Cultural conventions - Address formats, name order, and document structure

Setting Up PDF Generation

Installing Dependencies

# pubspec.yaml
dependencies:
  pdf: ^3.10.0
  printing: ^5.11.0
  path_provider: ^2.1.0
  intl: ^0.18.0

Basic Localized PDF Structure

import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:intl/intl.dart';

class LocalizedPdfGenerator {
  final Locale locale;
  final Map<String, String> translations;
  late pw.Font _regularFont;
  late pw.Font _boldFont;
  late pw.TextDirection _textDirection;

  LocalizedPdfGenerator({
    required this.locale,
    required this.translations,
  }) {
    _textDirection = _isRtlLocale(locale)
        ? pw.TextDirection.rtl
        : pw.TextDirection.ltr;
  }

  bool _isRtlLocale(Locale locale) {
    return ['ar', 'he', 'fa', 'ur'].contains(locale.languageCode);
  }

  Future<void> loadFonts() async {
    // Load appropriate fonts based on locale
    switch (locale.languageCode) {
      case 'ar':
      case 'fa':
      case 'ur':
        _regularFont = await _loadFont('assets/fonts/NotoNaskhArabic-Regular.ttf');
        _boldFont = await _loadFont('assets/fonts/NotoNaskhArabic-Bold.ttf');
        break;
      case 'zh':
        _regularFont = await _loadFont('assets/fonts/NotoSansSC-Regular.ttf');
        _boldFont = await _loadFont('assets/fonts/NotoSansSC-Bold.ttf');
        break;
      case 'ja':
        _regularFont = await _loadFont('assets/fonts/NotoSansJP-Regular.ttf');
        _boldFont = await _loadFont('assets/fonts/NotoSansJP-Bold.ttf');
        break;
      case 'ko':
        _regularFont = await _loadFont('assets/fonts/NotoSansKR-Regular.ttf');
        _boldFont = await _loadFont('assets/fonts/NotoSansKR-Bold.ttf');
        break;
      case 'he':
        _regularFont = await _loadFont('assets/fonts/NotoSansHebrew-Regular.ttf');
        _boldFont = await _loadFont('assets/fonts/NotoSansHebrew-Bold.ttf');
        break;
      default:
        _regularFont = pw.Font.helvetica();
        _boldFont = pw.Font.helveticaBold();
    }
  }

  Future<pw.Font> _loadFont(String path) async {
    final fontData = await rootBundle.load(path);
    return pw.Font.ttf(fontData);
  }

  String tr(String key) => translations[key] ?? key;

  pw.TextStyle get regularStyle => pw.TextStyle(
    font: _regularFont,
    fontSize: 12,
  );

  pw.TextStyle get boldStyle => pw.TextStyle(
    font: _boldFont,
    fontSize: 12,
    fontWeight: pw.FontWeight.bold,
  );

  pw.TextStyle get headerStyle => pw.TextStyle(
    font: _boldFont,
    fontSize: 18,
    fontWeight: pw.FontWeight.bold,
  );
}

Creating Localized Invoices

Invoice Generator

class LocalizedInvoiceGenerator extends LocalizedPdfGenerator {
  final NumberFormat currencyFormat;
  final DateFormat dateFormat;

  LocalizedInvoiceGenerator({
    required Locale locale,
    required Map<String, String> translations,
  }) : currencyFormat = NumberFormat.currency(
         locale: locale.toString(),
         symbol: _getCurrencySymbol(locale),
       ),
       dateFormat = DateFormat.yMMMMd(locale.toString()),
       super(locale: locale, translations: translations);

  static String _getCurrencySymbol(Locale locale) {
    final symbols = {
      'en_US': '\$',
      'en_GB': '£',
      'de_DE': '€',
      'ja_JP': '¥',
      'ar_SA': 'ر.س',
    };
    return symbols['${locale.languageCode}_${locale.countryCode}'] ?? '\$';
  }

  Future<pw.Document> generateInvoice(Invoice invoice) async {
    await loadFonts();

    final pdf = pw.Document();

    pdf.addPage(
      pw.Page(
        pageFormat: PdfPageFormat.a4,
        textDirection: _textDirection,
        build: (context) => pw.Column(
          crossAxisAlignment: _textDirection == pw.TextDirection.rtl
              ? pw.CrossAxisAlignment.end
              : pw.CrossAxisAlignment.start,
          children: [
            _buildHeader(invoice),
            pw.SizedBox(height: 20),
            _buildCompanyInfo(invoice),
            pw.SizedBox(height: 20),
            _buildCustomerInfo(invoice),
            pw.SizedBox(height: 20),
            _buildItemsTable(invoice),
            pw.SizedBox(height: 20),
            _buildTotals(invoice),
            pw.Spacer(),
            _buildFooter(invoice),
          ],
        ),
      ),
    );

    return pdf;
  }

  pw.Widget _buildHeader(Invoice invoice) {
    return pw.Row(
      mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
      textDirection: _textDirection,
      children: [
        pw.Text(tr('invoice'), style: headerStyle),
        pw.Column(
          crossAxisAlignment: _textDirection == pw.TextDirection.rtl
              ? pw.CrossAxisAlignment.start
              : pw.CrossAxisAlignment.end,
          children: [
            pw.Text('${tr('invoice_number')}: ${invoice.number}', style: regularStyle),
            pw.Text('${tr('date')}: ${dateFormat.format(invoice.date)}', style: regularStyle),
            pw.Text('${tr('due_date')}: ${dateFormat.format(invoice.dueDate)}', style: regularStyle),
          ],
        ),
      ],
    );
  }

  pw.Widget _buildCompanyInfo(Invoice invoice) {
    return pw.Column(
      crossAxisAlignment: _textDirection == pw.TextDirection.rtl
          ? pw.CrossAxisAlignment.end
          : pw.CrossAxisAlignment.start,
      children: [
        pw.Text(tr('from'), style: boldStyle),
        pw.SizedBox(height: 5),
        pw.Text(invoice.company.name, style: regularStyle),
        pw.Text(_formatAddress(invoice.company.address), style: regularStyle),
        pw.Text('${tr('tax_id')}: ${invoice.company.taxId}', style: regularStyle),
      ],
    );
  }

  pw.Widget _buildCustomerInfo(Invoice invoice) {
    return pw.Column(
      crossAxisAlignment: _textDirection == pw.TextDirection.rtl
          ? pw.CrossAxisAlignment.end
          : pw.CrossAxisAlignment.start,
      children: [
        pw.Text(tr('bill_to'), style: boldStyle),
        pw.SizedBox(height: 5),
        pw.Text(invoice.customer.name, style: regularStyle),
        pw.Text(_formatAddress(invoice.customer.address), style: regularStyle),
        if (invoice.customer.taxId != null)
          pw.Text('${tr('tax_id')}: ${invoice.customer.taxId}', style: regularStyle),
      ],
    );
  }

  String _formatAddress(Address address) {
    // Different countries have different address formats
    switch (locale.countryCode) {
      case 'JP':
        // Japanese: Postal code, Prefecture, City, Street
        return '〒${address.postalCode}\n${address.state}${address.city}${address.street}';
      case 'DE':
      case 'FR':
        // European: Street, Postal code City, Country
        return '${address.street}\n${address.postalCode} ${address.city}\n${address.country}';
      default:
        // US/UK: Street, City, State, Postal code, Country
        return '${address.street}\n${address.city}, ${address.state} ${address.postalCode}\n${address.country}';
    }
  }

  pw.Widget _buildItemsTable(Invoice invoice) {
    final headers = [
      tr('description'),
      tr('quantity'),
      tr('unit_price'),
      tr('total'),
    ];

    if (_textDirection == pw.TextDirection.rtl) {
      headers.reversed.toList();
    }

    return pw.Table(
      border: pw.TableBorder.all(color: PdfColors.grey400),
      columnWidths: {
        0: pw.FlexColumnWidth(3),
        1: pw.FlexColumnWidth(1),
        2: pw.FlexColumnWidth(1.5),
        3: pw.FlexColumnWidth(1.5),
      },
      children: [
        pw.TableRow(
          decoration: pw.BoxDecoration(color: PdfColors.grey200),
          children: headers.map((h) => pw.Padding(
            padding: pw.EdgeInsets.all(8),
            child: pw.Text(h, style: boldStyle, textDirection: _textDirection),
          )).toList(),
        ),
        ...invoice.items.map((item) => pw.TableRow(
          children: [
            pw.Padding(
              padding: pw.EdgeInsets.all(8),
              child: pw.Text(item.description, style: regularStyle, textDirection: _textDirection),
            ),
            pw.Padding(
              padding: pw.EdgeInsets.all(8),
              child: pw.Text(item.quantity.toString(), style: regularStyle),
            ),
            pw.Padding(
              padding: pw.EdgeInsets.all(8),
              child: pw.Text(currencyFormat.format(item.unitPrice), style: regularStyle),
            ),
            pw.Padding(
              padding: pw.EdgeInsets.all(8),
              child: pw.Text(currencyFormat.format(item.total), style: regularStyle),
            ),
          ],
        )),
      ],
    );
  }

  pw.Widget _buildTotals(Invoice invoice) {
    return pw.Container(
      alignment: _textDirection == pw.TextDirection.rtl
          ? pw.Alignment.centerLeft
          : pw.Alignment.centerRight,
      child: pw.Column(
        crossAxisAlignment: pw.CrossAxisAlignment.end,
        children: [
          _buildTotalRow(tr('subtotal'), invoice.subtotal),
          _buildTotalRow('${tr('tax')} (${invoice.taxRate}%)', invoice.taxAmount),
          pw.Divider(),
          _buildTotalRow(tr('total'), invoice.total, bold: true),
        ],
      ),
    );
  }

  pw.Widget _buildTotalRow(String label, double amount, {bool bold = false}) {
    final style = bold ? boldStyle : regularStyle;
    return pw.Padding(
      padding: pw.EdgeInsets.symmetric(vertical: 4),
      child: pw.Row(
        mainAxisSize: pw.MainAxisSize.min,
        textDirection: _textDirection,
        children: [
          pw.SizedBox(width: 100, child: pw.Text(label, style: style)),
          pw.SizedBox(width: 100, child: pw.Text(
            currencyFormat.format(amount),
            style: style,
            textAlign: pw.TextAlign.right,
          )),
        ],
      ),
    );
  }

  pw.Widget _buildFooter(Invoice invoice) {
    return pw.Column(
      crossAxisAlignment: _textDirection == pw.TextDirection.rtl
          ? pw.CrossAxisAlignment.end
          : pw.CrossAxisAlignment.start,
      children: [
        pw.Divider(),
        pw.SizedBox(height: 10),
        pw.Text(tr('payment_terms'), style: boldStyle, textDirection: _textDirection),
        pw.Text(invoice.paymentTerms, style: regularStyle, textDirection: _textDirection),
        pw.SizedBox(height: 10),
        pw.Text(tr('thank_you'), style: regularStyle, textDirection: _textDirection),
      ],
    );
  }
}

RTL Document Support

Handling Right-to-Left Languages

class RtlPdfHelper {
  static pw.Widget buildBidirectionalText({
    required String text,
    required pw.TextStyle style,
    required pw.TextDirection direction,
  }) {
    // Check if text contains mixed LTR/RTL content
    final hasArabic = RegExp(r'[\u0600-\u06FF]').hasMatch(text);
    final hasHebrew = RegExp(r'[\u0590-\u05FF]').hasMatch(text);
    final hasLatin = RegExp(r'[a-zA-Z]').hasMatch(text);

    if ((hasArabic || hasHebrew) && hasLatin) {
      // Mixed content - needs special handling
      return _buildMixedDirectionText(text, style, direction);
    }

    return pw.Text(text, style: style, textDirection: direction);
  }

  static pw.Widget _buildMixedDirectionText(
    String text,
    pw.TextStyle style,
    pw.TextDirection direction,
  ) {
    // Split by direction changes and build inline spans
    final segments = _splitByDirection(text);

    return pw.RichText(
      textDirection: direction,
      text: pw.TextSpan(
        children: segments.map((segment) => pw.TextSpan(
          text: segment.text,
          style: style,
        )).toList(),
      ),
    );
  }

  static List<TextSegment> _splitByDirection(String text) {
    final segments = <TextSegment>[];
    final buffer = StringBuffer();
    bool? currentIsRtl;

    for (final char in text.characters) {
      final isRtl = _isRtlChar(char);

      if (currentIsRtl != null && isRtl != currentIsRtl && buffer.isNotEmpty) {
        segments.add(TextSegment(buffer.toString(), currentIsRtl));
        buffer.clear();
      }

      buffer.write(char);
      currentIsRtl = isRtl;
    }

    if (buffer.isNotEmpty) {
      segments.add(TextSegment(buffer.toString(), currentIsRtl ?? false));
    }

    return segments;
  }

  static bool _isRtlChar(String char) {
    final code = char.codeUnitAt(0);
    return (code >= 0x0590 && code <= 0x05FF) || // Hebrew
           (code >= 0x0600 && code <= 0x06FF) || // Arabic
           (code >= 0x0750 && code <= 0x077F) || // Arabic Supplement
           (code >= 0xFB50 && code <= 0xFDFF) || // Arabic Presentation Forms-A
           (code >= 0xFE70 && code <= 0xFEFF);   // Arabic Presentation Forms-B
  }
}

class TextSegment {
  final String text;
  final bool isRtl;

  TextSegment(this.text, this.isRtl);
}

Multi-Language Reports

Generating Reports in Multiple Languages

class MultiLanguageReportGenerator {
  final Map<String, Map<String, String>> _allTranslations;

  MultiLanguageReportGenerator(this._allTranslations);

  Future<Map<String, Uint8List>> generateReportsAllLanguages(
    ReportData data,
    List<String> targetLanguages,
  ) async {
    final results = <String, Uint8List>{};

    for (final langCode in targetLanguages) {
      final translations = _allTranslations[langCode] ?? _allTranslations['en']!;
      final locale = Locale(langCode);

      final generator = LocalizedReportGenerator(
        locale: locale,
        translations: translations,
      );

      final pdf = await generator.generateReport(data);
      results[langCode] = await pdf.save();
    }

    return results;
  }

  Future<pw.Document> generateBilingualReport(
    ReportData data,
    String primaryLang,
    String secondaryLang,
  ) async {
    final primaryTranslations = _allTranslations[primaryLang]!;
    final secondaryTranslations = _allTranslations[secondaryLang]!;

    final pdf = pw.Document();

    pdf.addPage(
      pw.MultiPage(
        build: (context) => [
          _buildBilingualHeader(
            primaryTranslations['report_title']!,
            secondaryTranslations['report_title']!,
          ),
          pw.SizedBox(height: 20),
          _buildBilingualContent(
            data,
            primaryTranslations,
            secondaryTranslations,
          ),
        ],
      ),
    );

    return pdf;
  }

  pw.Widget _buildBilingualHeader(String primary, String secondary) {
    return pw.Column(
      children: [
        pw.Text(primary, style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
        pw.Text(secondary, style: pw.TextStyle(fontSize: 18, color: PdfColors.grey700)),
      ],
    );
  }

  pw.Widget _buildBilingualContent(
    ReportData data,
    Map<String, String> primary,
    Map<String, String> secondary,
  ) {
    return pw.Table(
      children: data.items.map((item) => pw.TableRow(
        children: [
          pw.Padding(
            padding: pw.EdgeInsets.all(8),
            child: pw.Column(
              crossAxisAlignment: pw.CrossAxisAlignment.start,
              children: [
                pw.Text(primary[item.key] ?? item.key),
                pw.Text(
                  secondary[item.key] ?? item.key,
                  style: pw.TextStyle(fontSize: 10, color: PdfColors.grey600),
                ),
              ],
            ),
          ),
          pw.Padding(
            padding: pw.EdgeInsets.all(8),
            child: pw.Text(item.value),
          ),
        ],
      )).toList(),
    );
  }
}

Testing Localized PDFs

PDF Generation Tests

void main() {
  group('LocalizedInvoiceGenerator', () {
    test('generates English invoice correctly', () async {
      final generator = LocalizedInvoiceGenerator(
        locale: Locale('en', 'US'),
        translations: englishTranslations,
      );

      final invoice = createTestInvoice();
      final pdf = await generator.generateInvoice(invoice);
      final bytes = await pdf.save();

      expect(bytes.isNotEmpty, true);

      // Verify PDF contains expected text
      final pdfText = await extractPdfText(bytes);
      expect(pdfText, contains('Invoice'));
      expect(pdfText, contains('\$1,234.56'));
    });

    test('generates RTL Arabic invoice', () async {
      final generator = LocalizedInvoiceGenerator(
        locale: Locale('ar', 'SA'),
        translations: arabicTranslations,
      );

      final invoice = createTestInvoice();
      final pdf = await generator.generateInvoice(invoice);
      final bytes = await pdf.save();

      expect(bytes.isNotEmpty, true);

      final pdfText = await extractPdfText(bytes);
      expect(pdfText, contains('فاتورة'));
    });

    test('formats currency correctly for locale', () async {
      final usGenerator = LocalizedInvoiceGenerator(
        locale: Locale('en', 'US'),
        translations: englishTranslations,
      );

      final deGenerator = LocalizedInvoiceGenerator(
        locale: Locale('de', 'DE'),
        translations: germanTranslations,
      );

      expect(usGenerator.currencyFormat.format(1234.56), '\$1,234.56');
      expect(deGenerator.currencyFormat.format(1234.56), '1.234,56 €');
    });
  });
}

Best Practices

PDF Localization Checklist

  1. Use Unicode fonts - Embed fonts that support target languages
  2. Handle RTL properly - Mirror layouts for Arabic, Hebrew, Persian
  3. Format numbers/dates - Use locale-aware formatting
  4. Test with real content - Long translations may break layouts
  5. Consider cultural conventions - Address formats, name order
  6. Validate output - Ensure PDFs are readable in target languages

Performance Tips

  • Cache loaded fonts across PDF generations
  • Use font subsetting to reduce file size
  • Generate PDFs in isolates for heavy documents
  • Consider lazy loading translations

Conclusion

PDF localization in Flutter requires attention to fonts, text direction, formatting, and cultural conventions. By building a solid foundation with proper font handling and locale-aware formatting, you can generate professional multilingual documents for any market.

Related Resources