技術記事

Delphiでのリニアライズド PDF出力: HotPDFヒントテーブル

HotPDFは、THotPDFLinearizeOutputプロパティを通じて、Acrobatが Fast Web View と呼ぶレイアウトのリニアライズドPDFファイルを書き込む。BeginDocの前にこれを設定すると、HotPDFは完成したオブジェクトグラフを並べ替え、バイト範囲対応のリーダーがファイル全体をダウンロードすることなくファイルの先頭部分だけを取得した時点で1ページ目を表示できるようにする。その仕組みはISO 32000-1 Annex Fだ

これが重要になる理由は地味だ。通常のPDFはクロスリファレンステーブルを末尾に置くので、ビューアは何がどこにあるかを知る前にファイルの最後のバイトに到達しなければならない。200ページのスキャンレポートをブラウザに渡すと、ユーザーが欲しかったのが1ページ目だけであっても、転送全体が終わるまでスピナーを見つめ続けることになる。リニアライズはこれを、書き込み時にコストを支払うことで解決する。この記事はその書き込みパス、つまり分割、計測ループ、そして厳格な限界について具体的に扱う。Fast Web Viewが何をもたらすかという概念的な背景については、以前のPDFリニアライズとFast Web Viewの説明で扱っている

リニアライズドレイアウトが実際に保証すること

リニアライズドファイルは、極めて特定の物理的順序を持つ通常のPDFであり、それが提供するすべての保証は、新しいオブジェクト型ではなくその順序から来ている。HotPDFはAnnex Fが規定する順序でパーツを出力する:先頭1024バイト以内のリニアライズパラメータ辞書、早期クロスリファレンステーブル、ドキュメントレベルのオブジェクト、プライマリヒントストリーム、1ページ目とそのプライベートオブジェクト、残りのページ、共有オブジェクト、その他すべて、そして最後にメインのクロスリファレンステーブルだ

分割は宣言されるのではなく導出される。HotPDFは各ページオブジェクトから参照グラフをたどり、すべての間接オブジェクトについて、いくつのページがそれに到達するか、どのページが最初に到達したかを記録する。ちょうど1つのページから使われるオブジェクトは、そのページのプライベートになる。複数から到達されるオブジェクトは共有になる。カタログと、/ViewerPreferences/OpenAction/Threads/AcroFormの下で参照するものすべて、そして保護が有効な場合は暗号化辞書が、すべてに先行しなければならないドキュメントレベルのグループを形成する。ページツリーのノードは意図的に保留され、1ページ目のセクションを汚染しないようにされている

パラメータ辞書は、リーダーが他の何かを読む前に必要な数値を運ぶ:ファイル全体の長さのための/L、ヒントストリームのオフセットと長さのための/H、1ページ目のオブジェクト番号のための/O、1ページ目セクションが終わるバイトのための/E、ページ数のための/N、そしてメインのクロスリファレンステーブルのエントリのオフセットのための/Tだ。これらのそれぞれが、それらを必要とする瞬間にはまだ存在しないファイルへのバイトオフセットになっている

なぜヒントテーブルのオフセットは収束しなければならないのか

パラメータ辞書の中の数値は、それ自身を含むファイルを記述しており、そのどれかを変更するとファイルが変わるからだ。それがリニアライズドライターの中心的な難しさであり、HotPDFが一度書くのではなく繰り返し計測する理由だ。/Tを6桁から7桁に広げると、パラメータ辞書は1バイト成長する。ヘッダーが成長する。すべてのオブジェクトがずれる。メインのクロスリファレンステーブルが動く。/Tは今度は違う値を必要とする。レイアウトは、実際の出力の1バイトがコミットされる前に不動点に到達しなければならない

HotPDFはこれを有界の反復で処理する。まずすべてのオブジェクトを、バイトを保持せず長さだけを記録するカウントストリームにシリアライズするので、各オブジェクトは既知のシリアライズサイズを持つ。それからレイアウトパスを実行し、ドキュメントレベルのグループ、ヒントストリーム、1ページ目のグループ、後続のページグループ、共有グループ、残りの部分にオフセットを割り当て、メインのクロスリファレンステーブルがどこに落ち着くかを報告する。その結果は次のパスの入力としてフィードバックされる。このループは8回の試行で打ち切られ、収束しない場合は、それらしく見える間違ったオフセットを持つファイルを生成するのではなく、例外を発生させる

CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
  CalculateLayout(CandidateMainOffset, FirstXRefData,
    HintOffset, EndFirstPage, NewMainOffset);
  if NewMainOffset = CandidateMainOffset then
    Break;
  CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
  raise Exception.Create('Linearization layout did not converge');

2つの細部がこのループの空回りを防いでいる。パラメータ辞書は固定384バイトのスロットに、空白でパディングされて書き込まれるので、それ自身の成長がレイアウト全体を不安定にすることは決してない。もし辞書のテキストがその予約枠を超えたら、HotPDFはすべてを黙ってずらすのではなく例外を発生させる。そして収束後、HotPDFはもう1回確認のレイアウトパスを実行し、ヒントストリームの長さを再チェックする。ヒントストリーム自体がレイアウトが確定して初めて分かるオフセットをエンコードしているからだ。この計測すべての見返りは、HotPDFがドキュメントの2つ目のコピーを決してバッファリングしないことだ:オフセットが確定すれば、オブジェクトは送信先ストリームに直接シリアライズされ、各セクション境界で書き込まれたバイトが約束されたオフセットと一致することをアサートする

Delphiから有効にする

APIサーフェスは1つのブール値で、唯一の要件は生成開始前に設定することだ。LinearizeOutputのデフォルトはFalseであり、レイアウトパスはドキュメントが書き込まれるときに実行されるので、EndDocのあとに割り当てても何も達成しない

var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := 'fast-view.pdf';
    PDF.Version := pdf17;
    PDF.LinearizeOutput := True;      // must precede BeginDoc
    PDF.BeginDoc;
    PDF.Canvas.TextOut(72, 72, 'First page');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

コード側のあらゆることに優先する展開上の注意点が1つある。リニアライズが元を取れるのは、転送層がHTTPレンジリクエストをサポートしている場合だけだ。同じファイルを丸ごとストリーミングするエンドポイントから、あるいはRangeを無視するCDN設定から配信すれば、書き込みパスが遅くなり、ファイルが大きくなるだけで、ユーザーに見える利点は何もない。コードを確認する前にサーバーを確認しよう

なぜリニアライズはUseXRefStreamとUseObjectStreamsを上書きするのか

リニアライズドライターは、すべてのオブジェクトが独自の直接アドレス可能なバイトオフセットを持つことを必要としており、そのどちらの機能もそれを奪ってしまうからだ。したがってHotPDFは、呼び出し側がUseXRefStreamUseObjectStreamsも設定していたとしても、LinearizeOutputが有効な場合は常に従来型のテキストクロスリファレンステーブルとパック解除された間接オブジェクトを出力する。これは自分で解決すべき衝突ではなく、意図的な上書きだ

この理由はヒントテーブルから導かれる。ヒントテーブルは、ページセクションがどこから始まりどれだけの長さかを記述しているので、リーダーはまさにその範囲をリクエストできる。/ObjStmコンテナに詰め込まれたオブジェクトは、独立したオフセットをまったく持たない。それはユニットとして取得され解凍されなければならない別の圧縮ストリーム内のスライスとしてのみ存在する。ファイルサイズのためにオブジェクトストリームに頼っていたなら、リニアライズと圧縮はここで反対方向に引っ張り合っていることを理解しておこう。このトレードオフはHotPDFにおけるオブジェクトストリームとインクリメンタル更新の併載記事で扱われている。同じ緊張関係が、ストリームベースのテーブルと並んで古いリーダーを動作させ続けるために存在するハイブリッド参照ファイルの形を決めており、Office生成PDFにおけるハイブリッドクロスリファレンスストリームの記事で扱われている

バージョンの下限もある。リニアライズにはPDF 1.2以降が必要だ。選択されたバージョンがそれより古い場合、HotPDFは自動的にそれを引き上げる。ただしStrictVersionLockが設定されている場合は例外で、その場合は書き込み時に、意図的に固定したドキュメントを黙って昇格させる代わりに例外を発生させる

4GiBの壁、そしてHotPDFが切り詰めではなく拒否する理由

リニアライズヒントテーブルはオフセットを32ビット値として格納するので、リニアライズドファイルは4GiB以上のどこも指すことができず、HotPDFはラップアラウンドしたオフセットを持つファイルを書き込む代わりに、そのような出力を明示的な例外で拒否する。この限界はHotPDFの実装上の選択ではなく、Annex Fが定義しているフィールドの幅そのものだ

このチェックは3か所で適用されており、そのすべてが重要だ。HotPDFは、シリアライズ長が分かった時点で各オブジェクトを検証し、ヒントエントリを構築する際に各ページセクション長を検証し、メインのクロスリファレンステーブルのサイズが決まったあとに最終的なファイル長を検証する。早期に失敗させることこそが要点全体だ:オフセットが黙って切り詰められたヒントテーブルは、ファイル全体をダウンロードするビューアでは正しく開くファイルを生成するが、リニアライズが存在する目的であるバイト範囲クライアントに対してのみ失敗する。それは最悪の失敗モードだ。あなたのテスト用ビューアが決してそれを再現しないからだ。数ギガバイトの出力を生成しているなら、リニアライズは適切なツールではなく、大容量PDFワークフロー向けダイレクトファイルAPIのノートで説明されているストリーミングアプローチが見るべき方向だ

読み込んだファイルでリニアライズを検出する

THotPDF.IsLoadedLinearizedは、現在読み込まれているドキュメントがすでにリニアライズド形式で書かれていたかどうかを報告する。これはライブストリームからではなく、パース前に取られたスナップショットから答えを返す。HotPDFはソースストリームの位置ゼロから最初の1024バイトを読み、その中を最初のobjキーワードについてスキャンし、次に値1を持つ/Linearizedエントリについてスキャンし、そのブール結果をキャッシュする

var
  PDF: THotPDF;
  PageCount: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PageCount := PDF.LoadFromFile('incoming.pdf');
    if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
      Writeln('Source is not Fast Web View ready');
  finally
    PDF.Free;
  end;
end;

この説明の中の2つの制約は本質的に重要だ。検出はストリーム位置に頼ることができない。アプリケーションコードがその問いを尋ねる頃には、パーサーがすでにそれを動かしているからだ。そしてオンデマンドで再読み込みすることもできない。LoadFromFileは読み込みが終わると内部のソースストリームを解放してしまうからだ。だからこそ、パース前にキャプチャしてキャッシュする設計になっている。このスキャンはまた、値についても意図的にリテラルだ:/Linearized 1、あるいは小数部がすべてゼロで数値的に等価な形式のみが受理される。パラメータ辞書が何か他のことを言っているファイルは、Annex Fの約束をしていないからだ

盗む価値のあるDelphiのレコードの罠

動的配列を含むローカルレコードは、管理対象フィールドだけを初期化し、それ以外は何もしない。配列の隣に単純なCountフィールドを保持しているなら、それは自分でクリアしなければならない。これは開発中にリニアライズの分割処理に噛みついた問題であり、1つのプラットフォームがそれを隠すために丸1日かかるタイプのバグだ

type
  THPDFLinearIndexList = record
    Values: THPDFIntegerArray;  // managed field: cleared for you
    Count: Integer;             // plain field: whatever was on the stack
  end;

// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);

動的配列フィールドは参照カウント式なので、コンパイラはそれをゼロにする。その隣のCountは普通の整数であり、そのような保証は何もない。初期化されていないCountは、最初の追記を任意のインデックスに送り込む。Win32では、たまたまスタックスロットがゼロを保持しており、追記はインデックス0に着地し、すべてのテストが通った。同じコードがWin64では配列の末尾を越えて書き込んだ。この教訓はリニアライズをはるかに超えて一般化される:レコードが管理対象フィールドと非管理対象フィールドを混在させている場合は、Default(TRecord)を割り当て、コンパイラがどのフィールドをカバーしているかを推測するのをやめること。そして、Win32での実行が緑色だったからといって、それを初期化が正しい証拠として扱わないこと

ここで説明したLinearizeOutputIsLoadedLinearizedのメンバーは、Delphi・C++Builder向け標準HotPDF Componentに付属している。製品ページには、クロスリファレンスストリーム、オブジェクトストリーム、バージョンロックとの相互作用ルールを含む完全なプロパティリファレンスがある