Article technique

Fichiers JBIG2 à accès direct : les décoder en Delphi

Voici une erreur qu'un lecteur JBIG2 écrit à la main commet sur le premier fichier inhabituel qu'il rencontre. Il parse l'en-tête de fichier, parse le premier en-tête de segment, lit la longueur de données sur quatre octets et prend autant d'octets comme corps du segment. Sur la plupart des fichiers .jb2, c'est exactement ce qu'il faut. Sur un fichier dont l'octet de drapeaux a le bit 0 à zéro, ces octets de « corps » sont en réalité les deuxième, troisième et quatrième en-têtes de segment, et le décodeur arithmétique en fait un bitmap de bruit ou file au-delà de la fin du tampon. Le fichier n'est pas cassé. Il utilise l'organisation à accès direct de l'annexe D.2 de l'ITU-T T.88, où tous les en-têtes de segment viennent d'abord et toutes les données de segment suivent ensuite, dans le même ordre

Les fichiers à accès direct sont rares. Ils sortent de systèmes d'archivage et d'imagerie documentaire qui veulent qu'un lecteur voie chaque page et chaque dépendance de dictionnaire avant de toucher un octet compressé. Si un job de conversion mélange des fichiers d'une telle source avec des scans ordinaires, un fichier à accès direct peut se retrouver au milieu d'un lot par ailleurs entièrement séquentiel. Le décodeur qui les lit est le même décodeur Pascal natif qui gère les tables de Huffman personnalisées et les codes préfixes canoniques JBIG2, donc le côté codage ne change pas ; seul l'ordre dans lequel les segments sont trouvés diffère

Trois dispositions de fichier derrière un seul octet de drapeaux

PDF Library for Delphi lit les fichiers JBIG2 autonomes via TPLJBIG2Decoder dans l'unité PDFlibJBIG2, et le décodeur déduit tout seul la disposition à partir de l'en-tête de fichier. L'annexe D du T.88 autorise trois arrangements pour les mêmes segments : le séquentiel (D.1) place chaque en-tête juste devant ses données, l'accès direct (D.2) place tous les en-têtes d'abord et toutes les données ensuite, et l'embarqué (D.3) est la forme sans en-tête utilisée dans d'autres conteneurs. Un fichier autonome commence par l'identifiant de huit octets 97 4A 42 32 0D 0A 1A 0A, puis un octet de drapeaux, puis un compte de pages sur quatre octets sauf si les drapeaux disent que le compte est inconnu

Le bit 0 de cet octet de drapeaux choisit l'organisation : 1 signifie séquentiel, 0 signifie accès direct. Le bit 1 à un signifie que le nombre de pages est inconnu et que le compte sur quatre octets est absent, donc le premier en-tête de segment commence à l'octet 9 au lieu de l'octet 13. Les bits 2 à 7 sont réservés ; le décodeur accepte les fichiers qui les positionnent au lieu de les rejeter, parce qu'un bit réservé égaré ne dit rien sur la lisibilité des segments eux-mêmes

Organisations de fichiers JBIG2 dans PDFlibPas : l'octet de drapeaux que setFileHeaderFlags lit choisit la séquentielle D.1 à en-têtes entrelacés, l'accès direct D.2 avec chaque en-tête avant le bloc de données, ou l'embarquée D.3, la forme sans en-tête qu'utilise un flux JBIG2Decode avec dictionnaires dans JBIG2Globals
Les trois dispositions portent les mêmes segments, mais seul l'accès direct fait voir à un lecteur chaque dépendance de page et de dictionnaire avant de toucher un octet compressé, et c'est pourquoi les chaînes d'archivage l'ont demandé

Le PDF lui-même ne transporte jamais la disposition à accès direct. Un flux d'image JBIG2Decode, décrit dans ISO 32000-1 §7.4.7, ne contient que des segments de page sous forme embarquée, avec les dictionnaires de symboles partagés déplacés dans un flux JBIG2Globals séparé et sans en-tête de fichier, ni segments end-of-page ou end-of-file. Quand le décodeur ne trouve pas l'identifiant de huit octets, il traite l'entrée exactement comme ce type de flux : séquentiel, une page. Dans l'autre sens, quand PDF Library for Delphi exporte une image JBIG2Decode en fichier .jb2 natif, il écrit l'octet de drapeaux $03 (séquentiel, compte de pages inconnu) et ajoute un en-tête de segment end-of-file. L'accès direct n'arrive donc jamais que de l'extérieur, comme fichier autonome passé directement à TPLJBIG2Decoder, en général en route vers une conversion ou une recompression pour PDF avec les backends d'encodage JBIG2 de PDFlibPas

Pourquoi ne peut-on pas lire un fichier à accès direct dans l'ordre du fichier ?

Parce que rien dans le flux d'octets ne marque où le bloc d'en-têtes finit et où le bloc de données commence, sauf l'en-tête de segment end-of-file (type 51) lui-même. Les en-têtes de segment JBIG2 n'ont pas de taille fixe. Le compte de segments référencés est soit une forme courte sur trois bits, soit une forme longue suivie d'un bitmap de rétention ; chaque numéro de segment référencé occupe un, deux ou quatre octets selon la taille du numéro du segment courant ; le champ d'association de page fait un octet ou quatre. Un lecteur doit parser chaque champ d'un en-tête rien que pour savoir où commence le suivant

Un lecteur séquentiel qui parcourt le fichier dans l'ordre a le premier en-tête juste et tout le reste faux. Pire, il peut ne rien remarquer pendant un moment : un décodeur de région générique nourri d'octets d'en-tête produit des pixels, pas une erreur, jusqu'à ce qu'un contrôle de longueur ou de référence ultérieur finisse par déclencher. Notre avis est qu'un décodeur qui n'indexe pas d'abord les en-têtes devrait refuser tout net les fichiers à accès direct. Un rejet clair coûte un fichier dans un lot ; un bitmap d'apparence plausible mais faux peut finir dans une archive et y rester

Indexer chaque en-tête avant le premier corps

Quand l'octet de drapeaux dit accès direct, PDF Library for Delphi fait une passe sur le bloc d'en-têtes avant de décoder quoi que ce soit. Chaque en-tête est parsé en entier puis jeté ; la seule chose conservée est son offset en octets dans le fichier. Les données compressées ne sont jamais copiées dans un second tampon réordonné, ce qui compte sur de gros scans d'archive où le bloc de données peut représenter l'essentiel du fichier. La passe s'arrête au premier en-tête end-of-file, et à ce moment la position de lecture se trouve sur le premier octet des données du premier segment

Un fichier à accès direct a moins de redondance qu'un fichier séquentiel, donc le pré-balayage est strict. Il rejette le fichier quand :

  • un en-tête est coupé par la fin du tampon, signalé par JBIG2 truncated random-access header at byte N avec l'offset où cet en-tête commençait
  • les numéros de segment ne croissent pas strictement (comparés en valeurs non signées), parce que deux en-têtes revendiquant le même numéro rendent ambiguë la liste de références d'une région ultérieure
  • une longueur de données a son bit de poids fort à un, ce qui inclut le marqueur 0xFFFFFFFF de « longueur inconnue » ; dans cette disposition, il n'y a aucun autre moyen de trouver où commence le corps suivant, donc le décodeur la refuse immédiatement au lieu de chercher un marqueur de fin plus loin
  • l'en-tête end-of-file déclare des données, ou n'apparaît jamais (JBIG2 random-access file is missing its end-of-file header)
  • les longueurs déclarées totalisent plus que les octets restants, ou moins
Pré-balayage IndexRandomHeaders dans PDFlibPas : chaque en-tête de segment est analysé puis jeté, seul son offset en octets est gardé, les numéros de segment doivent strictement croître, la longueur inconnue 0xFFFFFFFF est refusée, le balayage s'arrête à l'en-tête end-of-file de type 51, et les longueurs déclarées doivent égaler exactement les octets restants
La rigueur est délibérée : dans une disposition où les en-têtes donnent la seule carte des données, un octet égaré signifie que tout corps ultérieur peut être décalé, donc un décodeur qui le tolère ne sait pas distinguer du remplissage d'un désalignement

La dernière règle est celle sur laquelle on trébuche. Un seul octet de trop après le dernier corps échoue avec JBIG2 trailing random-access data. Cela paraît dur à côté du chemin séquentiel, où le décodage s'arrête simplement au segment end-of-file et où tout ce qui suit est ignoré. L'asymétrie a une raison. En disposition séquentielle, chaque en-tête est collé à ses propres données, donc des déchets en fin de fichier n'ont rien pu décaler. En disposition à accès direct, les en-têtes sont la seule carte du bloc de données, et si les longueurs ne tombent pas juste, le décodeur ne peut pas distinguer un remplissage inoffensif d'un octet perdu ou ajouté bien plus tôt qui a décalé tous les corps suivants

Pourquoi une boucle comptée en octets perd-elle le dernier segment end-of-page ?

Une boucle qui tourne « tant qu'il reste des octets » se termine trop tôt sur un fichier à accès direct, parce que les données s'épuisent avant l'index des en-têtes. Les segments end-of-page (type 49) et end-of-file transportent zéro octet de données et viennent normalement en dernier. Une fois le dernier corps de région consommé, la position de lecture est exactement à la fin du tampon ; une boucle comptée en octets sort là, et le segment end-of-page de longueur nulle n'est jamais dispatché, ce qui laisse la page inachevée

Boucle de décodage à accès direct dans PDFlibPas : chaque itération se positionne sur les HeaderOffsets de l'en-tête courant, remet bitPointer à 7 pour rattraper les fins d'octet partiels, saute à NextBodyOffset pour le corps, et compte des en-têtes plutôt que des octets, si bien que les segments end-of-page de longueur nulle sont distribués avant la fin de la boucle
Comme les segments end-of-page et end-of-file portent zéro octet de données, le flux de données s'épuise avant l'index des en-têtes, et seule une boucle qui compte des en-têtes peut donner leur tour à ces derniers segments

Pour les fichiers à accès direct, le décodeur compte donc des en-têtes, pas des octets. Chaque étape revient en arrière sur le prochain en-tête indexé, se réaligne sur une frontière d'octet (un corps codé arithmétiquement peut finir au milieu d'un octet), parse l'en-tête à nouveau, puis saute en avant là où commence le corps suivant et avance de la longueur déclarée. Un corps dont le décodeur termine tôt est sauté jusqu'à sa fin déclarée ; un corps qui lit au-delà est rejeté. À partir de là, le segment passe par les mêmes handlers et contrôles de référence que dans un fichier séquentiel. Les erreurs levées pendant le décodage d'un corps portent la position d'origine de l'en-tête, sous la forme JBIG2 segment N (type T) at byte B: ..., si bien que l'offset dans LastError pointe vers quelque chose que vous pouvez retrouver dans un éditeur hexadécimal

Charger un fichier .jb2 à accès direct en Delphi

Le code applicatif ne change pas avec l'organisation. TPLJBIG2Decoder.LoadFromByteArray détecte l'en-tête de fichier et la disposition, renvoie False sur toute entrée rejetée avec la raison dans LastError, et expose la page décodée via Width, Height et GetScanline, qui remplit un octet par pixel avec 1 pour le noir

uses
  SysUtils, Classes, PDFlibJBIG2;

function ReadJb2(const FileName: string): TJBIG2ByteArray;
var
  FS: TFileStream;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, FS.Size);
    if Length(Result) > 0 then
      FS.ReadBuffer(Result[0], Length(Result));
  finally
    FS.Free;
  end;
end;

function CountBlackPixels(const FileName: string): Integer;
var
  Decoder: TPLJBIG2Decoder;
  Row: TJBIG2ByteArray;
  X, Y: Integer;
begin
  Result := 0;
  Decoder := TPLJBIG2Decoder.Create;
  try
    // Fichiers autonomes séquentiels et à accès direct passent par le même appel
    if not Decoder.LoadFromByteArray(ReadJb2(FileName)) then
      raise Exception.Create('JBIG2 rejected: ' + Decoder.LastError);
    for Y := 0 to Decoder.Height - 1 do
      if Decoder.GetScanline(Y, Row) then
        for X := 0 to Decoder.Width - 1 do
          if Row[X] = 1 then
            Inc(Result);
  finally
    Decoder.Free;
  end;
end;

Si vous voulez savoir quelle disposition utilise un fichier, pour la journalisation ou pour trier un corpus avant une conversion, lisez l'octet de drapeaux vous-même. La bibliothèque n'expose pas l'organisation qu'elle a détectée, et neuf octets suffisent. Le helper ci-dessous est à ajouter à votre propre code ; il ne fait que reformuler les règles d'en-tête du T.88 :

type
  TJb2Layout = (jlNoFileHeader, jlSequential, jlRandomAccess);

// Votre propre helper, hors PDFlibJBIG2 : classe un tampon d'après son en-tête de fichier T.88
function DetectJb2Layout(const Data: TJBIG2ByteArray;
  out PageCountKnown: Boolean): TJb2Layout;
const
  Signature: array[0..7] of Byte = ($97, $4A, $42, $32, $0D, $0A, $1A, $0A);
var
  I: Integer;
begin
  Result := jlNoFileHeader;          // p. ex. un flux embarqué extrait d'un PDF
  PageCountKnown := False;
  if Length(Data) < 9 then
    Exit;
  for I := 0 to 7 do
    if Data[I] <> Signature[I] then
      Exit;
  PageCountKnown := (Data[8] and 2) = 0;   // bit 1 à un : pas de compte de pages sur quatre octets
  if (Data[8] and 1) = 0 then
    Result := jlRandomAccess
  else
    Result := jlSequential;
end;

Jobs par lots : tampons rembourrés et fichiers rejetés

La règle des données traînantes est l'endroit où le code de traitement par lots a tendance à se tromper, et quand un fichier qui s'ouvre ailleurs est rejeté de cette façon, regardez le tampon avant d'accuser le fichier. Un .jb2 tiré d'une colonne BLOB de base de données, d'une lecture réseau de taille fixe ou d'un format conteneur avec remplissage d'alignement peut arriver avec quelques octets nuls après la vraie fin. Un fichier séquentiel s'en moque. Un fichier à accès direct avec le même remplissage échoue avec JBIG2 trailing random-access data, et il en va de même d'un tampon coupé quelques octets trop court, qui échoue comme tronqué. Passez exactement les octets du fichier, dimensionnés d'après la vraie longueur de la source, jamais d'après une taille de chunk ou une capacité

Un fichier rejeté ne laisse rien derrière lui non plus. LoadFromByteArray remet à zéro Width, Height et les scanlines avant de commencer à décoder, donc un appel en échec rapporte des dimensions nulles plutôt que la page du fichier précédent, et la même instance de décodeur peut enchaîner directement sur le fichier suivant :

procedure DecodeBatch(const Files, Log, Quarantine: TStrings);
var
  Decoder: TPLJBIG2Decoder;
  I: Integer;
begin
  Decoder := TPLJBIG2Decoder.Create;
  try
    for I := 0 to Files.Count - 1 do
      if Decoder.LoadFromByteArray(ReadJb2(Files[I])) then
        Log.Add(Format('%s: %d x %d', [Files[I], Decoder.Width, Decoder.Height]))
      else
      begin
        // Width et Height valent 0 ici ; rien du fichier précédent ne survit
        Log.Add(Files[I] + ': ' + Decoder.LastError);
        if Pos('trailing random-access data', Decoder.LastError) > 0 then
          Quarantine.Add(Files[I]);   // vérifiez comment les octets ont été stockés avant d'accuser le fichier
      end;
  finally
    Decoder.Free;
  end;
end;

Vérifier cela dans votre propre pipeline ne demande pas de fichiers d'exemple rares. Réorganisez un .jb2 séquentiel en disposition à accès direct, en gardant chaque en-tête et chaque octet compressé, déplacez les en-têtes end-of-page et end-of-file à la fin du bloc d'en-têtes, et les pixels décodés doivent correspondre exactement à l'original séquentiel, avec et sans le champ de compte de pages. Puis donnez-lui chaque préfixe tronqué, un numéro de segment dupliqué, un octet de trop en fin et une longueur 0xFFFFFFFF ; chacun doit renvoyer False avec Width et Height à zéro

Ce que le décodeur renvoie, et ce qu'il attend

L'accès direct est ici une propriété de la disposition du fichier. TPLJBIG2Decoder renvoie le bitmap de la première page quelle que soit l'organisation du fichier, et Width et Height décrivent cette page. Chaque segment doit déclarer sa longueur de données ; le T.88 permet à une région générique immédiate d'utiliser le marqueur 0xFFFFFFFF, et le décodeur rejette ce marqueur dans les deux dispositions avec unknown or oversized segment length is not supported. Les longueurs de préfixes Huffman personnalisés et les comptes d'entrées de table sont plafonnés comme dans les fichiers séquentiels

Décoder le JBIG2 est une étape d'un chemin image plus long qui couvre aussi l'extraction d'images PDF, la recompression et l'encodage JBIG2 ; la page produit PDF Library for Delphi couvre le reste de ce pipeline et les formats d'image pris en charge