技術記事

DelphiのPDFium Componentを用いたPDFファイルからのテキスト抽出

PDFのテキスト抽出は単純に見えますが、テキストレイヤーが存在しないドキュメントや、破損しているドキュメント、あるいは意味のある順序を持たない数十の小さな文字データに細分化されているドキュメントに直面すると、話は別になります。PDFium Componentは2つのエントリーポイントを提供します。1つはページ上のすべてのグリフに対してインデックスベースで直接アクセスする生のCharacter[]配列、もう1つはPDFのタグツリーやヒューリスティック分析から段落や見出しを再構築する構造化されたビューを提供するReadablePageContentです。どちらも常に正しい選択肢となるわけではないため、それぞれが何を公開しているのかを理解することが重要です

ドキュメントを開く処理とサイレント失敗の罠

TPdfは、FileNameを設定し、Active := Trueに切り替えることでファイルを開きます。重要な詳細として、Active := Trueは例外を絶対に発生させません。ファイルが存在しない場合や、パスワードで保護されている場合、または破損している場合、PDFiumは内部でエラーをキャッチし、Activeは単にFalseのままになります。これは、すべての抽出ループでこの状態に対するガードを設ける必要があることを意味します

Pdf := TPdf.Create(nil);
try
  Pdf.FileName := 'report.pdf';
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    ShowMessage('Could not open PDF (damaged or wrong password)');
    Exit;
  end;
  // extraction follows here
finally
  Pdf.Active := False;
  Pdf.Free;
end;

パスワードで保護されたファイルは、Active := Trueにする前にPdf.Password := '...'を設定する必要があります。再試行の機会はありません。一度Activeが失敗した後は、ファイルをクローズし、正しいパスワードを設定して開き直す必要があります

Character[]によるページごとの抽出

最も低レベルなアプローチは、各ページ上のすべての文字を走査することです。Pdf.PageNumberを設定してそのページのテキストレイヤーをロードし、Character[]プロパティを使用してCharacterCount回ループ処理します。各文字データの2つのフラグをチェックする価値があります。CharacterGenerated[i]は、レンダラーによって挿入された、実際のUnicode値を持たない合成グリフ(改行時のソフトハイフンなど)を示します。CharacterMapError[i]は、PDFiumがグリフをコードポイントにマッピングできなかったことを示し、ToUnicodeテーブルを欠くフォントエンコーディングで発生します

procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
  Page, I: Integer;
  Line: string;
  Ch: WideChar;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := Page;
    Line := '';
    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
        Continue;
      Ch := Pdf.Character[I];
      if Ch = #13 then
        Ch := #10;   // normalize CR to LF
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

この方法で得られる結果は、PDFiumが列挙する順序(コンテンツストリーム内に出現する順序であり、必ずしも左から右への読取順序ではありません)のUnicodeコードポイントの平坦な文字列です。標準的なオフィスツールで作成された大部分のラテン文字ドキュメントでは、これで問題ありません。しかし、一般的ではないグリフシーケンスでOCR処理されたスキャン済みPDFや、右から左に書くテキストの場合、順序が崩れることがあります。このような場合には、ReadablePageContentを使用する方が有用です

ReadablePageContentによる構造化された抽出

ReadablePageContentは1つ上のレイヤーです。Fragments配列にタグ付きコンテンツフラグメント(それぞれ段落、見出し、リストアイテム、テーブルセルなどを識別するKindを持ちます)を保持するTPdfReadableContentレコードを返します。PDFに構造ツリーがある場合(Pdf.IsTaggedを確認)、ソースはrosStructureになり、読み取り順序は信頼性の高いものになります。タグが付いていないファイルの場合、PDFiumはrosHeuristicにフォールバックし、文字をバウンディングボックス(境界枠)によって妥当な読み取りユニットにグループ化しますが、正確性は保証できません

procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
  Page: Integer;
  Content: TPdfReadableContent;
  Fragment: TPdfContentFragment;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Content := Pdf.ReadablePageContent(Page);
    for Fragment in Content.Fragments do
    begin
      case Fragment.Kind of
        cfHeading   : Output.Add('# ' + Fragment.Text);
        cfParagraph : Output.Add(Fragment.Text);
        cfListItem  : Output.Add('- ' + Fragment.Text);
      else
        Output.Add(Fragment.Text);
      end;
    end;
  end;
end;

もしContent.Source = rosHeuristicであり、出力が文字化けしているように見える場合、そのドキュメントのテキストレイヤーはおそらく読み取り順序を考慮して書き込まれていません。その場合、唯一信頼できる解決策は、元のアプリケーションから適切なタグ付きで再エクスポートするか、あるいは文字の原点座標をY、Xの順でソートする後処理を実行することです

CharacterOriginとCharacterRectangleが提供するもの

どちらのプロパティも、ページ空間(ポイント単位、左下隅が原点、Y軸は上方向に向けて増加)における文字の位置を返します。CharacterOrigin[i]はグリフのベースラインアンカーポイントであり、CharacterRectangle[i]は完全なバウンディングボックス(境界枠)です。これらはプレーンテキストにとどまらず、列の境界の検出、Y座標を許容範囲内で比較することによる文字の行へのグループ化、あるいはビューアでのテキスト選択用のヒットテストマップの構築などの基礎となります。マウスクリックの下にある文字を見つけたい場合は、自分で矩形を反復処理しなくても、CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY)が直接その検索を行います

DLLの配置

PDFium Componentは、PDFのすべての解析処理をネイティブDLL(ターゲットプラットフォームに応じてpdfium32.dllまたはpdfium64.dll)に委譲します。コンポーネントには、適切なファイルをWindowsのシステムディレクトリにコピーするCopyDlls.batスクリプトが付属しています。開発用のPCで管理者として一度実行すれば十分です。配布時は、代わりにDLLをアプリケーションの実行ファイルと同じ場所にコピーします。V8対応のバリアント(pdfium32v8.dllpdfium64v8.dll)はかなりサイズが大きく、PDFに含まれるJavaScriptを実行する必要がある場合にのみ必要です。純粋なテキスト抽出の場合、標準ビルドが適切な選択肢です

実行時にDLLが存在しない場合、コンポーネントが内部でロードエラーをキャッチするため、ファイルが見つからない場合と同様にActive := Trueは何もエラーを出さずに失敗します。製品を出荷する前に、必ずクリーンな環境でテストしてください

レイアウト分析のためのCharacter[]と組み合わせたFontSize[]の使用

プレーンテキスト以外に、文字レベルのAPIは、各グリフの描画ポイントサイズを返すFontSize[i]を公開しています。これをCharacterOrigin[i]およびCharacterRectangle[i]と組み合わせることで、構造ツリーに依存せずとも本文と見出しを区別できます。タグが付いていないドキュメントにおいて、フォントサイズが特定のしきい値を超える文字の連続は、ほぼ確実に見出しです。同じテクニックは、キャプション(画像バウンディングボックスの下にある小さなテキスト)や脚注(ページの下部付近にある小さなテキスト)の検出にも適用できます。これらの処理にレンダリングは不要です。3つのプロパティすべて、Active := Trueの間にPDFiumが構築するテキストレイヤーから直接読み取ります

1つのニュアンスとして、FontSize[i]はページのCTM(現在の変換マトリクス)が適用された後のサイズを反映するため、作成者がページ全体をスケールさせたドキュメントでは、それに比例して調整されたサイズが報告されます。異なるページ寸法のページ間でサイズを比較する場合は、しきい値判定を行う前に、各ページのMediaBoxの高さに対して正規化を行ってください

ファイルへの出力書き込み

DelphiのTStringListは、XE以降、UTF-8出力をクリーンに処理します。BOMなしファイルが必要な場合は、WriteBOM := Falseを設定します(下流の多くのデータ処理プログラムは先頭のBOMを処理できずエラーになります)

var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    ExtractAllText(Pdf, Lines);
    Lines.WriteBOM := False;
    Lines.SaveToFile('output.txt', TEncoding.UTF8);
  finally
    Lines.Free;
  end;
end;

メモリ制限が懸念される非常に大きなドキュメントの場合、最初にすべてをリストに蓄積するのではないため、ページループ内で直接TEncoding.UTF8を使用してTStreamWriterに書き込みます

ここで紹介したCharacter[]CharacterCountCharacterOrigin[]CharacterRectangle[]ReadablePageContent、およびCharacterIndexAtPos APIは、DelphiおよびC++Builder用のPDFium Componentに含まれています