Teknisk artikel

JBIG2 random-access-filer: att avkoda dem i Delphi

PDFlibPas version 3.539.23 avkodar fristående JBIG2-filer som använder random-access-organisationen ur ITU-T T.88 Annex D.2, där alla segmentheaders kommer först och segmentdatan följer i samma ordning. Den inbyggda Pascal-avkodaren i PDFlibJBIG2.pas indexerar headeroffsets fram till obligatoriska end-of-file-headern, kontrollerar att segmentnummer ökar och att de deklarerade datalängderna summerar till exakt de byte som återstår, och avkodar sedan varje kropp i headerordning utan att kopiera eller ordna om den komprimerade datan. Före den här releasen kastade samma fil ett platt "random-access organisation is not supported"-fel i samma ögonblick som headerflaggorna lästes

Random-access-JBIG2-filer är ovanliga, vilket är precis varför de gör ont när de väl dyker upp. De kommer från arkiveringspipelines och dokumentavbildningssystem som vill att en läsare ska se varje segmentheader, och därmed varje sid- och dictionary-beroende, innan den rör en enda komprimerad byte. En Delphi-applikation som batchkonverterar skannade arkiv till PDF möter vanligen en av dessa mitt i ett jobb, efter att hundratals sekventiella filer gått igenom rent, och en avkodare som stannar helt på en välbildad fil är bara marginellt bättre än en som renderar skräp. Samma releaseserie hade nyss avslutat att lära avkodaren JBIG2 custom Huffman-tabeller och kanoniska prefixkoder, så random access var den sista organisationsluckan kvar i avkodarens dokumenterade kapacitetsgränser

Vad är JBIG2 random-access-organisationen?

Random-access-organisationen är ett av tre sätt T.88 Annex D tillåter samma segment att läggas ut på: sekventiell (D.1) flätar varje header med sin data, random-access (D.2) lägger alla headers först och all data efteråt, och embedded (D.3) är den headerlösa form som används inuti andra behållare som PDF. En fristående .jb2-fil börjar med den åtta byte långa identifieraren 97 4A 42 32 0D 0A 1A 0A, följt av en flags-byte och, när sidantalet är känt, ett fyra byte långt sidantal. Bit 0 i flags-byten väljer organisationen, där 1 betyder sekventiell och 0 betyder random-access; bit 1 satt betyder att antalet sidor är okänt och att det fyra byte långa antalet saknas. PDFlibPas läser dessa i checkHeader och setFileHeaderFlags, och de reserverade bitarna 2 till 7 tolereras i stället för att avvisas

JBIG2-filorganisationer i PDFlibPas: flags-byten som setFileHeaderFlags läser väljer sekventiell D.1 med flätade headers, random-access D.2 med varje header före datablocket, eller embedded D.3, den headerlösa form en JBIG2Decode-ström använder med dictionaries i JBIG2Globals
De tre layouterna bär samma segment, men bara random access låter en läsare se varje sid- och dictionary-beroende innan den rör en komprimerad byte, vilket är varför arkiveringspipelines bad om det
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access-organisation (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = sidantal utelämnat
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // PDF-ström: ingen filheader, embedded-organisation, en sida
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

PDF själv bär aldrig denna layout. En JBIG2Decode-bildström, beskriven i ISO 32000-1 §7.4.7, innehåller bara sidsegmenten i embedded-organisation, med delade symbol-dictionaries flyttade till en separat JBIG2Globals-ström och ingen filheader, end-of-page- eller end-of-file-segment. När decodeJBIG2 inte hittar den åtta byte långa identifieraren antar den exakt det och tvingar fram sekventiell, ensidig avkodning. Den inbyggda JBIG2-bildexporten går åt andra hållet och lindar in PDF-segmenten i en fristående fil vars flags-byte är $03, sekventiell med okänt sidantal, följt av en tillagd end-of-file-header. Random-access-arbetet rör alltså bara en väg: fristående filer som lämnas till TPLJBIG2Decoder direkt, typiskt innan de konverteras eller komprimeras om för PDF, jobbet som JBIG2-encoder-backenderna i PDFlibPas sköter på utdatasidan

Varför kan en random-access-fil inte läsas i filordning?

En random-access-fil kan inte läsas i filordning eftersom ingenting i byteströmmen markerar var headerblocket slutar och datablocket börjar, utom själva end-of-file-segmentheadern. JBIG2-segmentheaders har variabel längd: antalet referred-to-segment kan vara en trebitars kortform eller en lång form med en retention-bitmap, referred-to-segmentnummer tar en, två eller fyra byte beroende på segmentets eget nummer, och sidassociationsfältet är en eller fyra byte. En naiv sekventiell läsare tolkar första headern, läser dess datalängd och behandlar sedan de första bytena i den andra headern som det segmentets data. Avkodaren kan inte se att den gått fel förrän mycket senare, vilket är varför den gamla koden vägrade organisationen rakt av i stället för att försöka

Hur indexerar PDFlibPas random-access-segmentheaders?

PDFlibPas indexerar random-access-headers i en enda förskanning, IndexRandomHeaders, som tolkar varje header, registrerar bara dess byteoffset och stannar vid första end-of-file-headern (segmenttyp 51). Varje header tolkas fullständigt och kasseras, så indexet är en array av heltal i stället för en lista av objekt, och förskanningen ackumulerar de deklarerade datalängderna allt eftersom. När skanningen är klar står läsaren på första byten av första segmentets data, och den positionen blir NextBodyOffset

IndexRandomHeaders-förskanning i PDFlibPas: varje segmentheader tolkas och kasseras medan bara dess byteoffset sparas, segmentnummer måste strikt öka, den 0xFFFFFFFF okända längden avvisas, skanningen stannar vid typ 51 end-of-file-headern, och deklarerade längder måste exakt motsvara de återstående bytena
Striktheten är medveten: i en layout där headers är den enda kartan över datan betyder en ensam vilsekommen byte att varje senare kropp kan vara förskjuten, så en avkodare som tolererar det kan inte skilja padding från en förskjutning
// IndexRandomHeaders, lokal i 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);                   // Int64-ackumulator
    HeaderOffsets[HeaderCount] := Offset;      // växer i omgångar
    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;

Varje kontroll i den loopen finns för att en random-access-fil har mindre redundans än en sekventiell. Segmentnummer måste strikt öka, jämförda som teckenlösa värden, för att två headers som påstår samma nummer gör det tvetydigt vilken kropp en senare regions referred-to-lista menar. Datalängdfältet läses av handleSegmentDataLength, som avbildar varje värde med toppbiten satt, inklusive markören 0xFFFFFFFF för "unknown length", till -1; i random-access-layout finns det inget annat sätt att hitta var nästa kropp börjar, så PDFlibPas avvisar den längden omedelbart i stället för att söka efter en slutmarkör. Totalen måste matcha de återstående bytena exakt i båda riktningarna, och en enda extra byte efter sista kroppen misslyckas med "trailing random-access data". Striktheten är medveten: i den layouten betyder ett längdfel att varje kropp efter felspunkten är förskjuten, och en avkodare som rycker på axlarna åt en ensam vilsekommen byte har inget sätt att veta om den är harmlös padding eller det första symptomet på förskjuten data

Varför försvann sista end-of-page-segmentet?

Sista end-of-page-segmentet försvann för att den första versionen av avkodningsloopen behöll det sekventiella avslutstestet, while not reader.isFinished, och i random-access-layout tar dataströmmen slut innan headerindexet gör det. End-of-page-segment (typ 49) och end-of-file-segment bär noll byte data, och de är normalt de sista headers i filen. Efter att sista regionkroppen konsumerats står läsaren exakt vid slutet av bufferten, så loopen avslutas och de nollängd-segmenten skickas aldrig vidare, vilket lämnar sidan ofullständig. Fixen låter random-access-loopen räkna headers i stället för byte. Varje iteration hoppar läsaren till nästa indexerade header, nollställer bitPointer till 7 eftersom föregående kropp kan ha slutat mitt i en byte, tolkar den headern igen, flyttar sedan bytePointer till NextBodyOffset och för den förbi kroppen. De befintliga segmenthanterarna, referred-to-kontrollerna och Context-diagnostiken kör oförändrade, och ett felmeddelande rapporterar fortfarande headerns ursprungliga byteoffset, inte kroppspositionen

Random-access-avkodningsloop i PDFlibPas: varje iteration söker sig till HeaderOffsets för aktuell header, nollställer bitPointer till 7 för att ogöra mid-byte-svansar, hoppar till NextBodyOffset för kroppen, och räknar headers i stället för byte så att nollängds-end-of-page-segment skickas innan loopen slutar
Eftersom end-of-page- och end-of-file-segment bär noll byte data tar dataströmmen slut innan headerindexet gör det, och bara en loop som räknar headers kan ge de sista segmenten sin tur
// TJBIG2StreamDecoder.readSegments, huvudloop
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;                    // rikta om efter en partiell byte
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // används i felkontext
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // hoppa till detta segments data
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... skicka vidare till befintlig segmenthanterare, sök sedan till DataEnd
end;

Vad bevisar random-access-valideringen egentligen?

Valideringen bevisar att de omorganiserade bytena avkodar till samma pixlar som sina sekventiella original, och den bevisar att felformaterad random-access-indata misslyckas rent; den bevisar inte täckning av random-access-filer från godtyckliga encoders. Den gemensamma Pascal-regressionssviten använder en 235 byte stor syntetisk fil byggd på en custom-table-fixtur som måste avkoda till en rad av 7 gånger 1 svarta pixlar, både med känt sidantal och med antalsfältet borttaget, och matar sedan avkodaren varje avkortat prefix av filen, ett duplicerat segmentnummer, en avslutande extra byte och en okänd datalängd, och hävdar varje gång att LoadFromByteArray returnerar False och lämnar Width och Height på noll. Fallet med riktig bild är en refinement-bild på 500 gånger 473 med custom-table vars segment omorganiserades till random-access-layout med varje originalheader och komprimerad byte bevarad; dess SHA-256 matchar den granskade sekventiella baslinjen exakt. Den filen är en derivata skapad av en organisationstransform, inte ett naturligt random-access-dokument hittat i det vilda, och inget sådant naturligt exemplar var tillgängligt. Sviterna gick igenom vid 1 598 tester för Delphi Win32, 42 för Delphi Win64-bildsviten, 48 för FPC Win32 och 46 för FPC Win64, jämte de tre befintliga sekventiella pixelfallen

Läsa in en random-access-.jb2-fil och dess begränsningar

Applikationskoden ändras inte: TPLJBIG2Decoder.LoadFromByteArray upptäcker filheadern och organisationen på egen hand, returnerar False för varje avvisad indata med skälet i LastError, och exponerar den avkodade sidan genom Width, Height och GetScanline, som returnerar en 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
    // Sekventiella och random-access-fristående filer tar samma anrop
    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;

Gränserna är värda att säga rakt ut. Random-access-stödet är en filorganisationsfunktion, inte ett godtyckligt sid-API: TPLJBIG2Decoder returnerar fortfarande första sidans bitmap, och det finns inget anrop för att plocka sida 7 ur en 40-sidig fil eller för att avkoda sidor lättjädigt. Segment med okänd datalängd avvisas i random-access-filer, och de befintliga gränserna för custom Huffman-prefixlängder och tabellposter är oförändrade. De gränserna är snäva nog att en Delphi-applikation kan dirigera de avvisade fallen någon annanstans via LastError, och resten av bildpipelinen, från PDF-bildextraktion till JBIG2-kodning, täcks på produktsidan för PDFlibPas, PDF-biblioteket för Delphi