Articolo tecnico

File JBIG2 random-access: decodificarli in Delphi

PDFlibPas versione 3.539.23 decodifica i file JBIG2 standalone che usano l'organizzazione random-access di ITU-T T.88 Allegato D.2, dove ogni header di segmento viene prima e i dati dei segmenti seguono nello stesso ordine. Il decoder Pascal nativo in PDFlibJBIG2.pas indicizza gli offset degli header fino all'obbligatorio header end-of-file, verifica che i numeri di segmento crescano e che le lunghezze dichiarate dei dati sommino esattamente ai byte che restano, e poi decodifica ogni corpo in ordine di header senza copiare o riordinare i dati compressi. Prima di questa release lo stesso file alzava un secco errore «random-access organisation is not supported» appena letti gli header flag

I file JBIG2 random-access sono rari, ed è esattamente per questo che fanno male quando si presentano. Escono da pipeline di archiviazione e sistemi di document imaging che vogliono far vedere al reader ogni header di segmento, e quindi ogni pagina e dipendenza di dizionario, prima di toccare un solo byte compresso. Un'applicazione Delphi che converte in batch archivi scansionati in PDF di solito ne incontra uno in mezzo a un lavoro, dopo che centinaia di file sequenziali sono passati lisci, e un decoder che si blocca su un file ben formato è solo marginalmente meglio di uno che renderizza spazzatura. La stessa linea di release aveva appena finito di insegnare al decoder le tabelle Huffman custom e i codici prefisso canonici di JBIG2, quindi il random access era l'ultimo buco di organizzazione rimasto nei limiti di capacità documentati del decoder

Che cos'è l'organizzazione random-access di JBIG2?

L'organizzazione random-access è uno dei tre modi in cui l'Allegato D di T.88 consente di disporre gli stessi segmenti: sequential (D.1) intreccia ogni header con i suoi dati, random-access (D.2) mette tutti gli header prima e tutti i dati dopo, ed embedded (D.3) è la forma senza header usata dentro altri container come il PDF. Un file .jb2 standalone inizia con l'identificatore di otto byte 97 4A 42 32 0D 0A 1A 0A, seguito da un byte di flag e, quando il numero di pagine è noto, da un conteggio pagine di quattro byte. Il bit 0 del byte di flag sceglie l'organizzazione, con 1 che significa sequential e 0 che significa random-access; il bit 1 a 1 significa che il numero di pagine è sconosciuto e il conteggio a quattro byte è assente. PDFlibPas li legge in checkHeader e setFileHeaderFlags, e i bit riservati da 2 a 7 vengono tollerati anziché rifiutati

Organizzazioni dei file JBIG2 in PDFlibPas: il byte di flag che setFileHeaderFlags legge sceglie sequential D.1 con header intrecciati, random-access D.2 con ogni header prima del blocco dati, o embedded D.3, la forma senza header che uno stream JBIG2Decode usa con i dizionari in JBIG2Globals
I tre layout portano gli stessi segmenti, ma solo il random access fa vedere a un reader ogni pagina e dipendenza di dizionario prima di toccare un byte compresso, ed è per questo che le pipeline di archiviazione lo chiedevano
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = numero di pagine omesso
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // stream PDF: nessun header di file, organizzazione embedded, una pagina
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

Il PDF stesso non porta mai questo layout. Uno stream immagine JBIG2Decode, descritto in ISO 32000-1 §7.4.7, contiene solo i segmenti di pagina in organizzazione embedded, con i dizionari di simboli condivisi spostati in uno stream JBIG2Globals separato e niente header di file, segmenti end-of-page o end-of-file. Quando decodeJBIG2 non trova l'identificatore di otto byte presume esattamente questo e forza una decodifica sequenziale a pagina singola. L'export di immagini JBIG2 nativo va nella direzione opposta e avvolge i segmenti PDF in un file standalone il cui byte di flag è $03, sequential con numero di pagine sconosciuto, seguito da un header end-of-file accodato. Quindi il lavoro sul random access tocca un solo percorso: i file standalone consegnati direttamente a TPLJBIG2Decoder, tipicamente prima che vengano convertiti o ricompressi per il PDF, il lavoro di cui si occupano sul lato output i backend encoder JBIG2 in PDFlibPas

Perché un file random-access non si può leggere in ordine di file?

Un file random-access non si può leggere in ordine di file perché nulla nel flusso di byte segna dove il blocco degli header finisce e inizia quello dei dati, tranne l'header del segmento end-of-file stesso. Gli header dei segmenti JBIG2 hanno lunghezza variabile: il conteggio dei segmenti referenziati può essere una forma corta a tre bit o una forma lunga con una retention bitmap, i numeri dei segmenti referenziati occupano uno, due o quattro byte a seconda del numero del segmento stesso, e il campo di associazione pagina è di uno o quattro byte. Un lettore sequenziale ingenuo parsa il primo header, legge la lunghezza dei suoi dati, e poi prende i primi byte del secondo header come dati di quel segmento. Il decoder non può accorgersi di essere sbagliato se non molto dopo, ed è per questo che il vecchio codice rifiutava l'organizzazione in blocco invece di tentarla

Come indicizza PDFlibPas gli header dei segmenti random-access?

PDFlibPas indicizza gli header random-access in una sola pre-scansione, IndexRandomHeaders, che parsifica ogni header, registra soltanto il suo offset in byte, e si ferma al primo header end-of-file (tipo di segmento 51). Ogni header viene parsato per intero e scartato, così l'indice è un array di interi anziché una lista di oggetti, e la pre-scansione accumula le lunghezze dichiarate dei dati mentre procede. Quando la scansione finisce, il reader sta sul primo byte dei dati del primo segmento, e quella posizione diventa NextBodyOffset

Pre-scansione IndexRandomHeaders in PDFlibPas: ogni header di segmento viene parsato e scartato conservando solo il suo offset in byte, i numeri di segmento devono crescere strictamente, la lunghezza sconosciuta 0xFFFFFFFF viene rifiutata, la scansione si ferma all'header end-of-file di tipo 51, e le lunghezze dichiarate devono eguagliare esattamente i byte residui
La severità è voluta: in un layout dove gli header sono l'unica mappa dei dati, un byte fuori posto significa che ogni corpo successivo può essere spostato, quindi un decoder che lo tollera non sa distinguere il padding dal disallineamento
// IndexRandomHeaders, locale a 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);                   // accumulatore Int64
    HeaderOffsets[HeaderCount] := Offset;      // cresce a blocchi
    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;

Ogni controllo in quel loop esiste perché un file random-access ha meno ridondanza di uno sequenziale. I numeri di segmento devono crescere in modo strict, confrontati come valori unsigned, perché due header che dichiarano lo stesso numero rendono ambiguo quale corpo significhi la lista dei referenziati di una regione successiva. Il campo di lunghezza dati viene letto da handleSegmentDataLength, che mappa qualunque valore col bit alto a 1, incluso il marker di «lunghezza sconosciuta» 0xFFFFFFFF, a -1; nel layout random-access non c'è altro modo per trovare dove inizia il corpo successivo, quindi PDFlibPas rifiuta quella lunghezza subito invece di cercare un marker di fine. Il totale deve combaciare con i byte residui esattamente in entrambe le direzioni, e un solo byte in più dopo l'ultimo corpo fallisce con «trailing random-access data». Quella severità è voluta: in questo layout uno squilibrio di lunghezza significa che ogni corpo dopo il punto dell'errore è spostato, e un decoder che scrolla le spalle davanti a un byte fuori posto non ha modo di sapere se è padding innocuo o il primo sintomo di dati disallineati

Perché l'ultimo segmento end-of-page spariva?

L'ultimo segmento end-of-page spariva perché la prima versione del loop di decodifica teneva il test di terminazione sequenziale, while not reader.isFinished, e nel layout random-access il flusso dei dati finisce prima dell'indice degli header. I segmenti end-of-page (tipo 49) ed end-of-file portano zero byte di dati, e di solito sono gli ultimi header del file. Dopo che l'ultimo corpo di regione è consumato il reader sta esattamente alla fine del buffer, quindi il loop esce e quei segmenti a lunghezza zero non vengono mai dispatchati, lasciando la pagina incompleta. La correzione fa contare gli header al loop random-access anziché i byte. Ogni iterazione sposta il reader al prossimo header indicizzato, riporta bitPointer a 7 perché il corpo precedente può essere finito a metà byte, re-parsa quell'header, poi sposta bytePointer a NextBodyOffset e lo avanza oltre il corpo. I gestori di segmenti esistenti, i controlli sui segmenti referenziati e la diagnostica del Context girano invariati, e un messaggio di errore riporta comunque l'offset in byte originale dell'header, non la posizione del corpo

Loop di decodifica random-access in PDFlibPas: ogni iterazione cerca HeaderOffsets dell'header corrente, riporta bitPointer a 7 per annullare le code a metà byte, salta a NextBodyOffset per il corpo, e conta gli header anziché i byte così i segmenti end-of-page a lunghezza zero vengono dispatchati prima che il loop finisca
Poiché i segmenti end-of-page ed end-of-file portano zero byte di dati, il flusso dei dati finisce prima dell'indice degli header, e solo un loop che conta gli header può dare a quei segmenti finali il loro turno
// TJBIG2StreamDecoder.readSegments, loop 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;                    // riallinea dopo un byte parziale
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // usato nel contesto di errore
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // salta ai dati di questo segmento
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... dispatch al gestore di segmenti esistente, poi seek a DataEnd
end;

Che cosa prova davvero la validazione random-access?

La validazione prova che i byte riorganizzati decodificano negli stessi pixel dei loro originali sequenziali, e prova che l'input random-access malformato fallisce pulito; non prova copertura dei file random-access provenienti da encoder arbitrari. La regression Pascal condivisa usa un file sintetico di 235 byte costruito su una fixture a tabella custom che deve decodificare in una riga di 7 per 1 pixel neri, sia con numero di pagine noto sia con il campo del conteggio rimosso, e poi dà al decoder ogni prefisso troncato di quel file, un numero di segmento duplicato, un byte di coda e una lunghezza dati sconosciuta, asserendo ogni volta che LoadFromByteArray restituisce False e lascia Width e Height a zero. Il caso con immagine vera è un'immagine di refinement a tabella custom di 500 per 473 i cui segmenti sono stati riorganizzati in layout random-access con ogni header originale e ogni byte compresso preservati; il suo SHA-256 combacia esattamente con il baseline sequenziale riesaminato. Quel file è un derivato prodotto da una trasformazione di organizzazione, non un documento random-access naturale trovato in giro, e non era disponibile alcun campione naturale del genere. Le suite sono passate a 1.598 test per Delphi Win32, 42 per la suite immagini Delphi Win64, 48 per FPC Win32 e 46 per FPC Win64, accanto ai tre casi pixel sequenziali esistenti

Caricare un file .jb2 random-access e i suoi limiti

Il codice applicativo non cambia: TPLJBIG2Decoder.LoadFromByteArray rileva da solo l'header del file e l'organizzazione, restituisce False su qualunque input rifiutato con la motivazione in LastError, ed espone la pagina decodificata attraverso Width, Height e GetScanline, che restituisce un byte per 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
    // I file standalone sequential e random-access usano la stessa chiamata
    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;

I confini valgono la pena dirli chiari. Il supporto random-access è una funzionalità di organizzazione del file, non una API di pagine casuali: TPLJBIG2Decoder restituisce ancora la bitmap della prima pagina, e non c'è nessuna chiamata per pescare la pagina 7 da un file da 40 pagine o per decodificare le pagine in modo lazy. I segmenti con lunghezza dati sconosciuta vengono rifiutati nei file random-access, e i limiti esistenti sulle lunghezze dei prefissi Huffman custom e sui conteggi delle voci di tabella restano invariati. Quei limiti sono abbastanza stretti che un'applicazione Delphi può deviare altrove i casi rifiutati guardando LastError, e il resto della pipeline di immagini, dall'estrazione di immagini PDF alla codifica JBIG2, è coperto sulla pagina di prodotto della PDF library Delphi PDFlibPas