Article technique

Copie d'objets PDF entre documents en Delphi : cycles

Fusionnez deux PDF à la main, déplacez un simple objet de page vers le document cible, et la copie tombe droit sur une violation d’accès. PDFlibPas corrige cela dans CopyForeignObject : il copie en profondeur un objet indirect plus l’ensemble de sa fermeture de références, et résout les références arrière cycliques telles que /Parent vers null au lieu de récursiver

Pourquoi copier une page entre documents provoque-t-il un crash ?

Parce qu’un arbre de pages PDF n’est un arbre que si vous le lisez vers le bas. Parcourez-le comme le fait un copieur récursif, en suivant chaque valeur de chaque dictionnaire, et le dictionnaire de page vous tend /Parent, qui pointe vers le nœud /Pages d’où vous venez, et ce nœud vous tend /Kids, qui pointe vers la page. L’ISO 32000-1 §7.7.3 rend /Parent obligatoire sur chaque nœud de l’arbre de pages sauf la racine, donc ce n’est pas un fichier malformé que vous pouvez rejeter — c’est la forme normale de chaque document qu’on vous remettra

La seconde moitié du problème est la numérotation. Les objets indirects sont identifiés par un numéro d’objet local à un fichier (ISO 32000-1 §7.3.10), si bien qu’un objet tiré du document A vers le document B doit être renuméroté, et chaque référence à l’intérieur de la fermeture copiée doit être renumérotée de la même façon, sinon deux références qui pointaient vers une même police partagée pointent désormais vers deux choses sans rapport. Cette renumérotation est le même travail qu’une fusion rapide fait au niveau des octets, et il vaut la peine de lire les deux côte à côte : le décalage des références au niveau des octets pour une fusion rapide de PDF le résout en traduisant des fichiers entiers, tandis qu’une copie au niveau objet doit le résoudre une arête à la fois

Pourquoi une copie PDF entre documents en Delphi exige du soin : le dictionnaire de page et son nœud /Pages ferment un cycle via /Parent et /Kids, une fermeture de police descend et se termine, et PDFlibPas remappe chaque numéro d’objet local au fichier
L’arbre de pages ferme une boucle via /Parent et /Kids tandis que les fermetures de contenu se terminent, et chaque numéro d’objet copié doit être remappé en route

Ce que CopyForeignObject de PDFlibPas copie réellement

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) clone un objet indirect et tout ce qui en est atteignable — dictionnaires imbriqués, tableaux, chaînes, noms, nombres et flux avec leurs dictionnaires intacts — dans le document actuellement sélectionné, et renvoie un handle non nul vers la nouvelle référence indirecte. Les numéros d’objets sources sont remappés via une carte vivante maintenue pendant la durée de l’appel, si bien qu’un objet atteint deux fois dans la fermeture est cloné une fois et partagé deux fois. Elle renvoie zéro, sans lever d’exception, quand l’ID du document source est inconnu, quand la source est le document sélectionné lui-même, ou quand ObjectNumber est inférieur à 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile renvoie 1 en cas de succès
    SourceDoc := Lib.SelectedDocument;   // le chargement a sélectionné le document chargé
    Lib.SelectDocument(TargetDoc);       // la copie cible le document sélectionné
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Deux détails mordent au premier essai. LoadFromFile répond 1 ou 0, pas un ID de document, si bien que le handle dont vous avez besoin vient de SelectedDocument juste après le chargement ; et la copie écrit toujours dans ce que SelectDocument a rendu courant en dernier, jamais dans le document d’où vous avez chargé. En interne, la récursion porte aussi un plafond de profondeur strict de 64, qui est un garde-fou contre les imbrications pathologiques, pas le mécanisme qui gère les cycles — la gestion des cycles est séparée et délibérée

Pourquoi réserver un mappage Nil ne brise-t-il pas le cycle ?

Parce que Nil dans la table de mappage signifie deux choses différentes à la fois, et le code ne peut pas les distinguer. La défense évidente contre un cycle consiste à ajouter l’entrée de carte avant de récursiver dans l’objet, pour que tout ce qui reboucle trouve l’entrée et s’arrête. Mais l’entrée ne peut pas encore contenir la vraie cible — la cible n’existe pas tant que la fermeture en dessous n’a pas été écrite — donc elle contient Nil, et la recherche censée attraper l’arête arrière lit Nil et conclut que l’objet n’a jamais été mappé

// Cassé : une cible Nil réservée est indistinguable de "pas encore mappé"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // réservé, toujours Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // comblé seulement au retour
end;

Suivez cela dans la boucle des pages. Le clone de la page atteint /Parent, récursive dans le nœud /Pages, qui atteint /Kids, qui récursive vers la page — dont l’entrée réservée lit toujours Nil, si bien qu’elle est clonée une deuxième fois, puis une troisième, chaque niveau poussant une trame fraîche et un objet à moitié construit de plus. Ce que vous observez n’est pas non plus un débordement de pile propre : les trames externes reposent sur des références dont les cibles n’ont jamais été assignées, si bien que la première écriture à travers l’un de ces emplacements est une violation d’accès quelque part qui ne ressemble en rien à la copie de page qui l’a causée

Pourquoi réserver une cible Nil dans la carte n’arrête pas le cycle dans une copie PDFlibPas entre documents : la recherche ne peut distinguer une entrée réservée d’une entrée non mappée, si bien que le copieur descend à travers des trames à moitié construites de plus en plus profondes jusqu’à ce qu’une écriture crashe
Parce qu’une cible Nil répond à deux questions différentes à la fois, l’arête arrière n’est jamais reconnue et la page est clonée à nouveau à chaque passage

Le correctif : un état explicite in-progress

La réparation consiste à cesser de surcharger Nil et à poser la question directement. Une entrée de carte dont la cible reste non assignée signifie que cet objet est en cours de clonage, et un prédicat InProgress teste exactement cela avant que la recherche ordinaire ne s’exécute. Quand il est vrai, l’arête est un cycle vers un ancêtre du clone courant, et PDFlibPas émet un objet null pour elle plutôt que de la suivre

// Une entrée de carte avec une cible Nil marque un clone en cours
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... dans CloneObject, pour une référence indirecte :
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // arête arrière cyclique, ne pas récursiver
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // référence source pendante
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // réserver, puis récursiver
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // comblement
end;
Exit(NewRef);

Cela n’est sûr à généraliser qu’en raison d’un fait structurel du PDF : les cycles du graphe d’objets apparaissent sur les liens arrière, pas sur les arêtes de contenu. /Parent dans l’arbre de pages et /Prev dans une chaîne de signets pointent vers le haut ou vers l’arrière vers quelque chose de déjà visité ; la fermeture d’une police, d’un XObject d’image ou d’un XObject de formulaire descend et se termine. Donc une copie d’un descripteur de police, d’un espace colorimétrique ou d’un dictionnaire de shading n’est pas affectée par la substitution null — rien dans ces fermetures ne rencontre InProgress. Le coût, énoncé clairement, est que l’arête cyclique ne survit pas à la copie. Un dictionnaire de page cloné ainsi arrive avec /Parent comme objet null, ce que l’ISO 32000-1 §7.3.9 déclare équivalent à une entrée absente, si bien que la page copiée est un objet valide qui n’appartient à aucun arbre de pages tant que vous ne la reliez pas au nœud /Pages cible et ne corrigez pas /Count vous-même. Un élément de signet copié perd son /Prev de la même façon et exige que la chaîne des frères soit reconstruite. Tel est le compromis honnête : CopyForeignObject vous donne une fermeture correcte et laisse le re-rattachement structurel à l’appelant, ce qui est la même frontière dans laquelle le remplacement de pages en préservant les numéros d’objets s’inscrit

Le correctif dans CopyForeignObject de PDFlibPas pour Delphi : un test explicite InProgress s’exécute avant la recherche dans la carte, une arête arrière cyclique devient un objet null, et l’appelant relie ensuite la page copiée dans l’arbre de pages cible
Un état explicite in-progress remplace le Nil surchargé, si bien que l’arête arrière se résout en null et qu’il reste à l’appelant une seule réparation structurelle

Pourquoi l’entrée de carte doit être réservée avant NewObjRef

Une alternative évidente éviterait toute la danse in-progress : allouer d’abord un objet coquille vide, enregistrer son vrai numéro dans la carte, puis remplir la coquille une fois les enfants clonés. Cela ne fonctionne pas ici, parce que TPDFIndObj.Obj est en lecture seule et son contenu ne peut pas être remplacé après construction — il n’y a pas de coquille à remplir. Le numéro et le contenu sont décidés ensemble par NewObjRef, ce qui signifie que l’entrée de carte doit être créée avant l’appel récursif et complétée après, et l’intervalle entre ces deux moments est précisément ce que InProgress doit couvrir. Une conséquence à connaître avant de comparer des sorties : comme NewObjRef s’exécute après que la fermeture des enfants est écrite, la numérotation dans la cible sort de bas en haut, et les numéros d’objets ne refléteront pas l’ordre source. Rien dans le format de fichier ne s’en soucie, mais une comparaison d’octets contre une attente construite à la main, oui. Si une exécution laisse des objets que vous avez décidé de ne relier à rien, ils sont non référencés plutôt que corrompus, et la collecte mark-and-sweep des objets PDF inaccessibles est l’outil qui les élimine avant l’enregistrement

La régression qui couvre cela exige un détail qui surprend ceux qui écrivent des tests contre TPDFlib : le constructeur détient déjà un document par défaut, si bien que DocumentCount commence à 1 et un fixture à deux documents doit affirmer >= 2, pas = 2. À côté de la copie réussie, le test épingle les trois rejets — un ID source inconnu, le document sélectionné comme sa propre source, et un numéro d’objet zéro — tous renvoyant 0 plutôt que de lever, car une boucle de fusion est un mauvais endroit pour découvrir qu’une clause de garde lève

Où cela s’insère dans un pipeline de fusion

La copie au niveau objet est la primitive à laquelle on recourt quand la fusion de fichiers entiers est trop grossière : extraire un programme de police d’un gabarit, tirer un seul XObject de formulaire vers un document de tamponnage, ou déplacer une annotation avec ses flux d’apparence d’un fichier à l’autre sans entraîner le reste de la page. PDFlibPas l’expose comme un appel unique sur des documents chargés, et vous pouvez voir comment elle se situe avec le reste de l’API d’objets bas niveau dans la référence de la PDFlibPas Delphi PDF Library