Articol tehnic

Fișiere JBIG2 random-access: cum le decodăm în Delphi

PDFlibPas versiunea 3.539.23 decodează fișierele JBIG2 standalone care folosesc organizația random-access din ITU-T T.88 Anexa D.2, în care toate header-ele de segment vin primele, iar datele de segment urmează în aceeași ordine. Decoder-ul Pascal nativ din PDFlibJBIG2.pas indexează offset-urile de header până la header-ul obligatoriu end-of-file, verifică faptul că numerele de segment cresc și că lungimile declarate de date se adună exact în octeții rămași, apoi decodează fiecare corp în ordinea header-elor, fără să copieze sau să reordoneze datele comprimate. Înainte de această versiune, același fișier ridica o eroare plată de genul „organizația random-access nu este suportată” imediat ce flag-urile de header erau citite

Fișierele JBIG2 random-access sunt rare, ceea ce e exact motivul pentru care lovesc când apar. Ele ies din pipeline-uri de arhivare și sisteme de document-imaging care vor ca un cititor să vadă fiecare header de segment, deci fiecare pagină și dependență de dicționar, înainte să atingă un singur octet comprimat. O aplicație Delphi care convertește în lot arhive scanate în PDF întâlnește de regulă unul dintre astea în mijlocul unui job, după ce sute de fișiere secvențiale au trecut curat, iar un decoder care se oprește brusc pe un fișier bine format e doar marginal mai bun decât unul care randează gunoi. Aceeași linie de versiuni tocmai terminase să îi predea decoder-ului tabelele Huffman custom JBIG2 și codurile canonice de prefix, deci random access era ultimul gol de organizare rămas în limitele documentate de capabilitate ale decoder-ului

Ce este organizația JBIG2 random-access?

Organizația random-access este una dintre cele trei moduri în care Anexa D din T.88 permite aranjarea acelorași segmente: secvențial (D.1) împletește fiecare header cu datele lui, random-access (D.2) pune toate header-ele primele și toate datele după, iar embedded (D.3) este forma fără header folosită în interiorul altor containere precum PDF. Un fișier standalone .jb2 începe cu identificatorul de opt octeți 97 4A 42 32 0D 0A 1A 0A, urmat de un octet de flag-uri și, când numărul de pagini e cunoscut, de o numărare de pagini pe patru octeți. Bitul 0 al octetului de flag-uri alege organizația, cu 1 însemnând secvențial și 0 însemnând random-access; bitul 1 setat înseamnă că numărul de pagini e necunoscut și numărarea pe patru octeți lipsește. PDFlibPas le citește în checkHeader și setFileHeaderFlags, iar biții rezervați 2 până la 7 sunt tolerați, nu respinși

Organizațiile de fișiere JBIG2 în PDFlibPas: octetul de flag-uri citit de setFileHeaderFlags alege secvențial D.1 cu header-e împletite, random-access D.2 cu fiecare header înaintea blocului de date sau embedded D.3, forma fără header pe care un stream JBIG2Decode o folosește cu dicționarele în JBIG2Globals
Cele trei aranjamente cară aceleași segmente, dar doar random access face ca cititorul să vadă fiecare pagină și dependență de dicționar înainte să atingă un octet comprimat, motiv pentru care pipeline-urile de arhivare au cerut-o
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = număr de pagini omis
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // stream PDF: fără header de fișier, organizație embedded, o singură pagină
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

PDF-ul însuși nu cară niciodată acest aranjament. Un stream de imagine JBIG2Decode, descris în ISO 32000-1 §7.4.7, ține doar segmentele de pagină în organizație embedded, cu dicționarele de simboluri partajate mutate într-un stream JBIG2Globals separat și fără header de fișier, segmente end-of-page sau end-of-file. Când decodeJBIG2 nu găsește identificatorul de opt octeți, presupune exact asta și forțează decodare secvențială, cu o singură pagină. Exportul nativ de imagini JBIG2 merge în sens invers și înfășoară segmentele PDF într-un fișier standalone al cărui octet de flag-uri este $03, secvențial cu număr de pagini necunoscut, urmat de un header end-of-file adăugat la coadă. Deci lucrul pe random-access atinge o singură cale: fișierele standalone pasate direct lui TPLJBIG2Decoder, de regulă înainte să fie convertite sau recompresate pentru PDF, job pe care backend-urile encoder JBIG2 din PDFlibPas îl fac pe partea de output

De ce nu poate fi citit un fișier random-access în ordinea fișierului?

Un fișier random-access nu poate fi citit în ordinea fișierului pentru că nimic din byte stream nu marchează unde se oprește blocul de header și unde începe blocul de date, în afara header-ului de segment end-of-file însuși. Header-ele de segment JBIG2 au lungime variabilă: numărul de segmente referred-to poate fi o formă scurtă pe trei biți sau o formă lungă cu o bitmap de retenție, numerele de segment referred-to ocupă unu, doi sau patru octeți în funcție de numărul propriu al segmentului, iar câmpul de asociere de pagină este de unu sau patru octeți. Un cititor secvențial naiv parsează primul header, citește lungimea datelor lui și apoi tratează primii octeți ai celui de-al doilea header ca datele acelui segment. Decoder-ul nu poate spune că a greșit decât mult mai târziu, motiv pentru care vechiul cod refuza organizația din start în loc să încerce

Cum indexează PDFlibPas header-ele de segment random-access?

PDFlibPas indexează header-ele random-access într-un singur pre-scan, IndexRandomHeaders, care parsează fiecare header, înregistrează doar offset-ul lui în octeți și se oprește la primul header end-of-file (tipul de segment 51). Fiecare header e parseat complet și apoi aruncat, deci indexul e un tablou de întregi, nu o listă de obiecte, iar pre-scan-ul acumulează lungimile declarate de date pe măsură ce avansează. Când scanul se termină, cititorul stă pe primul octet al datelor primului segment, iar poziția aceea devine NextBodyOffset

Pre-scan-ul IndexRandomHeaders din PDFlibPas: fiecare header de segment e parseat și aruncat, păstrându-se doar offset-ul lui în octeți, numerele de segment trebuie să crească strict, lungimea necunoscută 0xFFFFFFFF e respinsă, scanul se oprește la header-ul end-of-file de tip 51, iar lungimile declarate trebuie să egaleze exact octeții rămași
Rigurozitatea e deliberată: într-un aranjament în care header-ele dau singura hartă a datelor, un singur octet rătăcit înseamnă că orice corp ulterior poate fi deplasat, deci un decoder care îl toleră nu poate deosebi padding-ul de o aliniere greșită
// IndexRandomHeaders, local în 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);                   // acumulator Int64
    HeaderOffsets[HeaderCount] := Offset;      // extins în tranșe
    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;

Fiecare verificare din bucla aceea există pentru că un fișier random-access are mai puțină redundanță decât unul secvențial. Numerele de segment trebuie să crească strict, comparate ca valori fără semn, pentru că două header-e care pretind același număr fac ambiguu cărui corp îi se referă lista referred-to a unei regiuni ulterioare. Câmpul de lungime de date e citit de handleSegmentDataLength, care maphează orice valoare cu bitul de sus setat, inclusiv markerul de „lungime necunoscută” 0xFFFFFFFF, la -1; în aranjamentul random-access nu există alt mod de a găsi unde începe următorul corp, deci PDFlibPas respinge imediat acea lungime în loc să caute un marker de sfârșit. Totalul trebuie să se potrivească exact cu octeții rămași în ambele direcții, iar un singur octet în plus după ultimul corp eșuează cu „trailing random-access data”. Rigurozitatea aceea e deliberată: în acest aranjament o nepotrivire de lungime înseamnă că fiecare corp de după punctul de eroare e deplasat, iar un decoder care dă din umeri la un octet rătăcit nu are cum să știe dacă e padding inofensiv sau primul simptom al unor date dezaliniate

De ce a dispărut ultimul segment end-of-page?

Ultimul segment end-of-page dispăruse pentru că prima versiune a buclei de decodare păstra testul de terminare secvențial, while not reader.isFinished, iar în aranjamentul random-access stream-ul de date se termină înaintea indexului de header. Segmentele end-of-page (tipul 49) și end-of-file cară zero octeți de date și sunt de regulă ultimele header-e din fișier. După ce corpul ultimei regiuni e consumat, cititorul stă exact la sfârșitul buffer-ului, deci bucla iese și acele segmente de lungime zero nu sunt niciodată dispecerizate, lăsând pagina neterminată. Reparația face ca bucla random-access să numere header-e în loc de octeți. Fiecare iterație sare cu cititorul la următorul header indexat, resetează bitPointer la 7 pentru că corpul anterior s-ar fi putut termina la mijlocul unui octet, re-parsează acel header, apoi mută bytePointer la NextBodyOffset și îl avansează peste corp. Handler-ele de segment existente, verificările de segmente referred-to și diagnosticul Context rulează neschimbate, iar un mesaj de eroare raportează în continuare offset-ul original al header-ului, nu poziția corpului

Bucla de decodare random-access în PDFlibPas: fiecare iterație sare la HeaderOffsets al header-ului curent, resetează bitPointer la 7 pentru a anula cozile la mijlocul octetului, sare la NextBodyOffset pentru corp și numără header-e în loc de octeți, astfel încât segmentele end-of-page de lungime zero să fie dispecerizate înainte de finalul buclei
Pentru că segmentele end-of-page și end-of-file cară zero octeți de date, stream-ul de date se termină înaintea indexului de header, și doar o buclă care numără header-e le poate da segmentelor finale rândul lor
// TJBIG2StreamDecoder.readSegments, bucla principală
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;                    // realiniere după un octet parțial
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // folosit în contextul de eroare
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // salt la datele acestui segment
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... dispecerizare către handler-ul existent de segment, apoi seek la DataEnd
end;

Ce dovedește de fapt validarea random-access?

Validarea dovedește că octeții reorganizați decodează în aceiași pixeli ca originalurile lor secvențiale și că input-ul random-access malformat eșuează curat; nu dovedește acoperire pentru fișiere random-access de la encoder-i arbitrari. Regresia Pascal partajată folosește un fișier sintetic de 235 de octeți construit pe un fixture cu tabelă custom care trebuie să decodifice într-un rând de 7 pe 1 de pixeli negri, atât cu număr de pagini cunoscut, cât și cu câmpul de numărare eliminat, apoi îi dă decoder-ului fiecare prefix trunchiat al fișierului, un număr de segment duplicat, un octet în coadă și o lungime de date necunoscută, afirmând de fiecare dată că LoadFromByteArray întoarce False și lasă Width și Height pe zero. Cazul cu imagine reală e o imagine de rafinare custom-table de 500 pe 473 ale cărei segmente au fost reorganizate în aranjament random-access cu fiecare header original și fiecare octet comprimat păstrate; SHA-256-ul ei se potrivește exact cu baseline-ul secvențial revisat. Acel fișier e un derivat produs de o transformare de organizare, nu un document natural random-access găsit în utilizare reală, și niciun asemenea eșantion natural nu a fost disponibil. Suitele au trecut cu 1.598 de teste pentru Delphi Win32, 42 pentru suita de imagini Delphi Win64, 48 pentru FPC Win32 și 46 pentru FPC Win64, alături de cele trei cazuri secvențiale de pixeli existente

Încărcarea unui fișier .jb2 random-access și limitele lui

Codul de aplicație nu se schimbă: TPLJBIG2Decoder.LoadFromByteArray detectează singur header-ul de fișier și organizația, întoarce False pe orice input respins, cu motivul în LastError, și expune pagina decodată prin Width, Height și GetScanline, care întoarce un octet 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
    // Fișierele standalone secvențiale și random-access primesc același apel
    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;

Granițele merită spuse pe față. Suportul random-access e o funcționalitate de organizare de fișier, nu un API de pagini random: TPLJBIG2Decoder întoarce în continuare bitmap-ul primei pagini și nu există niciun apel care să aleagă pagina 7 dintr-un fișier de 40 de pagini sau să decodifice pagini leneș. Segmentele cu lungime de date necunoscută sunt respinse în fișierele random-access, iar limitele existente pe lungimile de prefix Huffman custom și pe numărul de intrări de tabelă rămân neschimbate. Limitele astea sunt destul de strânse încât o aplicație Delphi să poată ruta cazurile respinse altundeva prin LastError, iar restul pipeline-ului de imagini, de la extragerea de imagini PDF la encoding JBIG2, e acoperit pe pagina de produs a bibliotecii PDFlibPas pentru Delphi