技術記事

Delphi で読み込んだ PDF から画像を抽出する: HotPDF

ディスク上に PDF があり、顧客は請求書の束をスキャンしていて、あなたの仕事はページ画像をビットマップとして取り出し、OCR 処理に回すことです。ファイルを読み込むと image XObject を見つけられますが、そこで誰も警告してくれない事実に気づきます。あのストリーム内のバイト列はピクセルではありません。JPEG の codestream だったり、ウェーブレット圧縮された JPEG 2000 の塊だったり、Group 4 の fax データだったり、Flate フィルターの裏に置かれたパレット付きインデックスラスタだったりします。画像オブジェクトは幅と高さを知っていますが、実際のサンプルは作成側が選んだフィルターの中に封じ込められています。使える TBitmap とは、そのフィルターを逆にたどることを意味します。PDF では、バイト列を封じる方法がざっと 8 通りあります

この空白を埋めるのが ExtractLoadedImage です。HotPDF, Delphi と C++Builder 向けのネイティブ VCL PDF コンポーネントが担います。読み込んだ文書内の image XObject を列挙し、それぞれが何かを示し、可能なものは 24 ビットのビットマップに戻してデコードします。面白いのは API の表面そのものではありません。そこにあるのは 3 つのメソッドだけです。別個のデコード経路がなぜ必要なのか、そして何をピクセルへ戻せて何を戻せないのかです

読み込んだ画像が最初からデコードされていない理由

HotPDF のローダーは、パススルーの忠実性を重視して作られています。LoadFromFile すると、画像ストリームはソースファイルに現れるそのままの形で保持されます。元のフィルター、元の圧縮済みバイト列、元の辞書です。これは意図的です。文書を読み込む目的は、たいていページのコピー、ファイルの結合、スタンプ付け、権限の再設定、そして再出力であり、そのすべてに対して最も安く安全なのは、各画像ストリームをそのままにしておくことだからです。読み込み時にすべての画像をラスターへデコードすると、ほとんどの呼び出し側が必要としない作業にメモリと CPU を費やすことになり、保存時に再エンコードすると、本来そのままコピーすべき画像まで劣化させてしまいます

その結果、読み込まれたオブジェクトグラフにはピクセルが載りません。/Filter/DCTDecodeJPEG バイト列を保持していても、HotPDF はそこに対して JPEG デコーダを一度も走らせません。コピーして書き換える経路では不要だからです。ですから、実際にピクセルが欲しいときは、抽出 API がその画像で使われているフィルターごとに、最初から自前でデコードしなければなりません。これと同じ理由で、エンコード側のコーデックもローダーとは独立しています。Delphi で JPEG 2000 画像を PDF に追加する では、JPX エンジンが作成側にどう組み込まれるかを説明していますが、抽出 API が必要になるまで、そのエンジンは読み取り経路にはつながっていませんでした

3 メソッドの API

表面は小さいです。 GetLoadedImageCount は、読み込んだ文書に含まれる image XObject の数を返します。 GetLoadedImageInfo は、インデックスで指定した 1 つについて descriptor レコードを埋めます。 ExtractLoadedImage は、デコード済みビットマップを返します。あるいは nil その画像をデコードできない場合に返します。列挙はインデックス基準で、同じ読み込みに対しては安定しています。内部では間接オブジェクト表を走査し、次の条件を満たすすべてのストリームを集めます: その /Subtype/ImageDelphi で JPEG 2000 画像を PDF に追加するGetLoadedImageInfo では、JPX エンジンが作成側にどう組み込まれるかを説明していますが、抽出 API が必要になるまで、そのエンジンは読み取り経路にはつながっていませんでした。ExtractLoadedImage

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I, Count: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-invoices.pdf', '') <= 0 then
      Exit;
    Count := Pdf.GetLoadedImageCount;
    for I := 0 to Count - 1 do
    begin
      if not Pdf.GetLoadedImageInfo(I, Info) then
        Continue;
      if not Info.Decodable then
        Continue;                       // filter or colour space not supported
      Bmp := Pdf.ExtractLoadedImage(I);
      if Bmp <> nil then
      try
        Bmp.SaveToFile(Format('img_%d.bmp', [I]));
      finally
        Bmp.Free;                       // caller owns the bitmap
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

ここでは 2 つの契約上のポイントが重要です。1 つ目は、返された TBitmap は自分で解放する必要があります。文書はキャッシュも所有もしていません。2 つ目は、Decodable を呼び出す前に確認し、返り値を nil と照合することです。このメソッドは未対応フィルターで例外を投げず、nil を返します。バッチループで黙って nil が起きると、1,000 ページのジョブの 1 ページ分が、誰にも気づかれないまま飲み込まれることになります

デコードする前にディスクリプタを読む

THPDFLoadedImageInfo は、完全なデコードを確定せずに画像の正体を示します。各フィールドは画像辞書からそのまま取得されます: WidthHeight をサンプル単位で、BitsPerComponentColorComponentsColorSpace デコード後の解釈を示します (gray は 1、RGB は 3、CMYK は 4)、Filter名前付き圧縮として、IsImageMaskステンシルマスク用として、ObjectNumber基になる間接オブジェクト用として、Decodable

最後のフラグこそが正直な指標です。 DecodableTrue 実行中のビルドが、この特定のフィルターと色空間の組み合わせを実際にビットマップへ変換できる場合にだけ成り立ちます。実際の対応状況を示すのであって、願望ではありません。フィルターが Filter 現在のビルドで理解できない場合は Decodable = False と判定された画像は、ログを残したり、スキップしたり、あるいは生のストリームを自分で抽出するフォールバックに回したりできます。これはヒントではなく前提条件として扱ってください

// Triage every image before committing to a decode.
var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  I: Integer;
begin
  // ... Pdf loaded ...
  for I := 0 to Pdf.GetLoadedImageCount - 1 do
  begin
    if not Pdf.GetLoadedImageInfo(I, Info) then
      Continue;
    if Info.Decodable then
      // ExtractLoadedImage(I) will return a TBitmap
    else
      // unsupported filter/colour space: log the object and skip
      Writeln(Format('Image %d obj %d: %dx%d %s/%s not decodable',
        [I, Info.ObjectNumber, Info.Width, Info.Height,
         String(Info.Filter), String(Info.ColorSpace)]));
  end;
end;

ディスクリプタレコードを手作業で組み立てる人がつまずきやすい実装上の注意点が 1 つあります。 THPDFLoadedImageInfo には 2 つの AnsiString フィールドがあります。Filter それは ColorSpace。これらは参照カウント付きのマネージド型なので、レコードを FillChar(Info, SizeOf(Info), 0) でゼロ初期化するのはここでは誤りです: それは文字列参照を減算せずに上書きしてしまい、リークや破損を招きます。HotPDF はまさにその理由でレコードをフィールドごとに初期化しており、あなたのコードでこのパターンを使うなら、同じようにしてください

1 つのディスパッチャー、8 つのフィルターパス

この機能が単発ではなく複数のリリースに分かれたのは、PDF には画像フォーマットという考え方がなく、フィルターしかないからです。ISO 32000-1 の §8.9.5 では、image XObject がそれらのどれを使うかを /Filterこの機能が単発ではなく複数のリリースに分かれたのは、PDF には画像フォーマットという考え方がなく、フィルターしかないからです。ISO 32000-1 の §8.9.5 では、image XObject がそれらのどれを使うかを /ColorSpace に指定でき、サンプルの解釈は別途 /BitsPerComponent/Decode および任意の ExtractLoadedImage 配列で制御されます

  • ExtractLoadedImage はフィルター名を読み取り、ケースごとに専用デコーダへ振り分けます。v2.229 から v2.231 にかけて積み上げられた対応セットは、現在 8 つの経路をカバーしています。生のラスター (FlateDecode、LZWDecode、またはフィルターなし)
  • は 8 ビットの DeviceRGB または DeviceGray です。バイト列はパックされたラスターに展開され、変換は後述するチャンネル入れ替えだけです。DCTDecode (JPEG)TJPEGImage。codestream は VCL の
  • に渡され、そこでサイズと色が解決され、結果は 24 ビットのビットマップに割り当てられます。JPXDecode (JPEG 2000)
  • 。OpenJPEG バックエンドでデコードされます。これは JPEG 2000 の記事で説明したのと同じエンジンで、高ビット深度のコンポーネントは 8 ビットにリサンプリングされます。インデックスカラー[/Indexed base hival lookup]。パレットは
  • 配列から読み込まれ、各サンプルはルックアップテーブルを通してトゥルーカラーへ展開されます。DeviceCMYK
  • 。4 チャネルのサンプルは、標準的なインク・オン・ホワイトの式で RGB に変換されます。8 ビット未満の DeviceGray と Indexed
  • は 1、2、4 ビット/コンポーネントで表され、サンプルごとに展開されて 0 から 255 の範囲へスケーリングされます。CCITTFaxDecode
  • 、Group 3 と Group 4 の fax フィルターで、専用の T.4/T.6 バックエンドでデコードされます。ネイティブ JBIG2 圧縮の記事 がエンコード側から扱っています

最終的にはすべて同じ場所に着地します。VCL の TBitmap がネイティブに保持し、下流の利用側が期待しているのが 24 ビット BGR ビットマップだからです

静かにピクセルを変える変換

この 2 つの経路には、さりげなく間違えやすく、デコーダに触らなくても理解しておく価値のある変換が含まれています。1 つ目は色順の入れ替えです。PDF の DeviceRGB ラスタは、サンプルを赤・緑・青の順に、上の行から格納します。VCL の 24 ビット scanline は青・緑・赤の順で格納します。したがって、単純な RGB 画像のデコードは memcpy ではありません。各ピクセルの 1 バイト目と 3 バイト目が scanline へ入る途中で入れ替わります。これを逆にすると赤と青が入れ替わり、グレースケールのテスト画像では問題なく見えても、カラー画像では致命的におかしくなります。行順については、その点では話が早いです。PDF の上から下へ進むラスターは VCL の ScanLine[0] を最上段の視覚行としてそのまま対応するので、縦方向の反転は不要です

2 つ目は CMYK です。PDF の DeviceCMYK 画像は 4 つのインクを持っており、RGB への変換はルックアップではなく、各チャネルごとの計算です。各出力チャネルは (255 - ink) * (255 - K) / 255。これは ICC プロファイルを通した色管理付き変換ではなくデバイス近似なので、表示や再ラスター化には十分ですが、印刷精度が必要なら適切な経路ではありません。ワークフローで忠実性が求められるなら、抽出したビットマップはプレビューとして扱い、元の CMYK ストリームは色管理付きパイプラインのために保持してください

Indexed 経路には、別の解析上の落とし穴があります。/Indexed color space のパレットは、リテラル文字列でも 16 進文字列でも格納できます。HotPDF は hex 文字列の値を hex テキストとして保存し、デコード済みバイト列としては保存しません。したがって、パレットが 16 進文字列のときは、ルックアップテーブルを最初に hex から bytes へデコードする必要があります。リテラル文字列ならすでに生のバイト列です。これを見落とすと、4 色のインデックス画像はごみになり、すべてのパレット項目を誤ったバイト境界から読んでしまいます

フィルターチェーン: 最後のフィルターが画像そのもの

単一の /Filter 名は簡単なケースです。PDF では チェーン 配列に、たとえば /Filter[/ASCII85Decode /FlateDecode] (ISO 32000-1 §7.4) として複数のフィルターを順に並べることもできます。意味は厳密です。エンコード時は左から右へ適用されるので、デコード時は右から左へ元に戻し、[/ASCIIHexDecode /DCTDecode]最後の 配列内のフィルターが実際に画像フォーマットを定義します。先頭のフィルターは、その周りを包む輸送用エンコーディングにすぎません。抽出器はこれを順に剥がして処理します。どの画像デコーダが走る前でも、チェーン内の最後以外のフィルターをすべて適用して最終フィルターが期待する入力を作り、その後でその最後のフィルターに対して振り分けを行います。したがって

まずストリームから ASCII85 を解除し、その結果を JPEG 経路に送ります。[/ASCII85Decode /DCTDecode] 生のラスターを包んだものは展開されてからラスター経路を実行します。こうすることで 8 つのデコーダを単純なまま保てます。どのデコーダも ASCII85 や hex の輸送ラッパーを知る必要はありません。デコーダがバイト列を見る時点では、ラッパーはすでに取り除かれているからです。また、最後のフィルターが未対応のチェーンは、途中で失敗するのではなく、振り分けの段階できれいに失敗します。[/FlateDecode] 生のラスターを包んだものは展開されてからラスター経路を実行します。こうすることで 8 つのデコーダを単純なまま保てます。どのデコーダも ASCII85 や hex の輸送ラッパーを知る必要はありません。デコーダがバイト列を見る時点では、ラッパーはすでに取り除かれているからです。また、最後のフィルターが未対応のチェーンは、途中で失敗するのではなく、振り分けの段階できれいに失敗します

抽出が止まる場所と、その後に取るべき手段

境界は正直に見ておくべきです。最終フィルターが対応セットの外にある画像は nil を返し、ビルドが解釈できない色空間の画像でも同じです。ソフトマスクと alpha はビットマップへ再構成されず、得られるのは合成結果ではなくベース画像です。JPEG 2000 の 8 ビットを超えるビット深度は下位ビットへリサンプルされますが、これは意図的な損失であり、表示ではなく再アーカイブを行うなら適切ではありません。また、色を持たない 1 ビットのステンシルである image mask は descriptor では説明されますが、絵画像とは別物です。写真だと思ってデコードすると意外な結果になります

GetLoadedImageCount Decodable

ガード付きループだけで、読み込んだ PDF を数行でビットマップのフォルダーに戻せます。ここで説明した decode フィルター一式とともに、読み込んだ画像を抽出する API は