Technischer Artikel

JBIG2-Random-Access-Dateien in Delphi dekodieren

PDFlibPas ab Version 3.539.23 dekodiert eigenständige JBIG2-Dateien, die die Random-Access-Organisation aus ITU-T T.88 Anhang D.2 verwenden, bei der jeder Segment-Header zuerst kommt und die Segmentdaten in derselben Reihenfolge folgen. Der native Pascal-Decoder in PDFlibJBIG2.pas indexiert die Header-Offsets bis zum zwingenden End-of-File-Header, prüft, dass die Segmentnummern steigen und dass die deklarierten Datenlängen exakt auf die verbleibenden Bytes aufgehen, und dekodiert dann jeden Body in Header-Reihenfolge, ohne die komprimierten Daten zu kopieren oder umzuordnen. Vor dieser Release warf dieselbe Datei den pauschalen Fehler „random-access organisation is not supported“, sobald die Header-Flags gelesen waren

Random-Access-JBIG2-Dateien sind selten – genau deshalb tun sie weh, wenn sie doch auftauchen. Sie kommen aus Archiv-Pipelines und Document-Imaging-Systemen, die wollen, dass ein Reader jeden Segment-Header und damit jede Seiten- und Dictionary-Abhängigkeit sieht, bevor er ein einziges komprimiertes Byte anfasst. Eine Delphi-Anwendung, die gescannte Archive stückweise nach PDF konvertiert, trifft meist mitten im Job auf so eine Datei, nachdem Hunderte sequenzielle Dateien sauber durchgelaufen sind, und ein Decoder, der bei einer wohlgeformten Datei stehen bleibt, ist nur wenig besser als einer, der Müll rendert. Dieselbe Release-Linie hatte dem Decoder gerade erst JBIG2 custom Huffman tables und kanonische Prefix-Codes beigebracht, Random Access war also die letzte Organisationslücke in den dokumentierten Fähigkeitsgrenzen des Decoders

Was ist die JBIG2-Random-Access-Organisation?

Die Random-Access-Organisation ist eine von drei Arten, wie T.88 Anhang D dieselben Segmente ablegen darf: sequential (D.1) verschränkt jeden Header mit seinen Daten, random-access (D.2) stellt alle Header voran und alle Daten dahinter, und embedded (D.3) ist die headerlose Form, die in anderen Containern wie PDF steckt. Eine eigenständige .jb2-Datei beginnt mit dem Acht-Byte-Bezeichner 97 4A 42 32 0D 0A 1A 0A, gefolgt von einem Flags-Byte und, wenn die Seitenzahl bekannt ist, einer Vier-Byte-Seitenzahl. Bit 0 des Flags-Bytes wählt die Organisation, 1 bedeutet sequential und 0 random-access; ist Bit 1 gesetzt, ist die Seitenzahl unbekannt und der Vier-Byte-Count fehlt. PDFlibPas liest das in checkHeader und setFileHeaderFlags, und die reservierten Bits 2 bis 7 werden toleriert statt zurückgewiesen

JBIG2-Dateiorganisationen in PDFlibPas: Das Flags-Byte, das setFileHeaderFlags liest, wählt sequential D.1 mit verschränkten Headern, random-access D.2 mit jedem Header vor dem Datenblock oder embedded D.3, die headerlose Form, die ein JBIG2Decode-Stream mit Dictionaries in JBIG2Globals nutzt
Die drei Layouts tragen dieselben Segmente, aber nur Random Access lässt einen Reader jede Seiten- und Dictionary-Abhängigkeit sehen, bevor er ein komprimiertes Byte anfasst, weshalb Archiv-Pipelines ihn verlangt haben
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = random-access (D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = Seitenzahl weggelassen
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // PDF-Stream: kein File-Header, embedded-Organisation, eine Seite
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

PDF selbst trägt dieses Layout nie. Ein JBIG2Decode-Bild-Stream, beschrieben in ISO 32000-1 §7.4.7, enthält nur die Seiten-Segmente in embedded-Organisation, mit gemeinsamen Symbol-Dictionaries in einem separaten JBIG2Globals-Stream und ohne File-Header, End-of-Page- oder End-of-File-Segmente. Findet decodeJBIG2 den Acht-Byte-Bezeichner nicht, nimmt es genau das an und erzwingt sequenzielles, einseitiges Dekodieren. Der native JBIG2-Bildexport geht den umgekehrten Weg und wickelt die PDF-Segmente in eine eigenständige Datei, deren Flags-Byte $03 ist – sequential mit unbekannter Seitenzahl –, gefolgt von einem angehängten End-of-File-Header. Die Random-Access-Arbeit betrifft also nur einen Pfad: eigenständige Dateien, die direkt an TPLJBIG2Decoder gehen, typischerweise bevor sie für PDF konvertiert oder neu komprimiert werden – der Job, den die JBIG2-Encoder-Backends in PDFlibPas auf der Ausgabeseite erledigen

Warum kann man eine Random-Access-Datei nicht in Dateireihenfolge lesen?

Weil nichts im Byte-Strom markiert, wo der Header-Block endet und der Datenblock beginnt – außer dem End-of-File-Segment-Header selbst. JBIG2-Segment-Header haben variable Länge: Die referred-to-Segment-Anzahl kann eine Drei-Bit-Kurzform oder eine Langform mit Retention-Bitmap sein, die referred-to-Segment-Nummern nehmen je nach eigener Nummer des Segments ein, zwei oder vier Bytes, und das Page-Association-Feld ist ein oder vier Bytes breit. Ein naiver sequenzieller Reader parst den ersten Header, liest seine Datenlänge und hält dann die ersten Bytes des zweiten Headers für die Daten dieses Segments. Der Decoder merkt den Fehler erst viel später – deshalb hat der alte Code die Organisation pauschal abgelehnt, statt es zu versuchen

Wie indexiert PDFlibPas Random-Access-Segment-Header?

PDFlibPas indexiert Random-Access-Header in einem einzigen Pre-Scan, IndexRandomHeaders, der jeden Header parst, nur sein Byte-Offset notiert und am ersten End-of-File-Header (Segmenttyp 51) anhält. Jeder Header wird vollständig geparst und wieder verworfen, der Index ist also ein Array von Integern statt einer Objektliste, und der Pre-Scan akkumuliert unterwegs die deklarierten Datenlängen. Ist der Scan fertig, sitzt der Reader auf dem ersten Byte der Daten des ersten Segments, und diese Position wird NextBodyOffset

IndexRandomHeaders-Pre-Scan in PDFlibPas: Jeder Segment-Header wird geparst und verworfen, nur sein Byte-Offset bleibt, Segmentnummern müssen streng steigen, die 0xFFFFFFFF-Unknown-Length wird abgelehnt, der Scan hält am End-of-File-Header vom Typ 51 an, und deklarierte Längen müssen exakt den verbleibenden Bytes entsprechen
Die Strenge ist Absicht: In einem Layout, in dem Header die einzige Karte der Daten sind, bedeutet ein einziges fremdes Byte, dass jeder spätere Body verschoben sein kann, sodass ein Decoder, der das toleriert, Padding nicht von Fehlausrichtung unterscheiden kann
// IndexRandomHeaders, lokal in 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-Akkumulator
    HeaderOffsets[HeaderCount] := Offset;      // wächst stückweise
    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;

Jede Prüfung in dieser Schleife existiert, weil eine Random-Access-Datei weniger Redundanz hat als eine sequenzielle. Segmentnummern müssen streng steigen, verglichen als vorzeichenlose Werte, denn zwei Header mit derselben Nummer machen es mehrdeutig, welchen Body die referred-to-Liste einer späteren Region meint. Das Datenlängen-Feld liest handleSegmentDataLength, das jeden Wert mit gesetztem Top-Bit – auch den „unknown length“-Marker 0xFFFFFFFF – auf -1 abbildet; im Random-Access-Layout gibt es keine andere Möglichkeit zu finden, wo der nächste Body beginnt, also weist PDFlibPas diese Länge sofort zurück, statt nach einem Ende-Marker zu scannen. Die Summe muss in beide Richtungen exakt den verbleibenden Bytes entsprechen, und ein einziges zusätzliches Byte nach dem letzten Body scheitert an „trailing random-access data“. Diese Strenge ist Absicht: In diesem Layout bedeutet eine Längenabweichung, dass jeder Body hinter dem Fehlerpunkt verschoben ist, und ein Decoder, der über ein fremdes Byte hinwegsieht, hat keine Möglichkeit zu wissen, ob es harmloses Padding ist oder das erste Symptom verhaspelter Daten

Warum ging das letzte End-of-Page-Segment verloren?

Weil die erste Version der Decode-Schleife den sequenziellen Abbruchtest while not reader.isFinished beibehielt, und im Random-Access-Layout ist der Datenstrom zu Ende, bevor der Header-Index es ist. End-of-Page- (Typ 49) und End-of-File-Segmente tragen null Bytes Daten und sind normalerweise die letzten Header der Datei. Nachdem der letzte Region-Body verbraucht ist, sitzt der Reader exakt am Buffer-Ende, also bricht die Schleife ab und diese Segmente mit Länge null werden nie dispatched – die Seite bleibt unfertig. Der Fix lässt die Random-Access-Schleife Header statt Bytes zählen. Jede Iteration springt den Reader zum nächsten indexierten Header, setzt bitPointer auf 7 zurück, weil der vorherige Body mitten im Byte enden kann, parst diesen Header erneut, dann setzt sie bytePointer auf NextBodyOffset und schiebt ihn über den Body hinweg. Die bestehenden Segment-Handler, die referred-to-Prüfungen und die Context-Diagnostik laufen unverändert, und eine Fehlermeldung meldet weiterhin das ursprüngliche Byte-Offset des Headers, nicht die Body-Position

Random-Access-Decode-Schleife in PDFlibPas: Jede Iteration springt zu HeaderOffsets des aktuellen Headers, setzt bitPointer auf 7 zurück, um Mid-Byte-Reste zu egalisieren, springt für den Body zu NextBodyOffset und zählt Header statt Bytes, sodass End-of-Page-Segmente der Länge null dispatched werden, bevor die Schleife endet
Weil End-of-Page- und End-of-File-Segmente null Bytes Daten tragen, ist der Datenstrom zu Ende, bevor der Header-Index es ist, und nur eine Schleife, die Header zählt, gibt diesen letzten Segmenten ihren Zug
// TJBIG2StreamDecoder.readSegments, Hauptschleife
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;                    // neu ausrichten nach einem Teilbyte
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // für den Fehlerkontext
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // Sprung zu den Daten dieses Segments
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... an den bestehenden Segment-Handler dispatchen, dann zu DataEnd springen
end;

Was beweist die Random-Access-Validierung wirklich?

Die Validierung beweist, dass die umgeordneten Bytes dieselben Pixel dekodieren wie ihre sequenziellen Originale, und dass fehlerhafte Random-Access-Eingaben sauber scheitern; sie beweist keine Abdeckung von Random-Access-Dateien beliebiger Encoder. Die gemeinsame Pascal-Regression nutzt eine 235-Byte-Synthetik-Datei auf einem Custom-Table-Fixture, die zu einer 7-mal-1-Reihe schwarzer Pixel dekodieren muss, einmal mit bekannter Seitenzahl und einmal mit entferntem Count-Feld, und füttert den Decoder dann mit jedem abgeschnittenen Präfix dieser Datei, einer doppelten Segmentnummer, einem einzelnen Trailing-Byte und einer unbekannten Datenlänge, wobei jedes Mal geprüft wird, dass LoadFromByteArray False zurückgibt und Width und Height auf null lässt. Der Real-Image-Fall ist ein 500-mal-473-Refinement-Image mit Custom Table, dessen Segmente in Random-Access-Layout umgeordnet wurden, mit jedem Original-Header und jedem komprimierten Byte erhalten; sein SHA-256 entspricht exakt dem geprüften sequenziellen Baseline. Diese Datei ist ein Derivat aus einem Organisations-Transform, kein natürliches Random-Access-Dokument aus freier Wildbahn, und ein solches natürliches Sample war nicht verfügbar. Die Suiten bestanden mit 1.598 Tests für Delphi Win32, 42 für die Delphi-Win64-Image-Suite, 48 für FPC Win32 und 46 für FPC Win64, neben den drei bestehenden sequenziellen Pixel-Fällen

Eine Random-Access-.jb2-Datei laden und ihre Grenzen

Anwendungscode ändert sich nicht: TPLJBIG2Decoder.LoadFromByteArray erkennt File-Header und Organisation selbst, gibt bei jeder abgelehnten Eingabe False zurück, mit dem Grund in LastError, und legt die dekodierte Seite über Width, Height und GetScanline offen, das ein Byte pro Pixel zurückgibt

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
    // Sequenzielle und Random-Access-Einzeldateien nehmen denselben Aufruf
    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;

Die Grenzen sollte man klar benennen. Random-Access-Support ist ein Feature zur Dateiorganisation, keine Random-Page-API: TPLJBIG2Decoder liefert weiterhin die Bitmap der ersten Seite, und es gibt keinen Aufruf, um Seite 7 aus einer 40-Seiten-Datei zu picken oder Seiten lazy zu dekodieren. Segmente mit unbekannter Datenlänge werden in Random-Access-Dateien abgelehnt, und die bestehenden Grenzen für Custom-Huffman-Prefix-Längen und Table-Entry-Counts bleiben unverändert. Diese Grenzen sind eng genug, dass eine Delphi-Anwendung die abgelehnten Fälle über LastError woanders hin umleiten kann, und der Rest der Bild-Pipeline, von der PDF-Bildextraktion bis zur JBIG2-Kodierung, ist auf der Produktseite der PDFlibPas-Delphi-PDF-Library abgedeckt