Article technique

Flux de type objet PDF et flux de renvois en Delphi

Les flux d'objets PDF 1.5 regroupent de nombreux petits objets indirects dans un seul conteneur compressé Flate, et losLab PDF Library les émet lors d'une sauvegarde complète via son indicateur PackObjectStreams. Le gain est réel : des centaines de dictionnaires de page, de police et d'annotation, qui coûtaient chacun des dizaines d'octets non compressés, se réduisent à une poignée de blobs compressés. Le coût est que chaque objet empaqueté a désormais besoin d'un flux de renvois pour le décrire

C'est cette seconde moitié qui fait échouer la plupart des écrivains. Construire un conteneur /ObjStm relève de l'arithmétique ; apprendre au mécanisme de renvois à pointer à l'intérieur relève de la refonte. Un écrivain qui produit un conteneur parfaitement valide puis décrit ses membres avec des décalages ordinaires de type 1 a produit un fichier qu'Acrobat n'ouvrira que le temps de le déclarer endommagé. Les deux fonctionnalités n'en forment qu'une seule, et cet article couvre le côté écriture des deux, tel que défini dans ISO 32000-1 §7.5.7 et §7.5.8

Que contient réellement un conteneur ObjStm ?

Un flux d'objets est un flux dont les octets décodés forment deux régions concaténées, et ISO 32000-1 §7.5.7 donne au dictionnaire exactement trois clés pertinentes pour la construction. /Type /ObjStm l'identifie, /N donne le nombre de membres, et /First donne la longueur en octets de la région d'en-tête — autrement dit, le décalage auquel commence le corps. L'en-tête est une suite de paires numéro d'objet / décalage séparées par des espaces ; le corps est constitué des membres sérialisés bout à bout, chaque décalage étant mesuré depuis le début du corps plutôt que depuis le début de la charge utile décodée. Lire un conteneur entièrement décodé rend cela évident : ci-dessous, /First vaut 14 parce que les trois lignes d'en-tête occupent quatorze octets, et l'objet 7 se trouve 55 octets à l'intérieur du corps parce que l'objet 4 s'est sérialisé en 54 caractères plus un séparateur

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

Deux règles d'appartenance sont absolues et proviennent toutes deux directement du §7.5.7. Un objet flux ne peut jamais être membre, car un flux transporte des octets bruts qu'il faudrait imbriquer à l'intérieur d'un autre flux. Et un membre doit être une valeur d'objet complète, jamais une simple référence indirecte — un objet compressé qui serait simplement 5 0 R créerait une indirection que le lecteur ne pourrait résoudre sans déjà savoir où elle pointe. losLab PDF Library filtre les deux cas dès la collecte des candidats, ainsi que le dictionnaire de chiffrement et l'objet 0, puis empaquette ce qui survit par groupes de 200 par conteneur. Cette limite est une décision liée à l'accès aléatoire plutôt qu'une contrainte de la spécification : un lecteur qui veut un seul membre doit décompresser tout le conteneur, si bien que des conteneurs surdimensionnés rendent coûteuses les petites consultations

Pourquoi les membres d'un ObjStm doivent-ils utiliser des entrées de renvois de type 2 ?

Parce qu'un objet empaqueté n'a aucun décalage de fichier à enregistrer. ISO 32000-1 §7.5.8 répond à cela avec trois types d'entrée dans un flux de renvois binaire : le type 0 pour les objets libres, le type 1 pour les objets ordinaires en usage stockés à un décalage d'octets, et le type 2 pour les objets compressés, dont les deux champs de données contiennent le numéro d'objet du conteneur et l'index du membre à l'intérieur de celui-ci. Il n'existe aucun moyen d'exprimer un objet empaqueté dans la table xref classique en texte clair, ce qui explique précisément pourquoi PDF 1.5 a introduit les deux fonctionnalités ensemble

L'ordre qui en découle piège presque toutes les premières implémentations, la nôtre y compris. Les objets ordinaires reçoivent des entrées de type 1. Les conteneurs /ObjStm eux-mêmes reçoivent des entrées de type 1, car un conteneur est un objet flux indirect tout à fait normal écrit à un décalage réel. Seuls les membres reçoivent des entrées de type 2. Et le flux de renvois est lui-même un objet indirect du fichier, il lui faut donc sa propre entrée de type 1 pointant vers le décalage où il vient d'être écrit — le même décalage que celui enregistré par startxref. Une version antérieure de notre écrivain excluait les numéros d'objets de conteneur de la boucle d'écriture au lieu d'exclure les membres, et le résultat était un fichier avec un flux de renvois mais sans le moindre flux d'objets : structurellement cohérent, sémantiquement vide, rejeté en aval. La valeur /Size cache un décalage d'un analogue, puisqu'elle correspond au numéro d'objet le plus élevé plus un, et le flux de renvois se voit lui-même attribuer le numéro d'objet le plus élevé, il doit donc aussi être compté

Dimensionner le tableau /W : pourquoi quatre octets ne suffisent pas

Le tableau /W déclare la largeur en octets de chacun des trois champs, et losLab PDF Library l'écrit sous la forme /W [1 Field2 Field3], le champ 1 étant fixé à un octet pour le code de type et le champ 3 fixé à deux octets, ce qui couvre à la fois les numéros de génération jusqu'à 65535 et les index de membre. Le champ 2 est celui qui ne peut pas être constant, car il porte deux grandeurs sans rapport entre elles : dans une entrée de type 1 c'est un décalage d'octets borné seulement par la taille du fichier, tandis que dans une entrée de type 2 c'est un numéro d'objet de conteneur et dans une entrée de type 0 c'est le prochain objet libre de la chaîne. Un champ 2 fixé à quatre octets fonctionne bien jusqu'à ce que le fichier dépasse 4 Go, moment où tout décalage au-delà de cette limite se tronque silencieusement et où la table entière devient inutilisable. L'écrivain parcourt donc la table assemblée pour trouver la plus grande valeur qu'un emplacement de champ 2 pourra jamais contenir, y compris le décalage du flux de renvois lui-même, et élargit le champ jusqu'à huit octets si nécessaire

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

Une fois les largeurs connues, la taille de la charge utile est connue exactement, si bien que l'écrivain préalloue l'intégralité du tampon et le remplit par index ; ajouter des entrées octet par octet à une AnsiString rend la construction de la table quadratique, ce que personne ne remarque sur une facture de dix pages et que tout le monde remarque sur un document de deux cent mille objets. Deux autres détails satisfont les lecteurs stricts. /Index déclare quelles plages de numéros d'objets la table couvre, et pour une réécriture complète il s'agit simplement de [0 N] sans aucun trou. Et chaque emplacement que l'écrivain n'a pas réellement émis doit par défaut être libre plutôt qu'en usage : l'objet 0 est en tête de la chaîne des objets libres, chaque emplacement libre pointe vers le suivant, et un emplacement ayant autrefois contenu un objet supprimé conserve son numéro de génération incrémenté de un. La note associée sur la sécurité mémoire lors de l'analyse de PDF non fiables développe le même argument de bornes du côté de la lecture

Pourquoi le flux de renvois ne doit-il jamais être chiffré ?

Parce qu'un lecteur doit l'analyser avant de pouvoir savoir comment déchiffrer quoi que ce soit. Le flux de renvois est ce qui indique au lecteur où se trouve le dictionnaire /Encrypt ; si ses octets étaient eux-mêmes chiffrés, le lecteur aurait besoin de la clé du fichier pour trouver l'objet qui décrit cette même clé. losLab PDF Library impose cela au moyen d'un unique prédicat : ShouldCryptStreamData retourne False dès que le dictionnaire du flux porte /Type /XRef, de sorte que l'exemption tient quel que soit le chemin qui atteint le sérialiseur

Le conteneur /ObjStm reçoit le traitement inverse, et cette asymétrie est délibérée. Un conteneur est chiffré dans son intégralité, à partir de son propre numéro d'objet, exactement comme n'importe quel autre flux. Ses membres ne sont pas chiffrés individuellement — ils sont empaquetés sous leur forme déchiffrée, et la passe unique sur le conteneur assemblé les couvre, chaînes de caractères comprises. Chiffrer les membres une seconde fois produit un fichier qui, une fois déchiffré, redonne du texte chiffré, et comme la couche externe réussit, l'échec apparaît sous la forme d'une erreur d'analyse profondément enfouie dans le graphe d'objets plutôt que sous la forme d'un échec d'authentification. Un objet reste alors entièrement en dehors de ce schéma : dans un document chiffré, le catalogue est conservé comme objet direct de type 1 et n'est jamais empaqueté, car l'empaqueter forcerait le chargeur à décompresser et déchiffrer un flux d'objets pour atteindre la racine du document, avant même que le contexte de déchiffrement que cette racine contribue à établir ne soit pleinement construit

Activer l'empaquetage depuis Delphi

Le commutateur public est PackObjectStreams, exposé comme champ de TPDFlibSaveOptions, comme accesseur autonome SetPackObjectStreams, et comme propriété de l'objet document. Il est activé par défaut et automatiquement conditionné par la version : l'écrivain n'empaquette que lorsque le document est déjà en PDF 1.5 ou ultérieur, et il appelle le garde-fou interne de version minimale afin qu'un document empaqueté soit remonté à 1.5 plutôt que mal étiqueté. Après la sauvegarde, GetLastSaveUsedObjectStreams indique si le verrou s'est réellement ouvert, ce qui est l'assertion à privilégier dans un test de non-régression plutôt qu'une comparaison de taille d'octets

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

L'ordre entre empaquetage et ramassage des objets orphelins a de l'importance. L'analyse d'atteignabilité doit s'exécuter en premier, car un membre qui survit dans un conteneur entraîne le conteneur avec lui — si un objet vivant est empaqueté, le numéro de son conteneur est atteignable par définition, et balayer le conteneur laisserait le membre sans aucun moyen d'être localisé. Exécuter le collecteur en premier signifie aussi que les objets morts n'entrent jamais dans un conteneur, ce qui explique le gain de taille cumulatif. L'empaquetage complète les autres leviers de réduction de taille plutôt que de les remplacer ; le tour d'horizon sur l'optimisation de la taille des fichiers PDF et le sous-ensemblage de polices couvre les leviers qui agissent sur les charges utiles des flux, là où les flux d'objets agissent sur la structure

Les limites à connaître avant de l'activer

Les sauvegardes incrémentales n'empaquettent jamais. Une mise à jour incrémentale ajoute de nouveaux objets et une nouvelle section de renvois tout en laissant les révisions antérieures physiquement intactes, si bien que réempaqueter les objets existants dans de nouveaux conteneurs orphelinerait les entrées de type 1 que la révision précédente référence encore ; losLab PDF Library désactive l'empaquetage dès que le mode ajout est actif, et l'article sur les mises à jour incrémentales et le flux en mode ajout couvre ce chemin en détail. Les documents antérieurs à PDF 1.5 conservent inconditionnellement la table de renvois en texte clair : un lecteur 1.4 n'a aucune idée de ce que signifie /ObjStm, et promouvoir silencieusement un document parce que l'écrivain a préféré un fichier plus petit serait le mauvais compromis à imposer au nom de l'appelant. Une clé optionnelle que nous choisissons délibérément de ne pas émettre est /Extends, qu'ISO 32000-1 §7.5.7 définit pour permettre à un conteneur de nommer un prédécesseur afin que les lecteurs puissent traiter une chaîne de conteneurs comme un groupe logique. Elle est réellement optionnelle, chaque conteneur que nous écrivons est autonome et décodable indépendamment, et l'omettre supprime toute une classe de bogues de cycles et de références pendantes côté écriture — les lecteurs doivent bien sûr toujours honorer /Extends lorsqu'ils le rencontrent dans des fichiers issus d'autres producteurs

L'empaquetage des flux d'objets et la production de flux de renvois font partie de losLab PDF Library pour Delphi et C++Builder, aux côtés du ramasse-miettes et de l'optimiseur de flux de contenu avec lesquels ils se combinent ; la page produit propose la référence complète des options de sauvegarde