技術記事

編集後の古いテキスト: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はそれらの変更をすでに開かれているテキストページに自動的に反映させることはない。すべての編集ごとにそれを再構築すると、バッチ編集が容認できないほど遅くなってしまう。そのため設計はそのコストをルールと引き換えにする——ハンドルを保持している誰であれ、コンテンツを変更する編集の後にそれを閉じ、次の読み取りが新しいものを構築する

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 already closed the cached text page, so this FindFirst
    // call rebuilds it fresh before it searches
    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 handle, cached in FTextPage
    Pdf.SetText(0, 'Amended Clause 4.2');
    // SetText already closed RawHandle and set Pdf.TextPage back to nil.
    // Calling any FPDFText_* function against the old value now touches a
    // handle PDFium has already freed — undefined behavior, not a bug you
    // can catch with a nil check
    StaleCount := FPDFText_CountChars(RawHandle);
  finally
    Pdf.Free;
  end;
end;

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

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

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

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

    // Strip every text object that looks like a draft watermark. Each
    // RemoveObject call already invalidates the cache on its own, so
    // nothing needs refreshing by hand between iterations
    for I := Pdf.ObjectCount - 1 downto 0 do
      if (Pdf.ObjectType[I] = otText) and (Pdf.ObjectBounds[I].Top > 700) then
        Pdf.RemoveObject(I, True);

    // Query once, after the whole batch is done, not once per removal
    if Pdf.FindFirst('DRAFT') < 0 then
      ShowMessage('Watermark cleared');
  finally
    Pdf.Free;
  end;
end;

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

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

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

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

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