Vous écrivez un petit validateur. Il ouvre un PDF, se positionne à la fin, trouve startxref, lit le décalage et s'attend à tomber sur le mot-clé xref suivi d'une table de référence croisée de largeur fixe. Depuis cette table, il collecte les décalages d'objets, puis effectue une recherche arrière pour trouver le mot-clé trailer afin de connaître le /Root et le /Size. Cela fonctionne parfaitement sur chaque fichier généré pour vos tests. Puis un fichier produit par une version récente de Word, ou par une bibliothèque ciblant la version PDF 1.5, arrive, et le validateur le déclare corrompu. Il n'y a aucun mot-clé xref là où pointe le décalage, aucun dictionnaire trailer nulle part, et la table d'objets construite par le validateur est presque vide. Le fichier est pourtant valide. Votre validateur l'analyse sous un angle vieux de quinze ans.
C'est la raison la plus courante pour laquelle une vérification de PDF au niveau de l'octet écrite pour la disposition classique échoue sur les documents modernes. La structure dont elle dépend (la table de référence croisée en texte brut et le mot-clé trailer) a été rendue facultative dans la version PDF 1.5 et est fréquemment absente. Deux fonctionnalités l'ont remplacée : le flux de référence croisée (cross-reference stream) et le flux d'objets compressés (compressed object stream). Tous deux sont décrits dans l'ISO 32000-1, et un validateur qui les ignore considère un fichier sain comme un ensemble d'objets manquants.
Ce que le PDF 1.5 a changé concernant la fin de fichier
La norme ISO 32000-1 §7.5.8 définit le flux de référence croisée, et le §7.5.7 définit le flux d'objets de type /ObjStm. Ensemble, ils permettent à un module d'écriture de se passer des deux structures sur lesquelles s'appuie un analyseur classique. Un fichier PDF 1.5 peut ne contenir aucune table xref. À la place, l'objet vers lequel pointe startxref est un objet flux ordinaire dont le dictionnaire contient /Type /XRef, et ce flux conserve les données de référence croisée sous une forme binaire compacte. Il n'y a pas non plus de mot-clé trailer, car le trailer correspond désormais au propre dictionnaire du flux. Les clés recherchées par un analyseur classique, /Root, /Size et /ID, résident dans ce dictionnaire.
Le second changement concerne le déplacement des objets eux-mêmes. Au lieu d'écrire chaque objet indirect à son propre décalage d'octets, un module d'écriture peut regrouper de nombreux petits objets (les dictionnaires de pages, d'annotations, l'arbre de structure) dans un flux d'objets unique et compresser l'ensemble du conteneur avec Flate. Les objets individuels n'ont plus de décalage d'octets dans le fichier. Ils ont une position dans un bloc compressé. Un validateur qui recherche 1 0 obj dans les octets bruts ne les trouvera jamais, car ce texte n'existe qu'après décompression. Pour un analyseur classique, la moitié du document a tout simplement disparu.
Les clés du trailer sont en texte brut, même dans un fichier compressé
Le point rassurant est que la lecture du trailer d'un flux de référence croisée ne nécessite aucune décompression. Un objet flux est écrit sous forme de dictionnaire suivi du mot-clé stream puis des octets compressés. Le dictionnaire reste en texte brut. Ainsi, lorsque startxref pointe vers un flux de référence croisée, les octets situés juste après le numéro d'objet ressemblent à un dictionnaire ordinaire, et /Root, /Size et /ID y figurent clairement, avant le mot-clé stream et le début des données Flate.
Cela signifie qu'un validateur peut obtenir les trois informations les plus nécessaires (l'emplacement du catalogue, le nombre d'objets annoncés par le fichier et l'identifiant du fichier) en analysant uniquement le dictionnaire du flux. Il n'a pas à décompresser les données de référence croisée ni à interpréter leurs entrées binaires. Le travail qui met en échec un analyseur simpliste n'est pas la lecture du trailer ; c'est la recherche des objets. Ce sont deux problèmes distincts, et résoudre le premier est peu coûteux.
Flux d'objets : un en-tête, puis un bloc Flate
Un flux d'objets est un conteneur. Son dictionnaire contient /Type /ObjStm, une entrée /N indiquant le nombre d'objets qu'il renferme, et une entrée /First indiquant le décalage d'octets, au sein des données décompressées, où commence le corps du premier objet. Le contenu compressé, une fois décompressé, débute par un court en-tête de /N paires d'entiers. Chaque paire comprend un numéro d'objet et le décalage du corps de cet objet par rapport à /First. Après l'en-tête se trouvent les corps des objets eux-mêmes, concaténés.
Développer un flux d'objets est mécanique une fois les octets décompressés. Vous lisez le dictionnaire pour obtenir /N et /First, décompressez le flux avec un décodeur Flate, parcourez les /N paires initiales pour associer chaque numéro d'objet à son décalage, puis extrayez chaque corps d'objet comme s'il s'agissait d'un objet indirect ordinaire. La seule dépendance réelle est le décodeur Flate, et vous en disposez déjà : Delphi fournit System.ZLib et Free Pascal inclut l'unité zstream, tous deux enveloppant zlib pour décompresser un flux Flate brut sans code tiers. Une routine ajoutant chaque objet extrait à la table des objets du validateur permet au reste du traitement (qui parcourt /Root et vérifie l'arborescence des pages) de se comporter exactement comme sur un fichier classique.
Ce que vous n'avez pas à implémenter
Il est facile de surestimer ce travail. Lire les clés du trailer depuis un fichier compressé ne requiert pas de décoder les entrées binaires du flux de référence croisée. Le flux de référence croisée décrit au §7.5.8 utilise trois types d'entrées, et l'entrée de type 2 (qui indique : « cet objet réside dans le flux d'objets N à l'index i ») est celle que vous devriez décoder pour construire une carte complète des décalages. Vous avez besoin de cette carte pour résoudre des objets arbitraires par leur numéro. Vous n'en avez pas besoin pour lire /Root, /Size et /ID, qui figurent dans le dictionnaire en texte brut, et vous n'en avez pas besoin pour développer les flux d'objets, car chaque /ObjStm annonce son propre contenu via /N et /First.
Vous n'avez pas non plus à gérer les fonctions de prédiction PNG et TIFF qu'un flux de référence croisée peut appliquer via son entrée /DecodeParms pour obtenir les clés du trailer. Les prédicteurs filtrent les lignes binaires de référence croisée pour améliorer leur compression ; ils n'ont rien à voir avec le dictionnaire qui précède le flux. La mise à niveau minimale pour rendre un validateur classique compatible avec les PDF modernes est donc restreinte : lorsque startxref désigne un flux plutôt que le mot-clé xref, analysez le dictionnaire du flux pour y trouver les clés du trailer, et développez tout objet /ObjStm rencontré pour insérer son contenu dans la table des objets. Décoder les entrées de type 2 et les prédicteurs est une tâche distincte et plus vaste que vous pouvez différer jusqu'à ce que vous ayez réellement besoin d'une résolution aléatoire d'objets.
Pourquoi un contrôle de conformité doit d'abord développer les flux
Cela cesse d'être théorique dès que vous lancez un contrôle de profil. Un validateur PDF/A ou PDF/X inspecte des objets spécifiques : le catalogue du document pour y chercher un tableau /OutputIntents, le flux /Metadata pour un paquet XMP avec le bon identifiant, chaque descripteur de police pour un fichier de police intégré, le trailer pour un /ID. Dans un fichier compressé, la plupart de ces objets résident au sein de flux d'objets. Un validateur qui n'a pas développé ces flux ne peut pas voir les clés du catalogue, ne trouve pas les métadonnées et ne peut énumérer les polices. Il signalera un document parfaitement conforme comme manquant d'intention de sortie, de métadonnées XMP et de la moitié de sa structure, car les éléments requis résident encore dans un bloc Flate non décompressé.
L'ordre est important. Le développement doit s'effectuer avant l'exécution des vérifications, et non en parallèle, car chaque test suppose qu'il peut accéder à un objet par son numéro. Si vous greffez un contrôle de profil directement sur un balayage d'octets bruts, il héritera de la cécité de l'analyseur classique et produira de fausses violations précisément sur les fichiers modernes les plus susceptibles d'être bien formés, ceux-ci provenant de chaînes d'outils assez récentes pour écrire des flux de références croisées.
Laisser PDFium s'occuper de l'analyse pour vous
Le composant PDFium analyse les flux de référence croisée et d'objets lors du chargement d'un document, ce qui évite d'avoir à implémenter manuellement l'étape de décompression et de développement. Lorsque vous chargez un fichier avec le composant TPdf, les objets regroupés dans les conteneurs /ObjStm sont déjà résolus, et les points d'entrée de validation voient le document entièrement développé. ValidatePdfA renvoie un enregistrement TPdfAValidationResult dont le champ Conformance est une valeur TPdfAConformance (comme pac1b ou pacNone), le champ Issues contient l'ensemble des problèmes trouvés, et la méthode IsCompliant renvoie vrai uniquement si un niveau de conformité a été détecté et que l'ensemble des problèmes est vide. Les objets ayant été développés lors du chargement, un tableau /OutputIntents ou une police intégrée résidant dans un flux d'objets est correctement localisé et non signalé manquant.
uses
PDFium, FPdfPdfa;
function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True; // parses xref/object streams on load
Result := Pdf.ValidatePdfA; // sees the expanded object table
finally
Pdf.Free;
end;
end;
Le même principe s'applique à ValidatePdfX, qui renvoie un TPdfXValidationResult de forme identique. L'intérêt de passer par PDFium est que la décompression structurelle décrite ci-dessus s'effectue une fois, correctement, au sein du chargeur, de sorte que votre code de validation ne perçoit aucune différence entre un fichier classique et un fichier entièrement compressé. Les deux parviennent au validateur sous la forme d'un ensemble d'objets résolus.
var
Pdf: TPdf;
R : TPdfXValidationResult;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'Press_Ready.pdf';
Pdf.Active := True;
R := Pdf.ValidatePdfX;
if R.IsCompliant then
Writeln('PDF/X conformance: ', Ord(R.Conformance))
else
Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
finally
Pdf.Free;
end;
end;
Si les octets sont déjà en mémoire plutôt que sur le disque, la même séquence chargement-puis-validation fonctionne via la surcharge LoadDocument(const Data: TBytes), qui prend le contenu brut du fichier et analyse ses flux de référence croisée et d'objets de la même manière que pour un chemin de fichier. Ce qu'il faut retenir pour un validateur écrit manuellement est la règle structurelle, non l'API : lisez les clés du trailer dans le dictionnaire du flux en texte brut, développez chaque /ObjStm avec un décodeur Flate avant de parcourir le document, et considérez le décodage des entrées binaires de référence croisée comme une tâche secondaire optionnelle.
Une fois la structure développée, un validateur peut y associer le reste d'un flux de travail. Pour un outil de contrôle en amont en ligne de commande qui signale la conformité sur un dossier d'entrées, consultez notre guide de construction d'un CLI de rapport de contrôle en amont par lot. Lorsque la validation sert de filtre avant de découper un grand document, les techniques présentées dans notre guide de découpage de documents PDF en plusieurs fichiers s'associent naturellement au modèle de chargement et de vérification présenté ici. Tous deux s'appuient sur la surface de chargement et de validation du composant PDFium pour Delphi et C++Builder.