Article technique

Fusionner des fichiers PDF en Delphi avec PDFium Component

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

Boucle de fusion Delphi avec PDFium Component : chaque fichier source est ouvert, copié par ImportPages à la position InsertAt, et le document de destination est écrit une seule fois avec SaveAs
ImportPages dépose chaque source à la position InsertAt, et un fichier absent ou endommagé échoue silencieusement tant que Active n'est pas vérifié

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 Title et Author de TPdf sont 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 OutlinePolicy par défaut bmopDiscard ; bmopReject transforme une source à signets en erreur par fichier. Pour garder les signets, l'unité FPdfMerge ajoute MergeFilesWithNavigation, qui reconstruit les signets, renomme les destinations nommées en conflit avec un préfixe Imported_ 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 par CreateDocument est sans chiffrement, et les pages copiées dedans aussi. Si le fichier fusionné doit être protégé, écrivez-le avec SaveAsEncrypted au lieu de SaveAs
ImportPages de PDFium emporte le contenu de page, les polices et les annotations dans un PDF fusionné, tandis que les métadonnées Info, les widgets AcroForm et le chiffrement restent dans chaque fichier source
ImportPages déplace tout ce qui est stocké avec la page, tandis que les métadonnées au niveau du document et l'arbre de champs AcroForm restent en arrière

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