Article technique

Pourquoi certains flux d'objets PDF se décodent en charabia en Delphi

Un flux d'objets PDF qui se gonfle sans erreur mais se lit tout de même comme du bruit manque généralement une étape : inverser le Predictor ISO 32000-1. Lorsque le dictionnaire /DecodeParms d'un flux porte un /Predictor de 2 ou plus, les octets que FlateDecode renvoie ne sont pas les données originales — ce sont des valeurs différenciées par ligne de style PNG ou différenciées horizontalement de style TIFF qui ont besoin d'une seconde passe de reconstruction avant qu'une recherche de dictionnaire ne fasse sens. PDFiumPas, la bibliothèque de composant PDF VCL native pour Delphi et C++Builder, a ajouté cette passe de reconstruction dans la v2.16.0, spécifiquement parce que les flux d'objets PDF 1.5+ se dilataient en octets différenciés qu'aucun analyseur de dictionnaire ne pouvait lire

Pourquoi FlateDecode seul ne suffit pas

FlateDecode lui-même n'est que de la décompression DEFLATE (ISO 32000-1 §7.4.4.1) : il reproduit quels que soient les octets que l'encodeur a transmis au compresseur, rien de plus. Le Predictor vit une couche au-dessus, dans le dictionnaire /DecodeParms du flux, et il décrit une transformation que l'encodeur a appliquée avant la compression — la différenciation transforme de longues suites de valeurs structurées similaires, comme les entiers étroitement compactés à l'intérieur d'un flux de référence croisée ou d'un flux d'objets, en longues suites de petits nombres que DEFLATE compresse bien mieux. ISO 32000-1 §7.4.4.3 (Tableau 8) est explicite sur le fait que défaire cette transformation fait partie du décodage d'un flux filtré, pas une passe de nettoyage facultative, et pourtant il est facile d'écrire un assistant FlateDecode qui n'appelle qu'inflate et s'arrête là

Le symptôme est distinctif une fois qu'on sait le chercher. Les octets différenciés par Predictor ne sont pas du bruit aléatoire — ils portent encore la forme d'un flux compressé, si bien qu'un analyseur naïf dépasse souvent quelques jetons d'apparence valide avant de heurter une séquence d'octets qui ne peut absolument pas être un nom, un nombre, ou un délimiteur PDF, et différentes lignes échouent à différents décalages selon à quel point les valeurs sous-jacentes se trouvaient différer de leurs voisines. Cette incohérence est ce qui rend le bogue difficile à cerner à partir d'un seul fichier en échec : deux PDF du même producteur peuvent ne différer que par les valeurs qui se répètent, si bien que l'un s'analyse presque par accident tandis que l'autre échoue purement et simplement

Que fait réellement le paramètre Predictor PDF ?

L'entrée /Predictor dans /DecodeParms indique à un lecteur conforme quelle inversion exécuter, et le Tableau 8 d'ISO 32000-1 définit les valeurs qui comptent en pratique : 1 signifie qu'aucune prédiction n'a été appliquée, 2 sélectionne TIFF Predictor 2 (différenciation horizontale), et toute valeur de 10 à 15 sélectionne une prédiction de style PNG. Trois clés supplémentaires voyagent à ses côtés — /Colors, /BitsPerComponent, et /Columns — et ensemble elles décrivent la géométrie de ligne contre laquelle la différenciation a été calculée, même lorsque le flux ne contient aucune donnée d'image du tout : un flux d'objets n'est pas une image, mais les écrivains PDF réutilisent la même mécanique de prédicteur basée sur les lignes pour lui, car delta-puis-deflate compresse plus étroitement des entiers et des décalages d'objets bien tassés que les déflater bruts

TIFF Predictor 2 est le plus simple des deux schémas : chaque composante est stockée comme la différence par rapport à la même composante dans le pixel précédent sur la même ligne, et chaque ligne se réinitialise à son bord gauche plutôt que de porter une différence depuis la ligne au-dessus. La prédiction PNG est plus particulière, car le filtre réel peut changer de ligne en ligne : chaque ligne commence par un seul octet d'étiquette — 0 pour None, 1 pour Sub, 2 pour Up, 3 pour Average, 4 pour Paeth — et cette étiquette, pas la valeur /Predictor déclarée, décide comment cette ligne spécifique est reconstruite. Un /Predictor de 12 n'est en réalité que l'indice de l'encodeur selon lequel il a favorisé le filtre Up, où chaque octet est restauré en ajoutant l'octet directement au-dessus de lui dans la ligne précédente, mais un décodeur correct doit tout de même lire l'étiquette à chaque ligne plutôt que de supposer Up partout

Pourquoi les flux d'objets rendent-ils un Predictor manqué invisible ?

Les flux d'objets aggravent le problème plutôt que de simplement le répéter. ISO 32000-1 §7.5.7 permet à un écrivain PDF 1.5+ de regrouper plusieurs objets indirects dans un seul conteneur compressé, un /ObjStm, et il est courant que précisément les objets dont un validateur a le plus besoin — le catalogue, les /OutputIntents, ou un flux /Metadata XMP — voyagent à travers ce conteneur avec un /Predictor de 12 attaché, car ces objets sont assez courts et répétitifs pour bénéficier de la différenciation par ligne. Lorsque l'étape de prédicteur est manquante, dilater le flux d'objets ne lève pas d'erreur : cela produit une séquence d'octets qui a l'air superficiellement plausible mais ne se tokenise pas dans les objets attendus, si bien que ce qui était emballé à l'intérieur n'apparaît tout simplement pas. Le rendu remarque rarement, car un moteur de rendu conforme reconstruit déjà les données différenciées par prédicteur avant qu'elles n'atteignent jamais la mise en page ; le code qui remarque est exactement le genre dans lequel ce bogue se cachait — un validateur, un signataire, ou un vérificateur de version qui parcourt lui-même les octets PDF bruts pour répondre à une question structurelle, sans repli une fois que sa propre vue du flux d'objets revient fausse

PDFiumPas a rencontré exactement cet échec avant la v2.16.0. Les flux d'objets construits avec un /Predictor de 12, le cas courant pour les écrivains PDF 1.5+, se dilataient via PdfExpandObjectStreams en octets différenciés que le scanner structurel ne pouvait pas analyser, si bien que les objets catalogue, /OutputIntents, et /Metadata emballés à l'intérieur étaient effectivement invisibles pour les scans de conformité — aucune exception, aucun avertissement, juste un scan qui se comportait silencieusement comme si ces objets étaient absents. Les mécanismes plus profonds de la façon dont PDFiumPas résout un flux d'objets par rapport à la table de référence croisée active, y compris les cas de flux xref hybrides et purs, sont couverts séparément dans l'article sur la validation des flux d'objets et xref avec PDFiumPas ; l'étape de prédicteur décrite ici s'exécute après cette résolution, sur les octets que chaque objet compressé contient réellement

Inverser les lignes de Predictor PNG et TIFF en Pascal

PDFiumPas inverse la différenciation dans une seule routine, PdfApplyPredictor, et ses calculs de géométrie méritent d'être connus, que vous l'appeliez ou réimplémentiez l'idée dans votre propre code Delphi. La largeur de ligne en octets est ceil(Columns × Colors × BitsPerComponent ÷ 8) et la largeur d'octet par pixel que les deux algorithmes utilisent est ceil(Colors × BitsPerComponent ÷ 8) — trompez-vous sur l'un ou l'autre arrondi et la reconstruction lit à travers une limite de ligne plutôt qu'à l'intérieur d'une seule. Un /Predictor inférieur à 2 est laissé intact, puisque 1 signifie que l'encodeur n'a appliqué aucune transformation du tout ; 2 sélectionne la branche TIFF montrée ci-dessous, et tout ce qui va de 10 vers le haut passe à la reconstruction de filtre de ligne PNG, où l'octet d'étiquette au début de chaque ligne — pas la valeur /Predictor déclarée — décide comment cette ligne spécifique est défaite

function PdfApplyPredictor(const Src: TBytes;
  Predictor, Colors, Bpc, Columns: Integer): TBytes;
var
  RowLen, Bpp, R, I: Integer;
begin
  Result:= Src;
  if Predictor< 2 then
    Exit;                                   // 1 = no prediction, nothing to undo
  if Colors<= 0 then Colors:= 1;
  if Bpc<= 0 then Bpc:= 8;
  if Columns<= 0 then Columns:= 1;
  if (Colors> 64)or (Bpc> 32)or (Columns> 1 shl 24) then
    Exit;                                   // reject hostile row geometries
  RowLen:= (Columns* Colors* Bpc+ 7) div 8;  // ceil(), per ISO 32000-1 Table 8
  Bpp:= (Colors* Bpc+ 7) div 8;
  if Predictor= 2 then
  begin
    if Bpc<> 8 then
      Exit;                                 // only the 8-bit layout is reconstructed
    Result:= Copy(Src, 0, Length(Src));
    R:= 0;
    while R+ RowLen<= Length(Result) do
    begin
      for I:= R+ Bpp to R+ RowLen- 1 do
        Result[I]:= Byte(Result[I]+ Result[I- Bpp]);
      Inc(R, RowLen);
    end;
    Exit;
  end;
  // Predictor >= 10 falls through to PNG row-filter reconstruction below
end;
// Continues inside PdfApplyPredictor once Predictor>= 10 (PNG row filters).
// Rows:= Length(Src) div (RowLen+ 1); each row is a 1-byte filter tag
// followed by RowLen data bytes, decoded left to right.
for R:= 0 to Rows- 1 do
begin
  SrcOfs:= R* (RowLen+ 1);
  DstOfs:= R* RowLen;
  Tag:= Src[SrcOfs];
  Inc(SrcOfs);
  for I:= 0 to RowLen- 1 do
  begin
    if I>= Bpp then A:= Result[DstOfs+ I- Bpp] else A:= 0;   // byte to the left
    if R> 0 then B:= Result[DstOfs+ I- RowLen] else B:= 0;   // byte above
    case Tag of
    1: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ A);            // Sub
    2: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ B);            // Up
    3: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ (A+ B) div 2); // Average
    // Paeth (tag 4) adds whichever of A, B or the byte above-left sits
    // closest to the linear predictor A+ B- C; tag 0 (None) copies the
    // filtered byte through unchanged
    else Result[DstOfs+ I]:= Src[SrcOfs+ I];
    end;
  end;
end;

Ce que PDFiumPas a changé dans la v2.16.0

La correction livrée dans PDFiumPas v2.16.0 se trouve à l'intérieur de PdfReadAndDecodeStream, la routine qui lit les octets bruts d'un flux et les décode pour chaque appelant ayant besoin d'inspecter la structure PDF au niveau des octets, y compris l'expansion de flux d'objets ; elle ne tente la reconstruction qu'après avoir confirmé que /Filter est un FlateDecode nu, jamais une cascade, car un filtre chaîné ne peut pas être corrigé par prédicteur en toute sécurité à cette couche. Relire /Predictor, /Colors, /BitsPerComponent, et /Columns depuis le dictionnaire de flux n'a pas non plus besoin d'un analyseur de dictionnaire général : PdfDictRefNum trouve chaque clé par recherche directe de jeton nommé à l'intérieur de la plage d'octets de ce dictionnaire précis, ce qui est sûr ici précisément parce que ces quatre clés ne peuvent ni se répéter ni s'imbriquer à l'intérieur d'un seul dictionnaire de flux. Cette même recherche de jeton nommé est bien plus risquée une fois pointée sur une région plus large ou moins bornée d'un fichier PDF, ce qui est le sujet de l'article compagnon sur l'analyse sûre des dictionnaires PDF

// Inside PdfReadAndDecodeStream, right after PdfInflate() has already run:
if PdfFilterIsPureFlate(DictTxt) then
begin
  Inflated:= PdfInflate(Raw);
  Predictor:= PdfDictRefNum(Data, DS, DE, 'Predictor');
  if Predictor>= 2 then
  begin
    PColors:= PdfDictRefNum(Data, DS, DE, 'Colors');
    PBpc:= PdfDictRefNum(Data, DS, DE, 'BitsPerComponent');
    PColumns:= PdfDictRefNum(Data, DS, DE, 'Columns');
    Result:= PdfApplyPredictor(Inflated, Predictor, PColors, PBpc, PColumns);
  end
  else
    Result:= Inflated;
end;

Avant la v2.16.0, un flux d'objets construit avec un /Predictor de 12 se dilatait en octets différenciés sans lever d'erreur, si bien que tout objet catalogue, /OutputIntents, ou /Metadata emballé à l'intérieur disparaissait des scans structurels de PDFiumPas sans aucun avertissement. Après la correction, le même flux d'objets se gonfle puis se reconstruit correctement, et les objets emballés à l'intérieur redeviennent visibles pour ces scans. Des bornes défensives ont voyagé avec la correction : PdfApplyPredictor rejette désormais purement et simplement /Colors au-dessus de 64, /BitsPerComponent au-dessus de 32, et /Columns au-dessus de 2^24, car ces combinaisons décrivent des géométries de ligne dont aucun véritable producteur PDF n'a besoin et existent principalement pour faire allouer à un décodeur bien plus de mémoire que ce que les octets d'entrée justifient

var
  Pdf: TPdf;
  Report: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'incoming.pdf';
    Pdf.Active := True;
    Report := Pdf.ValidatePdfA;
    if not Report.IsCompliant then
      LogNonCompliance(Report); // caller-supplied handler
  finally
    Pdf.Free;
  end;
end;

Limites à connaître

La reconstruction de prédicteur de PDFiumPas a deux limites qui méritent d'être connues avant de s'y fier. La reconstruction TIFF Predictor 2 ne couvre que le cas 8 bits par composante ; PDF permet des empaquetages plus étroits, mais les données différenciées TIFF infra-octet passent sans être reconstruites plutôt que d'être devinées, si bien qu'un flux déclarant /Predictor 2 avec /BitsPerComponent 1, 2, ou 4 ne se décodera pas correctement via ce chemin aujourd'hui. La prédiction PNG n'a pas une telle restriction — chaque ligne fournit sa propre étiquette de filtre, et les cinq types définis sont tous reconstruits quelle que soit la valeur /Predictor déclarée entre 10 et 15, ce qui correspond à la façon dont le filtrage de style PNG fonctionne réellement : la valeur déclarée est plus proche d'un indice sur ce que l'encodeur a principalement utilisé qu'une promesse sur chaque ligne

Le moteur de rendu natif de PDFium reconstruit déjà correctement les données d'image et de flux de contenu différenciées par prédicteur, ce qui explique précisément pourquoi un fichier peut se rendre parfaitement dans n'importe quelle visionneuse ordinaire tandis qu'un validateur, signataire, ou vérificateur de version au niveau des octets construit par-dessus lit les mêmes octets de travers. Le décodage conscient du prédicteur décrit ici soutient les fonctionnalités de validation PDF/A, de scan structurel, et de signature de PDFiumPas, le composant PDFium VCL natif pour Delphi et C++Builder