技術記事

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

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

この記事は、それら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を呼び出して、ページごとのプログレッシブな状態を解放します。スライスの間にコントロールがコードに戻るため、メッセージをポンプしたり、進行状況インジケーターを更新したり、作業がまだ必要かどうかを確認したりできます

いつ譲るかを決定するためにPDFiumが提供するメカニズムは、IFSDK_PAUSEという名前のコールバック構造体です。それをStartおよびすべてのContinueに渡します。各チャンクの後、PDFiumはそのNeedToPauseNow関数ポインターを呼び出し、それが非ゼロの値を返した場合、現在のContinueは早期に停止し、FPDF_RENDER_TOBECONTINUEDとともにコントロールを返します。構造体には、1に設定する必要があるversionフィールドと、PDFiumが手付かずのままパススルーする自由形式のuserポインターも含まれています。その手付かずのポインターが、以下の設計全体の蝶番となります

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

NeedToPauseNowの元々の意図はタイムスライシングです。フレーム予算が費やされたときに非ゼロを返し、レンダリングを続けるにはゼロを返します。そうすれば、PDFiumは一時停止し、同じレンダリングを再開する前に別のことを実行できるようになります。PDFiumコンポーネントは、異なる動詞に同じシグナルを再利用します。コールバックは「一時停止して再開させるべきですか」と答える代わりに「この作業はキャンセルされましたか」と答えます。ループがフラグを見たときの動作により、これら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 reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  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; // non-zero: PDFium stops this chunk
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
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  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
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

ループは、各Continueの前にトークンをチェックし、さらにその中のコールバックに依存します。コールバックは現在のチャンクを短縮します。ループのチェックは、次のチャンクが開始されるのを停止します。これらが一緒になって、キャンセルが有効になるまでの時間を、だいたい1チャンクの期間に制限します

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

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

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

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

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

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

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

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

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

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