Perché generare PDF lato client
Fatture, ricevute, report di attività, schede tecniche: prima o poi quasi ogni app gestionale deve produrre un documento stampabile. La soluzione più diffusa è delegare tutto al backend, ma non sempre è la scelta migliore: significa una chiamata di rete in più, un servizio da mantenere e nessuna possibilità di generare documenti offline.
In Flutter esiste un'alternativa matura: il pacchetto pdf (scritto interamente in Dart, quindi funzionante su Android, iOS, Web, desktop) affiancato da printing, che aggiunge anteprima, invio alla stampante e condivisione del file. In questa guida vediamo come costruire un documento reale — una fattura multipagina con logo, tabella e numerazione — e come integrarlo nel flusso dell'app.
Setup
dependencies:
pdf: ^3.11.1
printing: ^5.13.4
path_provider: ^2.1.4
Un dettaglio fondamentale da chiarire subito: i widget del pacchetto pdf non sono i widget di Flutter. Vivono in un namespace separato e hanno un motore di layout proprio, orientato alla pagina stampata. Per questo la convenzione è importarli con un prefisso:
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
I nomi sono familiari (pw.Column, pw.Row, pw.Container, pw.Text) ma non aspettarti MediaQuery, setState o animazioni: qui si descrive un documento statico, non una UI interattiva.
Il primo documento
La struttura minima è sempre la stessa: si crea un Document, si aggiungono una o più pagine, si serializza in Uint8List.
import 'dart:typed_data';
Future<Uint8List> buildSimplePdf() async {
final doc = pw.Document();
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
build: (context) => pw.Center(
child: pw.Text(
'Ciao Flutter!',
style: pw.TextStyle(fontSize: 32, fontWeight: pw.FontWeight.bold),
),
),
),
);
return doc.save();
}
doc.save() restituisce i byte del PDF: da lì puoi salvarli su disco, caricarli su un server o passarli a printing.
Font: il primo scoglio da risolvere
I font di default incorporati (Helvetica e simili) coprono solo un sottoinsieme di caratteri Latin-1. Se il documento contiene simboli particolari, accenti insoliti o l'euro in certe posizioni, rischi i famigerati rettangoli vuoti. La soluzione è caricare un font TrueType dagli asset e impostarlo come tema del documento.
flutter:
assets:
- assets/fonts/Roboto-Regular.ttf
- assets/fonts/Roboto-Bold.ttf
import 'package:flutter/services.dart' show rootBundle;
Future<pw.ThemeData> buildTheme() async {
final regular = pw.Font.ttf(
await rootBundle.load('assets/fonts/Roboto-Regular.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/Roboto-Bold.ttf'),
);
return pw.ThemeData.withFont(base: regular, bold: bold);
}
In alternativa printing espone PdfGoogleFonts, che scarica i font da Google Fonts a runtime (await PdfGoogleFonts.robotoRegular()): comodo in prototipazione, meno adatto se l'app deve funzionare offline.
Documenti multipagina con MultiPage
Per i report reali si usa MultiPage: distribuisce automaticamente il contenuto su più fogli, ripetendo header e footer. È il widget che userai nel 90% dei casi.
class InvoiceItem {
const InvoiceItem(this.description, this.qty, this.unitPrice);
final String description;
final int qty;
final double unitPrice;
double get total => qty * unitPrice;
}
Future<Uint8List> buildInvoice({
required String number,
required String customer,
required List<InvoiceItem> items,
required Uint8List logoBytes,
}) async {
final doc = pw.Document(title: 'Fattura $number');
final theme = await buildTheme();
final logo = pw.MemoryImage(logoBytes);
final grandTotal = items.fold<double>(0, (s, i) => s + i.total);
doc.addPage(
pw.MultiPage(
theme: theme,
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.symmetric(horizontal: 32, vertical: 40),
header: (context) => pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(height: 48, width: 48, child: pw.Image(logo)),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text('FATTURA $number',
style: pw.TextStyle(
fontSize: 18, fontWeight: pw.FontWeight.bold)),
pw.Text(customer,
style: const pw.TextStyle(color: PdfColors.grey700)),
],
),
],
),
footer: (context) => pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Pagina ${context.pageNumber} di ${context.pagesCount}',
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey600),
),
),
build: (context) => [
pw.SizedBox(height: 24),
_itemsTable(items),
pw.SizedBox(height: 16),
pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Totale: € ${grandTotal.toStringAsFixed(2)}',
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold),
),
),
],
),
);
return doc.save();
}
Da notare: il parametro build di MultiPage restituisce una lista di widget, non un widget singolo. Ogni elemento della lista può essere spezzato tra due pagine, ed è proprio questo che rende il layout scalabile su documenti lunghi.
Tabelle leggibili con TableHelper
Costruire una tabella a mano con pw.Table e pw.TableRow è possibile ma verboso. Per i dati tabellari conviene TableHelper.fromTextArray, che genera intestazione, bordi e allineamenti in poche righe e — cosa cruciale — supporta la ripetizione dell'header sulle pagine successive.
pw.Widget _itemsTable(List<InvoiceItem> items) {
return pw.TableHelper.fromTextArray(
headers: ['Descrizione', 'Q.tà', 'Prezzo', 'Totale'],
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
cellHeight: 28,
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.center,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
},
data: items
.map((i) => [
i.description,
'${i.qty}',
'€ ${i.unitPrice.toStringAsFixed(2)}',
'€ ${i.total.toStringAsFixed(2)}',
])
.toList(),
);
}
Anteprima, stampa e condivisione con printing
Il pacchetto printing offre tre strumenti che coprono praticamente ogni esigenza.
Anteprima integrata — PdfPreview è un widget Flutter a tutti gli effetti: mostra le pagine, permette lo zoom e include già i pulsanti di stampa, condivisione e salvataggio.
class InvoicePreviewPage extends StatelessWidget {
const InvoicePreviewPage({super.key, required this.items});
final List<InvoiceItem> items;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Anteprima fattura')),
body: PdfPreview(
build: (format) => buildInvoiceFor(format, items),
canDebug: false,
pdfFileName: 'fattura-2024-001.pdf',
),
);
}
}
Il callback build riceve il PdfPageFormat scelto dall'utente: se lo propaghi al documento, il layout si adatta automaticamente ad A4, Letter o formati custom.
Stampa diretta
await Printing.layoutPdf(
onLayout: (format) => buildInvoice(/* ... */),
name: 'fattura-2024-001',
);
Condivisione
final bytes = await buildInvoice(/* ... */);
await Printing.sharePdf(bytes: bytes, filename: 'fattura.pdf');
Salvare il file su disco
Se il documento va archiviato localmente, combina i byte con path_provider:
import 'dart:io';
import 'package:path_provider/path_provider.dart';
Future<File> saveInvoice(Uint8List bytes, String name) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$name.pdf');
return file.writeAsBytes(bytes);
}
Attenzione: su Web dart:io non esiste. Se il target include il browser, isola questa logica dietro un'interfaccia o usa direttamente Printing.sharePdf, che sul Web attiva il download del file.
Da widget Flutter a PDF
A volte serve replicare esattamente una schermata già disegnata (un grafico, una card di riepilogo). printing mette a disposizione Printing.convertHtml per l'HTML e, per la UI Flutter, la strada più affidabile è catturare il widget con RepaintBoundary e inserire l'immagine risultante nel PDF:
final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final png = byteData!.buffer.asUint8List();
doc.addPage(pw.Page(build: (_) => pw.Center(child: pw.Image(pw.MemoryImage(png)))));
È una soluzione pragmatica, ma ricorda che il risultato è un'immagine raster: niente testo selezionabile e file più pesanti. Usala solo per contenuti grafici, mai per intere pagine di testo.
Performance: genera in un isolate
La generazione di un PDF è calcolo puro in Dart, e su documenti di decine di pagine può bloccare il thread UI per centinaia di millisecondi. Poiché il pacchetto pdf non dipende dai binding di Flutter, puoi spostare il lavoro pesante con compute:
final bytes = await compute(_renderReport, reportData);
L'unico vincolo è che i font e le immagini vanno caricati prima (con rootBundle, che richiede il thread principale) e passati all'isolate già come Uint8List, insieme al resto dei dati.
Best practice finali
- Separa i dati dal layout: costruisci il documento a partire da modelli immutabili, così potrai testare la generazione senza UI.
- Definisci un tema una volta sola (font, colori, stili di testo) e riusalo in tutti i documenti: evita duplicazioni e garantisce coerenza visiva.
- Fissa il formato di pagina solo quando ha senso: passando il
PdfPageFormatdalPdfPreviewottieni documenti adattivi senza sforzo. - Verifica il rendering su un lettore reale (Acrobat, Anteprima di macOS):
PdfPreviewè affidabile, ma i visualizzatori esterni possono evidenziare problemi di font mancanti. - Comprimi le immagini prima di inserirle: una foto a piena risoluzione può far esplodere il peso del file di diversi megabyte.
Con poche centinaia di righe di codice hai un generatore di documenti completo, offline e multipiattaforma: per fatture, ricevute e report è spesso tutto ciò che serve, senza aggiungere un solo endpoint al backend.