Articolo tecnico

Unire file PDF in Delphi con PDFium Component

PDFium Component espone la fusione dei PDF attraverso un unico metodo: ImportPages. Lo schema è sempre lo stesso: create un documento di destinazione vuoto, aprite ogni file sorgente, chiamate ImportPages per copiarne le pagine, chiudete la sorgente e ripetete. Quando il ciclo termina, SaveAs scrive il risultato su disco. Non esiste una modalità di fusione speciale, nessuna configurazione da attivare. La complessità vive nei casi limite, e ce ne sono alcuni che mordono senza preavviso

Il ciclo principale

Vi bastano due istanze di TPdf. Una contiene il documento di destinazione, creato vuoto con CreateDocument. L'altra apre a turno ogni file sorgente. Di seguito una procedura che prende un elenco di percorsi di file e scrive l'output unito in un unico percorso:

procedure MergeFiles(const FileList: TStrings; const OutputPath: string);
var
  PdfDest, PdfSrc: TPdf;
  InsertAt, I: Integer;
begin
  PdfDest := TPdf.Create(nil);
  PdfSrc  := TPdf.Create(nil);
  try
    PdfDest.CreateDocument;
    InsertAt := 1;  // ImportPages usa una posizione di destinazione in base 1

    for I := 0 to FileList.Count - 1 do
    begin
      PdfSrc.FileName := FileList[I];
      PdfSrc.Active   := True;

      if not PdfSrc.Active then
        raise Exception.CreateFmt('Cannot open: %s', [FileList[I]]);

      PdfDest.ImportPages(
        PdfSrc,
        '1-' + IntToStr(PdfSrc.PageCount),  // intervallo dell'intero documento
        InsertAt);

      Inc(InsertAt, PdfSrc.PageCount);
      PdfSrc.Active := False;
    end;

    PdfDest.SaveAs(OutputPath);
  finally
    PdfSrc.Free;
    PdfDest.Free;
  end;
end;

Due cose in quel codice sono facili da trascurare a una prima lettura. La prima è il modo in cui PDFium segnala i fallimenti di caricamento. Active := True non solleva mai un'eccezione: se il file manca, è danneggiato o è protetto da password, PDFium intercetta l'errore internamente e lascia Active a False. Senza il controllo esplicito alla riga 10, un file difettoso uscirebbe silenziosamente dalla fusione senza alcuna traccia nell'output. Il PDF finale avrebbe meno pagine del previsto e non sapreste quale file sia il colpevole

La seconda è il contatore InsertAt. Il terzo argomento di ImportPages è la posizione in base 1 nella destinazione in cui atterra la prima pagina importata. Partire da 1 mette il primo documento sorgente all'inizio di un file altrimenti vuoto. Dopo ogni sorgente il contatore avanza di PdfSrc.PageCount, così il gruppo di pagine successivo si accoda all'ultimo. Dimenticate di incrementarlo e ogni sorgente successiva sovrascrive le pagine alla posizione 1, lasciandovi l'ultimo documento dell'elenco e nient'altro

Ciclo di fusione in Delphi con PDFium Component: ogni file sorgente viene aperto, copiato tramite ImportPages nella posizione InsertAt, e il documento di destinazione viene scritto una sola volta con SaveAs
ImportPages colloca ogni sorgente nella posizione InsertAt, e un file mancante o danneggiato fallisce in silenzio se non si controlla Active

Intervalli di pagine selettivi

Non siete obbligati a prendere ogni pagina da una sorgente. La stringa di intervallo passata come secondo argomento segue un semplice formato a virgole e trattini: "1-3" prende le pagine da 1 a 3, "2,4,6" sceglie tre pagine specifiche e "1-" significa dalla pagina 1 alla fine del documento. Gli intervalli possono essere combinati in una sola stringa, quindi "1-3,5,7-" salta le pagine 4 e 6. Qui conta una sottigliezza: i numeri si riferiscono sempre alle pagine del documento sorgente, a partire da 1, indipendentemente da dove quelle pagine finiscano nella destinazione. Se volete le pagine da 40 a 50 di un catalogo di 200 pagine, la stringa di intervallo è "40-50", non una posizione relativa a ciò che è già nella destinazione

// Estrae la copertina e un riepilogo esecutivo di tre pagine da un lungo report
PdfSrc.FileName := 'annual-report.pdf';
PdfSrc.Active   := True;
if PdfSrc.Active then
begin
  // La pagina 1 è la copertina; le pagine 3-5 sono il riepilogo
  PdfDest.ImportPages(PdfSrc, '1,3-5', InsertAt);
  Inc(InsertAt, 4);  // 1 copertina + 3 pagine di riepilogo = 4 pagine aggiunte
  PdfSrc.Active := False;
end;

Quando calcolate l'incremento di InsertAt, contate le pagine che avete effettivamente importato, non il numero di pagine della sorgente. Se passate '1,3-5' avete importato 4 pagine, quindi avanzate di 4. Avanzare di PdfSrc.PageCount lascerebbe un vuoto di posizioni libere nella destinazione e collocherebbe il documento sorgente successivo più avanti nel file di quanto previsto

Cosa ImportPages conserva e cosa no

Le pagine copiate da ImportPages portano con sé intatto il contenuto visibile. Testo, grafica vettoriale, immagini raster, font incorporati e form XObject vengono trasferiti tutti come parte dei content stream di pagina. Anche le annotazioni a livello di pagina, inclusi commenti, evidenziazioni e tratti a mano libera, passano, perché sono memorizzate nel dizionario di pagina anziché a livello di documento

I metadati a livello di documento sono un altro discorso. Le stringhe di titolo, autore, oggetto e parole chiave nel dizionario Info della sorgente restano indietro. Il documento di destinazione parte con metadati vuoti dopo CreateDocument, quindi se l'output unito ha bisogno di quei campi popolati dovete assegnarli direttamente a PdfDest prima di chiamare SaveAs. Le proprietà Title, Author, Subject, Keywords e Creator su TPdf accettano stringhe semplici e scrivono nel dizionario Info al salvataggio

I campi modulo interattivi sono più complicati. Le definizioni dei campi AcroForm risiedono in un dizionario a livello di documento anziché dentro i singoli stream di pagina. Quando ImportPages copia una pagina che contiene campi modulo, l'aspetto visivo di quei campi viene trasferito perché è renderizzato nel content stream della pagina, ma i widget di campo che li rendono interattivi fanno parte della struttura AcroForm e non seguono. In una fusione tipica un campo di testo di un documento sorgente mostrerà il valore che aveva al momento dell'importazione, ma non sarà modificabile nel file unito. Se avete bisogno che i campi restino compilabili, appiattiteli in ogni documento sorgente prima di importarli: così i valori correnti vengono fissati nel content stream e l'overlay interattivo viene rimosso, dandovi un risultato visivo pulito senza widget rotti nell'output

ImportPages di PDFium porta contenuto di pagina, font e annotazioni in un PDF unito, mentre metadati Info, widget AcroForm e cifratura restano indietro in ogni file sorgente
ImportPages sposta tutto ciò che è memorizzato con la pagina, mentre i metadati a livello di documento e l'interattività AcroForm restano indietro

File sorgente cifrati

I documenti sorgente protetti da password si aprono allo stesso modo di quelli non cifrati, con una sola proprietà in più da impostare prima. Assegnate la password a PdfSrc.Password prima di portare Active := True, e PDFium la userà durante l'apertura:

PdfSrc.Password := 'user-password';
PdfSrc.FileName := 'protected.pdf';
PdfSrc.Active   := True;
if not PdfSrc.Active then
  raise Exception.Create('Wrong password or file cannot be opened');

PdfDest.ImportPages(PdfSrc, '1-' + IntToStr(PdfSrc.PageCount), InsertAt);
Inc(InsertAt, PdfSrc.PageCount);
PdfSrc.Active := False;

Una password sbagliata produce lo stesso esito silenzioso Active = False di un file mancante, quindi il controllo esplicito è altrettanto necessario qui. La cifratura non si trasferisce alla destinazione: le pagine importate da una sorgente protetta atterrano nella destinazione come contenuto non protetto. Se anche l'output unito ha bisogno di cifratura, configuratela su PdfDest prima di chiamare SaveAs

Salvare il risultato

SaveAs su TPdf accetta un percorso di file oppure un TStream. Per la maggior parte delle fusioni, l'overload su file è quello che vi serve:

PdfDest.SaveAs('merged-output.pdf');

Il secondo argomento opzionale è un TSaveOption che controlla la modalità di salvataggio. Il valore predefinito, saNone, scrive un aggiornamento incrementale se il documento è stato caricato da un file, oppure una riscrittura completa se è stato creato da zero. Poiché una destinazione costruita con CreateDocument è sempre nuova, l'output sarà un file compatto a revisione singola. Il terzo argomento, TPdfVersion, vi permette di fissare l'intestazione di versione PDF quando avete consumatori a valle che richiedono una versione specifica; lasciarlo a pvUnknown lascia scegliere a PDFium in base al contenuto

I metodi ImportPages e SaveAs mostrati qui fanno parte di PDFium Component per Delphi e C++Builder