技術記事

PDFのFast Web View: 線形化が変えるもの

リンクから 200 ページの PDF を開くと、ブラウザはファイルの大部分が届くまで真っ白なままのことがよくあります。Fast Web View は、リニアライズド PDF(ISO 32000-1 Annex F)に対する Adobe Acrobat の呼び名です。1 ページ目と小さなインデックスがファイルの先頭に置かれ、ビューアは 1 ページ目を早く描画し、その後のページは HTTP Range リクエストで取得します。HotPDF と PDFium Component はどちらも、Delphi からリニアライズドファイルを書き出せます

これらのどれも別のファイル形式ではありません。リニアライズド PDF は、準拠リーダーなら特別な処理なしで開ける普通の PDF です。違いはバイトの順序と、線形化パラメータ辞書とヒントストリームという 2 つの追加構造にあります。読者が恩恵を感じるかどうかは 2 つの条件で決まります。ファイルが線形化されており、その状態を保っていること。追記された編集はこれを静かに崩してしまいます。そして Web サーバーがバイトレンジリクエストに 206 Partial Content で応答すること。ローカルディスクではファイル末尾へのシークは無償なので、リニアライズド PDF は他の PDF とまったく同じように振る舞い、Fast Web View は URL から直接開く大きな文書で効果を発揮する、純粋なネットワーク最適化です

線形化された PDF は何を再配置するのか

リニアライズド PDF は、1 ページ目に必要なすべてを、他のどのページよりも先の、ファイル先頭近くの連続したブロックに集めます。普通の PDF ではオブジェクトは任意の順序で置かれ得ます。末尾の相互参照テーブルが各オブジェクト番号をバイトオフセットへマップするからです。リーダーは末尾へシークし、startxref ポインタを読み、テーブルをロードすれば、あとは何でも見つけられます(PDF ファイル構造の概要が、そのヘッダー、ボディ、xref、トレイラーのレイアウトを解説しています)。この設計はディスク上では優秀ですが、ネットワーク越しでは貧弱です。ネットワークでは末尾こそが最後に届く部分だからです。1 ページ目を描くために従来のリーダーが必要なのは、ページオブジェクト、そのコンテンツストリーム、そして使うフォントと画像ですが、順序不定のファイルでは、そのどれもが最後の 1 メガバイトに置かれている可能性があります

線形化はこの順序を固定します。ヘッダーの後に線形化辞書と小さな 1 ページ目相互参照セクションが来て、次にカタログなどの文書レベルオブジェクト、プライマリヒントストリーム、そして全リソース込みの 1 ページ目が続きます。残りのページが 1 つずつ並び、その後に後続ページ間で共有されるオブジェクト、最後にメインの相互参照テーブルが置かれます。この最適化を無視するリーダーも、やはり末尾から始めてメインテーブルで全オブジェクトを見つけます。リニアライズドファイルが古いソフトウェアとも完全に互換を保てるのはこのためです。結果として、オブジェクト番号はヘックスエディタでは逆順に見えます。先頭ブロックが最大の番号を担い、その後ろは 1 から番号が付いていきます

通常の PDF と、Fast Web View を可能にするリニアライズド PDF のバイトレイアウト比較
リニアライズ済みファイルは、リニアライゼーション辞書、先頭ページの xref、ヒントストリームを他のすべてより前に移します。普通の PDF は唯一のインデックスをファイル末尾に置いたままです

線形化辞書とヒントテーブル

%PDF ヘッダーの後の最初のオブジェクトは線形化パラメータ辞書で、Annex F はそれが先頭 1024 バイト以内に置かれることを要求します。ストリーミングリーダーが開始パケットでそれを見つけられるようにするためです。Table F.1 がエントリを列挙します。/L はファイル全体のバイト長、/H はプライマリヒントストリームのオフセットと長さ、/O は 1 ページ目のオブジェクト番号、/E は 1 ページ目の終端オフセット、/N はページ数、/T はメイン相互参照テーブルの最初のエントリのオフセットです。/L エントリは見た目より重い役割を担います。実際のファイルサイズと食い違うとき、Table F.1 はリーダーに、その文書を普通の PDF として扱い、内部のヒントをすべて無視せよと告げるのです

40 ページ目へのジャンプを安価にするのがヒントストリームです。ページオフセットヒントテーブルは、すべてのページについてオブジェクトがどこから始まり何バイトを占めるかを記録し、共有オブジェクトヒントテーブルは、複数ページで使われるフォント、ロゴ、その他のリソースについて同じことを記録します。40 ページ目が欲しいリーダーはバイト範囲を調べ、そのスライスだけを要求し、届き次第描画します。まだ保持していない共有オブジェクトも同じやり方で引っ張ります。自家製リニアライザがつまずく細部が 2 つあります。ヒントオフセットは論理値で、ヒントストリーム自体が存在しないかのように計算されます。物理オフセットを保存するライターは、それ以降のすべてのレンジリクエストをヒントオブジェクトのサイズぶんずらします。さらに、リソースを収集する際にページの /Parent リンクを辿るライターは、ページツリーを逆に下って文書全体を 1 ページ目セクションへ引きずり込みます。どちらのファイルも線形化を名乗ったままです。どちらも速くはロードできません

Fast Web View にはなぜサーバーの協力が必要なのか

Fast Web View が機能するのは、サーバーが要求に応じてファイルの任意のスライスを配信するときだけです。リーダーは Range ヘッダー付きの HTTP リクエストを送り、206 Partial Content の返答を期待します。サーバーが Accept-Ranges: bytes を一度もアドバタイズしない場合や、前段のプロキシや CDN がレンジリクエストを完全転送に潰してしまう場合、リーダーは 40 ページ目を単独では取得できず、ファイル全体をダウンロードすることになります。PDF 内部の構造は正しいのに完全に無駄になり、これが壊れた線形化だと誤診されやすい失敗です。文書を再構築する前に、ビューアが開くちょうどその URL の最初の 1 キロバイトを、たとえば curl -r 0-1023 で要求し、ステータスが 206 であることを確認してください。静的ファイルホストは大抵デフォルトで正しく動きますが、独自ハンドラで PDF をストリームするアプリケーションエンドポイントは頻繁にそうではありません

Fast Web View PDF の 1 ページに対して 206 Partial Content を返す HTTP レンジリクエストを駆動する線形化ヒントテーブル
ヒントテーブルがリーダーに任意のページのバイト範囲を教えるため、レンジ対応ホストは 1 回の部分応答でページ 40 を供給します。バイトサービングがなければ、ファイル全体をもう一度ダウンロードします

PDF が Fast Web View を持つかどうか確認する方法

ファイルを生成したツールを信用するのではなくバイトを確認します。条件の両半分を確認します。ファイルが自身を線形化と宣言していること、そしてその宣言が今もディスク上のファイルと一致していること。デスクトップでの一見から公開パイプラインの自動ゲートまで、3 つのチェックがほぼすべてのケースをカバーします:

  • Adobe Acrobat または Reader:File > Properties を開くと、Description タブの右下に Fast Web View: Yes または No と表示されます。Acrobat は構造が存在し、かつ最新のときにだけ Yes と報告するため、線形化の後に編集されたファイルは No になります
  • qpdf コマンドライン:qpdf --check-linearization がヒントテーブルを実際のオブジェクト位置に対して検証し、qpdf --show-linearization が辞書とすべてのヒントテーブルエントリを出力します
  • ファイルの先頭キロバイト:テキストエディタかヘックスエディタで開き、先頭 1024 バイト以内に /Linearized 1 を含むオブジェクトがあるかを探します。あれば、その /L の値を OS が報告するファイルサイズと比較します
# 線形化辞書とヒントテーブルをレイアウトに対して検証する
qpdf --check-linearization report-web.pdf

# /L、/H、/O、/E、/N、/T とすべてのヒントテーブルエントリを出力する
qpdf --show-linearization report-web.pdf

パイプラインに組み込む価値があるのは qpdf のチェックです。ヒントテーブルを実際のオブジェクト位置と突き合わせるからです。辞書の存在だけを確認するチェックは、オフセットが間違ったバイトを指すファイルをお墨付きにします。Delphi からは、HotPDF の THotPDF.IsLoadedLinearized が、ロードした文書が自身を線形化と宣言しているかを報告します。パースの前に最初の 1024 バイトを /Linearized のために走査します。PDFium Component は、クラス関数 TPdf.InspectAvailability で同じ問いに答え、その Linearization フィールドは文書を一切ロードせずに plnLinearized、plnNotLinearized、plnUnknown のいずれかを返します。どちらのフラグもファイルの宣言を報告するだけです。その宣言が今もディスク上のバイトと一致するかは、/L の比較が教えてくれます:

uses
  System.SysUtils, System.Classes, System.Math, System.StrUtils, PDFium;

type
  TFastWebView = (fwvNo, fwvStale, fwvYes);

// 自作のヘルパー:ファイルが線形化されているかを PDFium が判定し、
// その後の追記編集を /L の比較が検出する
function FastWebViewState(const FileName: string): TFastWebView;
var
  Stream: TFileStream;
  Head: AnsiString;
  Text: string;
  P, Q: Integer;
begin
  Result := fwvNo;
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if TPdf.InspectAvailability(Stream).Linearization <> plnLinearized then
      Exit;
    SetLength(Head, Min(Stream.Size, 1024));   // Annex F:先頭 1024 バイト
    Stream.Position := 0;
    if Length(Head) > 0 then
      Stream.ReadBuffer(Head[1], Length(Head));
    Text := string(Head);
    P := Pos('/Linearized', Text);
    if P = 0 then
      Exit;
    P := PosEx('/L ', Text, P + Length('/Linearized'));
    if P = 0 then
      Exit;
    Inc(P, 3);
    Q := P;
    while (Q <= Length(Text)) and CharInSet(Text[Q], ['0'..'9']) do
      Inc(Q);
    if StrToInt64Def(Copy(Text, P, Q - P), -1) = Stream.Size then
      Result := fwvYes
    else
      Result := fwvStale;   // かつて線形化された後、変更された
  finally
    Stream.Free;
  end;
end;

Acrobat、qpdf、Delphi で Fast Web View を有効にする

Annex F 順でファイル全体を書き直すツールなら何でも Fast Web View を有効にでき、最も重要なのはそれを最後に実行することです。Acrobat では、Preferences > Documents で Save As optimizes for Fast Web View を有効にし、Save ではなく File > Save As を使います。普通の Save は増分更新を追記することがあるためです。サーバーやビルドスクリプトでは、qpdf --linearize in.pdf out.pdf がどんな PDF もリニアライズド形式に書き直し、入力には触れません。Delphi コードには、文書を生成するか受け取るかで 2 つの経路があります。HotPDF は THotPDF.LinearizeOutput が True ならリニアライズド出力を直接書きます。BeginDoc の前に他の出力オプションと一緒に設定すると、Annex F レイアウトは EndDoc の内部で計算され、書き込まれます:

qpdf --linearize report-final.pdf report-web.pdf
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;       // EndDoc の後にビューアを開かない
    PDF.FileName := 'catalog-web.pdf';
    PDF.LinearizeOutput := True;   // BeginDoc の前に設定する
    PDF.BeginDoc;                  // BeginDoc の後でページ 1 が存在
    PDF.CurrentPage.TextOut(72, 72, 0, 'Spring catalog');
    PDF.AddPage;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Price list');
    PDF.EndDoc;                    // 線形化レイアウトはここで書き込まれる
  finally
    PDF.Free;
  end;
end;

すでに存在するファイルには、PDFium Component の TPdf.SaveAsLinearized が用意されています。通常の PDFium 保存を中間ストアへ実行し、ページ編集やフォーム値は他の保存と同じ経路を通ります。その出力を、ターゲットに書き込む前に Annex F に従って再構成します。このメソッドは SaveAs と同じ TSaveOption と TPdfVersion パラメータを受け入れ、saIncremental と PDF 1.2 未満のバージョンを拒否し、ファイル名オーバーロードは一時ファイルへ書き出して、保存が成功した後でのみターゲットを置き換えます。ライターは現在のリビジョンからファイルを再構築するため、追記編集を拾ったファイルの Fast Web View を復元する方法でもあります。暗号化入力には saRemoveSecurity が必要です。オブジェクトの再番号付けは標準セキュリティハンドラのオブジェクトごとの鍵を無効化するため(ISO 32000-1 §7.6.2)、Web 用コピーは暗号化なしで書き込まれます

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'manual-rev3.pdf';   // 追記編集を含む
    Pdf.Active := True;
    if not Pdf.SaveAsLinearized('manual-web.pdf', saNoIncremental) then
      raise Exception.Create('Linearized save failed');
  finally
    Pdf.Free;
  end;
end;

増分更新は Fast Web View を静かにオフにする

正しく線形化されたファイルが後から Fast Web View を失う理由で最も多いのが、増分更新です。ツールが増分保存で署名を追加したり、フォームフィールドを埋めたり、付箋を添付したりするとき、元のバイトには触れず、変更されたオブジェクト、新しい相互参照セクション、新しいトレイラーを末尾へ追記します。先頭の線形化辞書はまだ古いレイアウトを記述しており、その /L はもはやファイルサイズと一致しません。線形化構造がファイルの前半にそのまま残っていても、準拠リーダーは文書を普通の PDF として扱うフォールバックに落ちます。HotPDF は BeginIncrementalUpdate と SaveIncrementalUpdate でまさにこの種のリビジョンを追記します。文書がまだ変動している間は正しい道具であり、Web サーバーへ向かうファイルには間違った最終ステップです:

// リビジョンを追記する:高速で、既存のバイトはそのまま、
// 結果は Fast Web View を失う
PDF.BeginIncrementalUpdate('report.pdf');
PDF.AddPage;
PDF.CurrentPage.TextOut(72, 760, 0, 'Addendum');
PDF.SaveIncrementalUpdate('report-rev2.pdf');
増分更新はリビジョンを追記して線形化辞書をズラしていき、最後の一度の書き直しが Fast Web View を復元する
増分保存のたびに、古くなった先頭辞書の先へもう 1 つのリビジョンが積まれます。最後に完全な書き直しで締めて再リニアライズすれば、Fast Web View は無傷のままです

実用的な規則はこうです。文書が変動している間は増分で編集し、最後の最後のステップとして一度だけ線形化する。誰かが開く前にメールされたりダウンロードされたりするファイルは、先頭寄せされた大きなインデックスから何も得ないため、Fast Web View をオフのままにします。署名された文書は現実の選択を迫ります。完全な書き直しは、各署名の /ByteRange がカバーするバイトを変えます。後から署名すれば /L の不一致をすぐに持ち込む更新を追記します。契約書なら署名を保ち、完全ダウンロードを受け入れます。最初の 1 バイトが書かれる前にオフセットがどう収束するか、リニアライズド出力がなぜオブジェクトストリームを諦めるかを含む Annex F の書き込み側は、HotPDF のヒントテーブルでリニアライズド PDF を書くとDelphi の PDFium Component による Fast Web View 線形化で解説しています。LinearizeOutput と IsLoadedLinearized はHotPDF Delphi Componentに、SaveAsLinearized と InspectAvailability はDelphi、C++Builder、Lazarus 向け PDFium Componentに同梱されています