Technical Article

JBIG2 Random-Access Files: Decoding Them in Delphi

PDFlibPas version 3.539.23 decodes standalone JBIG2 files that use the random-access organization from ITU-T T.88 Annex D.2, where every segment header comes first and the segment data follows in the same order. The native Pascal decoder in PDFlibJBIG2.pas indexes the header offsets up to the mandatory end-of-file header, checks that segment numbers increase and that the declared data lengths add up to exactly the bytes that remain, and then decodes each body in header order without copying or reordering the compressed data. Before this release the same file raised a flat "random-access organisation is not supported" error the moment the header flags were read

Random-access JBIG2 files are rare, which is exactly why they hurt when they do show up. They come out of archival pipelines and document-imaging systems that want a reader to see every segment header, and therefore every page and dictionary dependency, before touching a single compressed byte. A Delphi application that batch-converts scanned archives into PDF usually meets one of these in the middle of a job, after hundreds of sequential files went through cleanly, and a decoder that stops dead on a well-formed file is only marginally better than one that renders garbage. The same release line had just finished teaching the decoder JBIG2 custom Huffman tables and canonical prefix codes, so random access was the last organization gap left in the decoder's documented capability limits

What is the JBIG2 random-access organization?

The random-access organization is one of three ways T.88 Annex D allows the same segments to be laid out: sequential (D.1) interleaves each header with its data, random-access (D.2) puts all headers first and all data afterwards, and embedded (D.3) is the headerless form used inside other containers such as PDF. A standalone .jb2 file starts with the eight-byte identifier 97 4A 42 32 0D 0A 1A 0A, followed by one flags byte and, when the page count is known, a four-byte page count. Bit 0 of the flags byte selects the organization, with 1 meaning sequential and 0 meaning random-access; bit 1 set means the number of pages is unknown and the four-byte count is absent. PDFlibPas reads these in checkHeader and setFileHeaderFlags, and the reserved bits 2 to 7 are tolerated rather than rejected

JBIG2 file organizations in PDFlibPas: the flags byte that setFileHeaderFlags reads picks sequential D.1 with interleaved headers, random-access D.2 with every header before the data block, or embedded D.3, the headerless form a JBIG2Decode stream uses with dictionaries in JBIG2Globals
The three layouts carry the same segments, but only random access makes a reader see every page and dictionary dependency before touching a compressed byte, which is why archival pipelines asked for it
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = page count omitted
noOfPagesKnown := pagesKnown = 0;

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

PDF itself never carries this layout. A JBIG2Decode image stream, described in ISO 32000-1 §7.4.7, holds only the page segments in embedded organization, with shared symbol dictionaries moved into a separate JBIG2Globals stream and no file header, end-of-page or end-of-file segments. When decodeJBIG2 fails to find the eight-byte identifier it assumes exactly that and forces sequential, single-page decoding. The native JBIG2 image export goes the other way and wraps the PDF segments in a standalone file whose flags byte is $03, sequential with an unknown page count, followed by an appended end-of-file header. So the random-access work touches only one path: standalone files handed to TPLJBIG2Decoder directly, typically before they are converted or recompressed for PDF, the job that the JBIG2 encoder backends in PDFlibPas handle on the output side

Why can a random-access file not be read in file order?

A random-access file cannot be read in file order because nothing in the byte stream marks where the header block stops and the data block begins, except the end-of-file segment header itself. JBIG2 segment headers have variable length: the referred-to segment count can be a three-bit short form or a long form with a retention bitmap, referred-to segment numbers take one, two or four bytes depending on the segment's own number, and the page association field is one or four bytes. A naive sequential reader parses the first header, reads its data length, and then treats the first bytes of the second header as that segment's data. The decoder cannot tell it has gone wrong until much later, which is why the old code refused the organization outright instead of attempting it

How does PDFlibPas index random-access segment headers?

PDFlibPas indexes random-access headers in one pre-scan, IndexRandomHeaders, that parses every header, records only its byte offset, and stops at the first end-of-file header (segment type 51). Each header is parsed fully and discarded, so the index is an array of integers rather than a list of objects, and the pre-scan accumulates the declared data lengths as it goes. When the scan finishes, the reader sits on the first byte of the first segment's data, and that position becomes NextBodyOffset

IndexRandomHeaders pre-scan in PDFlibPas: every segment header is parsed and discarded while only its byte offset is kept, segment numbers must strictly increase, the 0xFFFFFFFF unknown length is refused, the scan stops at the type 51 end-of-file header, and declared lengths must equal the remaining bytes exactly
The strictness is deliberate: in a layout where headers give the only map of the data, one stray byte means every later body may be shifted, so a decoder that tolerates it cannot tell padding from misalignment
// IndexRandomHeaders, local to 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 accumulator
    HeaderOffsets[HeaderCount] := Offset;      // grown in chunks
    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;

Every check in that loop exists because a random-access file has less redundancy than a sequential one. Segment numbers must strictly increase, compared as unsigned values, because two headers claiming the same number make it ambiguous which body a later region's referred-to list means. The data-length field is read by handleSegmentDataLength, which maps any value with the top bit set, including the 0xFFFFFFFF "unknown length" marker, to -1; in random-access layout there is no other way to find where the next body starts, so PDFlibPas rejects that length immediately instead of scanning for an end marker. The total must match the remaining bytes exactly in both directions, and a single extra byte after the last body fails with "trailing random-access data". That strictness is deliberate: in this layout a length mismatch means every body after the error point is shifted, and a decoder that shrugs off one stray byte has no way to know whether it is harmless padding or the first symptom of misaligned data

Why did the last end-of-page segment go missing?

The last end-of-page segment went missing because the first version of the decode loop kept the sequential termination test, while not reader.isFinished, and in random-access layout the data stream runs out before the header index does. End-of-page (type 49) and end-of-file segments carry zero bytes of data, and they are normally the last headers in the file. After the final region body is consumed the reader sits exactly at the end of the buffer, so the loop exits and those zero-length segments are never dispatched, leaving the page unfinished. The fix makes the random-access loop count headers instead of bytes. Each iteration jumps the reader to the next indexed header, resets bitPointer to 7 because the previous body may have ended mid-byte, re-parses that header, then moves bytePointer to NextBodyOffset and advances it past the body. The existing segment handlers, referred-to segment checks and the Context diagnostics run unchanged, and an error message still reports the header's original byte offset, not the body position

Random-access decode loop in PDFlibPas: each iteration seeks to HeaderOffsets of the current header, resets bitPointer to 7 to undo mid-byte tails, jumps to NextBodyOffset for the body, and counts headers instead of bytes so zero-length end-of-page segments are dispatched before the loop ends
Because end-of-page and end-of-file segments carry zero bytes of data, the data stream runs out before the header index does, and only a loop that counts headers can give those final segments their turn
// TJBIG2StreamDecoder.readSegments, main loop
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;                    // realign after a partial byte
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // used in error context
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // jump to this segment's data
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... dispatch to the existing segment handler, then seek to DataEnd
end;

What does the random-access validation actually prove?

The validation proves that the reorganized bytes decode to the same pixels as their sequential originals, and it proves that malformed random-access input fails cleanly; it does not prove coverage of random-access files from arbitrary encoders. The shared Pascal regression uses a 235-byte synthetic file built on a custom-table fixture that must decode to a 7 by 1 row of black pixels, both with a known page count and with the count field removed, and then feeds the decoder every truncated prefix of that file, a duplicate segment number, one trailing byte and an unknown data length, asserting each time that LoadFromByteArray returns False and leaves Width and Height at zero. The real-image case is a 500 by 473 custom-table refinement image whose segments were reorganized into random-access layout with every original header and compressed byte preserved; its SHA-256 matches the reviewed sequential baseline exactly. That file is a derivative produced by an organization transform, not a natural random-access document found in the wild, and no such natural sample was available. The suites passed at 1,598 tests for Delphi Win32, 42 for the Delphi Win64 image suite, 48 for FPC Win32 and 46 for FPC Win64, alongside the three existing sequential pixel cases

Loading a random-access .jb2 file and its limits

Application code does not change: TPLJBIG2Decoder.LoadFromByteArray detects the file header and organization on its own, returns False on any rejected input with the reason in LastError, and exposes the decoded page through Width, Height and GetScanline, which returns one 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
    // Sequential and random-access standalone files take the same call
    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;

The boundaries are worth stating plainly. Random-access support is a file organization feature, not a random page API: TPLJBIG2Decoder still returns the first page's bitmap, and there is no call to pick page 7 out of a 40-page file or to decode pages lazily. Segments with unknown data length are rejected in random-access files, and the existing limits on custom Huffman prefix lengths and table-entry counts are unchanged. Those limits are narrow enough that a Delphi application can route the rejected cases elsewhere by LastError, and the rest of the image pipeline, from PDF image extraction to JBIG2 encoding, is covered on the PDFlibPas Delphi PDF library product page