Technical Article

PDFiumを使用した大容量PDFのオンデマンドストリーミング

スキャンされたアーカイブは、単一のPDFで数ギガバイトに達することがあります。そのようなファイルを開くビューアは、通常1つのページ(目次や、ユーザーがブックマークからジャンプしたページなど)を表示したいだけです。2ページをレンダリングするためだけにファイル全体をメモリに読み込むのは、あらゆる面で無駄です。アドレス空間を消費し、長時間の初期読み込みでユーザーを待たせ、32ビットのDelphiプロセスでは1ページも表示されないままクラッシュすることさえあります。PDFiumはこれを念頭に置いて構築されています。必要なときに、必要な特定のバイト範囲を要求するコールバックを通じてドキュメントを読み込むことができ、ファイル全体を一度に要求することはありません。

このコンポーネントは、ストリームアダプタを通じてそのパスを公開しています。任意のTStreamを渡すと、PDFiumはオンデマンドでそのストリームからブロックをプルします。ファイルはディスク上、データベースのBLOBフィールド、または他のあらゆるTStream派生クラスの背後に配置することができ、事前にメモリにコピーされることはありません。

PDFiumがバイトデータを要求する仕組み

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

これが、バッファロードとストリーミングロードの違いです。バッファロードは、PDFiumがバイトゼロを参照する前に、ファイルを端から端まで読み取ります。ストリーミングロードはその関係を逆転させます。すなわち、PDFiumが読み取りを主導し、アクセスされないバイトデータは決して読み取られません。1ページずつ表示される数ギガバイトのファイルにとって、これは使い物にならない読み込み速度と、瞬時に表示される速度との決定的な差になります。

ストリームアダプタ

DelphiのTStreamFPDF_FILEACCESSに架橋するアダプタが、TPdfStreamAdapterです。そのコンストラクタはストリームと所有権フラグを受け取り、ストリームの長さを一度キャプチャし、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が実行するすべての読み取り処理よりも長生きしなければなりません。なぜなら、PDFiumはFPDF_FILEACCESSポインタを保持しており、初期ロード時だけでなく、ドキュメントが開いている間は任意の時点でコールバックを実行する可能性があるからです。

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

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

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

// 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の制限はこのロードパスにおける本物の制約であり、偶然コンパイルが通った演算処理で取り繕うよりも、大々的にエラーを表面化させるのが正しい姿勢です。

失敗を境界の向こうに持ち越してはならない

読み取りは失敗することがあります。ストリームがタイムアウトするネットワーク型オブジェクトであったり、途中で閉じられた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 Componentの一部として提供されている、ここで解説したストリーミングロードをベースにしています。