Generating PDFs in Flutter: Invoices, Reports, and Documents on Any Device
Build invoices, reports, and printable documents in Flutter with the pure Dart pdf package, covering fonts, layout, saving, sharing, and printing.
Published on • October 4, 2026
AI Assistant

Why pure Dart wins for PDF generation
Every Flutter app eventually meets the same requirement: a button that produces a PDF — invoices, monthly reports, boarding passes, certificates, exports. You have two options. The first is a platform channel into a native engine, which means writing Kotlin, Swift, and probably a Windows shim, then maintaining all three forever. The second is generating the file yourself in Dart.
The second option is the sane one, and the pdf package is the standard way to do it. At version 3.13.1 it is a pure Dart library targeting Android, iOS, Linux, macOS, Windows, and web — the identical code path on every platform, with no plugin registration and no ProGuard rules. It has passed 1.6 million downloads on pub.dev and ships under Apache-2.0.
Architecturally the package is split in two, and understanding the split keeps you from fighting the API:
- A low-level PDF producer that writes the actual PDF bit-level structures (objects, streams, cross-reference tables).
- A widget system modeled on Flutter’s layout primitives (
pw.Container,pw.Row,pw.Text,pw.Table) that you compose declaratively and hand to the producer.
There is a third piece, the printing package, which handles device-side concerns: real printing, runtime Google Fonts, network images, and sharing. The pdf package deliberately stops at “here are the bytes.”
Project setup
Add the dependencies:
dependencies:
flutter:
sdk: flutter
pdf: ^3.13.1
path_provider: ^2.1.5
share_plus: ^11.0.0
printing: ^5.13.4
Then import the two halves. Note the namespaced widget import — this is not Flutter’s Material library, and mixing the two will produce confusing compile errors:
import 'dart:io';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
The document model
A document is a pw.Document to which you add pages. Each page takes a build callback that returns a widget tree:
final pdf = pw.Document();
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
build: (context) => pw.Center(child: pw.Text('Hello World')),
),
);
The coordinate system uses the internal PDF unit where 1.0 equals 1/72 of an inch — a PostScript point. PdfPageFormat exports constants for centimeters, millimeters, and inches, so PdfPageFormat.a4 is 595.28 x 841.89 points. Coming from Flutter’s logical pixels the numbers look close, but one point is not one device-independent pixel.
Beyond pw.Text, the widget vocabulary you will actually use is:
pw.Container— padding, margins, alignment,BoxDecorationwith borders and radii.pw.Row/pw.Column/pw.Flex/pw.Wrap— flexbox-style layout withMainAxisAlignmentandCrossAxisAlignment.pw.Table— grid layout withpw.TableRow,pw.TableCell, and column widths;pw.TableHelper.fromTextArrayis a convenience constructor for plain header-plus-rows data.pw.BarcodeWidget— renders EAN-13, QR, Code-128 and friends directly, backed by thebarcodepackage thatpdfdepends on:
pw.BarcodeWidget.fromData(
data: 'INV-2026-0042',
barcode: pw.Barcode.qrCode(),
width: 80,
height: 80,
),
pw.Imagewithpw.MemoryImagefor bytes, pluspw.SvgImagefor inline SVG strings.
Fonts and Unicode: the part everyone gets wrong
Out of the box the package ships a built-in Helvetica encoded as WinAnsi. That is fine for Hello and terrible for everything else: Cyrillic, Greek, Hebrew, Vietnamese diacritics, or a customer’s name written in a non-Latin script render as empty boxes or ? characters, with no error thrown.
The fix is to register TrueType fonts and load them from your asset bundle:
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
DejaVu Sans is the pragmatic default: freely licensed, metrics-friendly for tables, and broad coverage across Latin, Greek, Cyrillic, and Hebrew. Bundle the .ttf files under assets/fonts/ in your pubspec.yaml and remember that on web you must go through rootBundle — dart:io File does not exist there.
Registering a font is not enough; you must also wire it into the theme, otherwise bold and italic runs silently fall back to Helvetica:
final theme = pw.ThemeData.withFont(base: base, bold: bold);
If you would rather not ship font files, the printing package exposes PdfGoogleFonts (for example await PdfGoogleFonts.nunitoRegular()), which downloads the font on first use. And for emoji, register a fallback — PdfGoogleFonts.notoColorEmoji() — and attach it through pw.TextStyle(fontFallback: [emoji]).
Page vs MultiPage: the layout model
pw.Page gives you exactly one page and total control. pw.MultiPage gives you automatic pagination, and it is what you want for documents of unknown length.
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
pageTheme: pw.PageTheme(
margin: const pw.EdgeInsets.fromLTRB(40, 36, 40, 40),
theme: pw.ThemeData.withFont(base: base, bold: bold),
),
header: (context) => pw.Text('Acme Corp — Statement'),
footer: (context) => pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Confidential'),
pw.Text('Page ${context.pageNumber} of ${context.pagesCount}'),
],
),
build: (context) => [/* content widgets */],
),
);
Key behaviors worth memorizing:
- Headers and footers are measured first, so their space is reserved before content lays out.
- Spanning widgets —
pw.Flex,pw.Column,pw.Wrap,pw.Table,pw.Partition,pw.GridView— split across page breaks; everything else moves to the next page whole. - Wrap a heading and its first paragraph in
pw.Inseparable(defaultcanSpan: true) to keep them glued together. pw.NewPage()forces a break;pw.NewPage(freeSpace: 40)breaks only when under 40 points remain.maxPagesdefaults to 20 and is enforced in debug only — a runaway layout still builds in release.
Getting the bytes out: save, share, print
pdf.save() returns a Uint8List. From there:
final bytes = await pdf.save();
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/invoice.pdf');
await file.writeAsBytes(bytes, flush: true);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)]),
);
(On share_plus versions before 10.1, use Share.shareXFiles([XFile(file.path)]).)
To send the document straight to a printer or print preview, use printing:
await Printing.layoutPdf(
onLayout: (format) async => pdf.save(),
name: 'Invoice',
);
Printing also provides networkImage(url) for remote logos and Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf') as a one-liner. On web, there is no filesystem: build a data: URL, attach it to an anchor element, and click it to trigger the browser download.
A full invoice example
Future<List<int>> buildInvoice(Invoice inv) async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final pdf = pw.Document(theme: pw.ThemeData.withFont(base: base, bold: bold));
pw.Widget cell(String t, {bool head = false}) => pw.Padding(
padding: const pw.EdgeInsets.all(6),
child: pw.Text(t, style: head ? pw.TextStyle(font: bold) : null),
);
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
pageTheme: pw.PageTheme(
margin: const pw.EdgeInsets.fromLTRB(40, 36, 40, 40),
),
footer: (context) => pw.Text(
'Page ${context.pageNumber}/${context.pagesCount}',
style: const pw.TextStyle(fontSize: 8),
),
build: (context) => [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('INVOICE', style: pw.TextStyle(font: bold, fontSize: 24)),
pw.Text(inv.number),
pw.Text('Due: ${inv.dueDate}'),
],
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text('Redline Soft Co., Ltd.'),
pw.Text(inv.customer),
],
),
],
),
pw.SizedBox(height: 24),
pw.Table(
border: pw.TableBorder.all(),
columnWidths: {
0: const pw.FlexColumnWidth(5),
1: const pw.FlexColumnWidth(1),
2: const pw.FlexColumnWidth(2),
3: const pw.FlexColumnWidth(2),
},
children: [
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey300),
children: ['Item', 'Qty', 'Unit', 'Total']
.map((h) => cell(h, head: true))
.toList(),
),
...inv.lines.map(
(l) => pw.TableRow(
children: [
l.name,
'${l.qty}',
l.unitPrice.toStringAsFixed(2),
l.total.toStringAsFixed(2),
].map(cell).toList(),
),
),
],
),
pw.SizedBox(height: 16),
pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Grand total: ${inv.grandTotal.toStringAsFixed(2)} USD',
style: pw.TextStyle(font: bold, fontSize: 14),
),
),
pw.SizedBox(height: 24),
pw.BarcodeWidget.fromData(
data: inv.number,
barcode: pw.Barcode.qrCode(),
width: 72,
height: 72,
),
],
),
);
return pdf.save();
}
Swap the hardcoded fields for your model, drop in a logo via pw.MemoryImage, and you have a shippable export screen.
Performance and gotchas
- Memory.
save()materializes the whole document as a byte array. For long reports, implement aPdfStreambacked by a seekable file and callpdf.write(output); it needs byte-write, offset, and random-access-patch support and bounds peak memory. - Images dominate size. Resize logos and photos before handing them to
pw.MemoryImage; a 4000x3000 photo inflates the PDF more than all your vector content. - Platform divergence. Assets are where the code stops being universal: use
rootBundleeverywhere, neverFile(path).readAsBytesSync(). - Widget parity is not Flutter parity. The
pwwidgets mimic Flutter’s API but omit Material specifics; check the docs rather than assuming aCardexists. - Security. Encryption (RC4-40/128, AES-128/256) and SHA-1/SHA-256 signatures live in the separate
pdf_cryptopackage, which also parses existing PDFs.
Conclusion
You do not need a native PDF engine to ship professional documents. The pdf package gives you a Flutter-like layout system that compiles to identical bytes on six platforms; add a real TTF, choose MultiPage for anything variable-length, and reach for printing when it is time to hit a printer.