Un ciclo di merge che passa 1 come posizione di inserimento a ogni chiamata produce un file con il numero giusto di pagine e i documenti in ordine inverso. L'ultimo file della lista finisce sulla pagina 1, perché la posizione 1 significa "inserisci prima della prima pagina corrente", e ogni nuova sorgente spinge le precedenti più in fondo. Niente va perso, ed è esattamente per questo che l'errore sopravvive a un rapido controllo del numero di pagine e salta fuori quando qualcuno legge davvero il fascicolo
Il ciclo pagina per pagina con ImportPages
PDFium Component costruisce un PDF unito in Delphi da due istanze di TPdf: una destinazione creata vuota con CreateDocument, e una sorgente che apre a turno ogni file di input. TPdf.ImportPages(Pdf, Range, PageNumber) copia le pagine nominate in Range da Pdf nella destinazione e restituisce True in caso di successo. PageNumber è in base 1 e vale 1 per default. Nomina la posizione di destinazione in cui atterra la prima pagina importata, quindi 1 premette e PageCount + 1 accoda
Il modo più pulito per tenere i file in ordine di lista è smettere del tutto di tracciare un cursore e chiedere alla destinazione dov'è la sua fine. Anche un contatore InsertAt progressivo funziona, ma va avanzato del numero di pagine effettivamente importate, e quel numero è facile sbagliarlo appena entrano in gioco gli intervalli. PdfDest.PageCount + 1 è sempre giusto:
procedure MergeFileList(const FileList: TStrings; const OutputPath: string);
var
PdfDest, PdfSrc: TPdf;
I: Integer;
begin
PdfDest := TPdf.Create(nil);
PdfSrc := TPdf.Create(nil);
try
PdfDest.CreateDocument;
for I := 0 to FileList.Count - 1 do
begin
PdfSrc.FileName := FileList[I];
PdfSrc.Active := True; // non solleva eccezioni in caso di fallimento
if not PdfSrc.Active then
raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);
// posizione in base 1: PageCount + 1 accoda dopo l'ultima pagina
if not PdfDest.ImportPages(PdfSrc,
'1-' + IntToStr(PdfSrc.PageCount), PdfDest.PageCount + 1) then
raise Exception.CreateFmt('Page import failed for %s', [FileList[I]]);
PdfSrc.Active := False; // FileName può cambiare solo mentre è inattivo
end;
if not PdfDest.SaveAs(OutputPath) then
raise Exception.Create('Cannot write ' + OutputPath);
finally
PdfSrc.Free;
PdfDest.Free;
end;
end;
Due controlli in quel ciclo portano un peso vero. Impostare Active := True non solleva mai un'eccezione: se il file manca, è danneggiato o richiede una password, TPdf intercetta l'errore di caricamento e lascia Active a False. Il componente resta in uno stato chiuso coerente e una form non va in crash su un file cattivo. Un ciclo di merge che salta il controllo, però, produce semplicemente un output più corto. Il secondo controllo è il risultato booleano di ImportPages, che torna False quando la stringa di intervallo non si parsa o la posizione va oltre PageCount + 1. Nota anche che FileName e Password si possono assegnare solo mentre il componente è inattivo, ed è per questo che la sorgente viene chiusa in fondo a ogni passata
Stringhe di intervallo: cosa accetta il parser
L'argomento di intervallo è una lista separata da virgole di pagine singole e intervalli chiusi, numerati da 1 nel documento sorgente. '1-3' prende le prime tre pagine, '2,4,6' ne prende tre precise, e '1,3-5' prende una copertina più un riepilogo di tre pagine. I numeri si riferiscono sempre alla sorgente; dove quelle pagine atterrano nella destinazione lo decide solo l'argomento posizione
Gli intervalli aperti sono dove la gente inciampa. Una stringa come '7-' sembra "dalla pagina 7 alla fine", ma il parser degli intervalli di PDFium richiede entrambe le estremità di un intervallo e rifiuta l'intera stringa. Lo stesso accade con un intervallo invertito ('5-3'), un numero di pagina 0, o qualunque pagina oltre il numero di pagine della sorgente. Un solo elemento cattivo fa fallire l'intera chiamata: ImportPages restituisce False e nessuna pagina di quella stringa viene copiata. Ecco perché il ciclo di sopra costruisce '1-' + IntToStr(PdfSrc.PageCount) invece di scrivere '1-'
Quando hai già gli indici di pagina in un array, ImportPagesByIndex(Source, PageIndices, InsertAt) salta il passaggio dalla stringa. Occhio però al cambio di convenzione: sia gli indici sia InsertAt sono in base zero lì, quindi accodare è InsertAt = PdfDest.PageCount e la selezione copertina-più-riepilogo diventa [0, 2, 3, 4]. Passare un array vuoto importa tutte le pagine
Esiste un merge in una sola chiamata per una lista di file?
Sì. TPdf.MergeFiles(Sources, Options, Report) prende un array di record TPdfBatchMergeSource, ognuno costruito con TPdfBatchMergeSource.FromFile(FileName, PageRange, Password), li apre uno alla volta, e riempie un TPdfBatchMergeReport con ciò che è successo. Un PageRange vuoto significa tutte le pagine. La sintassi degli intervalli è lo stesso formato virgole-e-intervalli, e un intervallo malformato diventa un errore per-file nel report anziché un salto silenzioso
I default di TPdfBatchMergeOptions.Default conviene conoscerli prima di cambiarne qualcuno. CreateDestination è True, quindi una destinazione inattiva viene creata per te. InsertAt è -1, che significa accodare; qualunque altro valore è un indice in base zero, coerente con ImportPagesByIndex e non con ImportPages. ErrorPolicy è bmepStop e RollbackOnCancelOrFailure è True, quindi un file illeggibile termina l'esecuzione e rimuove ogni pagina che l'esecuzione aveva aggiunto. CopyViewerPreferences copia le viewer preferences della prima sorgente importata con successo
procedure BuildBundle(const OutputPath: string; Log: TStrings);
var
Dest: TPdf;
Sources: TPdfBatchMergeSources;
Options: TPdfBatchMergeOptions;
Report: TPdfBatchMergeReport;
I: Integer;
begin
SetLength(Sources, 3);
Sources[0] := TPdfBatchMergeSource.FromFile('cover.pdf');
Sources[1] := TPdfBatchMergeSource.FromFile('annual-report.pdf', '1,3-5');
Sources[2] := TPdfBatchMergeSource.FromFile('appendix.pdf', '', 'user-password');
Options := TPdfBatchMergeOptions.Default;
Options.ErrorPolicy := bmepSkip; // continua oltre i file illeggibili
Dest := TPdf.Create(nil); // inattivo: lo crea MergeFiles
try
if not Dest.MergeFiles(Sources, Options, Report) then
raise Exception.Create('Merge failed: ' + Report.ErrorMessage);
for I := 0 to High(Report.Issues) do
Log.Add(Format('%s: %s',
[Report.Issues[I].FileName, Report.Issues[I].MessageText]));
Log.Add(Format('%d pages from %d of %d files',
[Report.ImportedPageCount, Report.ImportedSourceCount, Report.SourceCount]));
Dest.SaveAs(OutputPath);
finally
Dest.Free;
end;
end;
Quale policy di errore usare dipende da cosa significa per l'output un file mancante. Per un fascicolo di contratti o una mandata di fatture, un fascicolo senza una delle sue parti è un documento sbagliato: tieni il default bmepStop con rollback. Passa a bmepSkip solo per raccolte best-effort, come un digest settimanale di report, e in quel caso tratta bmrsCompletedWithErrors in Report.Status come qualcosa che una persona deve guardare. Quale che tu scelga, leggi Report.Issues: ogni voce porta l'indice della sorgente, il nome del file e il messaggio, che risponde alla domanda "quale file era il colpevole" a cui il ciclo scritto a mano deve rispondere con le proprie eccezioni
Altre due opzioni contano per le esecuzioni lunghe. OnProgress e CancellationToken lasciano che una UI mostri il file corrente e fermi l'esecuzione, e un'esecuzione annullata viene ripristinata come una fallita. ResourcePolicy vale per default bmrpSourceScoped, che importa le pagine selezionate di ogni sorgente in una chiamata nativa sola, così i font e le immagini condivisi da quelle pagine vengono copiati una volta sola. bmrpChunked importa in batch di PageChunkSize pagine, il che dà punti di cancellazione più fini al prezzo di risorse condivise ricopiate per ogni batch
Cosa porta con sé una pagina importata e cosa resta nella sorgente
Una pagina importata porta tutto ciò che è raggiungibile dal suo dizionario di pagina: il content stream, i font, le immagini e i form XObject nelle sue risorse, e le sue annotazioni, inclusi commenti, evidenziazioni e tratti a mano libera. Qualsiasi cosa viva a livello di documento nella sorgente non arriva, perché ISO 32000-1 la tiene fuori dall'albero delle pagine
- Il document information dictionary (ISO 32000-1 §14.3.3) resta indietro, quindi il file unito non ha nessun Title, Author o Keywords delle sorgenti. Le proprietà
TitleeAuthorsuTPdfsono di sola lettura, il che le rende comode per loggare ciò che ogni sorgente dichiarava prima di chiuderla - Gli outline (§12.3.3) vengono buttati sotto il default
OutlinePolicydibmopDiscard;bmopRejecttrasforma una sorgente con outline in un errore per-file. Per tenere i bookmark, l'unitàFPdfMergeaggiungeMergeFilesWithNavigation, che ricostruisce gli outline, rinomina le named destination in collisione con un prefissoImported_per default, e rimappa i link tra pagine; l'articolo su editing degli outline e rimappatura delle pagine copre come vengono riscritte quelle destinazioni - La cifratura (§7.6) appartiene al file sorgente. Una sorgente protetta si apre con la sua
Password, ma una destinazione creata conCreateDocumentè non cifrata, e così le pagine copiate dentro. Se il file unito deve essere protetto, scrivilo conSaveAsEncryptedinvece diSaveAs
Perché i campi modulo smettono di funzionare dopo un merge?
I campi modulo sono spaccati su due livelli di un PDF. L'annotazione widget che disegna una casella di testo sta sulla pagina, mentre il campo vero e proprio, con il suo nome e il suo valore, è elencato nell'array /Fields del dizionario della form interattiva del catalog (ISO 32000-1 §12.7.2). Un import nativo di pagina copia il widget insieme alle altre annotazioni di pagina, ma niente lo aggiunge all'albero dei campi della destinazione. La casella disegna comunque il suo ultimo aspetto. Un viewer che costruisce il suo modello di form da /AcroForm, però, non ha un campo a cui attaccare il widget, quindi il valore non è modificabile e qualunque operazione di form che percorre l'albero dei campi lo salta
TPdfBatchMergeOptions.FormPolicy dà tre risposte per un merge semplice. bmfpPreserveNative, il default, conserva il risultato dell'import nativo descritto sopra. bmfpFlatten genera gli aspetti e appiattisce le pagine selezionate di ogni sorgente con form in memoria prima di importarle, così i valori diventano normale contenuto di pagina; il file sorgente su disco non viene modificato. bmfpReject trasforma ogni sorgente con una form AcroForm o XFA in un errore per-file
Quando i campi devono restare compilabili, usa MergeFilesWithForms dall'unità FPdfFormMerge. Importa il contenuto delle pagine, ricostruisce i widget dall'albero dei campi di ogni sorgente, e innesta quei campi nella form di destinazione. I nomi dei campi sono la parte difficile. Secondo §12.7.3.2 due campi con lo stesso nome completamente qualificato sono lo stesso campo e condividono un valore, quindi un campo Total in una fattura e un campo Total in un documento di consegna si sovrascriverebbero a vicenda. Il default CollisionPolicy è pagcpReject, che ferma il merge alla prima collisione:
uses
PDFium, FPdfCompress, FPdfFormMerge;
procedure MergeFillableForms(Dest: TPdf;
const Sources: TPdfBatchMergeSources; Log: TStrings);
var
Options: TPdfFormMergeOptions;
Report: TPdfFormMergeReport;
begin
Options := TPdfFormMergeOptions.Default;
// I nomi in collisione prendono il RenamePrefix ('Imported_' se non lo cambi)
Options.GraftOptions.CollisionPolicy := pagcpRename;
if Dest.MergeFilesWithForms(Sources, Options, Report) then
Log.Add(Format('%d fields grafted, %d renamed',
[Report.GraftedFieldCount, Report.RenamedFieldCount]))
else
Log.Add('Form merge failed: ' + Report.ErrorMessage);
end;
pagcpNamespace mette invece i campi di ogni sorgente sotto un proprio prefisso (Imported più il numero di sorgente), e pagcpMergeCompatible unisce i campi omonimi solo quando tipi e opzioni concordano. MergeFilesWithForms lavora su sorgenti e destinazioni AcroForm senza campi firma. Rifiuta in toto le form XFA e i file firmati, dato che riscrivere un file firmato invalida l'intervallo di byte firmato descritto in ISO 32000-1 §12.8.1. Anche una destinazione aperta deve essere non cifrata: fare merge dentro un file protetto da password fallisce, quindi aggiungi la protezione dopo con SaveAsEncrypted. Una pagina selezionata due volte in una stessa sorgente, come in '1,1', richiede una RepeatedPagePolicy esplicita
Il documento di destinazione viene sostituito solo dopo che il risultato preparato è stato riaperto e controllato. Nomi, valori e tipi dei campi vengono confrontati pagina per pagina, e un merge di form fallito lascia Dest com'era. L'articolo sull'innesto AcroForm entra più a fondo nella gestione delle collisioni
Salvare il documento unito
SaveAs ha un overload per file e uno per TStream, che prendono entrambi un TSaveOption opzionale (default saNone) e un TPdfVersion (default pvUnknown, che lascia l'header alla libreria). Entrambi restituiscono un Boolean, e in un job batch quel risultato merita lo stesso controllo di Active. Fissa la versione con qualcosa come pv17 solo quando un sistema a valle pretende un header specifico
Il lavoro inverso, tagliare un file in parecchi, usa le stesse stringhe di intervallo e le stesse posizioni in base 1; l'articolo sullo splitting ti accompagna passo per passo
ImportPages, MergeFiles e il merge consapevole delle form descritto qui viaggiano con il PDFium Component, che porta queste operazioni sulle pagine alle applicazioni Delphi e C++Builder