技術記事

DelphiでJBIG2シンボル辞書をページ間で共有する

50ページのスキャン契約書はどのページでも同じ文字集合を繰り返すが、画像1枚につき1つのシンボル辞書を構築するJBIG2エンコーダは、その文字集合を50回別々に再学習してしまう。DelphiおよびC++Builder向けネイティブPDFコンポーネントであるHotPDFは、代わりに文書全体にわたって一つの共有シンボル辞書を蓄積し、それを一つの文書レベルの/JBIG2Globalsストリームに昇格させることができる。そのため各ページ自身のJBIG2ストリームは、文字集合の独自のコピーを保存するのではなく、単にシンボルIDを参照するだけになる

本稿は意図的に範囲を狭く保ち、HotPDFが内部でこのページ間共有をどう構築するかだけを扱う——JBIG2の基礎、CCITTとの比較、そしてLosslessとLossyLevelのトレードオフはすでにDelphiにおけるネイティブ JBIG2 バイレベル圧縮の関連記事で扱われており、本稿はそれを読んでいることを前提とする

なぜページ単位のJBIG2圧縮は同じコストを繰り返してしまうのか

その答えは、呼び出し間で何も状態を運ばないからである。HotPDFのエンコーダが1枚の画像のためにシンボル辞書を構築するたびに、その辞書はその一回のAddImage呼び出しにスコープされる:形状マッチングのパスはゼロから始まり、ページ上のすべてのグリフが新規として分類され、結果のビットマップは新たに算術符号化され保存される。同じ書体で組まれた50ページを同じエンコーダに与えると、それはその学習パス全体を50回喜んで繰り返す。なぜならその視点からは、各ページはたまたま似て見える無関係な画像だからである。ページ単位のUseSymbolDictionaryは単一ページでは平坦な汎用リージョンエンコードを大きく上回るが、本物の複数ページスキャンがテーブルに残す上限にははるかに届かない

HotPDFはどうやってページ間で単一のシンボル辞書を共有するのか

THPDFJBIG2OptionsAccumulateGlobalsAcrossPagesを有効にすると、HotPDFは各画像の後にそれを破棄するのではなく、文書の生涯にわたって一つのシンボル辞書をメモリ上に生かし続ける。以降のすべてのページのグリフは、何かが再コード化される前にその実行中の辞書に対してチェックされる:すでに存在する形状はそのシンボルIDによって再利用され、誰もまだ見たことがない形状だけが辞書に追加されコード化される。この比較は、単一ページでLossyLevelが適用するのと同じ許容誤差ロジックを再利用する——同じ文字の少しノイズの多いスキャンでも依然として一致とみなされる——そのため、蓄積器は同じグリフのピクセルレベルの変異ごとに1つの辞書エントリへと静かに膨れ上がることはない。抽出はまずその比較を先行し供給する:HotPDFは各ページのビットマップを歩き、黒ピクセルに対するフラッドフィルによって連結された形状を取り出す。これはインクの染みを手でトレースするのと同じ発想であり、生のピクセルブロックではなくこれらの抽出された形状こそが、実行中の辞書と比較される

共有辞書は/JBIG2Globalsストリームの中にどう収まるのか

蓄積された辞書は/JBIG2Globalsストリームの中の一つのシンボル辞書セグメントとして書き込まれ、固定のセグメント番号に保持されるため、どのページも同じターゲットを指すことができる。ISO 32000-1 §7.4.7が定義する埋め込みJBIG2の構成の中では、テキストリージョンセグメントはセグメントヘッダー内の参照先セグメントフィールドを通じて別のセグメントを自身のシンボルソースとして指名できる。これがまさにHotPDFが依拠する仕組みである:グローバルストリームは一つの大きなシンボル辞書を運び、各ページ自身のJBIG2ストリームは、参照先リストがグローバルセグメントを指し示すページ情報セグメントとテキストリージョンセグメントだけに縮小される。かつてページごとに自己完結したビットストリームだったものは、位置とシンボルIDの短いリストになり、この方法で構築されたすべてのページは、そのコピーではなく同一の間接/JBIG2Globalsオブジェクトを参照する。HotPDF自身の回帰テストはまさにそれをチェックする:各ページが異なるグリフレイアウトを持つ短い文書をエンコードし、それを再読み込みし、ファイル内に何個の異なる/JBIG2Globalsオブジェクト参照が現れるかを数える——何ページがそこにシンボルを提供したかにかかわらず、1文書、1オブジェクト参照である

ページ間シンボル辞書蓄積を有効にする

このスイッチは関連記事で扱われているのと同じオプションレコードにあり、実際に蓄積が動作するには互いに一致する4つの設定が必要である

var
  Pdf: THotPDF;
  Bmp: TBitmap;
  PageIdx, ImgIdx: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.JBIG2Options.Lossless := True;
    Pdf.JBIG2Options.UseSymbolDictionary := True;
    Pdf.JBIG2Options.UseGlobalSegments := True;
    Pdf.JBIG2Options.AccumulateGlobalsAcrossPages := True;  // opt-in, default False
    Pdf.JBIG2Options.UseExternalEncoder := False;            // accumulation needs the native path
    Pdf.JBIG2Options.UseNativeArithmeticFallback := True;
    Pdf.BeginDoc;
    for PageIdx := 0 to ScannedPages.Count - 1 do
    begin
      if PageIdx > 0 then
        Pdf.AddPage;
      Bmp := ScannedPages[PageIdx];             // 1-bit TBitmap for this page
      ImgIdx := Pdf.AddImage(Bmp, icJBIG2);
      Pdf.CurrentPage.ShowImage(ImgIdx, 0, 0, Bmp.Width, Bmp.Height, 0);
    end;
    Pdf.EndDoc;                                  // the shared /JBIG2Globals stream is finalized here
  finally
    Pdf.Free;
  end;
end;

この組み合わせは省略可能な飾りではない。バイレベル圧縮の記事で説明されている外部エンコーダの接続点——本番品質の圧縮率のためにRegisterJBIG2EncoderBackendを通じて登録するもの——は画像単位のエンコーディングを前提に構築されており、HotPDF自身の蓄積デモと回帰テストは常にAccumulateGlobalsAcrossPagesUseExternalEncoder := Falseと組み合わせている。これは提案ではなく厳格な要件として扱ってほしい:ページ間共有はネイティブエンコーダの機能であり、登録された外部バックエンドは共有辞書を構築する経路の一部では単純にない

複数ページのスキャンは実際どれだけ小さくなるのか

正直な答えは、まず何が針を動かさなかったかから始まる。以前のリリースは/JBIG2Globalsストリーム用のコンテンツアドレス指定キャッシュを追加した——ストリームバイトの64ビットFNV-1aハッシュをキーとするルックアップであり、たまたまバイト単位で同一のグローバルデータを生成した2つの画像が1つのPDFオブジェクトを共有できるようにするものだった。実際の出力に対して測定すると、このキャッシュはほとんど役に立たなかった。なぜなら、HotPDFの既存の画像全体の重複検出が、そのキャッシュが動く機会を得る前に、すでにバイト単位で同一の画像を折り畳んでいたからである。この教訓は、ストリームレベルの重複排除は、2つの本当に異なるページ画像がそれでも一つの成長する辞書を共有できて初めて元が取れるということであり、それこそが真のページ間蓄積がもたらすものだ

その困難なケースについて、HotPDF自身のエンジニアリング上の見積もりは、1つの繰り返されるフォントから構築された典型的な複数ページスキャンで、ストリームレベルの重複排除だけが達成する場合よりさらに30〜60パーセント小さくなるとしている——この範囲は、文書の視覚的な語彙のどれだけが実際に繰り返されるかによって変動する。ユニークな図表で埋め尽くされたページは、辞書に再利用できるものを何も与えないからだ。これは特定の入力に対する保証ではなく設計上の目標として扱い、単一の数値を信じるのではなく自分自身の文書で測定してほしい。HotPDFに同梱されているJBIG2Benchmarkデモはまさにその目的のために存在する:同じ複数ページのスキャンを4通りの異なる方法でエンコードし、各構成の結果ファイルサイズを出力する。そのため比較は合成データではなく自分自身のスキャンの組み合わせに対して実行される

procedure RunScenario(const Title: string; AccumulateGlobals: Boolean);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.JBIG2Options.Lossless := True;
    Pdf.JBIG2Options.UseSymbolDictionary := True;
    Pdf.JBIG2Options.UseGlobalSegments := True;
    Pdf.JBIG2Options.AccumulateGlobalsAcrossPages := AccumulateGlobals;
    Pdf.JBIG2Options.UseExternalEncoder := not AccumulateGlobals;
    // ... encode the same three-page scan here, then compare file sizes.
  finally
    Pdf.Free;
  end;
end;

begin
  RunScenario('Per-image lossless baseline', False);
  RunScenario('Cross-page accumulated globals', True);
end.

ページ間蓄積が限界に達するところ

蓄積された辞書は4096シンボルで上限が設けられており、これは単一ページで画像単位のネイティブエンコーダがすでに強制している上限と同じである。文書の途中でその制限を超えても、HotPDFは例外を発生させたり実行を中断したりしない:蓄積器は新しいグリフを拒否し、それを導入したページは自動的に独立した画像単位のエンコーディングにフォールバックする。そのため文書は依然として正しく仕上がる——上限を超えたページについてページ間の節約が得られなくなるだけである。2つ目の安全策はシンボル数ではなく合計サイズを監視する:蓄積された辞書の合計シンボル幅が131071ピクセルを超えると、HotPDFは一つのメモリ内構造を無制限に成長させるのではなく、現在のバッチをディスクに書き出し自動的に新しいグローバルグループを開始する。どちらの制限もあなた側でコードを必要としない。両方とも、あなたが捕捉する必要のある例外ではなく自動的なフォールバックだからである

PDF/A適合性は、この仕組み全体を単に制限するのではなく丸ごとオフにする唯一の設定である。HotPDFは、PDFAComplianceが空でない瞬間に、AccumulateGlobalsAcrossPagesJBIG2Optionsの他の何にも関わらず、すべてのページでJBIG2の代わりに静かにCCITT Group 4を代用する——これは意図的な適合性の選択でありバグではないが、アーカイブ用プロファイルとページ間シンボル共有は今日のところ相互に排他的であることを意味する。どの構成に落ち着くにせよ、書いたものを信頼する前に必ずデコードすること:LoadFromFileでファイルを読み戻し、ExtractLoadedImageを通じて各ページを引き出す。これは準拠するあらゆるリーダーと同じように共有グローバルをあなたの代わりに解決してくれるので、その結果をソースのビットマップと比較すればよい

var
  Loaded: THotPDF;
  PageBmp: TBitmap;
  PageIdx: Integer;
begin
  Loaded := THotPDF.Create(nil);
  try
    Loaded.LoadFromFile('scanned-contract.pdf');
    for PageIdx := 0 to Loaded.PagesCount - 1 do
    begin
      PageBmp := Loaded.ExtractLoadedImage(PageIdx);   // resolves the shared globals for you
      try
        // Compare PageBmp against the source bitmap for this page.
      finally
        PageBmp.Free;
      end;
    end;
  finally
    Loaded.Free;
  end;
end;

ページ間辞書共有は文書のバイレベル画像側にのみ触れる。同じパイプラインがスキャンと並行して生成されたテキストページ——表紙、索引ページ、OCRテキストレイヤー——も出力する場合、オブジェクトストリームとxrefストリームがそれらのページが追加する文書構造を圧縮することでファイルサイズ予算のもう半分を攻略する。ページ間JBIG2グローバルは、画像単位のJBIG2オプションと圧縮パイプラインの残りとともに、DelphiおよびC++Builder向けHotPDFコンポーネントの一部として出荷される