技術記事

Delphiで埋め込みPDF用PDFiumバイト範囲読み込み

PDFium Componentは、より大きなバッファの中に存在するPDFを、バイト範囲から直接開くことができる。オーバーロードLoadDocument(const Data: TBytes; Index, Count: Integer; Buffered: Boolean)は、ウィンドウをその場でアドレス指定するので、事前のCopyは必要ない。その代わりに、1つのルールを理解することが求められる:BufferedFalseのとき、その裏側の配列はコピーされるのではなく借用される

これはPDFium VCLでオンデマンドに大容量PDFをストリーミングするで説明されている、コールバック駆動のアプローチとは異なる仕組みだ。あちらはPDFiumにFPDF_FILEACCESSリーダーを渡し、必要に応じてディスクからブロックを引き出させる。あれはRAMに収まらないほど大きいドキュメントのためのものだ。こちらは、すでにRAM上にあり、何か他のものの内側の既知のオフセットに存在するドキュメントのためのものだ。この2つは相補的であり、最後のセクションでどちらがどちらの状況に属するかを説明する

誰も求めていない40MBのコピー

このシナリオは、PDFが他のフォーマットの内側を移動するあらゆる場所で現れる。メールストアはメッセージ本文と添付ファイルを1つのレコードに保持する。アーカイブコンテナは、マニフェスト、いくつかの画像、PDFを連結する。カスタムのワイヤープロトコルは、長さプレフィックス付きヘッダーの後ろにドキュメントをフレーム化する。いずれの場合も、1つの大きなTBytesを保持していて、PDFがバイト1,182,336から始まり312キロバイト続くことを知っている状態になる

バイト範囲オーバーロードが存在する前は、慣用的な答えはCopy(Data, Index, Count)であり、これは2つ目の配列を割り当ててウィンドウをmemcpyでそこにコピーする。その後、そのスライスをBuffered = TrueLoadDocumentに渡すと、コンポーネントのプライベートバッファへとそれをもう一度コピーする。同じバイトの2回のコピーで、そのうち片方は純粋な儀式であり、大きなメールボックスのスキャンではメッセージごとに繰り返される。バイト範囲オーバーロードは、1回目のコピーを無条件で、2回目のコピーを任意に取り除く

バイト範囲オーバーロードは実際には何をするのか

このオーバーロードは意図的に薄い:検証を行い、1つのポインタを計算し、すでにファミリー全体が漏斗のように収束しているポインタ形式のLoadDocumentに委譲する。Indexはゼロ始まり、Countはバイト長であり、Bufferedは他のオーバーロードとまったく同じくデフォルトでTrueだ。単一引数のLoadDocument(const Data: TBytes; Buffered: Boolean)自体も今やIndex = 0Count = Length(Data)でこれを呼び出すだけになっているので、検証パスは2つではなく1つだけになっている

これを呼び出すことは、すでに書いていたコードとほぼ同じに見える。スライスの部分が抜けるだけだ

var
  Frame: TBytes;          // whole container record, tens of megabytes
  Offset, Size: Integer;
begin
  Frame := LoadContainerRecord('mailbox.dat');
  LocateEmbeddedPdf(Frame, Offset, Size);   // your container parser

  // No Copy(Frame, Offset, Size) here - the window is addressed in place
  Pdf.LoadDocument(Frame, Offset, Size, True);
  try
    RenderPreview(Pdf);
  finally
    Pdf.UnloadDocument;
  end;
end;

なぜIndexとCountの合計は境界チェックをオーバーフローさせるのか

IndexCountはどちらもIntegerであり、2つの大きな正のInteger値の和は必ずしも大きな正のIntegerにはならないからだ。これはこのオーバーロードの技術的な核心であり、自然に見えるチェックがメモリ安全性の穴になっている唯一の場所だ。素直な定式化は間違っている

// WRONG: Index + Count is evaluated in Integer and can wrap negative
if Index + Count <= Length(Data) then
  DataPtr := @Data[Index];

// RIGHT: reject signs first, then bound each term separately,
// with the only arithmetic done as a subtraction that cannot wrap
Check(Index >= 0,  'PDF byte range index cannot be negative');
Check(Count >= 0,  'PDF byte range count cannot be negative');
Check(Index <= Length(Data), 'PDF byte range index exceeds data length');
Check(Count <= Length(Data) - Index, 'PDF byte range exceeds data length');

失敗するケースを追ってみよう。Index = 2000000000Count = 2000000000を取る。それらの真の合計は40億だが、32ビット符号付き算術では結果は正確にマイナス294,967,296にラップアラウンドする。その値はLength(Data)より快適に小さいので、間違ったチェックは通過し、@Data[Index]は配列を遥かに超えて取られ、PDFiumには野生のポインタと2ギガバイトの長さが渡される。その先で起きるのは、良い日ならアクセス違反、悪い日なら無関係なプロセスメモリの黙ったパースだ

正しい順序は、決して足し算をしないことでこれを修正する。負の値は何かがインデックスされる前に拒否されるので、@Data[Index]は決して配列の下に取られることがない。それからIndexLength(Data)に対して単独で境界チェックされ、これによりLength(Data) - Indexが非負のIntegerであることが保証される。それからようやくCountがその残りと比較される。すべての中間値は表現可能な範囲内にとどまるので、どんなビルド設定もその結果を変えることはできない。安全網として{$Q+}オーバーフローチェックに頼りたくなるのも避けるべきだ:リリースビルドは日常的にそれを無効にして出荷されるし、有効であってもメモリ安全性のバグを、検証ルーチンの途中から漏れ出すEIntOverflowに変換しただけだ。PDFium Componentは、信頼できない長さの算術を境界の他の部分とまったく同じように扱っており、それはDelphiにおけるPDFium VCL ABIとメモリ安全性の強化でより広く扱われている規律だ

なぜゼロ長のウィンドウはnilを渡さなければならないのか

検証が受理するすべてのIndexについて、@Data[Index]が合法な式であるとは限らないからだ。Count = 0を伴うIndex = Length(Data)は、バッファの末尾における完全に整形式の空のウィンドウであり、空のTBytesは要素ゼロをまったく持たない配列に対してIndex = 0を与える。どちらの場合もアドレスを取ることは末尾を越えてインデックスするか、nilの動的配列を参照解決することになる。だからこのオーバーロードは分岐する:Count = 0はnilポインタを生成し、それ以外のcountは@Data[Index]を生成する。そのnilはその後ポインタオーバーロードに流れ込み、そちら自身のガードは、サイズがゼロのときnilポインタを受け入れるので、読み込みはアクセス違反ではなく通常の「Cannot load PDF document」エラーで終わる。不正な形式のコンテナからゼロバイトのウィンドウを計算した呼び出し側は、他のあらゆる悪い入力と同様に、クリーンで捕捉可能なEPdfErrorを得る

借用かコピーか:Bufferedが決めること

Bufferedは所有権契約を選択するものであり、この呼び出しの中で1回の呼び出しを超える結果を持つ唯一のパラメータだ。Buffered = Trueでは、PDFium Componentは読み込みの前に、選択されたウィンドウ、そしてそのウィンドウだけを内部バッファにコピーする。40MBのコンテナはコピーされない。312KBのPDFがコピーされる。LoadDocumentが戻ったら、コンポーネントはもうそれを参照していないので、コンテナを即座に解放したり、再利用したり、上書きしたりできる。これがデフォルトであり、ほぼすべてのコードにとって正しい選択だ

Buffered = False@Data[Index]をそのままFPDF_LoadMemDocument64に渡し、PDFiumはバイトをコピーするのではなく、そのポインタをドキュメントの寿命の間保持する。それにより読み込みは割り当てフリーになり、裏側のTBytes全体が借用リソースになる。それはUnloadDocumentが実行されるかActiveFalseになるまで、生き続け変更されないままでなければならない。ウィンドウだけではなく配列全体だ:動的配列は単位として参照カウントされるので、あなたのコードのどこかで最後の参照を手放すことは、PDFiumがまだ読んでいるメモリを解放してしまう。それに対してLengthを設定することも同じくらい致命的だ。再割り当てがブロックを動かし得るからだ。このようなロードをどこかで公開しているなら、Pascalコードにおける他のあらゆる借用対所有の境界と同じ精神で、自分自身のAPIドキュメントにこれを述べておこう。その失敗モードは、バッファが所有されているように見えて所有されていないDelphiにおけるFillCharと結果文字列リークの記事で説明されているエイリアシングの危険とまったく同じだ

type
  TFrameSession = class
  private
    FFrame: TBytes;   // owns the backing storage for as long as FPdf is loaded
    FPdf: TPdf;
  public
    procedure OpenEmbedded(Offset, Size: Integer);
    destructor Destroy; override;
  end;

procedure TFrameSession.OpenEmbedded(Offset, Size: Integer);
begin
  // Buffered = False: FFrame must outlive the loaded document
  FPdf.LoadDocument(FFrame, Offset, Size, False);
end;

destructor TFrameSession.Destroy;
begin
  FPdf.UnloadDocument;   // release the borrow first
  FFrame := nil;         // only now may the storage go
  inherited;
end;

バイト範囲ウィンドウが間違った道具である場合

境界について正直であるべきだ。バイト範囲オーバーロードは、コンテナがすでに完全にメモリ上にあることを前提としており、CountIntegerなので、単一のウィンドウは2ギガバイトを超えることができない。コンテナがディスク上の6GBのアーカイブか、巻き戻せないソケット越しに届くものである場合、このオーバーロードは助けにならず、その中のウィンドウをアドレスするためだけに全体をTBytesに読み込むことは要点を打ち消してしまう。それこそがFPDF_FILEACCESSパスの居場所であり、オンデマンドストリーミングの記事は、ファイルのオフセットシフトされたビューをカスタムドキュメントソースとして公開する方法を示している。同様に、埋め込まれたバイトがPDFiumに見せる前に変換、つまり解凍、復号、アンラップステップを必要とするなら、本物のコピーは避けられず、変換された配列に対するBuffered = Trueが正直な答えだ。バイト範囲ウィンドウが元を取るのは、まさに1つの形だ:連続した、変更されていないPDFバイトが、すでに常駐した状態で既知のオフセットにある場合だ

ビューア、プレビューペイン、バッチインテークパイプラインのためにこれを検討しているなら、バイト範囲オーバーロードとストリーミングローダーは、PDFium Componentがファイル、ストリーム、生ポインタ読み込みと並んで出荷する読み込み戦略のうちの2つだ。完全なAPIサーフェス、ライセンス、Delphi・C++BuilderバージョンサポートはPDFium Component製品ページに文書化されている