技術記事

編集後の古いテキスト:PDFiumのFPDF_TEXTPAGEキャッシュ

PDFiumPasでPDFページに1行を刻印するためにAddTextを呼び、そのスタンプが着地したことを確認するためすぐにFindFirstを呼ぶと、その検索は空で戻ってくる。テキストはページ上にある——Acrobatはそれを表示する——しかしPDFiumPasのTPdfコンポーネントは、ページのコンテンツストリームから一度だけ解析された別個のキャッシュ済みFPDF_TEXTPAGE構造体を保持しており、編集はその構造体を自動的に遡及更新することはない。それがまだ更新されていない状態でクエリすると、変更後ではなく変更前に見えていた通りのページを読むことになる

なぜPDFiumは編集直後に古いテキストを返すのか

PDFiumPasはDelphiとC++Builder向けにGoogleのPDFiumレンダリングエンジンをラップしており、そのテキストと編集の呼び出しは、そのエンジン内部の2つの異なるサブシステムに届く。FPDF_TEXTPAGEは読み取り側に属する:FPDFText_LoadPageはページのコンテンツストリームを一度歩き、テキストページ——文字コード、位置、フォントメトリクス、単語境界——を構築し、PDFiumPasはページがロードされたままである限りその構造体をキャッシュし続ける。FPDFPage_InsertObjectFPDFPage_GenerateContentのような編集呼び出しは全く異なる表現、すなわちページのオブジェクトとコンテンツストリームのグラフに対して動作し、PDFiumはそれらの変更をすでに開かれているテキストページに自動的に反映させることはない。すべての編集ごとにそれを再構築すると、バッチ編集が容認できないほど遅くなってしまう。そのため設計はそのコストをルールと引き換えにする——ハンドルを保持している誰であれ、コンテンツを変更する編集の後にそれを閉じ、次の読み取りが新しいものを構築する

PDFium 編集はページコンテンツストリームへ書き込む一方、キャッシュされた FPDF_TEXTPAGE はロード時スナップショットのまま。AddText 直後の Delphi FindFirst 照会が編集前ページを読み、スタンプを見逃す
編集と読み取りは PDFium 内の 2 つの別個のサブシステムです。キャッシュされたテキストページはロード時点のスナップショットであり、どの編集もそれ自体では更新しません

TPdfのテキストキャッシュの内部:FTextPage、LoadTextPage、UnloadTextPage

TPdfは、キャッシュされたハンドルを単一のプライベートフィールドであるFTextPageで追跡し、そのライフサイクルを2つのメソッドでラップする。LoadTextPageFTextPageがnilかどうかをチェックし、その場合にのみ現在のページに対してFPDFText_LoadPageを呼ぶ;すでにハンドルが存在する場合、LoadTextPageは、そのページが構築されて以来変わったかどうかを尋ねることなくそれを再利用する。UnloadTextPageはもう一方の半分である:それはFPDFText_ClosePageでネイティブハンドルを閉じ、FTextPageをnilに戻し、キャッシュされたウェブリンクのリストと進行中のfindセッションも破棄する。なぜなら両方とも同じテキストページから導かれており、同じ理由で古くなるからだ

LoadTextPageのチェックなしの再利用の挙動こそが、まさに順序が重要である理由である。TPdf上のすべてのテキストクエリ——TextFindFirstGetWebLinks——は最初にLoadTextPageを経由する。そのためFTextPageが編集前のハンドルを保持し続けている限り、これらの呼び出しのいずれも変更が起きたことを知る方法がない。ページナビゲーションはここではリスクだったことはない:ページ切り替え、リロード、文書のクローズ時に実行されるUnloadPageは、常にページ自体と一緒にテキストページを閉じてきた。未解決だった疑問は、常に、まだ座っているページに適用される編集についてのものだった

どのPDFiumPasメソッドがキャッシュを自動的に更新するのか

TPdf自身のページ編集メソッド——AddTextSetTextSetTextPositionsAddPathRemoveObjectInsertFormObjectFromXObject——はそれぞれ、変更をコンテンツストリームへシリアライズするためにUpdatePage(PDFiumのFPDFPage_GenerateContent)を呼ぶ前にUnloadTextPageを呼ぶ。これらのいずれかを呼べば、その直後のTextFindFirst、あるいはGetWebLinks呼び出しは、あなた側で追加の呼び出しを一切必要とせず、現在の状態のコンテンツからテキストページを再構築する

var
  Pdf: TPdf;
  Index: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    Pdf.AddText('Reviewed by J. Alvarez', 'Helvetica', 10, 72, 40, clBlack, 255, 0);
    // AddText はキャッシュされたテキストページをすでに閉じているため、この FindFirst
    // の呼び出しは検索前にそれをクリーンな状態で再構築する
    Index := Pdf.FindFirst('Reviewed by J. Alvarez');
    if Index >= 0 then
      ShowMessage('Stamp confirmed at character ' + IntToStr(Index));
  finally
    Pdf.Free;
  end;
end;

それでも壊れるパターン:生のTextPageハンドルをキャッシュする

TPdfは、PDFiumPasがラップしていないFPDFText_*関数を呼ぶ必要がある稀なケースのために、読み取り専用のTextPageプロパティを通じてライブなハンドルを公開している。その脱出口こそが、自動的な無効化が助けになれない唯一の場所でもある:一度FPDF_TEXTPAGEの値をそのプロパティからローカル変数へコピーしてしまうと、PDFiumPasにはあなたがまだそれを保持し続けていることを知る方法がなく、あなたのコードの他の場所でUnloadTextPageが実行されたときにあなたのコピーを更新する方法もない

var
  Pdf: TPdf;
  RawHandle: FPDF_TEXTPAGE;
  StaleCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    RawHandle := Pdf.TextPage;    // FPDFText_LoadPage のハンドル。FTextPage にキャッシュされる
    Pdf.SetText(0, 'Amended Clause 4.2');
    // SetText は RawHandle をすでに閉じ、Pdf.TextPage を nil に戻している。
    // 旧値に対して FPDFText_* 関数を呼ぶと、現在は
    // PDFium がすでに解放したハンドルに触れることになる——未定義動作であり、
    // nil チェックで捕捉できるようなバグではない
    StaleCount := FPDFText_CountChars(RawHandle);
  finally
    Pdf.Free;
  end;
end;

FPDFText_ClosePageがそれに対して実行された後にハンドルを使うことは、あなたが無視するかどうか選べるPDFiumPasの慣習ではなく、PDFium自体における未定義動作である——それは最後に既知だったデータを返すかもしれないし、何も返さないかもしれないし、プロセスをクラッシュさせるかもしれない。そしてあるビルドでそのどれが起こるかは、アプリケーションのコードが依存すべきものではない。安全なルールは狭い:Pdf.TextPageを、それを必要とするFPDFText_*呼び出しの直前に新しく読み、ページを編集するかもしれない文の間でコピーを保持することは決してしない

編集をバッチ化し、一度だけクエリする

これは、すべてのAddTextRemoveObject呼び出しが、その結果をチェックするための防御的なテキストクエリをその直後に必要とすることを意味しない。各編集メソッドはすでにテキストページを一度閉じるコストを払っている;ループの中ですべての単一の編集の後にクエリすることは、そのコストを何の利益もなく再度払うことになる、なぜならFPDFText_LoadPageはそれが実行されるたびにコンテンツストリーム全体を再度歩くからだ

AddText、SetText、RemoveObject などの TPdf 編集メソッドが UpdatePage の前に UnloadTextPage を呼び、次の Delphi Text、FindFirst、GetWebLinks 照会が編集済みコンテンツから FPDF_TEXTPAGE を再構築
ラップされた各編集は、まず古いテキストページを捨て、次にコンテンツを生成します。その後のテキスト問い合わせが FPDF_TEXTPAGE を自動的に再構築します
var
  Pdf: TPdf;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'watermarked.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;

    // ドラフト透かしのように見えるすべてのテキストオブジェクトを除去する。各
    // RemoveObject 呼び出しはそれ自体でキャッシュを無効化するため、
    // 反復間で手動で更新する必要は何もない
    for I := Pdf.ObjectCount - 1 downto 0 do
      if (Pdf.ObjectType[I] = otText) and (Pdf.ObjectBounds[I].Top > 700) then
        Pdf.RemoveObject(I, True);

    // 除去ごとではなく、バッチ全体が終わった後に一度だけクエリする
    if Pdf.FindFirst('DRAFT') < 0 then
      ShowMessage('Watermark cleared');
  finally
    Pdf.Free;
  end;
end;

同じバッチ化のロジックは検索状態にも具体的に当てはまる。FindNextFindPreviousFindFirstが開始したセッションを継続するものであり、そのセッションは他のすべてと一緒にUnloadTextPageによって解体される。そのため、編集の後にFindFirstをもう一度呼ぶのではなくFindNextをもう一度呼ぶと、もはや存在しないコンテンツに対する検索を静かに再開するのではなく例外を発生させる。あらゆる編集をテキストコンテンツと検索位置の両方にとっての固い境界として扱い、編集群の向こう側で新しいFindFirstを一度呼んで検索を再開させること

生の FPDF_TEXTPAGE ハンドルを TPdf TextPage プロパティからコピーし、SetText 後にそれに対し FPDFText_CountChars を呼ぶと、Delphi コードは既に解放された PDFium ハンドルを使い続け、未定義動作になる
コピーされた FPDF_TEXTPAGE 値は、編集経路が既に閉じたハンドルを指し続けます。ラップされていない FPDFText_* 呼び出しの直前に、必ず Pdf.TextPage を新しく読んでください

抽出と注釈の作業とどう関わるか

単純なテキスト抽出——何も変更せずにページのテキストを読むこと——はこれのどれにも一切ぶつからない。なぜなら、どんな編集も触れていないハンドルを無効化するものは何もないからだ。未変更のページでText、文字矩形、単語境界がどう機能するかについては、PDFiumPasによるテキスト抽出の関連記事が、本稿がその上に追加しているテキストページキャッシュのライフサイクルなしにその領域を扱っている

このキャッシュのライフサイクルが最も重要になるのは、編集してすぐにその結果に対して行動するワークフローである:訂正をスタンプしてそれを検索すること、段落を編集除去してそれが消えたことを確認すること、あるいはテキストを挿入した直後にマークアップ注釈を固定するためのフレーズを見つけることである。最後のケースは単独で指摘する価値がある——クアッドポイントのマークアップ注釈は、テキストページから読み取った文字矩形から位置を決められる。そのため、編集前にキャプチャされた座標から構築された注釈は、その編集が着地すると間違った場所をハイライトすることになる

TPdfの編集・テキストAPIは、DelphiおよびC++Builder向けPDFiumコンポーネントの一部であり、製品ページには、ここで説明した編集・抽出・検索の全メソッドリファレンスが掲載されている