技術記事

ヒット座標付きの Delphi PDF テキスト検索: PDFlibPas

ページのテキストを抽出するだけなら簡単です。ですが、ユーザーが検索ボックスに単語を打ち込み、ビューアがその位置へジャンプして黄色い矩形で囲ってくれることを期待した瞬間、単なる平坦な文字列では得られない情報が必要になります。各一致が存在するページと、その一致が PDF 座標系で占める矩形です。ページ全体で連結された文字列は、そのジオメトリを失っています。部分文字列は見つけられても、指し示すことはできません

PDFlibPas は Delphi と C++Builder 向けのネイティブ Object Pascal PDF ライブラリであり、v3.78.0 からはまさにその問いに答えます。既存のテキストブロック抽出の上に 3 つの問い合わせ API が追加されました。SearchTextSearchTextWithBoundsEnumPageElementsEnumeratePageElementsGetTextInAreaExGetTextBlocksInRegionWithBounds

はページ範囲を歩き、各ヒットについてページ番号と軸平行矩形を返します

ExtractPageTextGetPageText が内部で使っている経路をそのまま再利用したくなるのが自然な発想です。ですが、その経路は一時的な抽出用「ファネル」を通り、ページ文字列を生成したあと、呼び出しが返る前に解放されます。結果を手にした時点では、ブロックごとの座標はもう残っていません。最初から保持する前提ではないからです

座標が生き残るのは別の構造です。ExtractPageTextBlocks(3)GetPageTextBlocks

SearchTextWithBoundsSearchTextGetTextBlockTextGetTextBlockText で各ブロックの文字列を読みます。クエリと照合し、一致したブロックについてはその quad を矩形へ縮約します。返るヒットは小さなレコードです

type
  TPDFlibSearchHit = record
    Page: Integer;                       // 1-based page of the match
    Left, Top, Right, Bottom: Double;    // axis-aligned hit rectangle
    MatchText: WideString;               // the block text that contained the query
  end;

Bound 配列は 4 つの頂点ではなく、X/Y が交互に並ぶ

最初に引っかかるのはこの細部です。GetTextBlockBound(ListID, Index, BoundIndex)GetTextBlockBoundBoundIndexBoundIndexX, Y, X, Y, X, Y, X, YX/Y が交互

回転があるからです。単純な矩形ではなく quad を持つ理由はそこにあります。角度付きで配置されたテキストブロックは真の 4 点ポリゴン境界を持ち、この 8 個の double はそれを正確に表します。ですが、ハイライトとジャンプの用途では、たいてい直立した矩形のほうが必要です。そのためライブラリは 4 点を走査して X と Y の最小値・最大値を取り、quad を軸平行矩形へ縮約します。回転したテキストは、それを内包する直立ボックスへ折りたたまれ、ハイライト表示にはそれが適しています

var
  Pdf: TPDFlib;
  Hits: array[0..255] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract.pdf', '');
    // Search pages 1 to 10, case-insensitive, substring match.
    Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
          [Hits[I].Page, Hits[I].Left, Hits[I].Top,
           Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
  finally
    Pdf.Free;
  end;
end;

この矩形は、ページ左下を原点とする PDF ユーザ空間ポイントで表されます。描画や注釈 API に渡すのと同じ座標系です。これは意図的です。検索ヒットから返ってくる矩形は、変換なしでそのままハイライト注釈や「ここへスクロール」コマンドへ渡せる矩形です

大文字小文字、単語全体一致、そして CJK が異なる点

第 2 引数は TPDFlibSearchOptionsTTextSearchOptionssoCaseSensitivetsoMatchCasesoWholeWordtsoWholeWord[][]soCaseSensitivetsoMatchCaseIndemnityInvoiceindemnityinvoicesoWholeWordtsoWholeWordsigncatsignatureconcatenate

word 文字クラス[A-Za-z0-9_]直前と直後の文字が ではない word 文字である場合だけ whole-word 一致になります(または一致がブロック端にある場合です)

非ラテン文字系に対する帰結は、多言語検索ボックスを出荷する前に知っておくべき点です。漢字、かな、その他の非 ASCII 文字はそのクラス外なので、それらの隣接位置はすべて非 word 境界として扱われます。実際には、CJK テキストに対する whole-word 検索は、すべての位置が有効な単語境界であるかのように振る舞い、事実上部分文字列一致へ劣化します。これはバグではなく、仕様として文書化された制限であり、この機能が手本とした挙動とも一致しています。コーパスの大半が CJK なら、whole-word モードは専用トークナイザのような分かち書きを提供しません。それを前提に設計してください

ほかの場所で起こる微妙な失敗の一群を説明してくれる実装上の脚注が 1 つあります。大文字小文字を無視する比較は UpperCaseCompareTextWideStringUnicodeStringAnsiUpperCaseAnsiCompareTextAnsiStringAnsiStringWideStringUnicodeString

ライブラリ全体で 1 つのページ範囲パーサーを使う

第 3 引数は "1,3,5-9"1-3,5,9-12PLParsePageRangeListParsePageRangePrintPagesPrintPagesSearchTextSearchTextWithBounds

コストに影響するのは範囲です。1000 ページ文書のうち 10 ページだけを検索するなら、抽出するブロックも 10 ページ分だけで済みます。ループが選択して抽出するのは、範囲が指したページだけだからです。目的の条項が付録にあると分かっているなら、範囲で示して残りのファイルを飛ばしてください

finallyfinallySearchTextWithBoundsSearchText をページ構築の途中で呼んでも、戻ってきたときの選択ページは呼び出し前と同じです。この保存と復元の契約は、欠けて初めて気付く類いのものですが、だからこそ入っています

ページ全体を列挙する: テキストと画像を 1 つの一覧に

検索は「この単語はどこにあるか」に答えます。もう半分のイントロスペクションは「このページには何が載っているか」で、それを担うのが EnumPageElementsEnumeratePageElementsKindKind

type
  TPDFlibPageElementKind = (ekText, ekImage);

  TPDFlibPageElement = record
    Kind: TPDFlibPageElementKind;
    Page: Integer;
    Left, Top, Right, Bottom: Double;
    Text: WideString;        // ekText
    FontName: WideString;    // ekText
    FontSize: Double;        // ekText
    ImageID: Integer;        // ekImage; usable with SelectImage / GetImageID
  end;

GetPageTextBlocksExtractPageTextBlocksGetPageImageCountFindImagesGetPageImageGetImageIDImageIDImageIDGetImageInfoSelectImage

var
  Pdf: TPDFlib;
  Elems: array[0..511] of TPDFlibPageElement;
  Total, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf', '');
    Total := Pdf.EnumPageElements(1, Elems);
    for I := 0 to Total - 1 do
      if I <= High(Elems) then
        if Elems[I].Kind = ekText then
          WriteLn(Format('text  %s/%.1f  "%s"',
            [Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
        else
          WriteLn(Format('image id=%d', [Elems[I].ImageID]));
  finally
    Pdf.Free;
  end;
end;

エレメント数であり、渡した配列長より大きい場合があります。関数は入るだけスロットを埋めたうえで、残りも含めた件数を数え続けます。シグネチャ列挙と同じ流儀です。したがってガードは常に同じです。ループは戻り値と Length(Elements)High(array) の小さいほうまでに制限し、件数をそのまま盲信して回してはいけません。上の例で I <= High(...)Min

このライブラリの低レベルなテキストブロック API を使ったことがあるなら、これはその上に載る型付きでジオメトリ対応の層です。基礎となる抽出は PDFlibPas を使った Delphi PDF のテキスト・画像・フォント抽出 で説明したものと同じです。そして、関心が「このテキストはどこにあるか」ではなく「この文書は支援技術向けにどう構造化されているか」にあるなら、並行する読み取り面は タグ付き PDF の構造ツリー です。こちらは物理的なブロック配置ではなく、論理的な読み順を公開します

見る場所が分かっているときの領域クエリ

ときには検索語がなく、矩形だけが手元にあります。フォームテンプレートでは請求書番号が常に右上にある、あるいはスキャンレイアウトでは表のために固定帯域が確保されている、といった場合です。GetTextInAreaExGetTextBlocksInRegionWithBoundsGetTextInAreaGetTextBlocksInRegion

var
  Pdf: TPDFlib;
  Hits: array[0..63] of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf', '');
    Pdf.SelectPage(1);
    // Left, Top, Width, Height in PDF points on the selected page.
    Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
    for I := 0 to Found - 1 do
      if I <= High(Hits) then
        WriteLn(Hits[I].MatchText);
  finally
    Pdf.Free;
  end;
end;

との違いで押さえるべき点は 2 つです。GetTextInAreaExGetTextBlocksInRegionWithBoundsSelectPageSelectPageSearchTextSearchTextWithBounds交差したときに 保持されます。完全に内包されたときだけではありません。そのため境界をまたぐ 1 行も返ってきます。手描きの選択ボックスに対しては、通常こちらのほうが望ましい挙動ですが、厳密な内包が必要なら、矩形を自分で持てるようになったので、返ってきた矩形に対して後段で絞り込めます

実務でどう使えるか

この 3 つの呼び出しを貫く主題は、ジオメトリがもはや後付けで再構成するものではなくなったという点です。検索ヒットはページとボックスを知っています。ページ要素は矩形を知り、テキストならフォントも分かります。領域クエリは各行がどこに落ちているかを報告します。これだけあれば、本物の検索ハイライト機能、クリックで位置へ飛ぶ索引、あるいはレイアウトを理解した抽出器を、公開 API より下へ潜らずに組み立てられます

これらの問い合わせ API は PDFlibPas Delphi PDF Library の一部として提供されており、それらが基盤とする完全なテキストブロック抽出層と、Delphi および C++Builder 向けのその他の読み取り側イントロスペクションと並んで利用できます