Article technique

Édition des name trees et number trees PDF en Delphi

Ajoutez un nom en patchant un tableau de feuille et l'entrée peut toujours se lire comme absente. PDFium Component répond à ça avec TPdfTreeEditor, un éditeur général pour les name trees et number trees ISO 32000 : il charge l'arbre avec une validation stricte, édite les entrées en mémoire, reconstruit un ensemble de nœuds équilibré avec des /Limits corrects, et écrit une révision incrémentale vérifiée

La défaillance qui a motivé cette unité est du genre qu'on ne diagnostique qu'en lisant les octets. Un outil ajoute une clé et une valeur à un tableau /Names de feuille, écrit une nouvelle révision, et chaque dump hexadécimal confirme que la clé est dans le fichier. Acrobat dit pourtant toujours que la pièce jointe n'existe pas. Rien n'est corrompu au sens naïf. L'arbre n'est simplement plus un arbre que l'algorithme de recherche peut descendre

Pourquoi un tableau de feuille patché casse-t-il la recherche ?

Parce que la recherche est une descente binaire à travers les /Limits, pas un balayage des feuilles. L'ISO 32000-1 §7.9.6 définit un name tree comme une racine dont les /Kids descendent jusqu'aux feuilles qui portent /Names, un tableau de clé et valeur en alternance, les clés étant ordonnées par comparaison d'octets sur tout l'arbre, et chaque nœud non racine déclarant /Limits comme sa propre clé minimale et maximale. Un lecteur conforme compare la clé cherchée à chaque plage de kid et ouvre exactement un enfant. Ajoutez une clé à une feuille, laissez les anciens /Limits intacts, et la descente passe tout droit devant le nœud qui la contient réellement

Schéma de la recherche dans un name tree PDF avec PDFium Component : le lecteur descend en binaire dans les plages de /Limits des kids par comparaison d'octets, donc une clé comme invoice-2026-09 ajoutée à une feuille dont les /Limits périmés se terminent encore à (f) n'est jamais ouverte, la descente prend le kid (g) (z) à la place, et la pièce jointe se lit comme absente alors qu'elle est dans le fichier
La recherche ouvre exactement un enfant en comparant la clé cherchée à chaque plage de kid, donc une feuille qui a gagné une clé sans rafraîchir /Limits se fait descendre tout droit

Les number trees (§7.9.7) ont la même forme avec /Nums et des clés entières, et ils portent du vrai trafic : /PageLabels au §12.4.2 est un number tree, et les catégories /Names du catalogue au §7.7.4 vous donnent /Dests, /EmbeddedFiles et /JavaScript comme name trees. Quatre invariants doivent donc tenir ensemble, pas un à la fois. Les clés doivent être strictement croissantes dans l'ordre de l'arbre, chaque niveau doit publier des limites qui correspondent à sa plage réelle de clés, les plages des frères ne doivent pas se chevaucher, et le numéro d'objet et la génération de la racine doivent rester exactement où ils étaient, car le catalogue et tout autre référent pointent vers cette racine par référence indirecte. Notre note sur l'utilisation des pièces jointes PDF en Delphi montre le côté consommateur de la même structure

Que refuse de charger TPdfTreeEditor ?

Plus que vous ne le penseriez, et volontairement. TPdfTreeEditor.TryLoad parcourt le graphe de nœuds via TPdfSparseDictionaryReader, résolvant chaque enfant avec ReadDictionary à la génération exacte nommée par la référence plutôt que d'accepter la révision qui se trouve active. Un arbre qu'on ne peut pas prouver bien formé est rejeté avant qu'une seule édition ne soit autorisée, ce qui empêche une entrée cassée de devenir une sortie cassée à l'apparence plausible

  • Les cycles et les références répétées, signalés tous deux comme ptviCycle, donc une entrée /Kids qui pointe vers le haut de la chaîne se termine au lieu de récurser
  • Les formes de nœud mixtes : un dictionnaire portant /Kids avec /Names ou /Nums est ptviInvalidNodeShape, tout comme une feuille non racine vide
  • Les clés non triées ou dupliquées, signalées comme ptviUnsortedKey et ptviDuplicateKey, vérifiées sur toute la traversée plutôt que dans une seule feuille
  • Les limites qui contredisent les clés réellement présentes, signalées comme ptviInvalidLimits
  • Une valeur brute qui n'est pas exactement un objet PDF complet, signalée comme ptviInvalidValue
  • Les dépassements de budget : ptviDepthLimit, ptviNodeLimit, ptviEntryLimit et ptviDeadlineExceeded, bornés à la fois par TPdfTreeEditOptions et par le budget central du parseur

TPdfTreeEditOptions.Default fixe ces bornes locales à l'arbre : 1 000 000 d'entrées, 100 000 nœuds, une profondeur de 64, 64 entrées par feuille et 32 kids par nœud. Les chiffres de feuille et de kid ne sont pas que des garde-fous ; ce sont les facteurs de branchement utilisés plus tard quand l'arbre est reconstruit, c'est pourquoi le record valide que les deux valent au moins deux

uses
  FPdfCompress;

var
  Options: TPdfTreeEditOptions;
  RootRef: TPdfTreeObjectReference;
  Editor: TPdfTreeEditor;
  Report: TPdfTreeValidationReport;
begin
  Options := TPdfTreeEditOptions.Default;
  RootRef.ObjectNumber := EmbeddedFilesRootObject;   { catalogue /Names /EmbeddedFiles }
  RootRef.Generation := EmbeddedFilesRootGeneration;
  if not TPdfTreeEditor.TryLoad(Source, ptkName, RootRef, Options,
    Editor, Report) then
  begin
    Writeln('tree rejected: ', Report.ErrorMessage);
    Exit;
  end;
  try
    { la valeur est les octets bruts d'un objet PDF complet }
    Editor.UpsertName('invoice-2026-09',
      '<< /Type /Filespec /F (invoice.pdf) /EF << /F 42 0 R >> >>');
    Editor.RemoveName('draft-2025-11');
  finally
    { Editor.Free après la sauvegarde ci-dessous }
  end;
end;

Les valeurs d'entrée restent des octets bruts d'objet PDF

Une valeur d'entrée est stockée verbatim, et c'est la seule décision de conception qui rend l'éditeur sûr à pointer vers des fichiers que vous n'avez pas produits. TPdfTreeEntry porte NameKey, NumberKey et RawValue, et RawValue est la suite d'octets bruts d'un objet PDF complet : un dictionnaire, un tableau, une chaîne, une référence de flux ou une référence indirecte nue. Rien n'est décodé en variant Delphi, en TStrings ou en record typé. La contrepartie, c'est un aller-retour sans perte des valeurs dont l'unité ne sait rien. Un /Filespec avec une extension de fournisseur, un tableau de destination avec un type de fit inhabituel, un dictionnaire d'action /JavaScript avec des clés privées, tout survit à une reconstruction octet par octet, car l'éditeur n'a jamais eu d'opinion sur leur contenu. Ça réduit aussi l'API de modification à quelque chose de prouvable : UpsertName et UpsertNumber n'ont qu'à vérifier que la valeur fournie se parse comme exactement un objet complet, et RemoveName, RemoveNumber et Clear ne touchent que la liste d'entrées

Comment un arbre équilibré est-il reconstruit sans renuméroter la racine ?

En deux passes avec un point fixe. BuildBalancedObjects découpe d'abord la liste triée d'entrées en feuilles d'au plus MaxLeafEntries clés, puis replie chaque niveau en parents d'au plus MaxKidsPerNode et répète jusqu'à ce qu'il ne reste qu'un seul nœud. Les limites sont calculées de bas en haut, une feuille prenant sa première et sa dernière clé, un nœud interne prenant la limite inférieure de son premier enfant et la limite supérieure de son dernier, donc des plages frères qui se chevauchent ne peuvent pas apparaître par construction

Le point fixe, c'est l'identité. La racine finale reçoit toujours le numéro d'objet et la génération avec lesquels l'arbre a été chargé, donc la référence du catalogue reste valide et rien d'autre dans le document n'a besoin d'être réécrit. Chaque autre nœud ne réutilise la référence d'un nœud déjà chargé que si le type de nœud correspond et que la plage complète de clés correspond, et sinon il prend un numéro frais alloué à partir du numéro d'objet maximum actif plus un. Cette combinaison garde les sous-arbres inchangés pointant vers leurs objets existants tout en garantissant que tout nœud dont le contenu a bougé est écrit comme un nouvel objet plutôt que réinterprété en silence. La même discipline de réutilisation des références pilote l'éditeur d'outline et son remappage de pages dans l'unité sœur

Schéma de la reconstruction TPdfTreeEditor dans PDFium Component : BuildBalancedObjects découpe la liste triée d'entrées en feuilles d'au plus MaxLeafEntries clés, replie chaque niveau en parents d'au plus MaxKidsPerNode, calcule les /Limits de bas en haut à partir des premières et dernières clés, et donne à la racine finie exactement le numéro d'objet et la génération avec lesquels l'arbre a été chargé
Les feuilles prennent leurs limites de leurs premières et dernières clés, les nœuds internes de leurs enfants les plus extérieurs, et la racine finale garde le numéro d'objet et la génération avec lesquels l'arbre a été chargé
var
  Serialized: TPdfTreeSerializedObjects;
  I: Integer;
begin
  { FirstNewObjectNumber vient de la révision source }
  if Editor.BuildBalancedObjects(MaximumObjectNumber + 1,
    Serialized, Report) then
  begin
    Writeln('nodes ', Report.NodeCount, ' depth ', Report.MaximumDepth);
    for I := 0 to High(Serialized) do
      Writeln(Serialized[I].Reference.ObjectNumber, ' ',
        Serialized[I].Reference.Generation, ' obj');
  end
  else
    for I := 0 to High(Report.Issues) do
      Writeln('issue ', Ord(Report.Issues[I].Kind), ' at obj ',
        Report.Issues[I].ObjectNumber, ': ', Report.Issues[I].Message);
end;
>

Écrivez la révision, puis relisez le résultat

Une sauvegarde n'est finie qu'une fois la sortie rouverte et comparée. SaveIncremental lit les faits de la révision source, refuse une racine qui est le catalogue du document lui-même, sérialise l'ensemble de nœuds équilibré plus l'objet catalogue, et confie le tout à AppendPdfSparseUpdate, qui ajoute une nouvelle révision selon le §7.5.6 tout en préservant la référence /Info existante et le tableau /ID du document

Puis vient la partie qui vaut la peine d'être copiée dans vos propres writers. L'éditeur appelle TPdfTreeEditor.TryLoad à nouveau, cette fois sur le flux de destination, avec la même référence de racine et les mêmes options, et compare le nombre d'entrées rechargées ainsi que chaque clé et RawValue à ce qu'il avait l'intention d'écrire. Si le rechargement échoue ou si une comparaison diffère, le flux de destination est réinitialisé au lieu d'être retourné, donc une révision partiellement écrite n'est jamais rendue comme un succès. La vérification relit la sortie par le même chemin sparse borné décrit dans notre article sur l'index d'objets PDF sparse et paresseux, ce qui garde le contrôle abordable sur les gros fichiers

Schéma de la sauvegarde incrémentale vérifiée dans PDFium Component : SaveIncremental sérialise l'ensemble de nœuds équilibré, AppendPdfSparseUpdate ajoute une nouvelle révision en préservant /Info et le tableau /ID du document, puis TPdfTreeEditor.TryLoad relit le flux de destination et compare le nombre d'entrées et chaque clé et RawValue, réinitialisant le flux au lieu de retourner une révision à moitié écrite
La sauvegarde n'est finie qu'une fois la sortie rouverte par le même chemin borné et comparée entrée par entrée, et une comparaison ratée réinitialise le flux de destination
var
  Dest: TFileStream;
begin
  Dest := TFileStream.Create(OutputName, fmCreate);
  try
    if Editor.SaveIncremental(Source, Dest, Report) then
      Writeln('wrote ', Report.NodeCount, ' nodes, ',
        Report.EntryCount, ' entries')
    else
      { destination déjà réinitialisée, rien d'à moitié écrit n'a été gardé }
      Writeln('save refused: ', Report.ErrorMessage);
  finally
    Dest.Free;
    Editor.Free;
  end;
end;
e>

Les sources chiffrées sont refusées, et c'est honnête

L'implémentation actuelle rejette une source chiffrée d'emblée. La raison est précise plutôt que défensive : une reconstruction d'arbre introduit de nouveaux objets chaîne et peut introduire de nouvelles références de flux, et le §7.6 exige qu'ils soient chiffrés par objet avec la clé du document et le numéro d'objet et la génération propriétaires. Recopier le trailer /Encrypt en avant tout en ajoutant des chaînes en clair produirait un fichier qui s'ouvre puis se lit comme du charabia exactement là où sont vos nouvelles clés, ce qui est pire qu'un refus clair. Deux autres bornes méritent d'être dites clairement. L'éditeur travaille sur un instantané du flux source, donc la source ne doit pas être modifiée pendant qu'un éditeur est vivant dessus, et il refuse de traiter le catalogue du document comme une racine d'arbre, puisque le catalogue est un dictionnaire qui contient des arbres par hasard plutôt qu'un nœud d'arbre lui-même. Si vous maintenez du code Delphi, C++Builder ou Lazarus qui écrit /EmbeddedFiles, /Dests, /JavaScript ou /PageLabels et que vous patchiez les tableaux de feuille à la main, l'éditeur d'arbre et sa sauvegarde incrémentale vérifiée sont livrés dans le composant Delphi PDFium PDFiumPas