Article technique

Validation de PDF compressés : Flux d'objets et XRef

Vous écrivez un petit validateur. Il ouvre un PDF, cherche jusqu'à la fin, trouve startxref, lit le décalage (offset), et s'attend à atterrir sur le mot-clé xref avec une table de références croisées à largeur fixe en dessous. À partir de cette table, il collecte les décalages d'objets, puis scanne vers l'arrière pour rechercher le mot-clé trailer pour apprendre le /Root et la /Size. Il fonctionne parfaitement sur tous les fichiers que vous avez générés pour le tester. Puis un fichier produit par une version actuelle de Word, ou par une bibliothèque qui cible PDF 1.5, arrive, et le validateur le déclare cassé. Il n'y a pas de mot-clé xref là où le décalage pointe, pas de dictionnaire trailer nulle part, et la table d'objets que le validateur a construite est presque vide. Le fichier est valide. Le validateur le lit à travers un prisme vieux de quinze ans

C'est la raison la plus courante pour laquelle une vérification de PDF au niveau de l'octet (byte-level) écrite par rapport à la disposition classique échoue sur les documents modernes. La structure dont elle dépend, la table de références croisées en texte brut et le mot-clé trailer, a été rendue facultative dans PDF 1.5 et est fréquemment absente. Deux fonctionnalités l'ont remplacée : le flux de références croisées (cross-reference stream) et le flux d'objets compressés (compressed object stream). Tous deux sont décrits dans la norme ISO 32000-1, et un validateur qui ne les connaît pas voit un fichier sain comme un tas d'objets manquants

Ce que PDF 1.5 a changé concernant la fin du fichier

La norme ISO 32000-1 §7.5.8 définit le flux de références croisées, et §7.5.7 définit le flux d'objets de type /ObjStm. Ensemble, ils permettent à un rédacteur (writer) d'abandonner les deux structures sur lesquelles s'appuie un analyseur (parser) classique. Un fichier PDF 1.5 peut se terminer sans aucune table xref. À sa place, l'objet pointé par startxref est un objet de flux ordinaire dont le dictionnaire porte /Type /XRef, et ce flux contient les données de références croisées sous une forme binaire compacte. Il n'y a pas non plus de mot-clé trailer, car le trailer est désormais le propre dictionnaire du flux. Les clés qu'un analyseur classique recherchait, /Root, /Size et /ID, vivent à l'intérieur de ce dictionnaire

Le deuxième changement déplace les objets eux-mêmes. Au lieu d'écrire chaque objet indirect à son propre décalage d'octets, un rédacteur peut emballer (pack) de nombreux petits objets, les dictionnaires de pages, les dictionnaires d'annotations, l'arborescence de la structure, dans un seul flux d'objets 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 à l'intérieur d'un blob compressé. Un validateur scannant les octets bruts à la recherche de 1 0 obj ne les trouve jamais, car ce texte n'existe qu'après le gonflage (inflation). Pour un analyseur classique, la moitié du document s'est tout simplement volatilisée

Les clés de trailer sont en texte brut, même dans un fichier compressé

La partie rassurante est que la lecture du trailer d'un flux de références croisées ne nécessite de ne rien gonfler. Un objet de flux est écrit sous la forme d'un dictionnaire suivi du mot-clé stream, puis des octets compressés. Le dictionnaire est en texte brut. Donc, lorsque startxref pointe vers un flux de références croisées, les octets juste après le numéro d'objet ressemblent à un dictionnaire ordinaire, et /Root, /Size et /ID s'y trouvent en clair, avant que le mot-clé stream et les données Flate ne commencent

Cela signifie qu'un validateur peut apprendre les trois faits dont il a le plus besoin, où se trouve le catalogue, combien d'objets le fichier revendique, et l'identifiant du fichier, en analysant uniquement le dictionnaire du flux. Il n'a pas à décompresser les données de références croisées, et il n'a pas à interpréter les entrées binaires à l'intérieur. Le travail qui met en échec un analyseur naïf n'est pas la lecture du trailer ; c'est de trouver les objets. Ce sont deux problèmes séparables, et résoudre le premier est peu coûteux

Flux d'objets : un en-tête, puis un blob Flate

Un flux d'objets est un conteneur. Son dictionnaire porte /Type /ObjStm, une entrée /N donnant le nombre d'objets emballés à l'intérieur, et une entrée /First donnant le décalage d'octets, dans les données gonflées, où commence le corps du premier objet. La charge utile compressée, une fois gonflée, commence par un petit en-tête de /N paires d'entiers. Chaque paire est un numéro d'objet et le décalage du corps de cet objet par rapport à /First. Après l'en-tête viennent les corps des objets eux-mêmes, concaténés

En étendre un est mécanique une fois que les octets sont gonflés. Vous lisez le dictionnaire pour obtenir /N et /First, gonflez le flux avec un décodeur Flate, parcourez les paires /N de tête pour apprendre quel numéro d'objet vit à quel décalage, puis soulevez (lift out) chaque corps comme s'il s'agissait d'un objet indirect ordinaire. La seule véritable dépendance est le décodeur Flate, et vous en avez déjà un : Delphi livre System.ZLib, et Free Pascal livre l'unité zstream, qui enveloppent toutes deux zlib et gonflent un flux Flate brut sans aucun code tiers. Une routine qui ajoute chaque objet extrait à la table d'objets du validateur fait que le reste du validateur, la partie qui parcourt /Root et vérifie l'arborescence des pages, se comporte exactement comme il le ferait sur un fichier classique

Ce que vous n'avez pas à implémenter

Il est facile de surestimer le travail. La lecture des clés de trailer d'un fichier compressé ne nécessite pas de décoder les entrées binaires du flux de références croisées. Le flux de références croisées §7.5.8 utilise trois types d'entrées, et l'entrée de type 2, celle qui dit cet objet vit à l'intérieur du flux d'objets N à l'index i, est ce que vous décoderiez pour construire une carte complète des décalages (offset map). Vous avez besoin de cette carte pour résoudre des objets arbitraires par numéro. Vous n'en avez pas besoin pour lire /Root, /Size et /ID, qui se trouvent dans le dictionnaire en texte brut, et vous n'en avez pas besoin pour étendre 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 (predictor functions) PNG et TIFF qu'un flux de références croisées peut appliquer via ses /DecodeParms juste pour obtenir les clés de trailer. Les prédicteurs filtrent les lignes binaires de références croisées pour qu'elles se compressent mieux ; ils n'ont rien à voir avec le dictionnaire qui précède le flux. La mise à niveau minimale qui rend un validateur classique conscient des PDF modernes est donc petite : lorsque startxref atterrit sur un flux plutôt que sur le mot-clé xref, analysez le dictionnaire du flux pour les clés de trailer, et étendez tous les objets /ObjStm que vous rencontrez afin que leur contenu entre dans la table d'objets. Le décodage des entrées de type 2 et des prédicteurs est une tâche séparée et plus vaste que vous pouvez différer jusqu'à ce que vous ayez véritablement besoin d'une résolution aléatoire des objets

Pourquoi une vérification de conformité doit étendre les flux en premier

Cela cesse d'être théorique (academic) dès que vous exécutez une vérification de profil. Un validateur PDF/A ou PDF/X inspecte des objets spécifiques : le catalogue du document pour 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 se trouvent à l'intérieur de flux d'objets. Un validateur qui n'a pas étendu les flux d'objets ne peut pas voir les clés du catalogue, ne peut pas trouver les métadonnées et ne peut pas énumérer les polices. Il signalera un document parfaitement conforme comme n'ayant pas d'intention de sortie (output intent), n'ayant pas de XMP, et manquant de la moitié de sa structure, car les preuves dont il a besoin reposent toujours dans un blob Flate qu'il n'a jamais gonflé

L'ordre a de l'importance. L'expansion doit se produire avant que les vérifications ne soient exécutées, et non en parallèle, car chaque vérification suppose qu'elle peut atteindre un objet par numéro. Si vous câblez (wire) une vérification de profil directement sur une analyse d'octets bruts, elle hérite de l'aveuglement de l'analyseur classique et produit de fausses violations précisément sur les fichiers modernes qui sont les plus susceptibles d'être bien formés, puisqu'ils sont issus de chaînes d'outils (toolchains) suffisamment récentes pour écrire des flux de références croisées en premier lieu

Laisser PDFium faire l'analyse pour vous

Le composant PDFium analyse les flux de références croisées et les flux d'objets dans le cadre du chargement d'un document, ce qui est le moyen pratique d'éviter de coder manuellement (hand-rolling) l'étape de gonflage et d'expansion. Lorsque vous chargez un fichier avec le composant TPdf, les objets emballés dans des conteneurs /ObjStm sont déjà résolus, et les points d'entrée de validation voient le document entièrement étendu. ValidatePdfA renvoie un enregistrement TPdfAValidationResult dont le champ Conformance est une valeur TPdfAConformance telle que pac1b ou pacNone, dont le champ Issues est un ensemble (set) des problèmes spécifiques rencontrés, et dont la méthode IsCompliant est vraie uniquement lorsqu'un niveau de conformité a été détecté et que l'ensemble des problèmes est vide. Parce que les objets ont été étendus pendant le chargement, un tableau /OutputIntents ou une police intégrée qui vivait à l'intérieur d'un flux d'objets est trouvé, 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;

Il en va de même pour ValidatePdfX, qui renvoie un TPdfXValidationResult avec la même forme. L'intérêt de passer par PDFium est que la décompression structurelle décrite ci-dessus se produit une fois, correctement, à l'intérieur du chargeur (loader), de sorte que votre code de validation ne voit jamais la différence entre un fichier classique et un fichier entièrement compressé. Les deux arrivent au validateur comme un ensemble résolu d'objets

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
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: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  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 charge-puis-valide fonctionne via la surcharge LoadDocument(const Data: TBytes), qui prend le contenu brut du fichier et analyse ses flux de références croisées et d'objets de la même manière que le chemin de fichier. La conclusion pour un validateur écrit à la main est la règle structurelle, pas l'API : lisez les clés de trailer à partir du dictionnaire de flux en texte brut, étendez chaque /ObjStm avec un décodeur Flate avant de parcourir le document, et traitez le décodage des entrées binaires de références croisées comme le travail plus vaste et facultatif qu'il est

Une fois la structure étendue, un validateur peut piloter le reste d'un flux de travail (workflow) sur celle-ci. Pour un harnais (harness) de contrôle en amont (preflight) en ligne de commande qui signale la conformité sur un dossier d'entrées, consultez notre guide pas à pas sur la création d'une CLI de rapport de contrôle en amont par lots (batch preflight report). Lorsque la validation est une étape (gate) précédant le découpage d'un grand document, les techniques de notre guide sur la séparation des documents PDF en plusieurs fichiers se marient naturellement avec le modèle chargement-et-vérification (load-and-check) présenté ici. Les deux s'appuient sur la surface de chargement et de validation du Composant PDFium pour Delphi et C++Builder