Articolo tecnico

File JBIG2 random-access: decodificarli in Delphi

Ecco un errore che un reader JBIG2 scritto a mano commette sul primo file insolito che incontra. Parsa l'header del file, parsa il primo header di segmento, legge la lunghezza dati a quattro byte e prende altrettanti byte come corpo del segmento. Sulla maggior parte dei file .jb2 è esattamente giusto. Su un file il cui byte di flag ha il bit 0 a zero, quei byte di "corpo" sono in realtà il secondo, il terzo e il quarto header di segmento, e il decoder aritmetico li trasforma in una bitmap di rumore o corre oltre la fine del buffer. Il file non è rotto. Usa l'organizzazione random-access dell'Annex D.2 di ITU-T T.88, dove ogni header di segmento viene prima e tutti i dati dei segmenti seguono dopo, nello stesso ordine

I file random-access sono rari. Escono da sistemi di archiviazione e document imaging che vogliono far vedere a un reader ogni pagina e ogni dipendenza di dizionario prima che tocchi un byte compresso. Se un job di conversione mescola file da una sorgente del genere con scansioni ordinarie, un file random-access può stare in mezzo a un batch altrimenti tutto sequenziale. Il decoder che li legge è lo stesso decoder Pascal nativo che gestisce le tabelle Huffman custom e i codici prefisso canonici di JBIG2, quindi il lato di codifica non cambia; cambia solo l'ordine in cui i segmenti vengono trovati

Tre layout di file dietro un unico byte di flag

PDF Library for Delphi legge i file JBIG2 standalone tramite TPLJBIG2Decoder nella unit PDFlibJBIG2, e il decoder ricava da solo il layout dall'header del file. L'Annex D di T.88 permette di disporre gli stessi segmenti in tre modi: sequential (D.1) mette ogni header subito davanti ai 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. Un file standalone inizia con l'identificatore di otto byte 97 4A 42 32 0D 0A 1A 0A, poi un byte di flag, poi un conteggio pagine a quattro byte a meno che i flag non dicano che il conteggio è sconosciuto

Il bit 0 di quel byte di flag sceglie l'organizzazione: 1 significa sequential, 0 significa random-access. Il bit 1 impostato significa che il numero di pagine è sconosciuto e il conteggio a quattro byte è assente, quindi il primo header di segmento inizia al byte 9 invece che al byte 13. I bit da 2 a 7 sono riservati; il decoder accetta i file che li impostano invece di rifiutarli, perché un bit riservato vagante non dice nulla su quanto i segmenti stessi siano leggibili

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

Il PDF stesso non porta mai il layout random-access. Uno stream immagine JBIG2Decode, descritto in ISO 32000-1 §7.4.7, contiene solo segmenti di pagina in forma embedded, con i dizionari di simboli condivisi spostati in uno stream JBIG2Globals separato e senza header di file, segmenti di fine pagina o di fine file. Quando il decoder non trova l'identificatore di otto byte, tratta l'input esattamente come quel tipo di stream: sequenziale, una pagina. Nel verso opposto, quando PDF Library for Delphi esporta un'immagine JBIG2Decode come file .jb2 nativo, scrive il byte di flag $03 (sequential, conteggio pagine sconosciuto) e accoda un header di segmento di fine file. Quindi il random access arriva solo dall'esterno, come file standalone passato direttamente a TPLJBIG2Decoder, di solito mentre viene convertito o ricompresso per il PDF con i backend di encoding JBIG2 in PDFlibPas

Perché non si può leggere un file random-access nell'ordine del file?

Perché niente nel flusso di byte segna dove finisce il blocco degli header e dove inizia il blocco dei dati, tranne l'header del segmento di fine file (tipo 51) stesso. Gli header di segmento JBIG2 non hanno dimensione fissa. Il conteggio dei segmenti referenziati è o una forma corta a tre bit o una forma lunga seguita da una bitmap di retention; ogni numero di segmento referenziato occupa uno, due o quattro byte a seconda di quanto è grande il numero del segmento corrente; il campo di associazione alla pagina è di un byte o di quattro. Un reader deve parsare ogni campo di un header solo per sapere dove inizia il successivo

Un reader sequenziale che percorre il file in ordine azzecca il primo header e sbaglia tutto quello che viene dopo. Peggio, potrebbe non accorgersene per un po': un decoder di regione generica a cui vengono dati in pasto byte di header produce pixel, non un errore, finché qualche controllo successivo di lunghezza o di riferimento finalmente scatta. La nostra opinione è che un decoder che non indicizza prima gli header dovrebbe rifiutare di netto i file random-access. Un rifiuto chiaro costa un file in un batch; una bitmap dall'aria plausibile ma sbagliata può finire in un archivio e restarci

Indicizzare ogni header prima del primo corpo

Quando il byte di flag dice random-access, PDF Library for Delphi fa un passaggio sul blocco degli header prima di decodificare qualsiasi cosa. Ogni header viene parsato per intero e poi buttato via; l'unica cosa conservata è il suo offset in byte nel file. I dati compressi non vengono mai copiati in un secondo buffer riordinato, cosa che conta sulle grandi scansioni di archivio dove il blocco dati può essere la maggior parte del file. Il passaggio si ferma al primo header di fine file, e a quel punto la posizione di lettura sta sul primo byte dei dati del primo segmento

Un file random-access ha meno ridondanza di uno sequenziale, quindi la pre-scansione è rigorosa. Rifiuta il file quando:

  • un header viene troncato dalla fine del buffer, segnalato come JBIG2 truncated random-access header at byte N con l'offset in cui quell'header iniziava
  • i numeri di segmento non crescono strettamente (confrontati come valori unsigned), perché due header che rivendicano lo stesso numero rendono ambigua la lista dei riferimenti di una regione successiva
  • una lunghezza dati ha il bit più alto impostato, il che include il marcatore 0xFFFFFFFF di "lunghezza sconosciuta"; in questo layout non c'è altro modo di trovare dove inizia il corpo successivo, quindi il decoder lo rifiuta subito invece di cercare in avanti un marcatore di fine
  • l'header di fine file dichiara dei dati, o non compare affatto (JBIG2 random-access file is missing its end-of-file header)
  • le lunghezze dichiarate sommano a più dei byte rimanenti, o a meno
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 strettamente, 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

L'ultima regola è quella su cui la gente inciampa. Un singolo byte in più dopo l'ultimo corpo fallisce con JBIG2 trailing random-access data. Sembra severo rispetto al percorso sequenziale, dove la decodifica si ferma semplicemente al segmento di fine file e tutto ciò che segue viene ignorato. L'asimmetria ha una ragione. Nel layout sequenziale ogni header sta subito accanto ai propri dati, quindi la spazzatura in coda non può aver spostato nulla. Nel layout random-access gli header sono l'unica mappa del blocco dati, e se le lunghezze non tornano, il decoder non può distinguere un padding innocuo da un byte perso o aggiunto molto prima che ha spostato ogni corpo successivo

Perché un ciclo che conta i byte perde l'ultimo segmento di fine pagina?

Un ciclo che gira "finché restano byte" termina troppo presto su un file random-access, perché i dati finiscono prima dell'indice degli header. I segmenti di fine pagina (tipo 49) e di fine file portano zero byte di dati e normalmente vengono per ultimi. Una volta consumato l'ultimo corpo di regione, la posizione di lettura è esattamente alla fine del buffer; un ciclo che conta i byte esce lì, e il segmento di fine pagina a lunghezza zero non viene mai dispacciato, il che lascia la pagina incompleta

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

Quindi per i file random-access il decoder conta gli header, non i byte. Ogni passo salta indietro al prossimo header indicizzato, si riallinea a un confine di byte (un corpo codificato aritmeticamente può finire a metà di un byte), parsa di nuovo l'header, poi salta in avanti a dove inizia il corpo successivo e lo oltrepassa della lunghezza dichiarata. Un corpo il cui decoder finisce in anticipo viene saltato fino alla sua fine dichiarata; uno che legge oltre viene rifiutato. Da lì il segmento passa dagli stessi handler e controlli di riferimento di un file sequenziale. Gli errori sollevati durante la decodifica di un corpo portano la posizione originale dell'header, nella forma JBIG2 segment N (type T) at byte B: ..., così l'offset in LastError punta a qualcosa che puoi trovare in un hex editor

Caricare un file .jb2 random-access in Delphi

Il codice applicativo non cambia con l'organizzazione. TPLJBIG2Decoder.LoadFromByteArray rileva header del file e layout, restituisce False su qualsiasi input rifiutato con il motivo in LastError, ed espone la pagina decodificata tramite Width, Height e GetScanline, che riempie un byte per pixel con 1 per il nero

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;

Se vuoi sapere quale layout usa un file, per il logging o per smistare un corpus prima di una conversione, leggi tu stesso il byte di flag. La libreria non espone l'organizzazione che ha rilevato, e nove byte sono tutto ciò che serve. L'helper qui sotto è qualcosa da aggiungere al tuo codice; si limita a riformulare le regole dell'header di T.88:

type
  TJb2Layout = (jlNoFileHeader, jlSequential, jlRandomAccess);

// Helper tuo, non fa parte di PDFlibJBIG2: classifica un buffer in base al suo header di file 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;          // es. uno stream embedded estratto da 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 impostato: nessun conteggio pagine a quattro byte
  if (Data[8] and 1) = 0 then
    Result := jlRandomAccess
  else
    Result := jlSequential;
end;

Job batch: buffer con padding e file rifiutati

La regola sui dati in coda è il punto in cui il codice batch tende a sbagliare, e quando un file che si apre altrove viene rifiutato in questo modo, guarda il buffer prima del file. Un .jb2 tirato fuori da una colonna BLOB di database, una lettura di rete a dimensione fissa o un formato container con padding di allineamento può arrivare con qualche byte a zero dopo la fine reale. Un file sequenziale se ne frega. Un file random-access con lo stesso padding fallisce con JBIG2 trailing random-access data, e così un buffer tagliato di qualche byte, che fallisce come troncato. Passa esattamente i byte del file, dimensionati dalla lunghezza reale della sorgente, mai da una dimensione di chunk o da una capacità

Un file rifiutato inoltre non lascia nulla dietro di sé. LoadFromByteArray azzera Width, Height e le scanline prima di iniziare a decodificare, quindi una chiamata fallita riporta dimensioni zero e non la pagina del file precedente, e la stessa istanza del decoder può passare direttamente al file successivo:

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
        // Qui Width e Height valgono 0; nulla del file precedente sopravvive
        Log.Add(Files[I] + ': ' + Decoder.LastError);
        if Pos('trailing random-access data', Decoder.LastError) > 0 then
          Quarantine.Add(Files[I]);   // controlla come sono stati memorizzati i byte prima di incolpare il file
      end;
  finally
    Decoder.Free;
  end;
end;

Verificare tutto questo nella tua pipeline non richiede file campione rari. Riorganizza un .jb2 sequenziale nel layout random-access, conservando ogni header e ogni byte compresso, sposta gli header di fine pagina e di fine file alla fine del blocco degli header, e i pixel decodificati devono coincidere esattamente con l'originale sequenziale, sia con sia senza il campo del conteggio pagine. Poi dagli in pasto ogni prefisso troncato, un numero di segmento duplicato, un byte extra in coda e una lunghezza 0xFFFFFFFF; ognuno dovrebbe restituire False con Width e Height a zero

Cosa restituisce il decoder, e cosa si aspetta

Il random access qui è una proprietà del layout del file. TPLJBIG2Decoder restituisce la bitmap della prima pagina qualunque sia l'organizzazione usata dal file, e Width e Height descrivono quella pagina. Ogni segmento deve dichiarare la sua lunghezza dati; T.88 permette a una regione generica immediata di usare il marcatore 0xFFFFFFFF, e il decoder rifiuta quel marcatore in entrambi i layout con unknown or oversized segment length is not supported. Le lunghezze dei prefissi Huffman custom e i conteggi delle voci di tabella sono limitati allo stesso modo dei file sequenziali

Decodificare JBIG2 è una tappa di un percorso immagine più lungo che copre anche estrazione di immagini da PDF, ricompressione ed encoding JBIG2; la pagina prodotto di PDF Library for Delphi copre il resto di quella pipeline e i formati immagine supportati