技術記事

DelphiにおいてPDFiumで巨大なPDFをオンデマンドでストリーミングする

スキャンされたアーカイブは、単一のPDFで数ギガバイトに及ぶことがあります。そのようなファイルを開くビューアーは、通常、1ページ(おそらく目次、またはユーザーがブックマークからジャンプしたページ)を表示したいと考えています。2つのページをレンダリングするためにファイル全体をメモリに読み込むことは、あらゆる面で無駄です:アドレス空間を消費し、長い初期読み取りの背後でユーザーを待たせ、32ビットのDelphiプロセスでは単一のページが表示される前に完全に失敗する可能性があります。PDFiumはこれを念頭に置いて構築されました。必要な特定のバイト範囲を必要なときに要求するコールバックを介してドキュメントをロードでき、ファイル全体を一度に要求することは決してありません。1つの境界が先頭に属します:このストリーミングチャネルはファイルを32ビットの長さで記述するため、最大4 GiBまでの単一のファイルを提供します。これは実際にはほぼすべてのスキャンされたアーカイブをカバーします。その境界を超えたファイルは、この記事の領域ではありません。スキャン時にボリュームに分割するか、代わりに直接アクセス戦略を通じて開かれることを望んでおり、上限を強制するガードは以下で独自のセクションで誠実に説明されています

コンポーネントは、ストリームアダプターを介してそのパスを公開します。任意のTStreamを渡すと、PDFiumはそのストリームからオンデマンドでブロックを引き出します。ファイルはディスク上、データベースのblobフィールド内、または他のTStreamの子孫の背後に置くことができ、そのどれも事前にメモリにコピーされません

PDFiumがバイトを要求する方法

PDFiumのC APIは、FPDF_FILEACCESS構造体によって記述された、呼び出し元が提供するオブジェクトからドキュメントをロードします。構造体には、ここで重要な3つの部分があります:長さフィールド、読み取りコールバック、および不透明なユーザーパラメーターです。それを消費するエントリポイントはFPDF_LoadCustomDocumentです。PDFiumがその構造体を保持すると、トレーラーを解析し、相互参照テーブルを見つけ、それ以降は特定の操作が必要とするものだけを読み取ります。ドキュメントを開くと、ファイルの末尾と少数のカタログオブジェクトに触れます。ページ400のレンダリングは、そのページのコンテンツストリームとリソースを読み取り、それ以外は何も読み取りません

これがバッファ付きロードとストリーミングロードの違いです。バッファ付きロードは、PDFiumがバイトゼロを見る前にファイルを端から端まで読み取ります。ストリーミングロードは関係を逆転させます:PDFiumが読み取りを推進し、決して触れられないバイトは決して読み取られません。一度に1ページずつ表示される数ギガバイトのファイルの場合、それが使用できないロードと即時のロードの間のギャップとなります

ストリームアダプター

DelphiのTStreamFPDF_FILEACCESSにブリッジするアダプターはTPdfStreamAdapterです。そのコンストラクターはストリームと所有権フラグを受け取り、ストリームの長さを1回キャプチャし、FPDF_FILEACCESSレコードに入力し、読み取りコールバックを配線します。後でPDFiumがオフセットとサイズを指定してコールバックすると、アダプターはストリームをそのオフセットまでシークし、その範囲を正確にPDFiumが提供したバッファーにコピーします

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 4 GiB.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

所有権フラグは、誰がストリームを解放するかを決定します。Falseを渡すと、呼び出し元がストリームを保持し、ドキュメントの存続期間中、ストリームをアクティブに保つ必要があります。Trueを渡すと、アダプターが引き継ぎ、ドキュメントが閉じるときにストリームを解放します。いずれにせよ、PDFiumはFPDF_FILEACCESSポインターを保持し、初期ロード時だけでなく、ドキュメントが開いている間はいつでもコールバックするため、ストリームはPDFiumが実行するすべての読み取りよりも長く存続しなければなりません

コールバックが静的関数である理由

PDFiumがm_GetBlockに保存する読み取りコールバックは、cdecl呼び出し規約を持つプレーンなC関数ポインターです。Delphiのメソッドは直接使用できません。メソッドには、Cの呼び出し元が何も知らず、決して提供しない隠されたSelf引数があるためです。したがって、アダプターはコールバックをcdecl; staticとマークされたclass functionとして宣言します。これは、PDFiumが期待するCフレームレイアウトを持ち、暗黙のSelfを持たない独立した関数にコンパイルされます

これにより呼び出し規約は解決されますが、2つ目の疑問が生じます:Selfがない場合、コールバックは読み取るべき特定のストリームにどのように到達するのでしょうか?その答えは不透明なユーザーパラメーターです。アダプターがレコードを構築するとき、それ自身のインスタンスポインターをm_Paramに保存します。PDFiumは、すべてのコールバックの最初の引数としてその同じポインターを返します。静的関数はそれをTPdfStreamAdapterにキャストして戻し、そのインスタンスのストリームに対して読み取りをディスパッチします。これは、オブジェクトの概念を持たないC境界を越えてオブジェクトコンテキストを手渡すための標準的なトランポリンです

// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // recover the instance from m_Param
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // report failure by return value, never by raising
  end;
end;

4 GiBの上限とガードが必要な理由

冒頭で述べた境界はここから来ています。FPDF_FILEACCESSの長さフィールドm_FileLenは、32ビットの符号なし値です。表現可能な最大長は4 GiBより1バイト短いです。TStreamはそのサイズをInt64として報告するため、ストリームはフィールドが保持できるよりもはるかに多くのバイトを記述できます。ストリームのサイズがその上限を超えた瞬間、ファイルの長さをPDFiumに正直に伝える方法はありません

間違った対応は、サイズを割り当ててラップさせることです。5 GiBの長さを32ビットのフィールドに切り捨てると、小さくもっともらしい数値が生成され、PDFiumはファイルがだいたい1ギガバイトで終わると信じてファイルを解析します。トレーラーと相互参照テーブルは、切り捨てられた長さをはるかに超えたファイルの実際の終端に存在するため、解析は実際の原因とは無関係な方法で失敗します。あなたは完全に有効なファイルの相互参照エラーをデバッグすることになり、2つ上のレイヤーで整数がラップしたというヒントはありません

代わりに、アダプターは入力を拒否します。コンストラクターはストリームサイズをHigh(FPDF_DWORD)と比較し、ストリームが大きすぎて記述できない瞬間にEPdfErrorを発生させます。明示的で即時のエラーは、構築の時点で実際の問題を名指しします。静かな切り捨ては、ずっと後になってから追いかけることになる誤解を招く症状の背後にそれを隠します。4 GiBの制限はこのロードパスの純粋な制約であり、誠実なことは、たまたまコンパイルされる演算でごまかすのではなく、それを大声で表面化することです。アーカイブが純粋にその境界を越えた場合、最初で約束された救済策はこのAPIの外にあります:スキャンを、それぞれが上限に収まるボリュームごとのファイルに分割するか、ドキュメントをディスクに残し、FPDF_FILEACCESSを介してではなく64ビットオフセットに基づいて構築された直接アクセス設計を介して提供します

障害は境界を越えてはならない

読み取りは失敗する可能性があります。ストリームがタイムアウトするネットワークバックアップのオブジェクトであったり、その下で閉じられたblobハンドルであったり、ドキュメントが開いた後に切り捨てられたファイルであったりする可能性があります。PDFiumの読み取りコールバックの契約は戻り値です:成功した場合は非ゼロ、失敗した場合はゼロです。これはCフレームであり、Pascalの例外をキャッチしたり伝播したりするメカニズムはありません

そのため、トランポリンはシークと読み取りをtry/exceptでラップして例外を飲み込み、ゼロを返します。Delphiの例外がコールバックから伝播することを許可された場合、それはPDFiumのcdeclスタックフレームを通じてアンワインドされますが、これはPascalの例外メカニズムによってアンワインドされるようには構築されていません。その結果、最良の場合でも未定義の動作、最悪の場合は使用可能なスタックのないPDFパーサーの奥深くでのハードクラッシュになります。ゼロを返すことで、障害は契約の範囲内に保たれます。PDFiumは失敗したブロック読み取りを確認し、操作をクリーンに中止し、FPDF_LoadCustomDocumentはドキュメントをロードできなかったと報告し、コンポーネントはそれを本来あるべきPascal側のEPdfErrorとして表面化します

この方法でドキュメントを開く

ストリーミングパスを駆動するコンポーネントメソッドはLoadCustomDocumentであり、TMemoryStreamを渡しても誤ってバッファー付きパスに到達しないように、別のLoadDocumentのオーバーロードではなく、個別のメソッドとして宣言されています。これはアダプターを構築し、FPDF_LoadCustomDocumentを呼び出し、ロードされたドキュメントの有効期間中、アダプターをアクティブに保ちます

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

同じ呼び出しは、TMemoryStream、データベースデータセットからのblobストリーム、またはカスタムのTStream子孫に対して機能します。ファイルが大きく、その一部しか読み取られない場合、オンデマンドのロードはその価値を発揮します:アーカイブビューアー、数ページをサンプリングするサムネイルジェネレーター、一度に1ページを引き出す検索インデックスなどです。ファイルが小さい場合、またはとにかくすべてを読み取る予定である場合は、バッファー付きのロードの方が簡単であり、ストリーミングメカニズムは何の利益ももたらしません。決定要因は、実際に触れるバイトとファイルに含まれるバイトの比率です

ページがオンデマンドでストリーミングされると、次の懸念事項は、ユーザーがズームおよびスクロールするときにレンダリングされたページを応答性に保つことになりますが、これについてはレンダリングキャッシュとズームのパフォーマンスに関するメモで説明されています。ストリーミングされたドキュメントが、ビューアーが表示するべきだがユーザーにエクスポートや変更を許可しないものである場合、セキュアなPDFプレビューのウォークスルーの技術は、このロードパスと自然にペアになります。どちらもここで説明されているストリーミングロードに基づいており、このブログの他の場所で取り上げられているレンダリング、テキスト抽出、および注釈APIとともに、DelphiおよびC++Builder用のPDFiumコンポーネントの一部として出荷されています