Article technique

Remplacer des pages PDF en Delphi sans casser les signets

Remplacer la page 3 d'un contrat déjà validé ne devrait pas déplacer la table des matières. Supprimez l'ancienne page, insérez la nouvelle, et chaque signet qui pointait auparavant vers cet endroit atterrit désormais ailleurs. La bibliothèque PDF Library for Delphi Delphi PDF évite cela en conservant l'objet page cible lui-même et en transférant uniquement les entrées qui portent le contenu visuel

Pourquoi les signets se cassent-ils après le remplacement d'une page PDF ?

Les signets se cassent parce qu'une destination PDF désigne une page par référence d'objet indirecte, pas par numéro de page. ISO 32000-1 §12.3.2.2 définit une destination explicite comme un tableau dont le premier élément est une référence indirecte vers l'objet page. Supprimez cet objet et ajoutez un remplaçant, et la référence devient pendante : la plupart des lecteurs réagissent en ramenant le lecteur à la page 1, ce qui est exactement le symptôme signalé après un remplacement de type suppression-puis-insertion. L'arbre des pages a l'air parfait, le nombre de pages est correct, le rendu est correct, et toute la couche de navigation est silencieusement erronée

Les destinations nommées ne vous sauvent pas non plus. §12.3.2.3 fait passer un nom par l'arbre de noms /Dests du catalogue de document, mais la feuille vers laquelle ce nom se résout est toujours un tableau de destination explicite contenant la même référence de page. Le nommage ajoute une couche d'indirection au-dessus de la référence de page, pas autour d'elle. Le même raisonnement couvre le reste de la couche interactive décrite au §12.5 : une annotation de lien porte un /Dest ou une action GoTo /A dont le /D est ce tableau, chaque annotation peut porter une entrée /P qui est une référence indirecte vers sa page, et un widget de champ de formulaire est une annotation exactement sur le même pied. Un échange de page naïf détache quatre sous-systèmes à la fois, et pour les voir énumérés sur un fichier réel, c'est le même graphe d'objets que parcourt l'introspection des signets, annotations et actions

Diagramme comparatif PDF Library for Delphi d'une destination de signet nommée par référence indirecte qui survit à un remplacement de page sur place mais devient pendante après un échange suppression-réajout
Les destinations lient signets, liens et widgets à un numéro d'objet de page, si bien que muter cet objet sur place maintient la navigation vivante là où supprimer puis insérer abandonne les lecteurs en page 1

Quelles entrées de page portent l'identité, lesquelles portent l'apparence

Un dictionnaire de page mélange deux sortes d'entrées, et un remplacement sur place réussit précisément lorsque vous les séparez. Le côté apparence est fini et énumérable : /Contents, /Resources, les cinq boîtes de page /MediaBox, /CropBox, /BleedBox, /TrimBox et /ArtBox, plus /Rotate, /Group, /UserUnit et /BoxColorInfo. Ces onze entrées décident de tout ce qu'un moteur de rendu produit pour la page, et rien d'autre dans le fichier ne les désigne par leur nom

Le côté identité est ce à quoi le reste du document s'est lié : le numéro d'objet et la génération de la page, le lien de retour /Parent vers l'arbre des pages, et /Annots. PDF Library for Delphi conserve chacun d'eux intact. ReplacePageRanges purge les onze entrées visuelles du dictionnaire de la page cible et les rajoute à partir de la page source importée, si bien que l'objet page cible est modifié sur place plutôt que remplacé. La structure de l'arbre des pages exigée par §7.7.3 reste également identique octet pour octet dans sa forme : l'ordre de /Kids, /Count, et chaque /Parent survivant sont les mêmes avant et après, car aucun nœud n'a jamais été détaché

Comment PDF Library for Delphi remplace-t-il une page sans renuméroter les objets ?

L'appel prend un document source, une page de début cible en base 1, une expression de plage source et un indicateur d'options. Les deux documents doivent être ouverts dans la même instance, et le document cible est celui sélectionné. Comme le nombre de pages cible ne change jamais, la plage demandée doit tenir dans le document en commençant à TargetStartPage, ce qui est vérifié avant même que quoi que ce soit soit créé

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // Le document dont les signets et les liens doivent survivre
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // La page de clause révisée, produite par quel que soit l'outil qui l'a générée
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // La page source 1 écrase les visuels de la page cible 3.
    // Le nombre de pages, le numéro d'objet de la page 3, les signets et les annotations sont conservés.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

En interne, les pages source ne peuvent pas simplement être lues à travers les frontières de documents, car chaque référence indirecte qu'elles contiennent appartient à la numérotation d'objets du document source. La plage source est donc d'abord importée de la façon habituelle, sous forme de pages temporaires ajoutées après la dernière page réelle, ce qui déclenche le remappage complet du graphe d'objets : flux de contenu, polices, XObjects, dégradés et espaces colorimétriques sont tous renumérotés dans le document cible. Ce n'est qu'ensuite que les onze entrées visuelles sont copiées de chaque page temporaire vers sa page cible, et ce n'est qu'ensuite que les pages temporaires sont détachées de l'arbre des pages. Le travail de remappage se produit là où il est peu coûteux et sûr, et la modification destructrice se réduit à un échange au niveau du dictionnaire sur des pages qui existent déjà

Le chemin de suppression qui détruirait ce que vous venez de transférer

Supprimer ces pages temporaires est l'étape qui paraît triviale et ne l'est pas. Le chemin ordinaire de suppression de page dans la bibliothèque fait plus que détacher un nœud : il fusionne les calques de chaque page supprimée, vide le premier flux de contenu, et récupère les ressources qu'aucune autre page ne partage. C'est un comportement correct pour une véritable suppression, et catastrophique ici, car au moment où les pages temporaires sont retirées, les pages cibles référencent déjà exactement ces flux de contenu et ces objets de ressources. Les vider viderait la page que vous venez de remplacer, et le balayage des ressources collecterait des polices et des images qui ont désormais un propriétaire bien vivant

La solution est un mode de préservation des objets référencés sur le chemin de suppression interne. Lorsqu'il est activé, la suppression saute à la fois le balayage des ressources non partagées et le vidage des flux de contenu, et ne fait rien d'autre que détacher les pages de l'arbre des pages et corriger la comptabilité de l'arbre. Les objets transférés survivent avec un nouveau propriétaire, et la propriété des objets après l'opération est exactement ce que vous dessineriez sur un tableau blanc : un flux de contenu, une page propriétaire, un numéro d'objet qui n'a jamais bougé. Les règles de cycle de vie associées pour créer, supprimer et réordonner des pages sont couvertes séparément dans les notes sur les opérations de cycle de vie du document et des pages

PDF Library for Delphi : anatomie du dictionnaire de page séparant les entrées d'identité dont le fichier dépend des onze entrées visuelles que ReplacePageRanges échange avec une page source importée
ReplacePageRanges purge les onze clés visuelles et les rajoute depuis l'import tandis que numéro d'objet, génération, /Parent et /Annots restent exactement comme ils étaient

Ordre, doublons et échec tout-ou-rien

L'indicateur d'options sélectionne la façon dont la plage source est interprétée. 0 trie les numéros de page analysés et supprime les doublons, ce qui est le comportement par défaut sensé lorsque l'appelant passe quelque chose comme '4-6,2' et veut simplement dire ces quatre pages. 1 préserve l'ordre tel qu'écrit et autorise une page à se répéter, si bien que '2,1,2' signifie véritablement trois remplacements pris sur deux pages source. La validation s'exécute d'abord et s'exécute complètement : la syntaxe de la plage, chaque numéro de page par rapport au nombre de pages source, la valeur de l'option elle-même, et la capacité de la cible sont tous vérifiés avant qu'un seul objet ne soit créé. Un appel rejeté positionne LastErrorCode à 412, restaure la page précédemment sélectionnée, et laisse le document exactement comme il était

PDF Library for Delphi : flux en trois étapes de ReplacePageRanges montrant l'import temporaire avec remappage d'objets, la copie des entrées visuelles, et un détachement préservant les références qui épargne les ressources transférées
Importer la source comme pages temporaires laisse le remappage ordinaire s'exécuter d'abord, si bien que l'édition destructive se réduit à copier les clés visuelles et à détacher les nœuds sans récupérer les ressources vivantes
var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1 : l'ordre source est préservé et les répétitions sont autorisées, donc
  // les pages cibles 5, 6 et 7 reçoivent respectivement les pages source 2, 1 et 2
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // En cas de succès, la sélection est la première page remplacée
  Assert(Lib.SelectedPage = 5);
end;

L'atomicité s'étend au-delà de la validation jusque dans le transfert lui-même. Avant que la première page source ne soit importée, les onze entrées visuelles de chaque page cible dans la plage sont capturées sous forme de valeurs encodées. Si l'import échoue, ou si le nombre de pages importées ne correspond pas à ce qui était demandé, les instantanés sont redécodés sur les pages cibles et les pages temporaires sont supprimées, si bien qu'un échec en cours d'exécution laisse quand même les visuels d'origine en place sur leurs objets d'origine. Cela compte plus qu'il n'y paraît : une plage de pages à moitié remplacée dans un contrat est pire qu'un appel échoué, car rien dans le fichier ne l'indique comme étant à moitié fait

// Post-conditions qu'il vaut la peine de vérifier dans un test de régression
Lib.SelectPage(3);
// La géométrie provient désormais de la page source
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Les annotations déjà présentes sur la page cible 3 sont toujours attachées
WriteLn(Lib.AnnotationCount);
// Le signet créé avant le remplacement pointe toujours vers la page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// Et le document a toujours la même longueur
WriteLn(Lib.PageCount);

Ce que le remplacement sur place ne fait toujours pas pour vous

Les annotations source, les champs de formulaire source et les signets source ne sont délibérément pas importés. Faire passer un widget sans son entrée de champ /AcroForm, ou une annotation porteuse de contenu marqué sans sa propriété dans l'arbre de structure, produirait un objet interactif importé à moitié dont aucun lecteur ne peut faire quoi que ce soit, si bien que l'opération ne transfère que l'apparence. La conséquence pratique est que si la page de remplacement doit porter de nouveaux champs de formulaire ou de nouveaux liens, vous les ajoutez ensuite à la page cible, sur l'objet page cible qui est toujours là, à les attendre

Deux autres limites méritent d'être vérifiées sur vos propres fichiers. Premièrement, /Annots est préservé mais pas la géométrie de la page, si bien que remplacer une page de 220 mm par une page de 320 mm conserve les rectangles d'annotation à leurs anciennes coordonnées à l'intérieur d'une /MediaBox de taille différente ; si la géométrie change, repositionnez les annotations que vous avez conservées. Deuxièmement, les entrées en dehors des onze clés visuelles restent avec la page cible par conception, ce qui est correct pour /Trans ou /AA et périmé pour /Thumb, régénérez donc les vignettes après un remplacement. Les documents étiquetés méritent une réflexion supplémentaire : les éléments de structure pointent toujours vers le bon objet page via /Pg, mais leurs identifiants de contenu marqué décrivent un contenu qui n'est plus là, si bien qu'un échange de page dans un flux PDF/UA est autant une modification de l'arbre de structure qu'une modification de contenu. Si votre besoin réel est un compositage plutôt qu'un échange, superposer des éléments graphiques sur des pages que vous conservez, l'approche d'assemblage de pages et de modèles est l'outil le moins coûteux

Tout ce qui est décrit ici, y compris la syntaxe de l'expression de plage, les valeurs d'option et l'API de manipulation de page environnante, est livré dans la PDF Library for Delphi Delphi PDF Library standard pour Delphi et C++Builder, dont la documentation de référence détaille intégralement l'appel de remplacement de page et ses codes d'erreur