技術記事

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

リニアライズドPDFの最初のオブジェクトは、それが座っているファイルの正確な長さをバイト単位まで述べ、しかも先頭1024バイト以内に現れなければなりません。ISO 32000-1のAnnex Fはそのエントリを/Lと呼び、実ファイルサイズと食い違うのを見つけたリーダーは、文書を普通のPDFとして扱い、中のリニアライズヒントをすべて無視せよと指示されます。つまりライターは、まだ書き終えていないファイルの長さを、ヒントストリームのオフセットや、最初のページの終端、ファイル後方のクロスリファレンステーブルの位置の隣に印刷しなければなりません。4つの数字はどれも、手前の何かがサイズを変えるたびに動きます

見返りはFast Web Viewです。普通のPDFはクロスリファレンステーブルを末尾に置くので、200ページのスキャンレポートを渡されたブラウザは、最後のバイトが届くまでページ1を特定できません。リニアライズドファイルはページ1と小さなクロスリファレンスセクションを先頭へ動かし、HTTPレンジリクエストを使うクライアントは最初のページを早く描けます。ここからは書き込み側の話です。読み込み側の全体像は、PDFリニアライズとFast Web Viewの解説にあります

HotPDFはリニアライズドファイルをどうレイアウトするのか

HotPDFはTHotPDF.LinearizeOutputがTrueのときリニアライズド出力を書き、ファイルはAnnex Fが定める物理順に、パートごとに並びます。ヘッダの後にリニアライズパラメータ辞書、続いて最初のページのクロスリファレンスセクションとそのtrailer、他の文書レベルオブジェクトを伴うcatalog、プライマリヒントストリーム、必要な全オブジェクトを伴う最初のページ、順番に並ぶ残りのページ、後続ページ間で共有されるオブジェクト、残りすべて、そして最後にメインのクロスリファレンステーブルです

DelphiのHotPDFが書くリニアライズドPDFのバイト順の図。リニアライズパラメータ辞書とプライマリヒントストリームから、メインのクロスリファレンステーブルまで
HotPDFはAnnex Fのパートを固定のバイト順で出力します。パラメータ辞書とプライマリヒントストリームが十分早く置かれるので、バイトレンジのリーダーはほぼすぐにページ1の描画を始められます

文書モデルのどこにも、オブジェクトがどのパートに属するかは書いてありません。ライターはEndDocが保存するとき、参照グラフからそれを割り出します。各ページオブジェクトから歩き、すべての間接オブジェクトに何ページが届くかを数えます。1ページからだけ届くオブジェクトはそのページのセクションへ。複数ページから届くオブジェクトは共有ですが、仕様を流し読みすると見落とすねじれが1つあります。ページ1が使用側に含まれるとき、そのオブジェクトはとにかく最初のページのセクションへ入ります。ページ1は先頭のバイトだけから描けなければならないからです。純粋に後続ページ間だけで共有されるオブジェクトだけが、共有パートで待ちます。catalogは、/ViewerPreferences、/OpenAction、/Threads、/AcroFormの各エントリが何を指していようと文書レベルのグループを構成し、保護された文書の暗号化辞書もそこへ入ります。ページツリーノードは後尾のパートへ押し込まれ、最初のページを膨らませません

出力を16進エディタで開くと、番号付けが逆に見えます。リニアライズ辞書からヒントストリームまでの前部ブロックは、ファイルで最も大きいオブジェクト番号を運び、その後ろのすべては1から採番されます。前方の小さなクロスリファレンスセクションはちょうどその高番号ブロックをカバーし、メインのテーブルは1からNまでをカバーします

パーティションリストのレコード初期化の罠

この種のリストは、動的配列と独立したカウントを持つレコードとして書きたくなりますが、その形にはDelphiの罠が隠れています。コンパイラはローカルレコードの管理対象フィールドを初期化します。隣に座る素のIntegerは何も受け取りません。Win32ではスタックスロットがたまたまゼロを保持していることが多く、最初の追加がインデックス0に着地し、すべてのテストが通ります。Win64ではスロットが残骸を保持していることがあり、最初の追加が任意のインデックスへ書き込みます

type
  TPartList = record
    Values: TArray<Integer>;  // 管理対象。コンパイラがクリアします
    Count: Integer;           // 非管理。スタックが保持していたもの何でも
  end;

procedure Append(var List: TPartList; Value: Integer);
begin
  if List.Count = Length(List.Values) then
    SetLength(List.Values, List.Count * 2 + 16);
  List.Values[List.Count] := Value;
  Inc(List.Count);
end;

procedure BuildParts;
var
  FirstPage, Shared: TPartList;
begin
  FirstPage := Default(TPartList);   // 必須。お化粧ではありません
  Shared := Default(TPartList);
  Append(FirstPage, 12);
  Append(Shared, 40);
end;

HotPDFのライターは、すべてのパーティションリストに触る前にDefaultを代入します。Win32での緑の実行は、そのようなレコードが初期化されたかどうかについて何も語りません

なぜ最初の1バイトを書く前にレイアウトを収束させねばならないのか

ファイルの前方が後方を指すオフセットを保持し、そのオフセットの桁数が背後のすべてをずらすからです。最初のページのtrailerは、メインのクロスリファレンステーブルのバイトオフセットを載せた/Prevエントリを運びます。そのオフセットを99998と書いた後で、テーブルが実際には100003に着地すると分かり、trailerが1桁伸びます。最初のページのクロスリファレンスセクションも伸び、背後の全オブジェクトが1バイト動き、メインテーブルは今度は100004に座ります。1回のパスでこれを正すのは運だけです

HotPDFは2つの手段で循環を断ちます。パラメータ辞書はスペースで埋めた固定384バイトのスロットに入るので、/L、/H、/E、/Tは必要な桁を取り、1つのオブジェクトも動かしません。辞書テキストが384バイトを超えそうになったら、書き込みはファイルをずらす代わりに例外を上げます。これで/Prevだけが自由変数として残り、不動点ループを回ります。まず全オブジェクトが、長さを記録しバイトを保持しないカウンティングストリームへ直列化されます。レイアウトパスがセクションごとにオフセットを割り当て、メインテーブルが着地する位置を報告し、その答えが次のパスの入力になります。提案されたオフセットが動かなくなったらループは止まり、8回の試行で諦めて例外を上げます

DelphiにおけるHotPDFリニアライズドレイアウトの収束ループのフローチャート。提案されたクロスリファレンスオフセットを不動点に達するまで繰り返し測定し、達しなければ例外が書き込みを中断します
最大8回の測定パスが、1バイトもコミットされる前にメインのクロスリファレンスオフセットを収束させます。収束しない場合は、推測のオフセットを書く代わりに例外を上げます

動くのは桁数が変わったときだけなので、3回目のパスが必要になるのは99998の例のような10のべき乗の境界付近だけです。収束の後、ライターは確認パスを1回走らせ、実オフセットでヒントストリームを組み立て直し、長さが変わらなかったことを確かめます。オフセットは固定幅フィールドに座るので、プレースホルダ版と最終版は一致しなければなりません。そして出力は、バッファリングされた2枚目のコピーなしに目的のストリームへ直行し、各セクション境界で、書かれたバイト数が約束のオフセットと比較されます。不一致はEndDocから上がります

ヒントテーブルと、Annex Fが2通りに読める場所

プライマリヒントストリームは2つのテーブルを運びます。ページオフセットヒントテーブルは、ページごとに、そのセクションが保持するオブジェクト数、セクションのバイト長、依存する共有オブジェクトを記録します。共有オブジェクトヒントテーブルは、各共有オブジェクトグループの長さを列挙します。両方があれば、リーダーはページ37を直接要求でき、ストリームの/Sエントリが、共有テーブルが始まるストリーム内の位置を与えます

Annex F F.4には間違えやすい細部が2つあります。1つ目は、ヒントテーブルに保存されるオフセットが論理オフセット、つまりプライマリヒントストリームが存在しないものとして計算されることです。最初のページオブジェクトと最初の共有オブジェクトは物理的にはどちらもヒントストリームの後に座るので、ライターはオフセットを記録する前にヒントオブジェクトの長さを引きます。物理オフセットを記録したライターは、生成するすべてのレンジリクエストを、ヒントオブジェクトのサイズちょうど分ずらします

2つ目は本当の曖昧さです。F.4のテキストは各テーブルを、最上位ビットから始まる1本の連続ビットストリームとして記述し、最初のページの共有グループと後続の共有グループを2つの別々の系列として列挙します。世に出回るヒントストリームは、すべての列をバイト境界へパディングし、共有グループを1つの結合リストとして扱います。そしてそれが、ヒントを消費するリーダーが受け付けることが知られている形式です。HotPDFはバイトアラインで結合されたレイアウトを書きます。選ぶべきはこちらです。F.4の文字面は満たすが読む側のクライアントでずれるテーブルは、リニアライズが報酬を払うはずのまさにその場所で失敗し、ファイル全体のダウンロードでは問題が顔を出すことすらありません

Delphiからリニアライズド出力を有効にする

API側のスイッチは1つのブーリアンです。LinearizeOutputの既定値はFalseで、ライターはEndDocが文書を直列化するときにそれを見ます。だから他の出力オプションと一緒にBeginDocの前に設定し、あとは触れません。上述のすべての失敗(収束しない、過大なパラメータ辞書、境界の不一致、後述の4 GiB天井)はEndDocからの例外として現れ、フォールバックを素直に書けます

procedure WriteReport(const FileName: string; Linearize: Boolean);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := FileName;
    PDF.Version := pdf17;
    PDF.LinearizeOutput := Linearize;   // BeginDoc の前に設定
    PDF.BeginDoc;                       // ページ1を作ります
    PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
    PDF.AddPage;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
    PDF.EndDoc;                         // レイアウトと書き込みはここで起きます
  finally
    PDF.Free;
  end;
end;

procedure PublishReport(const FileName: string);
begin
  try
    WriteReport(FileName, True);
  except
    on E: Exception do
    begin
      LogWarning('Linearization failed: ' + E.Message);  // 自作のロガー
      WriteReport(FileName, False);                       // 代わりに普通のPDF
    end;
  end;
end;

フォールバックは、どのビューアでも開ける普通のPDFを作ります。失うのは速い最初のページだけです。レポートパイプラインではこの取引は容易です。最初の描画が遅いのは悩みの種で、レポートが欠けるのは事故だからです

配備側はコードより上位です。リニアライズが見返りを払うのは、サーバーがHTTPのRangeリクエストを尊重するときだけです。ファイルを丸ごとストリーミングするエンドポイントは、速い最初のページの代わりに、遅い書き込みと大きなファイルを買ってきます。メールで送られ、添付され、誰かが見る前にダウンロードされるファイルにはLinearizeOutputを切っておいてください。ブラウザがURLから直接開く文書は、レスポンスがAccept-Ranges: bytesを運ぶことを確認した上で、スイッチを入れてください

LinearizeOutputはなぜオブジェクトストリームを切るのか

すべてのヒントテーブルエントリはバイトレンジを記述するのに、/ObjStmコンテナに詰め込まれたオブジェクトには自分のバイトレンジがありません。それは、丸ごと取得して伸長しなければならない別の圧縮ストリームの断片としてしか存在しないのです。LinearizeOutputが有効なとき、HotPDFはUseXRefStreamやUseObjectStreamsも設定されていても、伝統的なテキストのクロスリファレンステーブルと、梱包されていない間接オブジェクトを書きます。保存時にはリニアライズの経路が優先されるので、コードが解決すべき衝突はありません

代償はファイルサイズです。オブジェクトストリームが一緒に圧縮したはずの小さな辞書が、ばらけて残るからです。最初のページの待ち時間よりコンパクトな出力が重要なら、そのトレードオフはHotPDFのオブジェクトストリームとインクリメンタル更新に整理されています。Officeエクスポータは同じ緊張を逆側から経験します。だからOffice生成PDFのハイブリッドクロスリファレンスストリームとして記述されるレイアウトを出すものがあるのです

リニアライズにはPDF 1.2以降も必要です。Versionがそれより低いと、HotPDFは保存時に文書を1.2へ引き上げます。StrictVersionLockが設定されているときは代わりに例外を上げます。意図的に固定したバージョンは、背後で勝手に変わるべきではないからです

リニアライズド出力の4 GiB天井

ヒントテーブルはオフセットと長さを32ビットフィールドに保存するので、リニアライズドファイルは4 GiBを超える何もアドレスできず、HotPDFは周回するオフセットを書く代わりに生成を拒否します。天井はAnnex Fが定義するフィールド幅から来ています

Delphiにおいて、オブジェクトサイズ、ページセクション長、最終ファイル長を、32ビットのリニアライズヒントテーブルフィールドの4 GiB限界に対して測る、HotPDFの3つのチェックポイントの図
オブジェクト、ページセクション、ファイル全体の各計測は、出力が生まれる前に32ビットのヒントフィールド天井に対して検査されます

検査は3か所で走り、すべて最初の1バイトが目的地に届く前です。直列化された長さが分かった時点で各オブジェクトに、ヒントエントリを組み立てている間に各ページセクション長に、メインのクロスリファレンステーブルのサイズが決まった時点で最終ファイル長に。周回したオフセットは最悪の失敗の仕方です。ファイル全体をダウンロードするビューアはヒントを無視できますが、バイトレンジのクライアントは間違ったバイトへ跳ぶので、デスクトップでのテストは故障を再現できません。マルチギガバイトの出力には、大規模PDFワークフローのためのDirect File APIのストリーミング経路が調べるべき場所です

入ってくるファイルがリニアライズドかどうか調べる

THotPDF.IsLoadedLinearizedは、読み込まれた文書が自分をリニアライズドと宣言しているかを報告します。検査はパースの前に起きます。HotPDFはソースの位置ゼロから最大1024バイトを読み、最初のオブジェクトを見つけ、そのオブジェクトの中に値1の/Linearizedを探します。コメント、リテラル文字列、16進文字列はスキップされるので、文字列の中に名前が現れても数えられず、走査はstreamかendobjで止まります。受け付けられるのは1か、1.0のような等価な値だけです

答えは読み込み時にキャッシュされます。アプリケーションコードが尋ねる頃には、パーサーはストリーム位置を動かした後で、LoadFromFileはソースストリームをすでに解放済みだからです

このフラグはファイルが主張することを報告します。Annex Fはもう1つの条件を加えます。/Lは実際のファイル長と一致しなければならず、リニアライズの後に追記されたインクリメンタル更新はその一致を壊すので、リーダーはファイルを普通のものとして扱う後退をします。保存のたびに追記するエディタからファイルを受け取るパイプラインは、2つの数字を自分で比較できます

// 自作ヘルパー。HotPDF の一部ではありません。/L と実サイズを比較します
// 簡略化したパース。/L の後ろに空白が1つあると仮定します
function DeclaredLengthMatches(const FileName: string): Boolean;
var
  FS: TFileStream;
  Head: AnsiString;
  ActualSize: Int64;
  P, Q: Integer;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    ActualSize := FS.Size;
    SetLength(Head, 1024);
    SetLength(Head, FS.Read(Head[1], 1024));
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/L '), Head);
  Result := P > 0;
  if not Result then
    Exit;
  Inc(P, 3);
  Q := P;
  while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
    Inc(Q);
  Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;

procedure InspectUpload(const FileName: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    if PDF.LoadFromFile(FileName) <= 0 then
      Exit;
    if not PDF.IsLoadedLinearized then
      Writeln('Not linearized')
    else if not DeclaredLengthMatches(FileName) then
      Writeln('Linearized, but modified after linearization')
    else
      Writeln('Ready for Fast Web View');
  finally
    PDF.Free;
  end;
end;

LinearizeOutputとIsLoadedLinearizedは、DelphiとC++Builder向けのHotPDF Delphi Componentの一部で、製品ページはクロスリファレンスストリーム、オブジェクトストリーム、バージョンロックとの相互作用を文書化しています