技術記事

HotPDFでのPDFページ順序のバグ: 物理的構造と論理的構造

この症状は、HotPDF Component 上に構築されたページコピーツールで発生しました。3ページのドキュメントの1ページ目を要求すると、常に2ページ目が生成されました。インデックスのロジックを確認しても何も問題は見つかりませんでした。呼び出しは0ベースの論理インデックスを使用しており、計算は正しく、境界条件も問題ありませんでした。しかし、毎回間違ったページが出力されました

バグはコピーのコードには全くありませんでした。それは、HotPDFがファイルを読み込む際に内部のページ配列をどのように構築しているかにありました

PDFページの順序の概念: 物理的順序と論理的順序の違い
PDFのページ順序: Pagesツリーの/Kids配列が論理的な順序を定義しており、オブジェクトがどのように番号付けされているか、またはファイル内にどのように保存されているかには依存しません。

2つの順序、混乱の1つの原因

PDFファイルはインダイレクトオブジェクトの集合体であり、それぞれがオブジェクト番号で識別されます。ファイル構造において、これらの番号が読み取り順序を反映する義務はありません。オブジェクト1がページ2を保持し、オブジェクト20がページ1を保持することもあります。実際に読み取り順序を定義するのはページツリーです。これは、ビューアがページを表示すべき順序でページ参照をリストした /Kids 配列を持つ /Pages 辞書の階層です(ISO 32000-1 §7.7.3)

バグを引き起こしたドキュメントには、次のようなページツリー構造がありました:

{ Pages tree root, object 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { logical page 1 }
         1 0 R    { logical page 2 }
         4 0 R]   { logical page 3 }
>>
endobj

そのファイルは偶然、バイトストリーム内でオブジェクト20の前にオブジェクト1とオブジェクト4をリストしていました。ファイル順にインダイレクトオブジェクトを反復処理し、ページタイプの辞書を見つけるたびにそれらを PageArr に記録するパーサーは、インデックス0にオブジェクト1、インデックス1にオブジェクト4、インデックス2にオブジェクト20を配置することになります。論理ページ1は PageArr[2] に配置されます。ページインデックス0を要求すると、代わりに論理ページ2が取得されます

まさにそれが、HotPDFの両方の内部解析パスが行っていたことでした。PDF 1.3/1.4ファイルに使用される従来のパスと、オブジェクトストリームドキュメント(PDF 1.5以降)に使用される最新のパスは、どちらも /Kids チェーンをたどるのではなく、物理的なファイル順序でインダイレクトオブジェクトをたどることで PageArr を構築していました

仮説の確認

修正を加える前に、不一致を推測するのではなく証明する必要がありました。qpdfコマンドラインツールを使用すると、これが簡単にできます:

{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }

各ページを個別に抽出し、ファイルサイズを確認することでマッピングが確認されました。PageArr[0] が生成したものは論理ページ2に属するコンテンツであり、PageArr[2] は論理ページ1を保持していました。この循環的なずれが決定的な証拠でした。これは、問題が複数の異なるソースドキュメントで発生した理由も説明しています。ページオブジェクトが偶然、前の論理ページよりも小さなオブジェクト番号を持っているPDFであれば、この問題が引き起こされます

PDFがこの状態になるのには単純な理由があります。増分保存では、新しいオブジェクト番号で更新されたオブジェクトが追加され、相互参照テーブルの古いスロットはどこも指し示さなくなります。表紙を追加するエディタは、Kids配列内の位置に関係なく、大きなオブジェクト番号でそれを挿入します。一部のジェネレータは、論理的なページの順序ではなく、コンテンツのストリーミングに便利な順序でページを書き込むだけです。PDFフォーマットは、それ以外の方法で行うことを要求していません

修正: Kids配列をたどる

正しいアプローチは、インダイレクトオブジェクトをスキャンするのではなく、カタログのルートから /Kids チェーンをたどることによって PageArr を構築することです。両方の解析パスが最初のパスを完了した後、後処理ステップで論理的な順序を解決します:

procedure THotPDF.ReorderPageArrByPagesTree;
var
  PagesObj  : THPDFDictionaryObject;
  KidsArray : THPDFArrayObject;
  NewPageArr: array of THPDFDictArrItem;
  I, J, PageIndex, KidsIndex: Integer;
  RefObj    : THPDFLink;
  PageObjNum: Integer;
  Found     : Boolean;
begin
  { Locate root /Pages dictionary via FRootIndex }
  PagesObj := FindPagesRootFromCatalog;
  if PagesObj = nil then Exit;

  KidsIndex := PagesObj.FindValue('Kids');
  if KidsIndex < 0 then Exit;
  KidsArray := THPDFArrayObject(PagesObj.GetIndexedItem(KidsIndex));

  SetLength(NewPageArr, KidsArray.Items.Count);
  PageIndex := 0;

  for I := 0 to KidsArray.Items.Count - 1 do
  begin
    RefObj     := THPDFLink(KidsArray.GetIndexedItem(I));
    PageObjNum := RefObj.Value.ObjectNumber;

    Found := False;
    for J := 0 to Length(PageArr) - 1 do
    begin
      if PageArr[J].PageLink.ObjectNumber = PageObjNum then
      begin
        NewPageArr[PageIndex] := PageArr[J];
        Inc(PageIndex);
        Found := True;
        Break;
      end;
    end;
    { Non-page Kids (intermediate /Pages nodes) produce no match; skip }
  end;

  if PageIndex > 0 then
  begin
    SetLength(PageArr, PageIndex);
    for I := 0 to PageIndex - 1 do
      PageArr[I] := NewPageArr[I];
  end;
end;

この呼び出しは、すべてのオブジェクトがカタログ化された後、ページ操作が処理される前の各解析パスの最後に行われます:

{ Traditional path }
ListExtDictionary(THPDFDictionaryObject(IndirectObjects.Items[I]), FPageslink);
ReorderPageArrByPagesTree;
Break;

{ Modern path (object streams) }
if TryParseModernPDF then
begin
  Result := ModernPageCount;
  ReorderPageArrByPagesTree;
  Exit;
end;

並べ替えステップは O(n * m) です。ここで、n はKidsの数、m は現在のPageArrの長さですが、フラットなページツリー(すべての葉が深さ1にあり、実際のPDFの圧倒的多数を占めます)を持つドキュメントの場合、両方とも同じ値であり、コストは無視できます。深くネストされたページツリーでは、ここに示されている単一レベルのアプローチではなく、再帰的な探索が必要になります。本番環境での実装では、そのケースを個別に処理します

修正後の CopyPageFromDocument の使用

ReorderPageArrByPagesTree が適切に設定されていれば、論理ページインデックスは期待通りに機能します。より高レベルの CopyPageFromDocument は、0ベースの論理インデックスを受け取り、正しいページを宛先ドキュメントにコピーします:

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    Source.LoadFromFile('source.pdf');

    Dest.FileName := 'extracted.pdf';
    Dest.BeginDoc;

    { Copy logical page 0 (first page the user sees) }
    Dest.CopyPageFromDocument(Source, 0, 0);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

CopyPageFromDocument は、生の PageArr インデックスに依存するのではなく、内部的にページツリーの順序を照会するため、物理的な順序と論理的な順序が異なるドキュメントに対しても正しく動作します。バッチ操作の場合、InsertPagesFromDocument は論理インデックスの配列を受け入れ、それらを1回のパスでコピーします

これがPDF解析について明らかにするもの

PDFの仕様は明確です。論理的なページの順序は、オブジェクト番号やバイトオフセットではなく、ページツリーの /Kids 配列によって定義されます(ISO 32000-1 §7.7.3.2)。ショートカットとして異なる順序を使用するパーサーは、ほとんどのジェネレータがページを自然な順序で書き込み、連番のオブジェクト番号を割り当てるため、目にする大部分のドキュメントで正しい結果を生成します。このバグは、誰かが増分編集されたPDF、別のツールによって再編成されたPDF、または異なるレイアウトを選択したソフトウェアによって生成されたPDFを読み込むまで隠れています

自己生成されたPDFに対してのみテストを行うと、この種の問題を完全に見逃してしまいます。したがって、ページ順序の回帰を修正するには、増分保存、表紙が挿入されたスキャン済みドキュメント、オブジェクトグラフの線形化や最適化を異なる方法で行うツールによって生成されたPDFなど、さまざまなソースからのドキュメントのコーパスが必要です。元のバグを引き起こしたドキュメントは、回帰テストスイートに永久に残しておく必要があります

HotPDF Component のページでは、CopyPageFromDocumentInsertPagesFromDocument、および MovePage を含む、ページ操作の完全なAPIについて説明しています