Article technique

Chargement de PDF à référence hybride à partir de Word et Excel dans Delphi

Ouvrez un PDF produit par Microsoft Word ou Excel, parcourez-le et rien ne semble inhabituel. Chargez-le dans un programme Delphi, lisez le nombre de pages (page count) et le nombre est correct. Ré-enregistrez-le ensuite (re-save) avec le chiffrement activé et la tâche échoue avec une EListError, ou la sortie s'ouvre sur un avertissement de référence croisée (cross-reference) endommagée. Le fichier n'a jamais été corrompu. Il s'agit d'un fichier à référence hybride (hybrid-reference file), et la structure même qui permet à une visionneuse vieille de quinze ans de l'ouvrir est la structure qui met en échec un chargeur qui s'arrête de lire trop tôt

C'est l'une des façons les plus courantes pour qu'un pipeline PDF qui a réussi tous les tests internes rencontre un fichier qu'il ne peut pas traiter de bout en bout (round-trip). Les entrées ont toutes été générées en interne, elles n'ont donc jamais été hybrides. Le premier fichier hybride arrive le jour où un client fait suivre une facture exportée à partir d'un tableur

Ce que Word et Excel écrivent réellement

L'ISO 32000-1 décrit la disposition des références hybrides au §7.5.8.4. Une application qui souhaite des fonctionnalités PDF 1.5 telles que les flux d'objets (object streams), tout en laissant un lecteur PDF 1.4 ouvrir le fichier, écrit les informations de référence croisée deux fois. Il y a une table de références croisées classique, les lignes ASCII de largeur fixe qui terminaient chaque PDF jusqu'à la version 1.4, et il y a un flux de références croisées (cross-reference stream) qui indexe le reste. Le trailer de la section classique porte une entrée /XRefStm dont la valeur est le décalage en octets (byte offset) de ce flux

La division du travail est délibérée. Les objets qu'un ancien lecteur doit atteindre, le catalogue et l'arborescence des pages (page tree) parmi eux, sont adressables à partir de la table classique. Les objets qui ont été pliés dans des flux d'objets compressés sont marqués comme libres (free) dans la table classique, avec une entrée de type f, de sorte qu'un lecteur 1.4 les saute (skips straight past them) et ne trébuche jamais sur une structure qu'il ne peut pas analyser. Leurs véritables emplacements ne vivent que dans le flux de références croisées. La signature d'un tel fichier est sa queue (tail) : une courte section classique, souvent rien de plus que xref suivi d'un en-tête de sous-section 0 0, dont le trailer pointe vers le /XRefStm où se trouvent les véritables données de récupération

Pourquoi un nombre de pages correct ne prouve rien

Parce que le catalogue et l'arborescence des pages sont accessibles à partir de la table classique de manière intentionnelle, un chargeur qui ne lit que cette table trouve /Root, parcourt l'arborescence des pages et signale le bon nombre de pages. Tout ce dont un ancien lecteur a besoin est présent, le fichier semble donc sain. Les objets qui ont disparu sont ceux qui sont regroupés dans des flux d'objets : les dictionnaires de champs AcroForm, les éléments de structure PDF balisés (tagged-PDF), la longue traîne de petits dictionnaires qui n'ont jamais eu à être visibles pour une ancienne visionneuse

Vous ne remarquez pas l'écart jusqu'à ce que quelque chose touche ces objets, et un ré-enregistrement complet les touche tous. Parcourir le document pour le re-chiffrer ou le réécrire est précisément l'opération qui demande chaque numéro d'objet à tour de rôle, c'est pourquoi le symptôme fait surface au moment de l'enregistrement (save time) plutôt qu'au moment du chargement (load time), loin de sa cause

Le piège est un détecteur qui voit xref et s'arrête

Le moyen économique (cheap way) de décider comment un fichier est indexé est de suivre startxref et d'inspecter les premiers octets qu'il pointe. Le mot-clé xref signifie une table classique ; un objet flux (stream object) signifie un flux de références croisées. Ce test est correct pour tout fichier qui s'engage sur un seul schéma. Il est faux pour un fichier hybride, dont startxref vise une section classique dans le seul but de satisfaire les anciens lecteurs, tandis que le /XRefStm dans le trailer de cette section est l'endroit où la majeure partie du document est réellement indexée. Un détecteur qui renvoie "classique" sur le premier xref qu'il rencontre ne lit jamais /XRefStm, et chaque objet qui ne vit que dans le flux devient invisible

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

Avec le détecteur de sortie anticipée (early-exit detector) en place, le chargement semble correct et le ré-enregistrement est l'endroit où les objets absents s'annoncent. La solution n'est pas de lire plus d'octets au début ; il s'agit de reconnaître le trailer hybride et de suivre /XRefStm avant de décider que le fichier est terminé

L'ordre de fusion (Merge order) n'est pas négociable

Une fois les deux index lus, ils ne peuvent être combinés que dans une seule direction. Le flux de références croisées doit être fusionné en premier, avec les entrées classiques remplies autour de lui. La raison en est la petite tromperie au cœur du format. Un fichier hybride marque ses objets compressés comme libres dans la table classique afin que les anciens lecteurs les ignorent. Un chargeur qui honore une politique de type "le premier vu gagne" (first-seen-wins) et lit la table classique en premier enregistrera ces numéros d'objet comme libres, puis rejettera les entrées de flux qui les localisent réellement, car les emplacements (slots) sont déjà pris. Inversez l'ordre et les entrées de type 2 du flux, chacune étant un numéro de flux d'objets (object-stream number) plus un index, gagnent les emplacements qu'elles sont censées posséder, et les entrées classiques s'installent autour d'elles

La même discipline empêche une ancienne révision de ressusciter un objet supprimé. Les mises à jour incrémentielles s'enchaînent vers l'arrière via /Prev, et une entrée libre de type 0 est une sentinelle indiquant qu'une section plus récente a retiré un numéro d'objet. Une section ultérieure plus ancienne dans la chaîne ne doit pas être autorisée à écraser cette sentinelle avec un emplacement obsolète (stale location). Traitez le premier vu (first-seen) comme faisant autorité pour les marqueurs libres et l'objet supprimé reste supprimé ; traitez-le sans précaution et l'historique du fichier lui-même réanime le contenu que la dernière révision a supprimé

Ce que cela signifie dans HotPDF

Le moteur résout les fichiers à référence hybride pour vous, et il le fait sur chaque chemin qui doit analyser les données de référence croisée. Chargez un document avec LoadFromFile ou LoadFromStream, effectuez vos modifications et appelez SaveLoadedDocument ; ou exécutez une opération ponctuelle (one-shot) telle que EncryptFile qui lit une entrée et écrit une sortie. Quoi qu'il en soit, la récupération lit /XRefStm, fusionne la section de flux avant les entrées classiques, et résout les objets qui vivent dans les flux avant que l'écriture ne les énumère. Le chemin de chiffrement AES-256 est l'endroit où le problème s'est manifesté en premier, car le chiffrement d'un document réécrit chaque objet et exige donc que chaque objet ait déjà été localisé

// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

Le détail qui vaut la peine d'être retenu se situe en amont de l'API. Les fichiers qui arrivent de Word, Excel, PowerPoint et d'une longue liste de pipelines "Enregistrer au format PDF" sont couramment hybrides, de sorte qu'un chargeur que vous n'exercez que contre la sortie de votre propre générateur peut ne jamais en rencontrer lors des tests. Alimentez (Seed) vos fixations de test (fixtures) avec des documents exportés depuis de véritables applications Office, et non seulement avec des fichiers produits par votre propre code

Vérification d'un fichier que vous suspectez

Deux inspections règlent la question rapidement. Ouvrez le fichier dans une vue hexadécimale et lisez les octets après le dernier startxref ; un fichier hybride montre une courte section classique dont le dictionnaire de trailer contient /XRefStm. Ou comparez le nombre d'objets qu'une analyse complète (full parse) signale avec le numéro d'objet le plus élevé que /Size déclare dans le trailer. Un grand écart (large gap) signifie que des objets se cachent dans des flux que le chargeur n'a pas ouverts, ce qui est le même manque (shortfall) qui se transforme plus tard en un échec au moment de l'enregistrement

La queue (tail) d'une exportation Excel typique rend la première vérification concrète. Tout ce qui se trouve après le mot-clé final xref est du simple ASCII, la signature est donc lisible directement depuis une vue hexadécimale (décalages illustratifs, annotations ajoutées)

xref
0 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

La sous-section 0 0 est le signe révélateur (tell) : une table classique avec zéro entrée n'existe que pour porter le trailer, et le trailer existe principalement pour dire /XRefStm 87325. Un détecteur qui s'arrête au mot-clé xref a, à ce stade, vu un index de rien (index of nothing). Lorsque vous préférez scripter la vérification plutôt que de l'évaluer visuellement (eyeball it), le marqueur se trouve toujours dans les derniers kilo-octets du fichier, donc une lecture vers l'arrière délimitée (bounded backward read) suffit

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

Traitez la sonde (probe) comme un triage, pas comme un analyseur (parser) : elle vous indique quels fichiers dans un lot méritent de l'attention avant qu'une tâche de ré-enregistrement (resave) ne s'exécute, et rien de plus. Ce qu'un chargeur doit ensuite faire avec le décalage qu'il trouve, en suivant la chaîne de sections, en fusionnant les entrées de flux avant les entrées classiques, en respectant les sentinelles d'entrée libre (free-entry sentinels), est parcouru étape par étape dans notre article d'accompagnement sur la gestion des PDF à référence hybride à partir d'applications Office

Le côté rédacteur (writer's side) de cette histoire, la façon dont les flux d'objets et les références croisées compressées sont produits en premier lieu, est couvert dans notre article sur les flux d'objets et les mises à jour incrémentielles. Lorsque le fichier hybride en question est également très volumineux, les techniques de chargement de la présentation de l'API Direct File pour les flux de travail PDF volumineux vous permettent de l'inspecter sans le lire entièrement en mémoire. Les deux se marient naturellement avec la récupération décrite ici, qui est fournie dans le cadre du Composant HotPDF pour Delphi et C++Builder aux côtés des API de chargement, d'édition, de chiffrement et de signature couvertes ailleurs sur ce blog