技術記事

Delphiで既存PDF内のテキストを検索して置換する

HotPDF Delphiコンポーネントは、DelphiとC++Builderから既存PDFの中のテキストを検索して置換できます。SearchLoadedPageTextとSearchLoadedDocumentTextは文字列の出現箇所をすべてグリフ単位の精度で見つけ出し、ReplaceLoadedPageTextとReplaceLoadedDocumentTextは一致したバイト列をその場で書き換えます——ただし置換後の文字がすべて元のフォントを通じて再エンコードできることが条件です。これは物理的な制約であり、本記事は脚注に隠さず正直に扱います

この機能の背後にある要望は、いつも地味なものです。会社が社名を変えたのに、保管されている3千通の請求書が古い社名を抱えたままだとか。契約書のテンプレートが去年の失効日のまま出回ったとか。製品コードが廃止され、それに触れているデータシートのすべてを後継コードに差し替える必要があるとか。ワープロならどれも30秒で終わる仕事です。PDFではこれが本当に難しい問題であり、その理由を理解しているかどうかが、APIを上手に使うことと、実質は仕様の引用にすぎないバグ報告を出すこととの分かれ目になります

PDFのテキスト置換はなぜそんなに難しいのか

PDFのテキスト置換が難しいのは、PDFのページが編集可能なテキストを含んでいないからです——含んでいるのは位置決めされたグリフです。ISO 32000-1 §9.4のテキスト描画モデルのもとでは、コンテンツストリームがTjやTJのような演算子を駆動し、テキスト行列で定まる座標に文字コードの並びを描きます。それらのコードはUnicodeではありません。ページのフォントが宣言するエンコーディングへのインデックスであり、読める文字へ戻す対応づけは/ToUnicode CMap、エンコーディングの差分配列、あるいはCIDの対応づけの連鎖の中に置かれているかもしれません。段落オブジェクトもテキストの流れも存在せず、見た目の1単語が1つの文字列として格納されている保証すらありません

置換は、デコードの上にもう1段の難しさを重ねます。元のストリームのどのバイトがどのグリフを生んだのかを正確に知らなければ、まさにその範囲だけに新しいバイトを差し込み、それ以外に手を出さないということができないからです。テキスト抽出器はUnicodeを取り出してしまえばバイト位置を捨てても構いません。置換器はそうはいきません。だからこそHotPDFはこの作業を2つのリリースに分けました——v2.251.0でオフセット追跡と検索の層を作り、v2.252.0でその上に書き換えの層を築いたのです

テキストを見つける:バイトオフセット追跡付きのグリフ単位検索

HotPDFのSearchLoadedDocumentTextは、生のストリームのバイト列ではなく各ページのデコード済みUnicodeグリフ列と突き合わせて、検索語の出現箇所をすべて見つけます。ですからフォントがどう符号化していようと、当たりは当たりです。その下地はv2.251.0で導入されました。コンテンツストリームのトークナイザーは、すべての文字列オペランドについてStartOfsとEndOfsのバイト範囲を——その( )や< >の区切り記号も含めて——記録し、デコードされた各グリフはTokenIndexとItemIndexとByteOffsetの3つ組を携えて、それを生んだ正確なオペランド、TJ配列の項目、コード単位を指し戻します。同じグリフインタープリターがDelphiで読み込み済みPDFからテキストを抽出するで説明した抽出APIも動かしています。検索は、抽出が捨てる出自の情報をただ保ち続けるだけです

Delphi向けHotPDFの検索が、グリフをUnicodeへデコードしながらStartOfsとEndOfsのバイト範囲を追跡してTHPDFTextMatchの結果を作る仕組み
HotPDFの検索はデコード済みのグリフ列の上で働きつつ、置換が必要とするバイトの出自を保ち続けます

一致した箇所はそれぞれTHPDFTextMatchレコードとして返り、ページのインデックス、両端を含むグリフの範囲、当たりのユーザー空間でのX/Y原点と幅、元のトークンと項目のインデックス、そして一致したテキストそのものを携えています。ハイライトのオーバーレイ、レビュー用のUI、あるいは置換の手順を動かすにはこれで十分です。何も見つからなかった検索は失敗するのではなく空の配列を返すので、呼び出し側の書き方は素直なままです

var
  Pdf: THotPDF;
  Matches: THPDFTextMatchArray;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoices-2025.pdf') > 0 then
    begin
      if Pdf.SearchLoadedDocumentText('Acme Corp', False, Matches) then
        for I := 0 to Length(Matches) - 1 do
          WriteLn(Format('page %d at (%.1f, %.1f): "%s"',
            [Matches[I].PageIndex, Matches[I].X, Matches[I].Y,
             Matches[I].Text]));
    end;
  finally
    Pdf.Free;
  end;
end;

意図的な設計上の選択が1つあり、これは書き留めておく価値があります。CaseSensitiveがFalseのとき、比較が大文字と小文字を同一視するのはASCII文字だけです。これは狙ってそうしています。完全なUnicodeのケースフォールディングは、HotPDFが対応するDelphi 5からXEまでのツールチェーンごとに挙動が異なり、アプリケーションをどのコンパイラーで作ったかによって見つかる箇所が変わる検索APIは、文書化された予測可能な限界を持つAPIより悪いからです。名前、コード、日付といったラテン文字のビジネス文書なら、ASCIIの同一視で実務上の場面は足ります

テキストを置換する:逆エンコードと外科的な差し替え

HotPDF v2.252.0で加わったReplaceLoadedDocumentTextは、デコードの機構を逆向きに走らせて検索語の出現箇所をすべて書き換えます。HPDFEncodeUnicode関数は文字コードのデコーダーの逆です。同じ戦略の連鎖を逆順にたどり——/ToUnicodeのbfcharとbfrangeの参照、エンコーディングストリームのCID対応づけ、Type0のidentity対応づけ、そして定義済みのWinAnsiとMacRomanの表——置換後の各文字を、元のフォントが期待する文字コードのバイト列へ戻します。再エンコードされたバイト列はその後、整った形の文字列リテラルまたは16進文字列へ直列化され、トークナイザー自身のエスケープ規則を写し取るので、解析 → 再直列化の往復は安定します

差し替えそのものは、まとめてではなく外科的に行われます。文字列オペランドの中で置き換わるのは一致が覆うコードバイトの範囲だけで、同じオペランド内の一致しなかったバイト、トークン間の空白、周囲のあらゆる演算子は1バイトもたがえず元のまま保たれます。abcabcの中のbcaを置換すると、オペランドが潰れるのではなくa + 置換後の文字列 + bcになります。置換後の文字列は検索語より短くても長くても構いませんし——リテラルは再直列化され、ストリームの/Lengthも更新されます——複数ストリームのページでは各/Contentsストリームが独立して処理されるので、ページは整った形のままです

Delphi向けHotPDFの置換が、HPDFEncodeUnicodeでデコードの連鎖を逆にたどり、文字列オペランドのうち一致したバイト範囲だけを差し替える仕組み
逆エンコードがフォントの文字コードを組み直し、その後オペランド内の一致したバイト範囲だけが書き換えられます
var
  Pdf: THotPDF;
  ReplaceCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-draft.pdf') > 0 then
    begin
      if Pdf.ReplaceLoadedDocumentText('2025-12-31', '2026-12-31',
        True, ReplaceCount) then
        WriteLn(Format('%d operand rewrites performed', [ReplaceCount]));
      Pdf.SaveLoadedDocument('contract-final.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

このAPIがしないことにも注意してください。ページを組み直すことはしません。PDFにはリフローがないので、元より見た目の幅が広い置換はそのまま横方向の場所を余分に占め、その右に描かれていたものを押しのけるかもしれません。同じ長さか近い長さの差し替え——日付、バージョン文字列、部品番号、名前の訂正——が最も向いている用途です。文章を丸ごと書き直すのは、PDFではなく元の文書での仕事です

フォントのサブセットに含まれなかった文字へは、なぜ置換できないのか

埋め込まれたフォントのサブセットに含まれなかった文字へ置換できないのは、その文字を選ぶバイト列がフォントの対応表にそもそも存在しないからです。PDFの生成器がサブセットフォントを埋め込むとき、その/ToUnicode CMapとエンコーディングの構造が覆うのは、元の文書が実際に使ったグリフだけです。HPDFEncodeUnicodeが逆にたどれるのは、そこに存在する対応づけだけです。そのフォントで文字Eを一度も含まなかった文書には、逆にたどるべきEの文字コードがありません。これは特定のライブラリの制限ではなく、ファイルの物理的な性質です——埋め込まれたことのないグリフの対応づけを呼び出せる道具はありません

HotPDFはこの失敗を保守的に扱います。置換後の文字のうち1つでも再エンコードできなければ、その検索語の出現箇所は丸ごと飛ばされます。例外は出ず、中途半端に壊れたテキストも残らず、その箇所は単にReplaceCountに数えられません。実務上の帰結はこうです。事前の検索で得た一致数とReplaceCountを突き合わせ、不足を合図として扱ってください。上の日付の例なら、書き換えが成功するには数字の6が同じフォントで文書のテキストのどこかに現れている必要があります——請求書ならありそうですが、一般には決して保証されません。必要な文字がそもそも手に入らず、しかも目的が文章の言い換えではなく機微なテキストの除去なら、どのみち本当の内容削除の方が適した道具です。その道筋はDelphiで読み込み済みPDFを墨消しし再構成するをご覧ください

埋め込まれたフォントのサブセットに必要な文字が無いとき、HotPDFがDelphiでのPDFテキスト置換を丸ごと飛ばす理由と、ReplaceCountによる確認
フォントのサブセットが再エンコードできない置換文字はその出現箇所を丸ごと飛ばすので、ReplaceCountの不足は本物の合図です
var
  Matches: THPDFTextMatchArray;
  Expected, Replaced: Integer;
begin
  Pdf.SearchLoadedDocumentText('Acme Corp', True, Matches);
  Expected := Length(Matches);
  Pdf.ReplaceLoadedDocumentText('Acme Corp', 'Apex Corp', True, Replaced);
  if Replaced < Expected then
    WriteLn(Format('%d occurrence(s) skipped: characters missing ' +
      'from the font subset, or match spans multiple operands',
      [Expected - Replaced]));
end;

そのメッセージにある2つめの条件が、もう1つの文書化された境界です。複数の文字列オペランドにまたがる検索語——たとえば[(He)(llo)] TJの項目に分かれたHello——は検索では見つかります。検索はデコード済みのグリフ列と突き合わせるからです。しかし置換では飛ばされます。オペランドの境界をまたいで書き換えるには、隣り合うバイト範囲を併合する必要があるからです。検索してから確かめるやり方は、この2つの限界を沈黙させずに見えるようにします

保存するとファイルの何が変わるのか

置換された/Contentsストリームは非圧縮で保存されます。FlateDecodeで圧縮されたストリームは編集のために展開され、HotPDFが組み直したバイト列を書くときには、再圧縮するのではなくストリームの/Filterエントリを外して/Lengthを更新します。できあがるPDFは完全に妥当で、主要なビューアでも普通に描画されます。引き換えになるのは、編集されたストリームごとにファイルが大きくなることです。数千の文書を処理するバッチのパイプラインでは、その増加分を見込んでおくか、後段で別に圧縮の工程を走らせてください。書き換えられたオブジェクトが保存時に文書のクロスリファレンス構造とどう関わるかはそれ自体が1つの話題で、HotPDFのオブジェクトストリームと増分更新で扱っています

ファイルのそれ以外の部分には手が付きません。触れられなかったストリームは圧縮を保ち、フォントと画像は書き直されず、オペランド単位の差し替えのおかげで、編集されたストリームでさえ一致があった場所以外は元のファイルと変わりません。この保守性は意図したものです。ライブラリが読み込んだ文書を書き換える範囲が広いほど、想定していなかった生成器の癖を壊す機会も増えるからです

テキストの検索と置換は、抽出、墨消し、ページ描画と並んでHotPDFの読み込み済み文書向けの道具立てに加わり、どれも同じコンテンツストリームのインタープリターで動き、外部依存なしにDelphi 5から現行のRAD Studioまでで使えます。完全なAPIリファレンスと試用版のダウンロードはHotPDF Delphiコンポーネントの製品ページにあります