技術記事

JBIG2ランダムアクセスファイルをDelphiでデコードする

PDFlibPasバージョン3.539.23は、ITU-T T.88附属書D.2のランダムアクセス構成を使うスタンドアロンJBIG2ファイルをデコードします。すべてのセグメントヘッダーが先に来て、セグメントデータが同じ順序で続く構成です。PDFlibJBIG2.pasのネイティブPascalデコーダーは、必須のend-of-fileヘッダーまでヘッダーオフセットを索引付けし、セグメント番号が増加していること、宣言されたデータ長の合計が残りのバイト数とちょうど一致することを確認してから、各本体をヘッダー順に、圧縮データのコピーも並べ替えもせずにデコードします。このリリースより前は、同じファイルがヘッダーフラグを読んだ瞬間に「random-access organisation is not supported」という無味乾燥なエラーを上げていました

ランダムアクセスJBIG2ファイルは珍しく、だからこそ現れたときに痛いのです。出自はアーカイブパイプラインや文書イメージングシステムで、圧縮バイトに1つも触れる前に、リーダーにすべてのセグメントヘッダー、ひいてはすべてのページと辞書の依存関係を見せたいという要求から来ています。スキャンアーカイブをPDFへ一括変換するDelphiアプリケーションは、普通は数百個のシーケンシャルファイルが無事に通ったあとの作業途中で、これに出くわします。well-formedなファイルで固まるデコーダーは、ゴミを描画するデコーダーよりほんの少しましなだけです。同じリリース系列は直前に、デコーダーにJBIG2のカスタムHuffmanテーブルと正準プレフィックスコードを教え終えたところでした。ランダムアクセスは、デコーダーの文書化された能力限界に残された、最後の構成ギャップだったのです

JBIG2のランダムアクセス構成とは何か

ランダムアクセス構成は、T.88附属書Dが同じセグメント群に許す3つのレイアウトの1つです。シーケンシャル(D.1)は各ヘッダーをそのデータと交互に並べ、ランダムアクセス(D.2)はすべてのヘッダーを先に、すべてのデータを後に置き、embedded(D.3)はPDFのような他のコンテナの中で使われる、ヘッダーなしの形式です。スタンドアロンの.jb2ファイルは8バイトの識別子97 4A 42 32 0D 0A 1A 0Aで始まり、続いてフラグ1バイト、ページ数が既知なら4バイトのページ数が続きます。フラグバイトのビット0が構成を選び、1がシーケンシャル、0がランダムアクセスです。ビット1が立っているとページ数は不明で、4バイトのカウントは存在しません。PDFlibPasはこれらをcheckHeaderとsetFileHeaderFlagsで読み、予約ビットの2から7は拒否せずに受け入れます

PDFlibPasにおけるJBIG2ファイル構成。setFileHeaderFlagsが読むフラグバイトが、ヘッダーを交互に並べるシーケンシャルD.1、データブロックの前に全ヘッダーを置くランダムアクセスD.2、そしてJBIG2Decodeストリームが辞書をJBIG2Globalsに置いて使うヘッダーなしのembedded D.3を選びます
3つのレイアウトは同じセグメントを運びますが、圧縮バイトに触れる前にすべてのページと辞書の依存関係を見せるのはランダムアクセスだけです。アーカイブパイプラインがこれを求めたのはそのためです
// TJBIG2StreamDecoder.setFileHeaderFlags, PDFlibJBIG2.pas
headerFlags := reader.readByte;
fileOrganisation := headerFlags and 1;          // 0 = ランダムアクセス(D.2)
randomAccessOrganisation := fileOrganisation = 0;
pagesKnown := headerFlags and 2;                // 1 = ページ数の省略
noOfPagesKnown := pagesKnown = 0;

// TJBIG2StreamDecoder.decodeJBIG2
validFile := checkHeader;                       // 97 4A 42 32 0D 0A 1A 0A
if not validFile then
begin
  // PDFストリーム:ファイルヘッダーなし、embedded構成、1ページ
  noOfPagesKnown := True;
  randomAccessOrganisation := False;
  noOfPages := 1;
end
else
begin
  setFileHeaderFlags;
  if noOfPagesKnown then
    noOfPages := getNoOfPages;
end;

PDF自身がこのレイアウトを運ぶことはありません。ISO 32000-1 §7.4.7に記述されるJBIG2Decode画像ストリームは、embedded構成でページセグメントだけを保持し、共有シンボル辞書は別のJBIG2Globalsストリームへ移され、ファイルヘッダーやend-of-page、end-of-fileセグメントはありません。decodeJBIG2が8バイトの識別子を見つけられなければ、まさにその状況だと判断し、シーケンシャルで単一ページのデコードを強制します。ネイティブのJBIG2画像エクスポートは逆方向で、PDFのセグメントを、フラグバイトが$03、つまりページ数不明のシーケンシャル、末尾にend-of-fileヘッダーを追加したスタンドアロンファイルへ包みます。つまりランダムアクセスの仕事が触れるのは1つの経路だけです。TPLJBIG2Decoderへ直接渡されるスタンドアロンファイルで、典型的にはPDF向けの変換や再圧縮の前段階。出力側のその仕事は、PDFlibPasのJBIG2エンコーダーバックエンドが担います

ランダムアクセスファイルをファイル順に読めないのはなぜか

ランダムアクセスファイルをファイル順に読めないのは、end-of-fileセグメントヘッダーそのもの以外に、ヘッダーブロックがどこで終わりデータブロックがどこで始まるかを示すものがバイトストリームに存在しないからです。JBIG2のセグメントヘッダーは可変長で、参照セグメント数は3ビットの短形式かリテンションビットマップ付きの長形式、参照セグメント番号はセグメント自身の番号に応じて1、2、4バイト、ページ関連付けフィールドは1か4バイトになります。素朴なシーケンシャルリーダーは最初のヘッダーを解析し、そのデータ長を読み、そして2番目のヘッダーの先頭バイトをそのセグメントのデータとして扱います。デコーダーはずっと後まで間違いに気づけません。旧コードが試みる代わりに構成ごと拒否したのは、そのためです

PDFlibPasはランダムアクセスのセグメントヘッダーをどう索引するか

PDFlibPasはランダムアクセスのヘッダーを、1回のプリスキャンIndexRandomHeadersで索引します。すべてのヘッダーを解析し、バイトオフセットだけを記録し、最初のend-of-fileヘッダー(セグメントタイプ51)で停止します。各ヘッダーは完全に解析されてから捨てられるので、索引はオブジェクトのリストではなく整数の配列になり、プリスキャンは進みながら宣言されたデータ長を積算します。スキャンが終わると、リーダーは最初のセグメントのデータの先頭バイトに乗っており、その位置がNextBodyOffsetになります

PDFlibPasのIndexRandomHeadersプリスキャン。全セグメントヘッダーは解析されて捨てられ、バイトオフセットだけが保持され、セグメント番号は厳密に増加しなければならず、0xFFFFFFFFの未知長は拒否され、スキャンはタイプ51のend-of-fileヘッダーで停止し、宣言長の合計が残りバイトと正確に一致しなければなりません
厳格さは意図的です。ヘッダーがデータの唯一の地図であるレイアウトでは、迷子バイト1つで後続の全本体がずれ得ます。これを許すデコーダーは、パディングと位置ずれを見分けられません
// IndexRandomHeaders、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の積算器
    HeaderOffsets[HeaderCount] := Offset;      // チャンク単位で拡張
    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;

あのループの検査が1つ残らず存在するのは、ランダムアクセスファイルがシーケンシャルより冗長性で劣るからです。セグメント番号は、符号なし値として比較して厳密に増加しなければなりません。同じ番号を名乗る2つのヘッダーがあると、後のリージョンの参照リストがどちらの本体を指すのか曖昧になるからです。データ長フィールドはhandleSegmentDataLengthが読み、最上位ビットの立った値は、0xFFFFFFFFの「unknown length」マーカーを含め、すべて-1にマップされます。ランダムアクセスのレイアウトでは次の本体の始まりを探す他の方法がないので、PDFlibPasはendマーカーを探す代わりに、その長さを即座に拒否します。合計は残りバイトと両方向で正確に一致しなければならず、最後の本体の後の余分なバイト1つは「trailing random-access data」で失敗します。この厳格さは意図的です。このレイアウトでは長さの不一致は、エラー地点より後の全本体がずれていることを意味し、迷子バイト1つを軽く流すデコーダーには、それが無害なパディングなのか、位置ずれの最初の症状なのかを見分ける手だてがありません

最後のend-of-pageセグメントが消えたのはなぜか

最後のend-of-pageセグメントが消えたのは、デコードループの最初の版がシーケンシャル向けの終了条件while not reader.isFinishedを保っており、ランダムアクセスのレイアウトではヘッダー索引が尽きる前にデータストリームが尽きるからです。end-of-page(タイプ49)とend-of-fileセグメントはデータを0バイトしか運ばず、通常はファイルの最後のヘッダーです。最後のリージョン本体を消費し終えると、リーダーはバッファーの終端にちょうど乗っているので、ループは抜け、その長さゼロのセグメントは振り分けられないまま、ページは未完成のまま残ります。修正版のランダムアクセスループは、バイトの代わりにヘッダーを数えます。各反復はリーダーを次の索引付きヘッダーへ跳ばし、前の本体がバイト途中で終わっている可能性があるためbitPointerを7に戻し、そのヘッダーを再解析してから、bytePointerをNextBodyOffsetへ移し、本体を過ぎて進めます。既存のセグメントハンドラー、参照セグメントの検査、そしてContextの診断はそのまま動き、エラーメッセージは本体位置ではなく、ヘッダーの元のバイトオフセットを報告し続けます

PDFlibPasのランダムアクセスデコードループ。各反復は現在のヘッダーのHeaderOffsetsへシークし、バイト途中の尻尾を元に戻すためにbitPointerを7へ戻し、本体のためにNextBodyOffsetへ跳び、バイトの代わりにヘッダーを数えるので、長さゼロのend-of-pageセグメントはループ終了前に振り分けられます
end-of-pageとend-of-fileセグメントはデータを0バイトしか運ばないため、データストリームはヘッダー索引より先に尽きます。最後のセグメントに順番を与えられるのは、ヘッダーを数えるループだけです
// TJBIG2StreamDecoder.readSegments、メインループ
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;                    // 部分バイトの後の再整列
    Inc(HeaderIndex);
  end;
  SegmentOffset := reader.bytePointer;         // エラーコンテキストで使用
  readSegmentHeader(segmentHeader);
  if randomAccessOrganisation then
    reader.bytePointer := NextBodyOffset;      // このセグメントのデータへ跳ぶ
  DataLength := segmentHeader.getSegmentDataLength;
  DataEnd := reader.bytePointer + DataLength;
  NextBodyOffset := DataEnd;
  // ... 既存のセグメントハンドラーへ振り分け、その後DataEndへシーク
end;

ランダムアクセスの検証が実際に証明するもの

検証が証明するのは、再構成されたバイトがシーケンシャルの原本と同じピクセルにデコードされること、そして不正なランダムアクセス入力がきれいに失敗することです。任意のエンコーダーが作るランダムアクセスファイルを網羅する証明ではありません。共有のPascalリグレッションは、カスタムテーブルのフィクスチャーに基づく235バイトの合成ファイルを使います。既知のページ数のときとカウントフィールドを取り除いたときの両方で、黒ピクセルの7×1の行へデコードされなければならないファイルです。さらにそのファイルの切り詰めプレフィックスすべて、重複セグメント番号、末尾の余分バイト1つ、未知のデータ長をデコーダーに与え、そのたびにLoadFromByteArrayがFalseを返し、WidthとHeightをゼロのままにすることを表明します。実画像のケースは500×473のカスタムテーブルrefinement画像で、そのセグメントは元のヘッダーと圧縮バイトをすべて保ったままランダムアクセスのレイアウトへ再構成され、SHA-256は審査済みのシーケンシャル基準と正確に一致しました。このファイルは構成変換が作った派生物であって、野外で見つかった自然なランダムアクセス文書ではなく、そのような自然なサンプルは入手できませんでした。スイートは、既存の3つのシーケンシャルピクセルケースとともに、Delphi Win32で1,598テスト、Delphi Win64画像スイートで42、FPC Win32で48、FPC Win64で46を通過しました

ランダムアクセス.jb2ファイルの読み込みと限界

アプリケーションコードは変わりません。TPLJBIG2Decoder.LoadFromByteArrayがファイルヘッダーと構成を自分で検出し、拒否した入力には理由をLastErrorに入れてFalseを返し、デコードされたページをWidth、Height、そして1ピクセル1バイトを返すGetScanlineで公開します

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
    // シーケンシャルもランダムアクセスも、スタンドアロンなら同じ呼び出し
    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;

境界は率直に述べておく価値があります。ランダムアクセス対応はファイル構成の機能であって、ランダムページAPIではありません。TPLJBIG2Decoderが返すのは今も最初のページのビットマップで、40ページのファイルから7ページだけを取り出す呼び出しも、ページを遅延デコードする呼び出しもありません。未知のデータ長のセグメントはランダムアクセスファイルでは拒否され、カスタムHuffmanのプレフィックス長とテーブルエントリー数についての既存の限界も変わりません。この限界は狭く、Delphiアプリケーションは拒否されたケースをLastErrorで他へ回せます。画像パイプラインの残り、PDF画像の抽出からJBIG2エンコードまでは、PDFlibPas Delphi PDF libraryの製品ページで解説しています