技術記事

PDFiumのプログレッシブダウンロードとキャンセル(FPDFAvail)

PDFium Componentは、まだダウンロード中のPDFをTPdfProgressiveDocument、つまりPDFiumのFPDFAvail_*アベイラビリティAPIを包むTPdfサブクラス経由で開けます。BeginProgressiveLoadがセッションを始め、CheckDocumentAvailabilityがPDFiumがまだ必要としているバイト範囲を報告し、十分なバイトが揃った時点でOpenProgressiveDocumentがファイルを開き、CancelProgressiveLoadが中断されたダウンロードをネイティブハンドルを漏らさずに放棄します。難しいのはハッピーパスではありません。不安定な回線のビューアでは、ユーザーが25パーセントでタブを閉じ、気が変わり、同じリンクをもう一度開く、ということが起こります。打ち切られたセッションはどれも、ネイティブのアベイラビリティハンドル、2つのCコールバックレコード、ストリームアダプタ、そして飛行中のレンジリクエスト一式を抱えており、これらは正しい順で解放されなければなりません

TPdfProgressiveDocumentはダウンロード中のPDFをどうロードするのか

TPdfProgressiveDocumentは、ランダムアクセスストリームが満たされる間、PDFiumのアベイラビリティプロバイダを生かし続け、パースのステップごとに、そのプロバイダへ欲しいバイトがあるかを尋ねます。BeginProgressiveLoad(AStream, AFileSize, AOwnsStream, AInitialAvailableByteCount)はバッキングストリームとリモートファイルの論理サイズを受け取り、IsDataAvailコールバックとAddSegmentコールバックを2つのレコードへ配線し、FPDFAvail_Createを呼びます。PDFiumがある範囲の存在を尋ねるとき、コンポーネントは、その範囲がAvailableByteCountの表す連続プレフィックスの内側か、RangeRequestsスケジューラで既に完了した範囲の内側なら、yesと答えます。スパースなストア向けに、OnDataAvailableイベントが判定を上書きできます。CheckDocumentAvailabilityの呼び出しは毎回、3つのTPdfDataAvailability値(pdaAvailable、pdaNotAvailable、pdaError)のいずれかを返し、PDFiumが求めた範囲を、ソート済み・マージ済みのTPdfDownloadRanges配列として手渡します。スケジューラにはrrpImmediate優先度で既にキューイング済みです

// FetchRangeはあなたのトランスポート(HTTP Range GET、ソケット、blobリーダー):
// StoreのOffsetへSizeバイトを書き、届いた数を返す
function FetchRange(Store: TStream; Offset, Size: UInt64): UInt64; forward;

procedure OpenWhileDownloading(Pdf: TPdfProgressiveDocument; Store: TStream;
  RemoteSize: UInt64);
const
  MaxRounds = 64;
var
  Hints: TPdfDownloadRanges;
  State: TPdfDataAvailability;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Pdf.BeginProgressiveLoad(Store, RemoteSize, False);
  State := pdaNotAvailable;
  for Round := 1 to MaxRounds do
  begin
    State := Pdf.CheckDocumentAvailability(Hints);
    if State <> pdaNotAvailable then
      Break;
    // ヒントは既にキュー済み。まずバイトを書いてから完了させる
    while Pdf.RangeRequests.TryDequeue(Request) do
      Pdf.RangeRequests.CompleteRequest(Request,
        FetchRange(Store, Request.Offset, Request.Size));
  end;
  if State <> pdaAvailable then
    raise EPdfError.Create('The document could not be discovered');
  Pdf.OpenProgressiveDocument;
end;

このループには、骨を支える詳細が2つあります。ラウンド上限が効くのは、死んだリンクがCheckDocumentAvailabilityに永遠に同じ範囲を尋ねさせ、上限なしのループはネットワーク障害をUIのハングに変えるからです。順序が効くのは、スケジューラは自分の状態をクリティカルセクションで直列化するものの、バッキングストアのTStream.Positionには何もしないからです。トランスポートスレッドはCompleteRequestを呼ぶ前にレスポンスバイトをストリームへ書き込まねばなりません。完了が公開された瞬間、PDFiumはその範囲を読みに来るかもしれず、並行するライターにはpositioned I/Oか自分専用のロックが要ります

PDFium ComponentにおけるTPdfProgressiveDocumentのアベイラビリティループの図。BeginProgressiveLoadがFPDFAvailプロバイダを作り、CheckDocumentAvailabilityがrrpImmediate優先度でキュー済みの、ソート・マージ済みダウンロードヒントを手渡し、トランスポートはCompleteRequestが各範囲をPDFiumへ公開する前にストアへバイトを書き込みます。ループは64ラウンドで打ち止めです。死んだリンクは同じ範囲を尋ね続けるので
バイトを書いてからリクエストを完了させる。完了が公開された瞬間、PDFiumはその範囲を読みに来得ます。ストリーム位置を守ってくれるものはありません

AvailableByteCountが後戻りを拒む理由

AvailableByteCountは伸びる一方で、縮めようとするとsetterが「Available byte count cannot move backwards」付きのEPdfErrorを投げます。IsDataAvailコールバックがいったんPDFiumへ範囲の存在を告げたら、パーサーはすでにそこからオブジェクトを読んでキャッシュしているかもしれません。後からバイトを引き上げれば、アベイラビリティの答えが、PDFiumがすでに消費したものと食い違います。同じsetterはLogicalFileSizeより大きな値を拒否し、セッション外では「No progressive load is active」を投げます。だからロード開始前に既に手元にあるバイトは、早すぎるプロパティ代入ではなく、BeginProgressiveLoadのAInitialAvailableByteCount引数へ入れるべきです。ダウンロードストアが順不同で満たされるなら、プレフィックスで表現しようとするのはやめて、範囲はスケジューラ経由で完了させるか、OnDataAvailable経由で答えてください

部分的にダウンロードしたPDFが実際に開けるのはいつか

ファイル全体が届く前に開けるのは線形化PDF(ISO 32000-1 附属書Fの「Fast Web View」レイアウト)だけです。非線形化PDFは今も全バイトを要します。OpenProgressiveDocumentはLinearizationプロパティ(plnUnknown、plnNotLinearized、plnLinearized)を確認して経路を振り分けます。線形化ファイルは最初のページセクションとヒントテーブルが揃い次第、FPDFAvail_GetDocument経由で開き、非線形化ファイルは同じファイルアクセスレコード上でFPDF_LoadCustomDocument経由で開き、全体としてのみ読めるもの扱いです。この振り分けには具体的な理由があります。非線形化ファイルにFPDFAvail_GetDocumentを呼ぶと、ページ数ゼロの非nullハンドル、つまり開いたように見えて中身が空の文書が返り得ます。コンポーネント自身のテストスイートでは、51ページの線形化フィクスチャが、スパースなダウンロードストアがまだファイルをカバーしていない状態でpdaAvailableに達し、完全なページツリー付きで開きます

PDFium ComponentでOpenProgressiveDocumentが部分的ダウンロードを振り分ける方法の図。線形化ファイルは最初のページセクションとヒントテーブルの到着後、FPDFAvail_GetDocumentで開きます。非線形化ファイルはFPDF_LoadCustomDocumentと全バイトを要します。LoadAvailablePageはページ確認の前にFPDFAvail_IsFormAvailでフォームの可用性を確認し、非nullのゼロページハンドルの罠を避けます
先手を取れるのは線形化ファイルだけです。それ以外ではFPDFAvail_GetDocumentが、開いたように見えてページゼロの文書を返し得ます。振り分けが防ぐのはまさにそれです
function WaitForPage(Pdf: TPdfProgressiveDocument; Store: TStream;
  PageNumber: Integer): Boolean;
var
  Hints: TPdfDownloadRanges;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Result := False;
  for Round := 1 to 64 do
    case Pdf.LoadAvailablePage(PageNumber, Hints) of
      pdaAvailable:
        Exit(True);   // PageNumberがアクティブページになった
      pdaError:
        Exit(False);
      pdaNotAvailable:
        while Pdf.RangeRequests.TryDequeue(Request) do
          Pdf.RangeRequests.CompleteRequest(Request,
            FetchRange(Store, Request.Offset, Request.Size));
    end;
end;

LoadAvailablePageは1始まりのページ番号を受け取り、PDFiumが期待する順序を強制します。最初のページ確認の前にCheckFormAvailability、つまりFPDFAvail_IsFormAvailを包むものを実行し、その後にのみFPDFAvail_IsPageAvailを呼びます。pfaNotPresentはAcroFormのない文書への普通の答えで、何も妨げません。ページの準備ができたら、LoadAvailablePageはそれをアクティブページにします。線形化パンフレットの1ページ目は、残りページがまだ輸送中でも描画できるわけです。FirstAvailablePageNumberは、線形化辞書が最初と指名するページを教えます。PDFiumの0始まりインデックスからの変換済みです

CancelProgressiveLoadは何をどんな順で解放するのか

CancelProgressiveLoadは、並べ替えられない4ステップでセッションを解体します。レンジスケジューラをキャンセルし、文書を閉じ、アベイラビリティハンドルをFPDFAvail_Destroyで破棄し、それからコールバックレコードを廃棄してストリームアダプタを解放します。スケジューラのキャンセルが先なのは、ジェネレーションカウンタを進め、保留中と飛行中の全リクエストを落とし、飛行中のそれぞれにOnCancelRequestを発火させるからです。後から着地するトランスポート完了は旧ジェネレーションを運び、CompleteRequestは何にも触れずにFalseを返します。文書はアベイラビリティハンドルとアダプタが消える前に閉じねばなりません。PDFiumは文書を閉じている最中にファイルアクセスプロバイダへコールバックでき、アダプタがもうないと、そのコールバックは解放済みメモリを読みます

PDFium ComponentにおけるCancelProgressiveLoadの固定解体順序の図。まずレンジスケジューラをキャンセルし、遅れた完了が進められたジェネレーションカウンタに当たってFalseを返すようにします。ファイルアクセスアダプタが消える前に文書を閉じ、アベイラビリティハンドルをFPDFAvail_Destroyで破棄し、その後にコールバックレコードを廃棄してストリームアダプタを解放します
1つのべき等メソッドが、失敗した開始もユーザーによるキャンセルもデストラクタも同じように片付けます。ワーカースレッドがストアへ書き込む構成では、ストリームの所有権はあなたの手元に
procedure TDownloadForm.FormCreate(Sender: TObject);
begin
  FPdf := TPdfProgressiveDocument.Create(nil);
  // スケジューラはFPdfと同じ寿命なので、配線は1回でいい
  FPdf.RangeRequests.OnCancelRequest := RangeCancelled;
end;

procedure TDownloadForm.RangeCancelled(Sender: TObject; RequestId: UInt64;
  Attempt: Cardinal);
begin
  FTransport.Abort(RequestId);   // あなたのコード:そのソケットかリクエストを閉じる
end;

procedure TDownloadForm.CancelButtonClick(Sender: TObject);
begin
  FPdf.CancelProgressiveLoad;
  // ProgressiveLoading = False, Active = False, AvailableByteCount = 0
end;

このメソッドはべき等で、3つの状況に対する唯一の後片付け経路です。構築の途中で失敗したBeginProgressiveLoad、明示的なユーザーによるキャンセル、そしてデストラクタです。BeginProgressiveLoadは開始前にもこれを呼ぶので、同じオブジェクトを新しいURLで再開するのは、明示的なキャンセルなしで安全です。所有権の判断は1つ、あなたが正しく決める番です。ワーカースレッドがバッキングストリームへ書き込む構成なら、AOwnsStream = Falseを渡し、ワーカーが止まった後に自分でストリームを解放してください。所有権を渡した状態では、キャンセルがストリームを解放する一方で、遅れた書き込みがまだ途中かもしれないからです。OnCancelRequestの中で投げられた例外はリクエストごとに飲み込まれます。1つのトランスポートが失敗しても、残りのキャンセルを妨げないためです

ライフサイクルスイートがキャンセル経路の非リークをどう証明するか

PDFium Componentのライフサイクルストレステストスイートは、混合サイクルごとに、中断されるネットワーク風ダウンロードを演じます。各サイクルは、フィクスチャバイトの4分の1だけをストアが保持するプログレッシブロードを始め、空でないヒントリスト付きのpdaNotAvailableを要求し、CancelProgressiveLoadを呼び、オブジェクトがProgressiveLoadingもActiveも報告しないことをアサートします。その後、同じストリーミング経路をフルアベイラビリティで完走させ、OpenProgressiveDocument、レンダー、クローズまで進みます。デフォルトの混合ランは計測サイクル100回、オープン600回、レンダー2300回、プログレッシブキャンセル100回をカバーし、サンプリングしたプライベートメモリは32 MiBの予算に対し8.21 MiB増えました。スイートはプログレッシブキャンセルを、レンダーコールバックのキャンセルと別に数えます。打ち切られたダウンロードと、早めに止まったレンダーループは、受け入れ基準の違う別イベントだからです

プログレッシブ経路が役に立たなくなる場所

この上にビューアを組む前に知っておきたい限界がいくつかあります。元ファイルのバイトを必要とする機能は、不完全なプログレッシブソースを推測する代わりに拒みます。ReadXmpPacketは明示的に失敗し、署名検証はファイル全体が揃うまでIndeterminateを報告します。デフォルトのアベイラビリティテストは連続プレフィックスを仮定するので、範囲を順不同で取得するトランスポートは、RangeRequests経由で完了させるか、OnDataAvailable経由で答えねばなりません。さもないとPDFiumは、手元にあるバイトを尋ね続けます。非線形化ファイルは、最初のページまでの時間の面では何も得ません。速い初回描画が重要なら、サーバー側でファイルを線形化してください。そしてCancelProgressiveLoadは、ソケットを自分では閉じません。それが起こるフックはOnCancelRequestです

完全なローカルファイルをオンデマンドでロードする素のストリームアダプタ経路は、PDFiumで大きなPDFをオンデマンドでストリーミングするをご覧ください。より大きなバッファの内側にあるPDFを開くのは、埋め込みPDFのバイトレンジローディングです。すでにロード済みページの遅いレンダーを打ち切るのは別の仕組みで、キャンセル可能なプログレッシブページレンダリングで扱っています。TPdfProgressiveDocumentとレンジスケジューラは、DelphiとC++Builder向けPDFium Componentに同梱です