Technical Article

Chargement de PDF à références hybrides depuis Word et Excel dans Delphi

Ouvrez un PDF produit par Microsoft Word ou Excel, parcourez ses pages, et rien ne semble inhabituel. Chargez-le dans un programme Delphi, lisez le nombre de pages, et ce nombre est correct. Enregistrez-le ensuite en activant le chiffrement et l'opération échoue avec une exception EListError, ou le fichier généré s'ouvre avec un avertissement de table de références croisées endommagée. Le fichier n'a pourtant jamais été corrompu. Il s'agit d'un fichier à références hybrides, et la structure même qui permet à un lecteur vieux de quinze ans de l'ouvrir est celle qui met en échec un chargeur s'arrêtant de lire trop tôt.

C'est l'un des cas les plus fréquents où un pipeline PDF ayant réussi tous les tests internes se retrouve face à un fichier qu'il ne peut pas traiter en aller-retour. Les fichiers d'entrée étant tous générés en interne, ils n'étaient jamais hybrides. Le premier fichier hybride arrive le jour où un client transmet une facture exportée d'un tableur.

Ce que Word et Excel écrivent réellement

La norme ISO 32000-1 décrit la disposition des références hybrides au §7.5.8.4. Une application qui souhaite utiliser les fonctionnalités de PDF 1.5, telles que les flux d'objets (object streams), tout en permettant à un lecteur PDF 1.4 d'ouvrir le fichier, écrit les informations de référence croisée à deux reprises. Il y a une table de références croisées classique, composée de lignes ASCII à 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 comporte une entrée /XRefStm dont la valeur est l'offset en octets de ce flux.

La division du travail est délibérée. Les objets qu'un ancien lecteur doit atteindre, notamment le catalogue et l'arborescence des pages, sont adressables depuis la table classique. Les objets intégré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, afin qu'un lecteur 1.4 les ignore et ne bute pas sur une structure qu'il ne sait pas analyser. Leurs emplacements réels figurent uniquement dans le flux de références croisées. La signature d'un tel fichier est sa fin : une courte section classique, souvent réduite à xref suivi d'un en-tête de sous-section 0 0, dont le trailer pointe vers le /XRefStm où se trouvent les données de récupération réelles.

Pourquoi un nombre de pages correct ne prouve rien

Parce que le catalogue et l'arborescence des pages sont volontairement accessibles depuis la table classique, un chargeur qui lit uniquement cette table trouve le /Root, parcourt l'arborescence et renvoie le bon nombre de pages. Tout ce dont un ancien lecteur a besoin est présent, le fichier semble donc sain. Les objets manquants sont ceux regroupés dans les flux d'objets : les dictionnaires de champs AcroForm, les éléments de structure du PDF balisé, et toute la série de petits dictionnaires qui n'avaient pas besoin d'être visibles pour un lecteur hérité.

Vous ne remarquez cette lacune que lorsque quelque chose interagit avec ces objets, et un enregistrement complet interagit avec chacun d'eux. Parcourir le document pour le ré-encrypter ou le réécrire est précisément l'opération qui demande chaque numéro d'objet à son tour. C'est pourquoi le symptôme apparaît au moment de l'enregistrement et non lors du chargement, loin de sa cause d'origine.

Le piège est un détecteur qui s'arrête dès qu'il rencontre xref

Le moyen le plus simple de déterminer comment un fichier est indexé consiste à suivre startxref et à inspecter les premiers octets pointés. Le mot-clé xref indique une table classique ; un objet de type flux (stream) indique un flux de références croisées. Ce test est correct pour tout fichier qui s'en tient à un seul schéma. Il est erroné pour un fichier hybride, dont le startxref cible une section classique dans le seul but de satisfaire les anciens lecteurs, alors que l'entrée /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" au premier xref rencontré ne lira jamais /XRefStm, et chaque objet résidant uniquement dans le flux deviendra 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 à sortie anticipée, le chargement semble correct et c'est lors du réenregistrement que les objets absents se manifestent. La solution ne consiste pas à lire plus d'octets au départ ; elle consiste à identifier le trailer hybride et à suivre /XRefStm avant de décréter que la lecture du fichier est terminée.

L'ordre de fusion n'est pas négociable

Une fois les deux index lus, ils ne peuvent être combinés que dans un seul sens. Le flux de références croisées doit être fusionné en premier, et les entrées classiques renseignées ensuite tout autour. La raison en est la subtile tromperie au cœur du format. Un fichier hybride marque ses objets compressés comme libres dans la table classique pour que les anciens lecteurs les ignorent. Un chargeur qui applique la politique du "premier vu, premier gagnant" et lit la table classique en premier enregistrera ces numéros d'objets comme libres, puis ignorera les entrées du flux qui les localisent réellement car les emplacements sont déjà occupés. Inversez l'ordre et les entrées de type 2 du flux, composées d'un numéro de flux d'objets et d'un index, obtiennent les emplacements qui leur reviennent, les entrées classiques venant ensuite s'ajuster autour d'elles.

La même discipline protège contre la résurrection d'un objet supprimé par une révision plus ancienne. Les mises à jour incrémentielles remontent la chaîne via /Prev, et une entrée libre de type 0 sert de sentinelle indiquant qu'une section plus récente a retiré un numéro d'objet. Une section ultérieure et plus ancienne dans la chaîne ne doit pas être autorisée à écraser cette sentinelle avec un emplacement obsolète. Considérez la première occurrence vue comme faisant autorité pour les marqueurs libres et l'objet supprimé le reste ; traitez cela sans précaution et l'historique du fichier fera réapparaître du contenu que la dernière révision avait supprimé.

Ce que cela signifie dans HotPDF

Le moteur résout les fichiers à références hybrides à votre place, et ce sur chaque chemin qui doit analyser les données de références croisées. Chargez un document avec LoadFromFile ou LoadFromStream, effectuez vos modifications et appelez SaveLoadedDocument ; ou lancez une opération directe telle que EncryptFile qui lit une entrée et écrit une sortie. Dans tous les cas, la récupération lit /XRefStm, fusionne la section du flux avant les entrées classiques, et résout les objets résidant dans les flux avant que l'écriture ne les énumère. Le chemin de chiffrement AES-256 est celui où le problème s'est manifesté en premier, car chiffrer un document réécrit chaque objet et exige donc que chaque objet ait préalablement é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 important à retenir se situe en amont de l'API. Les fichiers provenant de Word, Excel, PowerPoint et de nombreux processus d'exportation PDF sont couramment hybrides. Ainsi, un chargeur testé uniquement avec les fichiers de votre propre générateur pourrait ne jamais en rencontrer. Alimentez vos batteries de tests avec des documents exportés depuis de réelles applications Office, et pas seulement avec des fichiers produits par votre propre code.

Vérifier un fichier suspect

Deux vérifications permettent de trancher rapidement. Ouvrez le fichier dans un éditeur hexadécimal et lisez les octets situés après le dernier startxref ; un fichier hybride présente une courte section classique dont le dictionnaire trailer contient /XRefStm. Vous pouvez également comparer le nombre d'objets signalé par une analyse complète avec le numéro d'objet le plus élevé déclaré par /Size dans le trailer. Un écart important signifie que des objets se cachent dans des flux que le chargeur n'a pas ouverts, ce qui correspond au manque d'informations qui se traduira plus tard par un échec à l'enregistrement.

L'aspect écriture, à savoir comment les flux d'objets et les références croisées compressées sont initialement générés, est traité dans notre article sur les flux d'objets et les mises à jour incrémentielles. Si le fichier hybride en question est volumineux, les techniques présentées dans le guide sur l'API Direct File pour les flux de documents PDF volumineux vous permettront de l'inspecter sans le charger intégralement en mémoire. Ces deux méthodes s'associent naturellement à la récupération décrite ici, intégrée au composant HotPDF pour Delphi et C++Builder, aux côtés des API de chargement, de modification, de chiffrement et de signature proposées sur ce blog.