Article technique

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

PDFlibPas version 3.539.23 décode les fichiers JBIG2 autonomes qui utilisent l'organisation à accès direct (random-access) de l'annexe D.2 de l'ITU-T T.88, où tous les en-têtes de segment viennent d'abord et les données de segment suivent dans le même ordre. Le décodeur Pascal natif de PDFlibJBIG2.pas indexe les offsets des en-têtes jusqu'à l'en-tête de fin de fichier obligatoire, vérifie que les numéros de segment croissent et que les longueurs de données déclarées tombent exactement sur les octets restants, puis décode chaque corps dans l'ordre des en-têtes sans copier ni réordonner les données compressées. Avant cette version, le même fichier levait une erreur sèche « random-access organisation is not supported » dès la lecture des drapeaux d'en-tête

Les fichiers JBIG2 à accès direct sont rares, et c'est précisément pourquoi ils font mal quand ils se pointent. Ils sortent de chaînes d'archivage et de systèmes d'imagerie documentaire qui veulent qu'un lecteur voie chaque en-tête de segment, donc chaque dépendance de page et de dictionnaire, avant de toucher un seul octet compressé. Une application Delphi qui convertit en masse des archives numérisées en PDF en rencontre généralement un en plein travail, après que des centaines de fichiers séquentiels sont passés sans encombre, et un décodeur qui s'arrête net sur un fichier bien formé n'est que marginalement mieux qu'un décodeur qui rend de la bouillie. La même lignée de versions venait tout juste d'apprendre au décodeur les tables Huffman personnalisées JBIG2 et les codes de préfixe canoniques, donc l'accès direct était la dernière brèche d'organisation restante dans les limites de capacité documentées du décodeur

Qu'est-ce que l'organisation JBIG2 à accès direct ?

L'organisation à accès direct est l'une des trois dispositions que l'annexe D du T.88 autorise pour les mêmes segments : séquentielle (D.1), où chaque en-tête est entrelacé avec ses données, à accès direct (D.2), qui place tous les en-têtes au début et toutes les données ensuite, et embarquée (D.3), la forme sans en-tête utilisée dans d'autres conteneurs comme le PDF. Un fichier .jb2 autonome commence par l'identifiant de huit octets 97 4A 42 32 0D 0A 1A 0A, suivi d'un octet de drapeaux et, quand le nombre de pages est connu, d'un compte de pages sur quatre octets. Le bit 0 de l'octet de drapeaux choisit l'organisation, 1 signifiant séquentielle et 0 accès direct ; le bit 1 positionné signifie que le nombre de pages est inconnu et que le compte de quatre octets est absent. PDFlibPas lit tout cela dans checkHeader et setFileHeaderFlags, et les bits réservés 2 à 7 sont tolérés plutôt que rejetés

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é
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = nombre de pages omis
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // Flux PDF : pas d'en-tête de fichier, organisation embarquée, une page
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

Le PDF lui-même ne transporte jamais cette disposition. Un flux d'image JBIG2Decode, décrit dans ISO 32000-1 §7.4.7, ne contient que les segments de page en organisation embarquée, les dictionnaires de symboles partagés étant déplacés dans un flux JBIG2Globals séparé, sans en-tête de fichier ni segments end-of-page ou end-of-file. Quand decodeJBIG2 ne trouve pas l'identifiant de huit octets, il suppose exactement cela et force un décodage séquentiel à page unique. L'export d'image JBIG2 natif fait l'inverse et enveloppe les segments du PDF dans un fichier autonome dont l'octet de drapeaux vaut $03, séquentiel avec un nombre de pages inconnu, suivi d'un en-tête de fin de fichier ajouté à la fin. Le travail sur l'accès direct ne touche donc qu'un seul chemin : les fichiers autonomes remis directement à TPLJBIG2Decoder, typiquement avant leur conversion ou recompression pour le PDF, le travail que gèrent en sortie les backends d'encodeur JBIG2 de PDFlibPas

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

Un fichier à accès direct ne peut pas se lire dans l'ordre du fichier parce que rien, dans le flux d'octets, ne marque où le bloc d'en-têtes s'arrête et où commence le bloc de données, hormis l'en-tête de segment end-of-file lui-même. Les en-têtes de segment JBIG2 sont de longueur variable : le compte de segments référencés peut être une forme courte de trois bits ou une forme longue avec bitmap de rétention, les numéros de segments référencés prennent un, deux ou quatre octets selon le numéro propre du segment, et le champ d'association de page fait un ou quatre octets. Un lecteur séquentiel naïf analyse le premier en-tête, lit sa longueur de données, puis traite les premiers octets du second en-tête comme les données de ce segment. Le décodeur ne peut pas savoir qu'il s'est trompé avant bien plus tard, et c'est pourquoi l'ancien code refusait l'organisation carrément au lieu de tenter sa chance

Comment PDFlibPas indexe-t-il les en-têtes de segments à accès direct ?

PDFlibPas indexe les en-têtes à accès direct en un seul pré-balayage, IndexRandomHeaders, qui analyse chaque en-tête, n'enregistre que son offset en octets et s'arrête au premier en-tête end-of-file (type de segment 51). Chaque en-tête est analysé entièrement puis jeté, si bien que l'index est un tableau d'entiers plutôt qu'une liste d'objets, et le pré-balayage accumule au passage les longueurs de données déclarées. Quand le balayage se termine, le lecteur est posé sur le premier octet des données du premier segment, et cette position devient NextBodyOffset

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
// IndexRandomHeaders, local à TJBIG2StreamDecoder.readSegments
while not reader.isFinished do
begin
  Offset := reader.bytePointer;
  Header := TSegmentHeader.Create;
  try
    readSegmentHeader(Header);
    if reader.BufferOverrun then
      raise EJBIG2DecodeError.CreateFmt(
        'JBIG2 truncated random-access header at byte %d', [Offset]);
    if (HeaderCount > 0) and
       (Cardinal(Header.getSegmentNumber) <= Cardinal(PreviousNumber)) then
      raise EJBIG2DecodeError.Create('JBIG2 random-access segment numbers must increase');
    PreviousNumber := Header.getSegmentNumber;
    Count := Header.getSegmentDataLength;
    if Count < 0 then
      raise EJBIG2DecodeError.Create('JBIG2 unknown or oversized segment length is not supported');
    Inc(TotalLength, Count);                   // accumulateur Int64
    HeaderOffsets[HeaderCount] := Offset;      // agrandi par blocs
    Inc(HeaderCount);
    if Header.getSegmentType = JBIG2_END_OF_FILE then
    begin
      if Count <> 0 then
        raise EJBIG2DecodeError.Create('JBIG2 invalid end segment length');
      FoundEnd := True;
      Break;
    end;
  finally
    Header.Free;
  end;
end;
if not FoundEnd then
  raise EJBIG2DecodeError.Create('JBIG2 random-access file is missing its end-of-file header');
if TotalLength > Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 truncated random-access segment data');
if TotalLength < Length(reader.Data) - reader.bytePointer then
  raise EJBIG2DecodeError.Create('JBIG2 trailing random-access data');
NextBodyOffset := reader.bytePointer;

Chaque contrôle de cette boucle existe parce qu'un fichier à accès direct a moins de redondance qu'un fichier séquentiel. Les numéros de segment doivent strictement croître, comparés comme valeurs non signées, parce que deux en-têtes revendiquant le même numéro rendent ambigu le corps auquel la liste de références d'une région ultérieure se rapporte. Le champ de longueur de données est lu par handleSegmentDataLength, qui fait correspondre à -1 toute valeur avec le bit de poids fort positionné, marqueur « unknown length » 0xFFFFFFFF compris ; dans une disposition à accès direct, il n'y a aucun autre moyen de trouver où commence le corps suivant, donc PDFlibPas rejette cette longueur immédiatement au lieu de chercher un marqueur de fin. Le total doit coller aux octets restants exactement dans les deux sens, et un seul octet en trop après le dernier corps échoue avec « trailing random-access data ». Cette rigueur est délibérée : dans cette disposition, une longueur qui ne colle pas signifie que tout corps après le point d'erreur est décalé, et un décodeur qui hausse les épaules devant un octet égaré n'a aucun moyen de savoir si c'est du remplissage inoffensif ou le premier symptôme de données désalignées

Pourquoi le dernier segment end-of-page a-t-il disparu ?

Le dernier segment end-of-page disparaissait parce que la première version de la boucle de décodage gardait le test d'arrêt séquentiel, while not reader.isFinished, et qu'en disposition à accès direct le flux de données s'épuise avant l'index des en-têtes. Les segments end-of-page (type 49) et end-of-file portent zéro octet de données, et ce sont normalement les derniers en-têtes du fichier. Une fois le corps de la dernière région consommé, le lecteur est posé exactement à la fin du tampon, donc la boucle sort et ces segments de longueur nulle ne sont jamais distribués, laissant la page inachevée. Le correctif fait compter des en-têtes plutôt que des octets à la boucle à accès direct. Chaque itération saute vers l'en-tête indexé suivant, remet bitPointer à 7 parce que le corps précédent a pu se terminer en plein milieu d'un octet, ré-analyse cet en-tête, puis déplace bytePointer vers NextBodyOffset et l'avance au-delà du corps. Les gestionnaires de segments existants, les contrôles de segments référencés et les diagnostics Context tournent à l'identique, et un message d'erreur rapporte toujours l'offset d'origine de l'en-tête, pas la position du corps

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
// TJBIG2StreamDecoder.readSegments, boucle principale
if randomAccessOrganisation then
  IndexRandomHeaders;
while (randomAccessOrganisation and (HeaderIndex < HeaderCount)) or
      ((not randomAccessOrganisation) and (not reader.isFinished)) do
begin
  if randomAccessOrganisation then
  begin
    reader.bytePointer := HeaderOffsets[HeaderIndex];
    reader.bitPointer := 7;                    // réaligner après un octet partiel
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // utilisé dans le contexte d'erreur
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // saut vers les données de ce segment
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... distribution au gestionnaire de segment existant, puis positionnement à DataEnd
end;

Que prouve réellement la validation de l'accès direct ?

La validation prouve que les octets réorganisés décodent vers les mêmes pixels que leurs originaux séquentiels, et elle prouve qu'une entrée mal formée à accès direct échoue proprement ; elle ne prouve pas la couverture des fichiers à accès direct venant d'encodeurs arbitraires. La régression Pascal partagée utilise un fichier synthétique de 235 octets bâti sur une fixture à table personnalisée qui doit décoder vers une rangée de 7 par 1 pixels noirs, avec un nombre de pages connu puis avec le champ de compte retiré, puis donne au décodeur chaque préfixe tronqué de ce fichier, un numéro de segment en doublon, un octet final en trop et une longueur de données inconnue, en affirmant chaque fois que LoadFromByteArray renvoie False et laisse Width et Height à zéro. Le cas image réelle est une image de raffinement à table personnalisée de 500 par 473 dont les segments ont été réorganisés en disposition à accès direct avec chaque en-tête d'origine et chaque octet compressé préservés ; son SHA-256 colle exactement à la baseline séquentielle relue. Ce fichier est un dérivé produit par une transformation d'organisation, pas un document à accès direct naturel trouvé dans la nature, et aucun tel échantillon naturel n'était disponible. Les suites sont passées à 1 598 tests pour Delphi Win32, 42 pour la suite image Delphi Win64, 48 pour FPC Win32 et 46 pour FPC Win64, aux côtés des trois cas pixels séquentiels existants

Charger un fichier .jb2 à accès direct et ses limites

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

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;

Les frontières méritent d'être énoncées clairement. Le support de l'accès direct est une fonctionnalité d'organisation de fichier, pas une API de pages au hasard : TPLJBIG2Decoder rend toujours le bitmap de la première page, et il n'y a aucun appel pour choisir la page 7 d'un fichier de 40 pages ou décoder les pages à la demande. Les segments à longueur de données inconnue sont rejetés dans les fichiers à accès direct, et les limites existantes sur les longueurs de préfixe Huffman personnalisé et les comptes d'entrées de table sont inchangées. Ces limites sont assez étroites pour qu'une application Delphi puisse rediriger les cas rejetés ailleurs via LastError, et le reste du pipeline d'images, de l'extraction d'images PDF à l'encodage JBIG2, est couvert sur la page produit de la bibliothèque PDF PDFlibPas pour Delphi