Technical Article

Delphiでのキャンセル可能なプログレッシブPDFレンダリング (PDFium)

ほとんどのPDFページは数ミリ秒でラスタライズされ、それについて考えることはありません。しかし、ユーザーがA1サイズのエンジニアリング図面、数万のベクターストロークが詰め込まれたページ、あるいは透過グループとソフトマスクで混雑したポスターを開くと、それを描画する1回の呼び出しに2〜3秒かかります。その呼び出しがUIスレッドで実行されると、ウィンドウの再描画が停止し、タイトルバーがグレーアウトし、オペレーティングシステムはアプリケーションを強制終了するかどうかを尋ねてきます。作業は正当なものです。そのページは本当にそれだけの時間を必要としています。欠陥は、レンダリングが1つの不可分なブロッキング呼び出しであり、息継ぎをする方法も停止する方法もないことです

この記事では、これら2つの問題のうちのまさに1つ、つまりUIをフリーズさせることなく、長時間の単一ページレンダリングをキャンセルすることについて説明します。ユーザーが次のページをクリックしたり、ズームしたり、ドキュメントを閉じたりした場合、実行中のレンダリングはもはや無駄な作業であり、最後まで実行するのではなく、次の機会に終了するべきです。すでにラスタライズされたものをキャッシュすることによってスクロールとズームをスムーズにすることは、独自の設計を伴う別の関心事であり、最後にリンクされている関連記事でカバーされています。ここでの唯一の疑問は、1つのプログレッシブレンダリングに、キャンセル要求へ迅速かつクリーンに応答させるにはどうすればよいかということです

PDFiumがすでに出荷しているプログレッシブレンダリングAPI

PDFiumは、問題の「フリーズする」半分を予期していました。ワンショットのFPDF_RenderPageBitmapと並んで、ページを作業のチャンクに分割するプログレッシブなバリアントを公開しています。宛先ビットマップに対してレンダリングをセットアップするためにFPDF_RenderPageBitmap_Startを1度呼び出し、その後FPDF_RenderPage_Continueを繰り返し呼び出します。各Continueは制限されたスライスをラスタライズし、ステータスを返します。FPDF_RENDER_TOBECONTINUEDはさらに行うべきことがあることを意味し、FPDF_RENDER_DONEはページが完了したことを意味し、FPDF_RENDER_FAILEDはエラーで停止したことを意味します。ループが終わったら、FPDF_RenderPage_Closeを呼び出して、ページごとのプログレッシブ状態を解放します。スライスの間に制御がコードに戻るため、メッセージをポンプしたり、進捗インジケーターを更新したり、作業がまだ必要かどうかをチェックしたりすることができます

いつ処理を譲る(yield)かを決定するためにPDFiumが提供するメカニズムは、IFSDK_PAUSEという名前のコールバック構造体です。これをStartおよびすべてのContinueに渡します。各チャンクの後、PDFiumはそのNeedToPauseNow関数ポインタを呼び出し、それがゼロ以外の値を返した場合、現在のContinueは早期に停止し、FPDF_RENDER_TOBECONTINUEDとともに制御を返します。この構造体はまた、1に設定しなければならないversionフィールドと、PDFiumが触れることなくそのまま通過させるフリーフォームのuserポインタを保持しています。その触れられていないポインタこそが、その後に続く設計全体の要なのです

一時停止をキャンセルとして再利用する

NeedToPauseNowの本来の意図はタイムスライシングです。フレーム予算を使い果たしたときにゼロ以外の値を返し、レンダリングを続ける場合はゼロを返すと、PDFiumは一時停止するため、同じレンダリングを再開する前に他の何かを行うことができます。PDFium Componentは、同じ信号を異なる動詞のために再利用します。「一時停止して再開させるべきか」と答える代わりに、コールバックは「この作業はキャンセルされたか」と答えます。ループがフラグを見たときの動作により、これら2つはきれいに互いにマッピングされます。純粋な一時停止は、後でContinueされることを期待しますが、キャンセルはそうではありません。呼び出し側のループがトークンがキャンセルされたことを確認すると、それはレンダーコンテキストを閉じ、再びContinueを呼び出すことはありません。したがって、PDFiumが「このチャンクを停止せよ」と読み取るのと同じ非ゼロの戻り値が、事実上「永久に停止せよ」になるのです

キャンセルは、IPdfCancellationTokenというインターフェースを通して表現されます。そのIsCancelledプロパティは、プログラムの他の部分がレンダリングの停止を要求したときに、falseからtrueに反転します。そのPascalのインターフェースとPDFiumのC言語のコールバックとの間の架け橋となるのは、単一のポインタです。トークンのインターフェース参照はIFSDK_PAUSE.userに書き込まれ、静的なcdeclコールバックがそれを読み出し、問い合わせます。これは、CライブラリにPascalへのコールバックを許可する際の古典的な問題です。PDFiumはPascalオブジェクトやSelfについて何も知らない裸の関数ポインタを保存し、呼び出すため、コールバックはメソッドではなく、Cの呼び出し規約を持つプレーンな関数でなければなりません

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFiumはこれを読み取る。 .userはトークンを保持する
    Token: IPdfCancellationToken; // 強い参照がトークンをアクティブに保つ
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // 非ゼロ: PDFiumはこのチャンクを停止する
end;

コールバックは、pThis^.userをインターフェース型にキャストし直すことによってトークンを回復し、IsCancelledを読み取ります。その中では何も割り当て(アロケーション)、ロック、またはブロックを行いません。PDFiumは各チャンクの後にレンダリングスレッドでこれを呼び出すため、ここで行われる作業はすべてレンダリング自体のコストに追加されるため、これは重要です。nilの構造体またはnilのuserフィールドに対するガードは、この同じ関数が、実際のトークンを与えられたことがないレンダリングにインストールしても安全であることを意味します

ループ全体でトークンをアクティブに保つ

インターフェースポインタを生のPointerを介してキャストしたり戻したりするところに、ライフタイムのバグが生まれます。DelphiにおけるIInterfaceは参照カウント方式であり、コンパイラがインターフェース型の変数が代入されているのを見ることができる場合にのみカウントが移動します。トークンを単なる裸のポインタとしてIFSDK_PAUSE.user内にのみ格納すると、参照カウンターから完全に隠されてしまいます。そのトークンへの唯一の他の参照が、Continueループがまだ実行されている間にスコープから外れた場合、オブジェクトはコールバックの下で解放され、次のチャンクはダングリングポインタを逆参照することになります

そのため、記述子は1つではなく、2つのものを保持するレコードになっています。PauseフィールドはPDFiumが読み取る構造体です。Tokenフィールドはコンパイラがカウントする実際のインターフェース型の参照であり、このレコードが存在する限り、トークンをメモリにピン留めする以外の理由では存在しません。このレコードはレンダリングルーチンのスタック上のローカル変数であるため、ループの全期間中有効であり、ルーチンが終了したときにのみ破棄されます。user内の裸のポインタと、Token内のカウントされた参照は、同じオブジェクトを指名しています。一方はPDFiumが読み取れるものであり、もう一方はそのオブジェクトがガベージコレクションされないように保つものです

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... EffectiveToken を選択する ...

  // 最初に強い参照、次に同じオブジェクトを.userを介してPDFiumに公開する。
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

ループの終了方法に関わらずレンダーコンテキストを閉じる

FPDF_RenderPageBitmap_Startへのすべての呼び出しは、PDFiumがページに関連付けるプログレッシブ状態を割り当て、その状態はFPDF_RenderPage_Closeによってのみ解放されます。ドライブループから抜け出すには3つの方法があります。ページが終了し、最後のステータスがFPDF_RENDER_DONEである場合。トークンがトリップし、ループがキャンセルを報告して早期に終了する場合。何かが失敗し、ステータスがFPDF_RENDER_FAILEDである場合。この3つはすべてCloseを呼び出さなければなりません。そして、キャンセルパスは最も間違いやすいものです。なぜなら、「キャンセルを見て、抜け出す」という自然な形は、出口に向かう途中でクリーンアップをスキップしがちだからです。Closeに到達しないままにしておくと、ページごとの状態がリークし、ユーザーに次々とレンダリングをキャンセルさせるビューアは、中止されたページごとにそのリークを蓄積することになります

堅牢な形は、ループと結果の分類をtryの中に置き、FPDF_RenderPage_Closeを対応するfinallyの中に置くことです。宛先ビットマップも同じブロックで破棄されます。キャンセルは早期のExitを通じてループから抜け出すことができますが、finallyは引き続き実行されるため、プログレッシブ状態を解放する場所は正確に1か所であり、これをバイパスすることはできません

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Startが割り当てたプログレッシブ状態を解放する。すべてのパスで必須。
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

ループは、各Continueの前にもトークンをチェックするだけでなく、その中のコールバックにも依存します。コールバックは現在のチャンクを短くし、ループのチェックは次のチャンクが開始するのを防ぎます。これらによって、キャンセルが有効になるまでの時間が概ね1チャンクの期間に制限されます

3つの結果と、キャンセル後にビットマップが保持するもの

パブリックエントリポイントはTPdf.RenderPageProgressiveであり、これはprsDoneprsCancelled、またはprsFailedのいずれかであるTPdfProgressiveStatusを返します。これらの値は、PascalのイディオムでPDFiumのFPDF_RENDER_*定数を反映していますが、キャンセルのケースをエラーとしてではなく、第一級の結果として折り込んでいます

人々を戸惑わせるポイントは、prsCancelledの後に宛先ビットマップに何が含まれているかです。それは空白ではありません。PDFiumは、チャンクごとに同じビットマップにプログレッシブにレンダリングするため、キャンセルによってループが停止したとき、ビットマップにはその時点までに描画されたものが保持されています。つまり、一部の帯域が完了し、残りはまだ塗りつぶし色を示しているという部分的な画像です。その部分的な結果が有用かどうかは、呼び出し元に依存します。ユーザーが他の場所にナビゲートしたためにビットマップを破棄しようとしているビューアは、単にそれを無視できます。低コストのプレビューを表示したいビューアは、それを保持することができます。してはならないことは、prsCancelledが空のビットマップや未定義のビットマップを意味すると想定することです。それは、未完成のレンダリングの真実のスナップショットを意味します

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // トークンはキャンセルされていない状態で開始する。別の場所からToken.IsCancelledを反転させ、
    // (UIアクション、ナビゲーションイベント) 実行中のレンダリングを中止する。
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // 完全にレンダリングされた
      prsCancelled: ;                            // 部分的なビットマップ、通常は破棄される
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

nilのトークンと分岐のないコールバックパス

キャンセルはオプトインです。メッセージポンピングのメリットのためだけにプログレッシブレンダリングを望み、中止する意図がない呼び出し元は、トークンにnilを渡すことができるべきです。これをサポートする素朴な方法は、コールバックとループのあちこちに「トークンが提供された場合」のチェックを散りばめることですが、これはチャンクごとに分岐すること、そして実際のトークンとその不在の両方を処理しなければならないコールバックを意味します

実装では、呼び出し元が何も渡さなかった場合にシングルトンを代入することで、これを回避しています。nilトークンは、IsCancelledが常にfalseであるインターフェース、PdfNoCancellationTokenに置き換えられます。その時点から、コールバックとループにはすべての場合において問い合わせるトークンがあるため、どちらもnilチェックを必要とせず、特別なパスも必要ありません。決してキャンセルされないトークンは単純に常にfalseを答え、コールバックは常にゼロを返し、レンダリングはキャンセル不可のものとまったく同じように完了まで実行されます。オプションの動作は、トークンの不在としてではなく、決して発火しないトークンとしてモデル化されており、これによりホットパスが均一に保たれます

// nil -> 決してキャンセルされないシングルトン。したがって、コールバックパスは
// 呼び出し元がキャンセルをオプトインしたかどうかにかかわらず同じである。
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

現れてくる形は小さく、それが再利用可能な部分であるため、繰り返す価値があります。コールバックをサポートするCライブラリは、状態をそのコールバックに渡すための正確に1つのチャネル、つまり不透明なユーザーポインタを提供します。そのポインタの後ろにカウントされたPascalインターフェースの参照を置き、構造体の隣に2つ目の実際の参照をアクティブに保って呼び出しの途中でオブジェクトがガベージコレクションされないようにし、静的なcdecl関数の内側でインターフェースを読み出します。ドライブループ全体をtryでラップし、finallyでネイティブコンテキストを解放します。Cがポインタを保持している間にPascalコードがライフタイムの制御を維持しなければならないような、プログレッシブまたはコールバック駆動のPDFium操作であれば、同じテンプレートが引き継がれます

キャンセルはレスポンシブなビューアの半分にすぎません。もう半分は、すでに描画したページを再レンダリングしないこと、そしてキャッシュされたビットマップを提供することによってズームとスクロールをスムーズに保つことであり、これはレンダーキャッシュとズームパフォーマンスに関する私たちの記事でカバーされています。キャンセル可能なレンダリングが、ナビゲーション、選択、および検索とともに完全なビューアにどのように適合するかについては、PDFium VCLコンポーネントを使用した機能豊富なPDFビューアの構築を参照してください。ここで説明したプログレッシブレンダリングは、このブログの他の場所で取り上げている読み込み、レンダリング、およびフォームのAPIとともに、DelphiおよびLazarus向けのPDFium Componentの一部として提供されています