Une boucle de fusion qui passe 1 comme position d'insertion à chaque appel produit un fichier avec le bon nombre de pages et les documents en ordre inverse. Le dernier fichier de la liste finit en page 1, parce que la position 1 signifie « insérer avant la première page courante », et chaque nouvelle source repousse les précédentes plus loin. Rien ne se perd, et c'est exactement pourquoi l'erreur survit à un contrôle rapide du nombre de pages et surgit quand quelqu'un lit vraiment le dossier
La boucle page par page avec ImportPages
PDFium Component construit un PDF fusionné en Delphi à partir de deux instances de TPdf : une destination créée vide avec CreateDocument, et une source qui ouvre chaque fichier d'entrée à tour de rôle. TPdf.ImportPages(Pdf, Range, PageNumber) copie les pages nommées dans Range depuis Pdf vers la destination et renvoie True en cas de succès. PageNumber est à base 1 et vaut 1 par défaut. Il nomme la position de destination où atterrit la première page importée, donc 1 préfixe et PageCount + 1 ajoute à la fin
La manière la plus propre de garder les fichiers dans l'ordre de la liste est de cesser de suivre un curseur et de demander à la destination où se trouve sa fin. Un compteur InsertAt qui court fonctionne aussi, mais il doit être avancé du nombre de pages réellement importées, et ce nombre est facile de se tromper dès que des plages entrent en jeu. PdfDest.PageCount + 1 est toujours juste :
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; // ne lève pas en cas d'échec
if not PdfSrc.Active then
raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);
// position à base 1 : PageCount + 1 ajoute après la dernière page
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 ne peut changer qu'à l'état inactif
end;
if not PdfDest.SaveAs(OutputPath) then
raise Exception.Create('Cannot write ' + OutputPath);
finally
PdfSrc.Free;
PdfDest.Free;
end;
end;
Deux contrôles de cette boucle portent vraiment. Positionner Active := True ne lève jamais : si le fichier est absent, endommagé ou demande un mot de passe, TPdf intercepte l'erreur de chargement et laisse Active à False. Le composant reste dans un état fermé cohérent et une fiche ne plante pas sur un mauvais fichier. Une boucle de fusion qui saute le contrôle produit simplement une sortie plus courte. Le second contrôle est le résultat booléen de ImportPages, qui revient False quand la chaîne de plage ne s'analyse pas ou que la position dépasse PageCount + 1. Notez aussi que FileName et Password ne peuvent être affectés que quand le composant est inactif, et c'est pourquoi la source est fermée au bas de chaque passage
Chaînes de plages : ce que l'analyseur accepte
L'argument de plage est une liste séparée par des virgules de pages simples et de plages fermées, numérotées à partir de 1 dans le document source. '1-3' prend les trois premières pages, '2,4,6' en prend trois précises, et '1,3-5' prend une couverture plus un résumé de trois pages. Les numéros se réfèrent toujours à la source ; l'endroit où ces pages atterrissent dans la destination n'est décidé que par l'argument de position
Les plages ouvertes sont l'endroit où les gens trébuchent. Une chaîne comme '7-' ressemble à « page 7 jusqu'à la fin », mais l'analyseur de plages de PDFium exige les deux bornes d'une plage et rejette la chaîne entière. Il en va de même pour une plage inversée ('5-3'), un numéro de page 0, ou toute page au-delà du compte de pages de la source. Un seul mauvais élément fait échouer tout l'appel : ImportPages renvoie False et aucune page de cette chaîne n'est copiée. C'est pourquoi la boucle ci-dessus construit '1-' + IntToStr(PdfSrc.PageCount) au lieu d'écrire '1-'
Quand vous tenez déjà des indices de pages dans un tableau, ImportPagesByIndex(Source, PageIndices, InsertAt) saute l'étape chaîne. Attention au changement de convention : les indices comme InsertAt y sont à base zéro, donc ajouter à la fin c'est InsertAt = PdfDest.PageCount et la sélection couverture-plus-résumé devient [0, 2, 3, 4]. Passer un tableau vide importe toutes les pages
Existe-t-il une fusion en un seul appel pour une liste de fichiers ?
Oui. TPdf.MergeFiles(Sources, Options, Report) prend un tableau d'enregistrements TPdfBatchMergeSource, chacun construit avec TPdfBatchMergeSource.FromFile(FileName, PageRange, Password), les ouvre un à un, et remplit un TPdfBatchMergeReport de ce qui s'est passé. Un PageRange vide signifie toutes les pages. La syntaxe de plage est le même format virgules-et-plages, et une plage malformée devient une erreur par fichier dans le rapport plutôt qu'un saut silencieux
Les défauts de TPdfBatchMergeOptions.Default valent d'être connus avant d'en changer un seul. CreateDestination est True, donc une destination inactive est créée pour vous. InsertAt vaut -1, signifiant ajouter à la fin ; toute autre valeur est un index à base zéro, conforme à ImportPagesByIndex et non à ImportPages. ErrorPolicy est bmepStop et RollbackOnCancelOrFailure est True, donc un fichier illisible termine l'exécution et retire chaque page que l'exécution avait ajoutée. CopyViewerPreferences copie les préférences de visionneur de la première source importée avec succès
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; // continuer malgré les fichiers illisibles
Dest := TPdf.Create(nil); // inactif : MergeFiles le crée
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;
Quelle politique d'erreur choisir dépend de ce qu'un fichier manquant signifie pour la sortie. Pour un dossier de contrat ou une série de factures, un dossier auquel il manque une pièce est un document faux : gardez le défaut bmepStop avec annulation. Passez à bmepSkip seulement pour des collections au mieux, comme une revue hebdomadaire de rapports, et traitez alors bmrsCompletedWithErrors dans Report.Status comme quelque chose qu'un humain doit examiner. Lequel que vous choisissiez, lisez Report.Issues : chaque entrée porte l'index de source, le nom de fichier et le message, ce qui répond à la question « quel fichier était le coupable » que la boucle écrite à la main doit répondre avec ses propres exceptions
Deux autres options comptent pour les longues exécutions. OnProgress et CancellationToken laissent une interface afficher le fichier courant et arrêter l'exécution, et une exécution annulée est annulée comme une exécution échouée. ResourcePolicy vaut par défaut bmrpSourceScoped, qui importe les pages sélectionnées de chaque source en un appel natif si bien que les polices et images partagées par ces pages sont copiées une fois. bmrpChunked importe par lots de PageChunkSize pages, ce qui donne des points d'annulation plus fins au prix de ressources partagées copiées à nouveau pour chaque lot
Ce qu'une page importée emporte, et ce qui reste dans la source
Une page importée amène tout ce qui est atteignable depuis son dictionnaire de page : le flux de contenu, les polices, images et XObjects de formulaire dans ses ressources, et ses annotations, commentaires, surlignages et traits compris. Tout ce qui vit au niveau du document dans la source ne vient pas, parce que l'ISO 32000-1 le garde en dehors de l'arbre de pages
- Le dictionnaire d'informations du document (ISO 32000-1 §14.3.3) reste derrière, donc le fichier fusionné n'a aucun Title, Author ou Keywords des sources. Les propriétés
TitleetAuthordeTPdfsont en lecture seule, ce qui les rend commodes pour journaliser ce que chaque source déclarait avant de la fermer - Les signets (§12.3.3) sont abandonnés sous le
OutlinePolicypar défautbmopDiscard;bmopRejecttransforme une source à signets en erreur par fichier. Pour garder les signets, l'unitéFPdfMergeajouteMergeFilesWithNavigation, qui reconstruit les signets, renomme les destinations nommées en conflit avec un préfixeImported_par défaut, et remappe les liens entre pages ; l'article sur l'édition des signets et le remappage de pages couvre la réécriture de ces destinations - Le chiffrement (§7.6) appartient au fichier source. Une source protégée s'ouvre avec son
Password, mais une destination créée parCreateDocumentest sans chiffrement, et les pages copiées dedans aussi. Si le fichier fusionné doit être protégé, écrivez-le avecSaveAsEncryptedau lieu deSaveAs
Pourquoi les champs de formulaire cessent-ils de marcher après une fusion ?
Les champs de formulaire sont répartis sur deux niveaux d'un PDF. L'annotation widget qui dessine une zone de texte se pose sur la page, tandis que le champ lui-même, avec son nom et sa valeur, est listé dans le tableau /Fields du dictionnaire de formulaire interactif du catalogue (ISO 32000-1 §12.7.2). Un import de pages natif copie le widget avec les autres annotations de page, mais rien ne l'ajoute à l'arbre de champs de la destination. La zone dessine encore sa dernière apparence. Un visionneur qui construit son modèle de formulaire depuis /AcroForm, lui, n'a aucun champ auquel attacher le widget, donc la valeur ne peut pas être éditée et toute opération de formulaire qui parcourt l'arbre de champs le saute
TPdfBatchMergeOptions.FormPolicy donne trois réponses pour une fusion simple. bmfpPreserveNative, le défaut, garde le résultat d'import natif décrit ci-dessus. bmfpFlatten génère les apparences et aplatit les pages sélectionnées de chaque source à formulaire en mémoire avant de les importer, si bien que les valeurs deviennent du contenu de page ordinaire ; le fichier source sur disque n'est pas modifié. bmfpReject transforme toute source avec un formulaire AcroForm ou XFA en erreur par fichier
Quand les champs doivent rester remplissables, utilisez MergeFilesWithForms de l'unité FPdfFormMerge. Il importe le contenu de page, reconstruit les widgets depuis l'arbre de champs de chaque source, et greffe ces champs dans le formulaire de destination. Les noms de champs sont la partie dure. Sous §12.7.3.2, deux champs de même nom complet sont le même champ et partagent une valeur, donc un champ Total dans une facture et un champ Total dans un bon de livraison s'écraseraient l'un l'autre. Le CollisionPolicy par défaut est pagcpReject, qui arrête la fusion au premier clash :
uses
PDFium, FPdfCompress, FPdfFormMerge;
procedure MergeFillableForms(Dest: TPdf;
const Sources: TPdfBatchMergeSources; Log: TStrings);
var
Options: TPdfFormMergeOptions;
Report: TPdfFormMergeReport;
begin
Options := TPdfFormMergeOptions.Default;
// Les noms en conflit reçoivent le RenamePrefix ('Imported_' sauf changement)
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 place les champs de chaque source sous son propre préfixe (Imported plus le numéro de source), et pagcpMergeCompatible ne joint les champs de même nom que quand leurs types et options concordent. MergeFilesWithForms marche sur des sources et destinations AcroForm sans champs de signature. Il refuse net les formulaires XFA et les fichiers signés, puisque réécrire un fichier signé invalide la plage d'octets signée décrite en ISO 32000-1 §12.8.1. Une destination ouverte doit aussi être sans chiffrement : fusionner dans un fichier protégé par mot de passe échoue, donc ajoutez la protection ensuite avec SaveAsEncrypted. Une page sélectionnée deux fois dans une même source, comme '1,1', demande un RepeatedPagePolicy explicite
Le document de destination n'est remplacé qu'après que le résultat en attente a été rouvert et vérifié. Noms de champs, valeurs et types sont comparés page par page, et une fusion de formulaire échouée laisse Dest tel quel. L'article sur le greffage AcroForm creuse la gestion des collisions
Enregistrer le document fusionné
SaveAs a une surcharge fichier et une surcharge TStream, prenant toutes deux un TSaveOption facultatif (défaut saNone) et un TPdfVersion (défaut pvUnknown, qui laisse l'en-tête à la bibliothèque). Toutes deux renvoient un booléen, et dans un traitement par lots ce résultat mérite le même contrôle que Active. Épinglez la version avec quelque chose comme pv17 seulement quand un système en aval exige un en-tête précis
Le travail inverse, couper un fichier en plusieurs, utilise les mêmes chaînes de plages et les mêmes positions à base 1 ; l'article sur la découpe le détaille
ImportPages, MergeFiles et la fusion consciente des formes décrite ici partent avec le PDFium Component, qui apporte ces opérations de pages aux applications Delphi et C++Builder