Generare documenti PDF in Flutter con i pacchetti pdf e printing

Foto di Brett Jordan su Unsplash

GuideIntermedio45 min Flutter 3.x

Generare documenti PDF in Flutter con i pacchetti pdf e printing

Prima o poi ogni app gestionale, di e-commerce o di field service si scontra con la stessa richiesta: "possiamo esportare questo in PDF?". In Flutter la risposta è il duo pdf (per costruire il documento) e printing (per anteprima, stampa e condivisione), due pacchetti dello stesso autore che funzionano su Android, iOS, web, macOS, Windows e Linux.

La cosa più interessante è che il pacchetto pdf espone una libreria di widget dichiarativi molto simile a quella di Flutter: Column, Row, Container, Table, Padding... ma importata con il prefisso pw e con un proprio sistema di layout pensato per le pagine di carta. Chi conosce Flutter si trova subito a casa, a patto di tenere ben separati i due mondi: i widget di package:flutter/widgets.dart non sono utilizzabili dentro un PDF e viceversa.

In questo tutorial costruiremo una fattura PDF dinamica: partiremo dal modello dati, genereremo un documento multipagina con intestazione, tabella delle righe, totali e numerazione delle pagine, aggiungeremo font e logo personalizzati, mostreremo l'anteprima di stampa e infine salveremo e condivideremo il file. Chiuderemo con alcune best practice su performance e isolate.

Prerequisiti: progetto Flutter 3.x funzionante, conoscenza di base di widget, Future e async/await.

  1. 1

    Installare le dipendenze e preparare gli asset

    Aggiungiamo al progetto i pacchetti necessari:

    • pdf: costruisce il documento (widget pw.*, salvataggio in Uint8List).
    • printing: anteprima, stampa, condivisione e accesso ai Google Fonts già pronti per il PDF.
    • path_provider: per ottenere una cartella dove salvare il file.
    • share_plus: per condividere il PDF con altre app (opzionale, printing offre già Printing.sharePdf).
    • intl: per formattare date e valute in italiano.

    Eseguiamo dal terminale:

    flutter pub add pdf printing path_provider share_plus intl
    

    Se vuoi inserire un logo aziendale, copia l'immagine in assets/images/logo.png e dichiarala nel pubspec.yaml.

    Nota piattaforme: su Android non serve alcun permesso se salvi nelle cartelle dell'app (getApplicationDocumentsDirectory o getTemporaryDirectory). Su Flutter Web la stampa apre il dialog del browser e il salvataggio avviene tramite download, quindi evita dart:io dietro a controlli di piattaforma.

    # pubspec.yaml
    dependencies:
      flutter:
        sdk: flutter
      pdf: ^3.11.1
      printing: ^5.13.2
      path_provider: ^2.1.4
      share_plus: ^10.1.2
      intl: ^0.19.0
    
    flutter:
      assets:
        - assets/images/logo.png

    Risultato atteso

    `flutter pub get` termina senza errori e l'asset del logo è dichiarato nel pubspec.yaml.

  2. 2

    Definire il modello dati della fattura

    Prima di disegnare il PDF conviene avere un modello dati pulito: il generatore del documento deve limitarsi a rappresentare i dati, non a calcolarli. Creiamo lib/models/invoice.dart con due classi immutabili e qualche getter per subtotale, IVA e totale.

    Mantenere la logica di calcolo nel modello rende il codice testabile con normali unit test, senza dover generare un PDF per verificare che i totali siano corretti.

    // lib/models/invoice.dart
    class InvoiceItem {
      final String description;
      final int quantity;
      final double unitPrice;
    
      const InvoiceItem({
        required this.description,
        required this.quantity,
        required this.unitPrice,
      });
    
      double get total => quantity * unitPrice;
    }
    
    class Invoice {
      final String number;
      final DateTime date;
      final String customerName;
      final String customerAddress;
      final List<InvoiceItem> items;
      final double vatRate;
    
      const Invoice({
        required this.number,
        required this.date,
        required this.customerName,
        required this.customerAddress,
        required this.items,
        this.vatRate = 0.22,
      });
    
      double get subtotal =>
          items.fold(0.0, (sum, item) => sum + item.total);
    
      double get vat => subtotal * vatRate;
    
      double get total => subtotal + vat;
    }

    Risultato atteso

    Il modello compila e `subtotal`, `vat` e `total` restituiscono i valori attesi per una lista di righe di esempio.

  3. 3

    Costruire il documento PDF multipagina con MultiPage

    Ora il cuore del tutorial. Creiamo un servizio InvoicePdfService che restituisce i byte del PDF (Uint8List): questa firma è perfetta perché è esattamente ciò che si aspettano Printing.layoutPdf, PdfPreview e File.writeAsBytes.

    Punti chiave:

    • pw.MultiPage spezza automaticamente il contenuto su più pagine quando build restituisce una lista di widget troppo alta: ideale per fatture con molte righe.
    • header e footer sono callback invocate per ogni pagina e ricevono un pw.Context da cui leggere pageNumber e pagesCount.
    • pw.TableHelper.fromTextArray genera rapidamente una tabella con intestazione, allineamenti e stili per cella (in passato era pw.Table.fromTextArray, oggi deprecato).
    • Tutte le misure si esprimono in punti PDF (1 punto = 1/72 di pollice); usa le costanti PdfPageFormat.cm e PdfPageFormat.mm per ragionare in unità familiari.

    Attenzione all'import: i widget del PDF vanno importati con il prefisso pw per non entrare in conflitto con quelli di Flutter.

    // lib/services/invoice_pdf_service.dart
    import 'dart:typed_data';
    
    import 'package:intl/intl.dart';
    import 'package:pdf/pdf.dart';
    import 'package:pdf/widgets.dart' as pw;
    
    import '../models/invoice.dart';
    
    class InvoicePdfService {
      final _currency = NumberFormat.currency(locale: 'it_IT', symbol: '€');
      final _dateFormat = DateFormat('dd/MM/yyyy', 'it_IT');
    
      Future<Uint8List> build(Invoice invoice) async {
        final doc = pw.Document(title: 'Fattura ${invoice.number}');
    
        doc.addPage(
          pw.MultiPage(
            pageFormat: PdfPageFormat.a4,
            margin: const pw.EdgeInsets.all(32),
            header: (context) => _header(invoice),
            footer: (context) => pw.Container(
              alignment: pw.Alignment.centerRight,
              child: pw.Text(
                'Pagina ${context.pageNumber} di ${context.pagesCount}',
                style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey600),
              ),
            ),
            build: (context) => [
              _customerBox(invoice),
              pw.SizedBox(height: 24),
              _itemsTable(invoice),
              pw.SizedBox(height: 16),
              _totals(invoice),
            ],
          ),
        );
    
        return doc.save();
      }
    
      pw.Widget _header(Invoice invoice) => pw.Column(
            crossAxisAlignment: pw.CrossAxisAlignment.start,
            children: [
              pw.Row(
                mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
                children: [
                  pw.Text('Acme S.r.l.',
                      style: pw.TextStyle(
                          fontSize: 20, fontWeight: pw.FontWeight.bold)),
                  pw.Column(
                    crossAxisAlignment: pw.CrossAxisAlignment.end,
                    children: [
                      pw.Text('Fattura n. ${invoice.number}'),
                      pw.Text(_dateFormat.format(invoice.date),
                          style: const pw.TextStyle(color: PdfColors.grey700)),
                    ],
                  ),
                ],
              ),
              pw.Divider(thickness: 1, color: PdfColors.grey400),
            ],
          );
    
      pw.Widget _customerBox(Invoice invoice) => pw.Container(
            padding: const pw.EdgeInsets.all(12),
            decoration: pw.BoxDecoration(
              border: pw.Border.all(color: PdfColors.grey400),
              borderRadius: pw.BorderRadius.circular(6),
            ),
            child: pw.Column(
              crossAxisAlignment: pw.CrossAxisAlignment.start,
              children: [
                pw.Text('Destinatario',
                    style: pw.TextStyle(
                        fontSize: 10,
                        fontWeight: pw.FontWeight.bold,
                        color: PdfColors.grey700)),
                pw.SizedBox(height: 4),
                pw.Text(invoice.customerName),
                pw.Text(invoice.customerAddress),
              ],
            ),
          );
    
      pw.Widget _itemsTable(Invoice invoice) => pw.TableHelper.fromTextArray(
            headers: ['Descrizione', 'Q.tà', 'Prezzo', 'Totale'],
            data: invoice.items
                .map((item) => [
                      item.description,
                      '${item.quantity}',
                      _currency.format(item.unitPrice),
                      _currency.format(item.total),
                    ])
                .toList(),
            headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
            headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
            cellAlignments: {
              0: pw.Alignment.centerLeft,
              1: pw.Alignment.center,
              2: pw.Alignment.centerRight,
              3: pw.Alignment.centerRight,
            },
            columnWidths: {
              0: const pw.FlexColumnWidth(4),
              1: const pw.FlexColumnWidth(1),
              2: const pw.FlexColumnWidth(2),
              3: const pw.FlexColumnWidth(2),
            },
          );
    
      pw.Widget _totals(Invoice invoice) => pw.Align(
            alignment: pw.Alignment.centerRight,
            child: pw.SizedBox(
              width: 220,
              child: pw.Column(
                children: [
                  _totalRow('Imponibile', _currency.format(invoice.subtotal)),
                  _totalRow('IVA ${(invoice.vatRate * 100).toStringAsFixed(0)}%',
                      _currency.format(invoice.vat)),
                  pw.Divider(),
                  _totalRow('Totale', _currency.format(invoice.total), bold: true),
                ],
              ),
            ),
          );
    
      pw.Widget _totalRow(String label, String value, {bool bold = false}) {
        final style = pw.TextStyle(
          fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
          fontSize: bold ? 14 : 11,
        );
        return pw.Padding(
          padding: const pw.EdgeInsets.symmetric(vertical: 2),
          child: pw.Row(
            mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
            children: [pw.Text(label, style: style), pw.Text(value, style: style)],
          ),
        );
      }
    }

    Risultato atteso

    Il servizio compila e `build(invoice)` restituisce un `Uint8List` non vuoto contenente il PDF della fattura.

  4. 4

    Aggiungere font personalizzati, caratteri accentati e logo

    I font Helvetica integrati nel PDF supportano i caratteri latini di base, ma possono creare problemi con simboli particolari (€ in alcuni contesti, emoji, alfabeti non latini) e offrono poca personalizzazione. Due strade:

    1. Google Fonts già pronti tramite printing: PdfGoogleFonts.nunitoRegular() scarica e memorizza in cache il font (richiede rete alla prima esecuzione).
    2. Font locale negli asset: più affidabile offline, basta caricare il .ttf con rootBundle e pw.Font.ttf.

    Il font si applica a tutto il documento tramite pw.ThemeData.withFont(...) passato a pw.Document(theme: ...) o al pageTheme. Il logo invece si carica come pw.MemoryImage a partire dai byte dell'asset.

    Importante: carica font e immagini una sola volta e passali al builder, evitando di rileggere il bundle a ogni pagina.

    import 'package:flutter/services.dart' show rootBundle;
    import 'package:printing/printing.dart';
    
    // Dentro InvoicePdfService
    Future<Uint8List> buildStyled(Invoice invoice) async {
      // 1. Font: da Google Fonts (rete) oppure dagli asset locali
      final regular = await PdfGoogleFonts.nunitoRegular();
      final bold = await PdfGoogleFonts.nunitoBold();
      // Alternativa offline:
      // final regular = pw.Font.ttf(await rootBundle.load('assets/fonts/Nunito-Regular.ttf'));
    
      // 2. Logo dagli asset
      final logoBytes = await rootBundle.load('assets/images/logo.png');
      final logo = pw.MemoryImage(logoBytes.buffer.asUint8List());
    
      final doc = pw.Document(
        title: 'Fattura ${invoice.number}',
        theme: pw.ThemeData.withFont(base: regular, bold: bold),
      );
    
      doc.addPage(
        pw.MultiPage(
          pageFormat: PdfPageFormat.a4,
          margin: const pw.EdgeInsets.all(32),
          header: (context) => pw.Row(
            mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
            crossAxisAlignment: pw.CrossAxisAlignment.start,
            children: [
              pw.SizedBox(height: 48, width: 48, child: pw.Image(logo)),
              pw.Text('Fattura n. ${invoice.number}',
                  style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
            ],
          ),
          build: (context) => [
            _customerBox(invoice),
            pw.SizedBox(height: 24),
            _itemsTable(invoice),
            pw.SizedBox(height: 16),
            _totals(invoice),
          ],
        ),
      );
    
      return doc.save();
    }

    Risultato atteso

    Il PDF mostra il logo in alto a sinistra e tutti i testi, accenti e simbolo euro inclusi, resi con il font scelto.

  5. 5

    Mostrare l'anteprima e stampare con il pacchetto printing

    Il pacchetto printing offre due approcci complementari:

    • PdfPreview: un widget a schermo intero con zoom, scorrimento pagine e una barra azioni (stampa, condividi, salva). Perfetto per una schermata "Anteprima documento".
    • Printing.layoutPdf: apre direttamente il dialog di stampa di sistema, utile quando l'utente ha già visto i dati nella UI dell'app.

    PdfPreview richiede una callback build che riceve il PdfPageFormat scelto dall'utente e restituisce i byte: questo permette, se vuoi, di rigenerare il documento in A4 o Letter al volo. Attenzione a non ricostruire il PDF a ogni setState: usa useActions, canChangePageFormat e memorizza il risultato quando la generazione è costosa.

    import 'package:flutter/material.dart';
    import 'package:printing/printing.dart';
    
    import '../models/invoice.dart';
    import '../services/invoice_pdf_service.dart';
    
    class InvoicePreviewPage extends StatelessWidget {
      const InvoicePreviewPage({super.key, required this.invoice});
    
      final Invoice invoice;
    
      @override
      Widget build(BuildContext context) {
        final service = InvoicePdfService();
    
        return Scaffold(
          appBar: AppBar(
            title: Text('Fattura ${invoice.number}'),
            actions: [
              IconButton(
                icon: const Icon(Icons.print),
                tooltip: 'Stampa',
                onPressed: () => Printing.layoutPdf(
                  name: 'fattura_${invoice.number}.pdf',
                  onLayout: (format) => service.build(invoice),
                ),
              ),
            ],
          ),
          body: PdfPreview(
            build: (format) => service.build(invoice),
            canChangePageFormat: false,
            canDebug: false,
            pdfFileName: 'fattura_${invoice.number}.pdf',
            loadingWidget: const Center(child: CircularProgressIndicator()),
          ),
        );
      }
    }

    Risultato atteso

    Aprendo la pagina vedi l'anteprima scorrevole del PDF; toccando l'icona di stampa si apre il dialog di stampa del sistema operativo.

  6. 6

    Salvare il PDF su file e condividerlo

    Per archiviare il documento o inviarlo via email/WhatsApp serve scriverlo su disco. Usiamo path_provider per ottenere una directory valida su tutte le piattaforme mobile/desktop:

    • getTemporaryDirectory() per file usa-e-getta da condividere subito (il sistema può ripulirli);
    • getApplicationDocumentsDirectory() per documenti che l'utente deve ritrovare nell'app.

    Per la condivisione hai due opzioni: Printing.sharePdf (zero configurazione, già inclusa) oppure share_plus, utile se devi condividere più file insieme o aggiungere testo e oggetto. Su Flutter Web evita dart:io: usa Printing.sharePdf, che gestisce il download del file dal browser.

    import 'dart:io';
    import 'dart:typed_data';
    
    import 'package:flutter/foundation.dart' show kIsWeb;
    import 'package:path_provider/path_provider.dart';
    import 'package:printing/printing.dart';
    import 'package:share_plus/share_plus.dart';
    
    Future<File> savePdf(Uint8List bytes, String fileName) async {
      final dir = await getApplicationDocumentsDirectory();
      final file = File('${dir.path}/$fileName');
      return file.writeAsBytes(bytes, flush: true);
    }
    
    Future<void> sharePdf(Uint8List bytes, String fileName) async {
      if (kIsWeb) {
        // Sul web avvia il download / la condivisione nativa del browser
        await Printing.sharePdf(bytes: bytes, filename: fileName);
        return;
      }
    
      final dir = await getTemporaryDirectory();
      final file = File('${dir.path}/$fileName');
      await file.writeAsBytes(bytes, flush: true);
    
      await Share.shareXFiles(
        [XFile(file.path, mimeType: 'application/pdf')],
        subject: 'Fattura',
        text: 'In allegato la fattura in formato PDF.',
      );
    }

    Risultato atteso

    Il file viene scritto nella cartella dell'app e il foglio di condivisione del sistema mostra il PDF allegato, apribile con un lettore esterno.

  7. 7

    Ottimizzare: generazione in isolate, caching e test

    La generazione di un PDF con centinaia di righe o immagini pesanti può bloccare il thread UI. Alcune accortezze:

    1. Sposta il lavoro su un isolate con compute. La costruzione del documento è pura CPU: può girare fuori dal thread UI. Il vincolo è che rootBundle e la rete non sono direttamente disponibili in un isolate secondario, quindi carica font e immagini sul main isolate e passali come Uint8List nel payload.

    2. Metti in cache i byte. Se l'utente apre l'anteprima e poi stampa, non rigenerare: conserva il Future<Uint8List> o il risultato in memoria.

    3. Comprimi le immagini prima di inserirle: un JPEG a 1000 px di larghezza è più che sufficiente per la stampa di un logo o di una foto di report, mentre un PNG 4000×3000 fa esplodere la dimensione del file.

    4. Testa il PDF. Puoi scrivere unit test che generano il documento e verificano che i byte inizino con l'header %PDF e che la dimensione sia plausibile; per una verifica visiva usa Printing.raster per convertire le pagine in immagini e confrontarle con un golden test.

    Con queste basi puoi estendere il generatore a ricevute, report con grafici (renderizzando un widget Flutter in immagine con RepaintBoundary e inserendolo come pw.MemoryImage) o etichette per stampanti termiche.

    import 'dart:typed_data';
    import 'package:flutter/foundation.dart';
    import 'package:pdf/widgets.dart' as pw;
    
    class PdfPayload {
      final Invoice invoice;
      final Uint8List regularFont;
      final Uint8List logo;
    
      const PdfPayload(this.invoice, this.regularFont, this.logo);
    }
    
    // Funzione top-level: requisito per compute()
    Uint8List buildInvoiceSync(PdfPayload payload) {
      final doc = pw.Document(
        theme: pw.ThemeData.withFont(
          base: pw.Font.ttf(payload.regularFont.buffer.asByteData()),
        ),
      );
      final logo = pw.MemoryImage(payload.logo);
    
      doc.addPage(
        pw.MultiPage(
          header: (_) => pw.SizedBox(height: 40, child: pw.Image(logo)),
          build: (_) => [
            pw.Text('Fattura n. ${payload.invoice.number}'),
            // ...resto del layout
          ],
        ),
      );
    
      return Uint8List.fromList(doc.save() as List<int>);
    }
    
    // Nel widget: asset caricati sul main isolate, PDF generato fuori
    Future<Uint8List> generateInBackground(Invoice invoice) async {
      final fontData = await rootBundle.load('assets/fonts/Nunito-Regular.ttf');
      final logoData = await rootBundle.load('assets/images/logo.png');
    
      return compute(
        buildInvoiceSync,
        PdfPayload(
          invoice,
          fontData.buffer.asUint8List(),
          logoData.buffer.asUint8List(),
        ),
      );
    }

    Risultato atteso

    Anche con fatture molto lunghe l'interfaccia resta fluida durante la generazione e il PDF prodotto è identico a quello creato sul main isolate.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!