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
- Use Unicode fonts - Embed fonts that support target languages
- Handle RTL properly - Mirror layouts for Arabic, Hebrew, Persian
- Format numbers/dates - Use locale-aware formatting
- Test with real content - Long translations may break layouts
- Consider cultural conventions - Address formats, name order
- 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
- Flutter Number and Currency Formatting
- Flutter DateTime Localization
- Flutter RTL Support Guide
- Free ARB Editor - Manage PDF translation strings