技術記事

HotPDFを使用してDelphiで読み込まれたPDFからテキストを抽出する

HotPDF Componentは、2つの呼び出しを介して、Delphiで読み込まれた任意のPDFからUnicodeテキストを抽出します。ExtractLoadedPageTextはページの読み取り順に沿ったテキストを返し、ExtractLoadedPageTextLayout(v2.263.0で追加)はページの視覚的な配置をプレーンテキストとして再構築するため、段組み、インデント、表の配置が出力内に維持されます。どちらの関数も、HotPDFが生成したのではないドキュメントに対して動作します。これこそが、顧客からメールで送られてきた請求書、スキャン業者から納品されたレポート、名前も分からないソフトウェアによって生成された契約書など、実務で実際に必要とされるユースケースです

PDFはテキストファイルのようにテキストを保存しないため、この機能の実現にはAPIの定義から想像される以上の仕組みが必要になります。本記事では、両方の抽出モードについて解説し、それを支える3つのコンポーネント(CMapリーダー、コンテンツストリームインタープリタ、およびフォントデコードのフォールバックチェーン)の内部構造を紐解きます。マッピングの動作原理を理解することは、文字化けした出力に対してお手上げになるか、原因を特定して対処できるかの分かれ道となります

なぜテキスト抽出はファイルから文字列を読み取るよりも難しいのか?

PDFのコンテンツストリームは、文字ではなく文字コードを記録します。TjおよびTJオペレータ(ISO 32000-1 §9.4.3)はバイト文字列を保持しており、その意味は直前のTfによって選択されたフォントに完全に依存します。例えば、バイト値0x41は、WinAnsiエンコーディングでは文字「A」を指す場合もあれば、サブセットフォント内の任意のグリフである場合もあり、あるいは複合CJKフォント内の2バイトのCIDの半分である場合もあります。ISO 32000-1 §9.10は、テキスト抽出をまさにこのデコード問題、すなわちフォント辞書から得られる情報に基づいて各コードをUnicodeにマッピングし直す処理として定義していますが、標準規格は、適合ファイルがこの変換を行うための十分な情報を提供することを必ずしも要求していません

この最後の規定は、「このPDFからコピー&ペーストすると文字化けする」というおなじみのバグ報告の原因を説明しています。/ToUnicodeテーブルを持たないサブセットフォントを埋め込んだPDF生成ツールは、レンダリングは完璧ですが、抽出すると無意味な文字列になるファイルを生成します。コードからグリフへのマッピングは存在しますが、コードからUnicodeへのマッピングが同梱されていないためです。したがって、適切な抽出APIはベストエフォット型のフォールバックチェーンにならざるを得ず、重要なのはそのチェーンがどれだけ深く対応しているかという点になります

ExtractLoadedPageTextによる読み取り順のテキスト抽出

検索インデックスの作成、キーワードマッチング、または分析パイプラインへのテキスト供給には、ExtractLoadedPageTextが適しています。関数の定義はfunction ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): booleanであり、ページインデックスは0から始まります。結果はDelphiネイティブのUnicodeStringとして取得され、ページに読み取り可能なコンテンツストリームが存在しない場合は例外をスローするのではないFalseを返します

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // AllText now holds the reading-flow text of the document
  finally
    Pdf.Free;
  end;
end;

出力内の改行は、簡潔なヒューリスティックに基づいて判定されます。グリフの垂直位置の起点が現在のフォントサイズの半分以上移動した場合(これはコンテンツストリーム内のTdまたはT*による移動の特徴です)、改行が挿入されます。デコーダが解決できない文字は、消滅するのではなくスペースに置き換えられるため、個々のグリフがデコードできない場合でも単語の境界は維持されます。このモードでは、読み取り順のクラスタリングや複数段組みの検出は行いません。2段組みのページは、コンテンツストリームの順序で交互に出力されます。これは通常、視覚的な順序と一致しますが、必ずしも一致するとは限りません

どのような場合にレイアウト維持抽出を使用すべきか?

ExtractLoadedPageTextLayoutは、位置情報に意味がある場合(表、フォーム、コードリスト、差分比較、grep、列ごとのパースなど)に適した呼び出しです。グリフを単純なテキストストリームにフラット化するのではなく、グリフをベースラインごとにグループ化し、各ベースラインをX座標でソートし、グリフの平均送り幅とフォントサイズから計算された等幅の文字グリッド上に水平および垂直の空白スペースを再現します。同一ベースライン上のテキストブロック間の広い隙間は複数のスペースになり、ベースライン間の広い隙間は空行になります。出力結果は、ページの見た目通りに読み取ることができます

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // Columns, indentation and table alignment survive as
  // spaces and blank lines on a character grid
end;

2つのモードはデコードエンジンのすべての部分を共有しており、デコードされたグリフの配置方法のみが異なります。そのため、どちらを選択しても精度への影響はありません。テキストの内容のみが必要な場合はExtractLoadedPageTextを、レイアウト情報が必要な場合はExtractLoadedPageTextLayoutを選択してください。複数段組みの読み取り順検出は両モードともにサポート範囲外です。2段組みページをグリッドレンダリングすると、両方の列が忠実に左右に並んで表示されます。これは差分比較には最適ですが、文章の流し込み(リフロー)には適していません

HotPDFはどのように文字コードをUnicodeにデコードするのか?

HotPDF Componentは、優先順位が付けられたフォールバックチェーンを介して各文字コードを解決します。まずフォントに埋め込まれた/ToUnicode CMap、次に/Encodingエントリ(ストリームまたは名前付きCMap)、続いて(複合フォントの場合)Adobe-GB1、Adobe-CNS1、Adobe-Japan1、Adobe-KRなどの文字コレクション用のAdobe標準CMapファイル、そして最後にシンプルなフォント用の内蔵WinAnsiおよびMacRomanテーブルを順に参照します。結果を得られないデコード処理は例外を発生させることなく次の方法へと暗黙的に移行し、すべてのチェーンで解決できなかった文字コードは0を返すため、呼び出し側は推測ではなく正確にデコード失敗数をカウントできます

/ToUnicode CMap(ISO 32000-1 §9.10.3)が最優先されるのは、これがテキスト抽出のためにPDF生成ツールが書き込んだ固有のマッピングだからです。Adobeの標準CMapパスは、マッピングデータを埋め込む代わりにUniGB-UTF16-Hなどの定義済みCMapを使用するCJKドキュメントで重要になります。HotPDFは、これらのコレクションファイルをresources\CMapディレクトリに同梱し、実行時に実行ファイルからの相対パスで特定し、解析された各マップをプロセス単位でキャッシュします。その中で最大のAdobe-GB1マップはソーステキストで約2 MBあり、これをページごとに再解析することを避けるための重要な仕組みです。このディレクトリが存在しない場合、デコーダはディスク上のCMapをスキップし、埋め込みテーブルと内蔵エンコーディングのみで動作します。これは、書き込み時に直面する文字とグリフの区別に対処した、HotPDFによる複雑なスクリプトのテキストシェイピングで説明している整形の課題に対する、読み込み側の鏡のような関係です

知っておくべきCMap構文の2つの罠

CMapファイルは一見簡単に解析できるように見えますが、実際はそうではありません。最初の開発でパーサーが失敗する主な原因は2つの詳細にあります。第1に、レコード数がセクションキーワードの「前」に配置される点です。セクションは2 beginbfcharと記述され、beginbfchar 2ではありません。キーワードの後にカウント値を想定するパーサーは、その数値を無関係なトークンとして処理してしまい、すべてのセクションでエントリが0個であると判断します。HotPDFのリーダーが採用している堅牢なアプローチは、このレコード数を完全に無視し、対応するendbfcharまたはendbfrangeキーワードが現れるまでループを実行することです。これにより、レコード数が誤って記述されている実際のファイルも許容できるようになります

第2の罠は、bfcharbfrangeの変換先が整数ではなくUTF-16BE文字列である点です。変換先<D83DDE00>はU+1F600を示しており、これは1つのコードポイントに再結合する必要があるサロゲートペアです。これらの4バイトをビッグエンディアンの整数として読み取ると、基本多言語面(BMP)以外のすべてのコードポイントで無意味な値が生成されます。PDFにおける絵文字の利用はもはや珍しくないため、サロゲートペアの再結合をスキップするデコーダは、ユーザーが実際に使用するファイルで動作しなくなります。HotPDFはまず16進リテラルを未加工のバイトにパースし、その後にUTF-16BEコードユニットを再結合します。これにより、合字マッピングが生成する複数文字の変換先にも対応できます

ExtractLoadedPageGlyphsによるグリフレベルの処理

両方のテキスト抽出関数はExtractLoadedPageGlyphsをベースにしており、その背後にあるTHPDFGlyphArrayはユーザーコードからも利用可能です。各THPDFGlyphRecordは、解決されたUnicodeコードポイントに加え、生の文字コード、CMapのcodespacerangeで決定されるコードのバイト幅(1、2、または4)、有効なフォントリソースキーとサイズ、ユーザ空間におけるXおよびY座標の原点、および文字の水平送り幅を保持しています。これは、コンテンツストリーム自体を直接処理することなく、単語境界検出、位置指定付きハイライト表示、またはカスタムレイアウトアルゴリズムを構築するのに十分な情報です

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d of %d glyphs have no Unicode mapping',
        [Unresolved, Length(Glyphs)]);
  end;
end;

どのようなPDFからテキストを抽出できないのか?

一部のファイルはどのような抽出ツールでも対応できず、出力結果を提供する前にそれらを検出する方が賢明です。最も分かりやすい例はスキャンされたドキュメントです。ページ全体が1つの巨大な画像である場合、テキストオペレータが一切含まれていないため、抽出結果は正しく空文字列を返します。この場合の解決策はOCRであり、ロードされたPDFからページ画像を抽出する処理がそのパイプラインの最初のステップとなります。より難しいのは、/ToUnicodeテーブルを持たないサブセットフォントです。/Encodingパスと標準CMapも機能しない場合、それらのグリフは0に解決され、テキスト抽出関数ではスペースとして出力されます。暗号化されたドキュメントは、LoadFromFileのオーバーロードを介してパスワードとともにロードすれば正常に抽出できます。これにより、インタープリタが処理する前にストリームがデコードされます

明確にしておくべき細かな制限事項があります。デコードチェーンはCMapとコンテンツストリームをHotPDFのFlateパスを介して読み取るため、ToUnicodeストリームが特殊なフィルタを使用しているフォントは、ページの読み込みを失敗させるのではなく、次のデコード方式へと移行します。実際、FlateDecodeは過去2年間に作成されたほぼすべてのドキュメントをカバーしており、この移行は設計によりサイレントに実行されます。例外を発生させるのではなく、ファイルが許容する範囲で最善のテキストを抽出します。ここでフォント辞書を解決する読み取り側のオブジェクト処理エンジンは、ロードされたドキュメントのメタデータ編集にも利用されているため、ドキュメント取り込みパイプライン内で抽出、検査、注釈付与を一括で実行できます

テキスト抽出、レイアウト維持レンダリング、グリフレベルのアクセス、およびそれらをベースに構築された検索と置換機能は、すべてDelphiおよびC++Builder向けの標準的なHotPDF Componentの一部です。外部DLLやOSのテキストサービスは不要で、特殊なファイルが入力された場合でもステップ実行で追跡可能なObject Pascalのみで実装されています