Generating PDFs and Invoices in Flutter with the pdf Package
How to generate PDFs and invoices in Flutter: the pdf package, embedded fonts, line-item tables, printing and sharing, money formatting, and when to use the server.

Sooner or later a client asks: “Can the app just make a PDF?” Usually they mean a quote, a receipt, a work order or an invoice that someone can email, print or attach to an accounting system.
The answer is yes, and Flutter does it well. But before writing any code, it’s worth asking a second question: should the app be the one making it? I’ll build a simple invoice on the device first, then come back to that question, because the answer changes for legal invoices.
The tools
Two packages do almost all the work:
- pdf builds the document. It has its own widget system that looks a lot like Flutter’s (
Column,Row,Text,Container,Table), but it renders to PDF instead of the screen. - printing sends that PDF to the system print dialog, the share sheet or an on-screen preview.
One thing to get used to: the pdf widgets are not Flutter widgets. They have the same names, so always import them with a prefix to keep the two apart:
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
Mixing up Text and pw.Text is the most common error in PDF code, and the prefix makes it obvious.
Step 1: A document and a page
A document holds pages. For something that always fits on one page, use pw.Page. For anything with a variable-length list, like an invoice, use pw.MultiPage. It breaks content across pages automatically and repeats headers and footers:
Future<Uint8List> buildInvoicePdf(Invoice invoice) async {
final doc = pw.Document(title: 'Invoice ${invoice.number}');
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
header: (context) => _header(invoice),
footer: (context) => pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text('Page ${context.pageNumber} of ${context.pagesCount}'),
),
build: (context) => [
_addresses(invoice),
pw.SizedBox(height: 24),
_lineItems(invoice),
pw.SizedBox(height: 16),
_totals(invoice),
],
),
);
return doc.save();
}
doc.save() returns the bytes. Everything after that (printing, sharing, uploading, saving to a file) works with those bytes.
Choose the page format on purpose. Clients in the US expect Letter (PdfPageFormat.letter), and most of the rest of the world expects A4. If the app has users in both, let the format follow the business’s region.
Step 2: Fonts, before anything breaks
The pdf package includes standard PDF fonts, but they only cover basic Latin characters. The first time a customer’s name has an accent the font doesn’t cover, or a business writes in Arabic, Bengali or Japanese, you’ll see missing glyphs or empty boxes.
The fix is to embed real TrueType fonts. Add them as assets and load them:
final regular = pw.Font.ttf(
await rootBundle.load('assets/fonts/NotoSans-Regular.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/NotoSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: regular, bold: bold),
);
Notes from experience:
- Use a font that covers the scripts your users actually write in. The Noto family is a good default because it covers many scripts across separate files.
- For right-to-left languages, set the text direction on the page or the widgets. A font alone doesn’t change direction.
- Embedded fonts increase file size. Include only the weights you use.
If the app is already localized, the PDF should follow the same locale rules as the UI. See Flutter localization in practice.
Step 3: Line items as a table
Invoices are mostly tables. pw.TableHelper.fromTextArray turns rows of strings into a styled table in one call:
pw.Widget _lineItems(Invoice invoice) {
return pw.TableHelper.fromTextArray(
headers: ['Description', 'Qty', 'Unit price', 'Amount'],
data: [
for (final item in invoice.items)
[
item.description,
'${item.quantity}',
money.format(item.unitPriceCents / 100),
money.format(item.totalCents / 100),
],
],
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200),
cellAlignments: {
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
},
);
}
Right-align every number column, so decimal points line up and totals are easy to scan. If you need more control (multi-line descriptions, a row that spans columns), build a pw.Table with pw.TableRows yourself. It’s more code, but every cell is yours to style.
Inside a MultiPage, long tables split across pages. Test with an invoice that has sixty line items, not three. That’s how you find out whether the header repeats and the totals block stays readable.
Step 4: Money, formatted properly
Two rules, and neither is optional.
Store money as integers in the smallest unit (cents), never as double. Floating-point rounding errors are small, but on an invoice a one-cent difference between the line items and the total looks unprofessional.
Format with intl’s NumberFormat, not string concatenation:
final money = NumberFormat.simpleCurrency(locale: 'en_US', name: 'USD');
money.format(1234.5); // $1,234.50
NumberFormat.currency gives you control over the symbol and decimal digits. The locale decides the separators, so the same amount looks right in both the US and Germany. And the currency should come from the invoice data, not from the device. A US business sending an invoice from a phone set to a different region still bills in dollars.
Compute totals, tax and discounts in integer cents, and convert to a display value only at the moment you format it.
Step 5: Print, share or preview
With the bytes ready, the printing package covers the usual actions:
// Opens the system print dialog (AirPrint on iOS, print services on Android).
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => buildInvoicePdf(invoice),
);
// Opens the share sheet with the PDF attached.
await Printing.sharePdf(
bytes: await buildInvoicePdf(invoice),
filename: 'invoice-${invoice.number}.pdf',
);
There’s also a PdfPreview widget, which shows the document on screen with print and share actions built in. I use it during development constantly, since checking layout changes is far quicker there than opening exported files.
For large documents, generation can take long enough to cause visible jank. Load fonts and images in the main isolate first, then build and save the document off the UI thread. Also show a progress indicator rather than freezing the button. My post on fixing jank in Flutter apps covers how to measure this.
Should the app generate it at all?
Here’s the question from the start. On-device generation is great for:
- Quotes, estimates and work orders.
- Reports and exports of data the user already has.
- Receipts that are just a summary, and anything that needs to work offline.
For legal invoices, I usually recommend the server, or better, the payment provider. On Dent Shop Manager, a CRM for auto-body shops, I integrated Stripe for payments, subscriptions and automated invoicing. With Stripe invoicing, Stripe numbers the invoices, renders their PDFs and tracks whether each one has been paid. An app built on top of that can show and link to those invoices without generating legal documents itself.
Why that’s often the better source of truth:
- Numbering. Invoice numbers usually need to be sequential and unique. Two phones generating invoices offline can’t guarantee that on their own.
- Immutability. A legal invoice shouldn’t change after it’s issued. A PDF built on the device from current data will change if the data does.
- Tax rules. Tax rates, required fields and wording vary by country and change over time. Updating that logic on a server is much easier than shipping app updates.
- One copy everyone trusts. The customer, the business and the accountant all see the same document.
A good split: the server (or Stripe) owns the invoice record and its PDF, and the app generates convenience documents and previews. I go into the backend side in Stripe subscriptions and invoicing with .NET.
Takeaways
- Use
pdfto build documents andprintingto print, share and preview them. Always import the pdf widgets with apwprefix. - Use
MultiPagefor anything with variable-length content, and test with long invoices. - Embed TTF fonts that cover your users’ scripts. The built-in fonts are basic Latin only.
- Keep money in integer cents and format it with
NumberFormatusing the invoice’s currency. - Generate convenience documents on the device. Let the server or Stripe own legal invoices.
If your app needs invoicing that holds up for real accounting, let’s talk about it.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).