技術記事

DelphiのPDFライブラリにおけるRange Check Error:根本原因

DelphiのPDFライブラリにおけるRange Check Error(範囲チェックエラー)は、一貫した入力パターンに従わないため、特定が難しいという評判があります。同じドキュメントでも、あるマシンでは発生し、別のマシンでは発生しません。また、同じコードパスでも、3ページのファイルでは例外が発生するのに、12ページのファイルでは正常に実行されたりします。このような不整合は、ほぼ常に単一の根本原因に起因しています。それは、PDFのページオブジェクトがファイルの順序どおりに格納されていないことです。カタログで宣言されたページツリーをたどるのではなく、オブジェクトを順番にスキャンして内部ページ配列を構築するライブラリの場合、呼び出し側が期待するものと有効範囲が一致しないインデックスが作成され、最悪のタイミングで範囲チェックがその不一致を捉えることになります

Delphiにおける範囲チェックの仕組み

{$R+} コンパイラディレクティブがアクティブな場合(Debug構成のデフォルト)、Delphi RTLは実行時にすべての配列インデックス、文字列の添字、および列挙型の代入を検証します。範囲外のアクセスを行うと、隣接するメモリを静かに読み取るのではなく、ERangeError が発生します。この動作は重要です。データ構造を破損させて数百行後になってから失敗するのではなく、潜在的なバグを早期に表面化させるからです。フラストレーションが溜まるのは、インデックスが誤って計算された場所ではなく、アクセスした場所で例外が発生することです。コールスタックにPDFユニットの深くネストされたメソッドが表示されている場合、本当のミスは通常、数フレーム前にあります

複合的な条件式は、この問題をさらに悪化させます。Delphiは短絡評価(ショートサーキット)を使用して and 式を左から右に評価しますが、短絡評価で評価がスキップされるのは左側が False の場合のみです。次のような式の場合:

if FDocStarted and (DestIndex < Length(PageArr)) and
   (PageArr[DestIndex].PageObj <> nil) then

これは安全に見えますが、FDocStartedTrueDestIndex が負ではない場合にのみ、範囲外のインデックスから保護されます。負の整数を負でない長さと比較すると符号付き演算で True が返され、その後の配列アクセスで依然として範囲エラーが発生するため、DestIndex が負の場合、DestIndex < Length(PageArr) のチェックは機能しません。境界チェックを最も外側の位置に移動するのが正しい修正です:

if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
  if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
    Result := PageArr[DestIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [DestIndex, Length(PageArr) - 1]);

これは機械的な修正です。これによりクラッシュは停止します。しかし、そもそもなぜ DestIndex が有効な範囲外の値を受け取ったのかについては説明していません

本当の原因:オブジェクトの順序とページの順序

ISO 32000-1 §7.7.3では、ページツリーを Pages ノードのツリーとして定義しており、その Kids 配列にはページオブジェクトが表示順にリストされています。ファイルは、ライターがたまたま選択したオフセットにこれらのオブジェクトを保存します。つまり、バイトストリームにおいて、オブジェクト番号20が物理的にオブジェクト番号3の前に来ることもあります。Kids チェーンをたどるのではなく、相互参照(クロスリファレンス)テーブルをオブジェクト番号順に反復処理することでページリストを構築するライブラリは、ユーザーが期待するものとは異なるシーケンスを生成します。ジェネレーターがたまたまページを順番どおりに書き込んだドキュメントでは、すべてが機能します。そうでないドキュメントでは、ライブラリのページ番号と呼び出し側のページ番号との間の不一致により、PageArr の範囲外となるインデックスが生成されます

正しいアプローチは、カタログから開始し、/Pages の間接参照を解決して、Kids 配列を再帰的にたどることです。中間の Pages ノードがないフラットなドキュメントの場合、トラバーサル(走査)は単純です:

procedure BuildPageIndexFromTree(
  const KidsArray: THPDFArray;
  var PageArr: TPageObjArray);
var
  i, Idx: Integer;
  Child: THPDFObject;
  ChildType: string;
begin
  for i := 0 to KidsArray.Count - 1 do
  begin
    Child := KidsArray.GetIndirectObject(i);
    if Child = nil then
      Continue;
    ChildType := Child.GetNameValue('/Type');
    if ChildType = 'Page' then
    begin
      Idx := Length(PageArr);
      SetLength(PageArr, Idx + 1);
      PageArr[Idx].PageObj := Child;
    end
    else if ChildType = 'Pages' then
    begin
      // intermediate node: recurse into its Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

これが実行されると、そのオブジェクトがバイトストリームのどこにあるかに関係なく、PageArr[0] がビューアによって表示される最初のページになります。表示順を想定した呼び出し側から渡されるインデックスが正しくマッピングされるようになり、範囲エラーは発生しなくなります

ハードコードされた回避策が問題を複雑にする

根本原因が特定されなかったコードベースでは、ヒューリスティックなパッチが見つかることがよくあります。合計数が3の場合に最初と最後のページを入れ替える、特定のジェネレーターからのドキュメントに対してインデックスを回転させる、最初のオブジェクト番号がしきい値を超えた場合にオフセットを適用する、などです。これらのパッチはそれぞれ、作成時に手元にあったテストファイルのセットに正確に適合しています。別のPDFソースを追加すると、パッチの1つが間違ったタイミングで起動し、二重に間違ったインデックスが生成されます。順序が狂った配列から計算されたために間違っており、さらに適用できないマッピングが上乗せされたために再び間違っているのです。下流のどこかで範囲チェッカーがこれをキャッチしますが、スタックトレースは役に立つ場所を指し示しません

唯一の生産的な方法は、ヒューリスティックなマッピングをすべて削除し、ページ配列の構築を適切なツリーの走査に置き換えることです。構造的にインデックスが正しくなれば、パッチは不要になり、範囲チェッカーは障害ではなく資産となります

このパターンを示すライブラリを保守している場合は、一時的にReleaseビルドで範囲チェックを有効にし、Word、LaTeX、スキャナーのファームウェア、PDF分割ユーティリティなどによって生成された多様なPDFコーパスに対して実行してみてください。例外を引き起こすファイルは、ページオブジェクトの順序がコードの想定する走査順序と異なるものです。それらはそれぞれがデータポイントであり、個別のバグではありません

DelphiのPDFライブラリを呼び出す新しいコードの場合、実践的なアドバイスとしては、ライブラリのページ数を信頼できるものとして扱い、0..PageCount - 1 の範囲内に収まっていることを最初に確認せずに、外部データの演算から導出されたインデックスを渡さないことです。HotPDF コンポーネント は、BeginDoc 後、またはドキュメントの読み込み後に、THotPDF.PageCount を通じて解決されたページ数を公開します。この値は常にページツリーの走査を反映しているため、あらゆるインデックス計算の上限として安全に使用できます