PDF 1.5のオブジェクトストリームは多数の小さな間接オブジェクトを1つのFlate圧縮コンテナに詰め込み、losLab PDF Libraryはフルセーブ時にPackObjectStreamsフラグを通じてこれを出力する。得られる効果は本物だ:未圧縮では数十バイトを消費するページ、フォント、注釈辞書の何百個もが、わずか数個の圧縮ブロブへと縮む。代償は、パックされたすべてのオブジェクトを説明するためにクロスリファレンスストリームが必要になることである
後半のこの部分でライターは壊れる。/ObjStmコンテナを構築するのは算術にすぎない。クロスリファレンス機構にそこへ指し示す方法を教えるのは設計のやり直しである。完全に妥当なコンテナを作ったうえで、そのメンバーを通常のtype-1オフセットで記述するライターは、Acrobatが開いた瞬間に壊れていると宣言するだけのファイルを作ったことになる。この2つの機能は本質的に1つの機能であり、本稿はISO 32000-1 §7.5.7と§7.5.8で定義されるとおり、両者の書き込み側を扱う
ObjStmコンテナが実際に含むもの
オブジェクトストリームとは、デコードされたバイト列が連結された2つの領域から成るストリームであり、ISO 32000-1 §7.5.7は構築に関わる3つのキーだけを辞書に与えている。/Type /ObjStmはそれを識別し、/Nはメンバー数を、/Firstはヘッダー領域のバイト長——同じことだが、本体が始まるオフセット——を示す。ヘッダーは空白で区切られたオブジェクト番号とオフセットの組であり、本体はメンバーを背中合わせに直列化したもので、各オフセットはデコード済みペイロードの先頭からではなく本体の先頭から測られる。完全にデコードされたコンテナを読めば一目瞭然だ:以下の例では、3行のヘッダーが14バイトを占めるため/Firstは14であり、オブジェクト4が54文字とセパレータ1文字にシリアライズされるため、オブジェクト7は本体の55バイト目に位置する
// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
// /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]
メンバー資格に関する2つの規則は絶対であり、どちらも§7.5.7から直接来ている。ストリームオブジェクトは決してメンバーになれない。ストリームは生のバイト列を運ぶため、別のストリームの中に入れ子にせざるを得なくなるからだ。そしてメンバーは完全なオブジェクト値でなければならず、裸の間接参照であってはならない——単に5 0 Rである圧縮オブジェクトは、リーダーがそれがどこを指すかをあらかじめ知っていない限り解決できない間接参照を作り出してしまう。losLab PDF Libraryは候補収集の段階で両方のケースを暗号化辞書とオブジェクト0とともに除外し、生き残ったものをコンテナごとに200個ずつの組にしてパックする。この上限は仕様上の制限ではなくランダムアクセスに関する判断だ:1つのメンバーだけがほしいリーダーもコンテナ全体を膨張させなければならないため、コンテナが大きすぎると小さなルックアップのコストが高くなる
ObjStmメンバーがtype-2クロスリファレンスエントリを使わなければならない理由
パックされたオブジェクトには記録すべきファイルオフセットが存在しないからだ。ISO 32000-1 §7.5.8は、バイナリのクロスリファレンスストリームにおける3つのエントリタイプでこれに答える:フリーオブジェクト用のtype 0、バイトオフセットに保存された通常の使用中オブジェクト用のtype 1、そして圧縮オブジェクト用のtype 2であり、その2つのデータフィールドはコンテナのオブジェクト番号とその中でのメンバーインデックスを保持する。古典的な平文のxrefテーブルではパックされたオブジェクトを表現する方法がまったくない。これこそPDF 1.5が両方の機能を同時に導入した理由である
その後に続く順序付けが、私たちのものも含めほぼすべての最初の実装をつまずかせる。通常のオブジェクトはtype-1エントリを得る。/ObjStmコンテナ自体もtype-1エントリを得る。コンテナは実際のオフセットに書かれる、ごく普通の間接ストリームオブジェクトだからだ。type-2エントリを得るのはメンバーだけである。そしてクロスリファレンスストリーム自体もファイル内の間接オブジェクトであるため、それがちょうど書かれたオフセット——startxrefが記録するのと同じオフセット——を指す自分自身のtype-1エントリが必要になる。私たちのライターの初期バージョンは、メンバーではなくコンテナのオブジェクト番号を書き込みループから除外してしまい、結果としてクロスリファレンスストリームはあるのにオブジェクトストリームがまったくないファイルができあがった:構造的には整合していても意味的には空であり、下流で拒否される。/Sizeの値も対応するオフバイワンを隠している。/Sizeは最大オブジェクト番号プラス1であり、クロスリファレンスストリーム自身も最大オブジェクト番号として割り当てられるため、これもカウントに含めなければならない
/W配列のサイズ決定:なぜ4バイトでは足りないのか
/W配列は3つのフィールドそれぞれのバイト幅を宣言し、losLab PDF Libraryはこれを/W [1 Field2 Field3]として書き出す。フィールド1はタイプコード用に1バイト固定、フィールド3は世代番号(最大65535まで)とメンバーインデックスの両方をカバーする2バイト固定である。定数にできないのはフィールド2であり、これは互いに無関係な2つの量を運ぶからだ:type-1エントリではファイルサイズにしか制約されないバイトオフセットであり、type-2エントリではコンテナのオブジェクト番号、type-0エントリではフリーチェーン内の次のオブジェクトである。固定4バイトのフィールド2はファイルが4GBを超えるまではうまく機能するが、その境界を超えたオフセットはすべて静かに切り詰められ、テーブル全体がガベージになる。そのためライターは、クロスリファレンスストリーム自身のオフセットも含め、いずれかのフィールド2スロットが保持しうる最大値を組み立て済みテーブルから走査し、必要に応じてフィールド幅を最大8バイトまで広げる
// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
Field2Value := XRefTable[X].ObjStrNum // type-2: container number
else
Field2Value := XRefTable[X].ObjPos; // type-1 offset / type-0 next-free
if Field2Value > MaxField2Value then
MaxField2Value := Field2Value;
end;
Field2 := 4;
while (Field2 < 8) and
(MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
Inc(Field2);
Field3 := 2; // generation numbers and member indices both fit
幅がわかればペイロードのサイズも正確にわかるため、ライターはバッファ全体をあらかじめ確保してインデックスで埋めていく。エントリを1バイトずつAnsiStringに追記していくとテーブル構築が二次的な計算量になってしまい、これは十ページの請求書では誰も気づかないが、20万オブジェクトの文書では誰もが気づくことになる。厳格なリーダーを満足させるにはさらに2つの詳細が要る。/Indexはテーブルがカバーするオブジェクト番号範囲を宣言し、フルリライトの場合は単に隙間のない[0 N]になる。そしてライターが実際には出力しなかったスロットはすべて、使用中ではなくフリーをデフォルトとしなければならない:オブジェクト0はフリーチェーンの先頭を担い、各フリースロットは次のスロットへリンクし、かつて削除されたオブジェクトを保持していたスロットは世代番号を1増やしたまま保持する。信頼できないPDFを解析する際のメモリ安全性に関する関連記事は、読み取り側から同じ境界の議論を行っている
クロスリファレンスストリームを決して暗号化してはならない理由
リーダーは何かを復号する方法を知る前に、それを解析しなければならないからだ。クロスリファレンスストリームはリーダーに/Encrypt辞書がどこにあるかを教えるものであり、もしそのバイト列自体が暗号化されていたら、リーダーはファイルキーを説明するオブジェクトを見つけるためにファイルキーを必要とすることになってしまう。losLab PDF Libraryはこれを単一の述語で強制している:ShouldCryptStreamDataはストリーム辞書が/Type /XRefを持つ場合には常にFalseを返し、この例外はシリアライザに到達する経路がどれであっても保持される
/ObjStmコンテナはその正反対の扱いを受け、この非対称性は意図的なものである。コンテナはほかのどんなストリームとも同じく、自分自身のオブジェクト番号をキーとして丸ごと暗号化される。そのメンバーは個別には暗号化されない——復号済みの平文の形でパックされ、組み立てられたコンテナに対する単一のパスが文字列も含めてそれらをカバーする。メンバーを二重に暗号化すると、復号すると暗号文になるファイルができてしまい、外側の層が成功してしまうために、失敗は認証エラーとしてではなくオブジェクトグラフの奥深くでの解析エラーとして表面化する。ただし1つのオブジェクトだけはこの仕組みから完全に外れたままになる:暗号化された文書ではCatalogは直接のtype-1オブジェクトとして保持され、決してパックされない。パックしてしまうと、ルートが確立を助けるはずの復号コンテキストが完全に構築される前に、文書のルートへたどり着くためにローダーがオブジェクトストリームを膨張・復号しなければならなくなるからだ
Delphiからパッキングを有効にする
公開されているスイッチはPackObjectStreamsであり、TPDFlibSaveOptionsのフィールドとして、単独のセッターSetPackObjectStreamsとして、そして文書オブジェクトのプロパティとして公開されている。デフォルトは有効で、バージョンによって自動的に制御される:ライターは文書がすでにPDF 1.5以降である場合にのみパックし、内部の最小バージョンガードを呼び出すため、パックされた文書は誤ってラベル付けされるのではなく1.5に引き上げられる。保存後、GetLastSaveUsedObjectStreamsはゲートが実際に開いたかどうかを報告する。これはバイトサイズの比較よりも回帰テストで使いたいアサーションである
var
Doc: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('report.pdf', '') <= 0 then
Exit;
Doc.SetInformation(0, '1.5'); // packing is gated on PDF 1.5+
FillChar(Options, SizeOf(Options), 0);
Options.CompressContent := True;
Options.GarbageCollect := True; // drop orphans before packing
Options.PackObjectStreams := True;
if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
if Doc.GetLastSaveUsedObjectStreams = 1 then
Writeln('Saved with ObjStm containers and an xref stream');
finally
Doc.Free;
end;
end;
パッキングとガベージコレクションの間では順序が重要だ。到達可能性解析を先に実行しなければならない。コンテナに生き残ったメンバーはコンテナ自体も道連れにするからだ——生きているオブジェクトがパックされているなら、そのコンテナ番号は定義上到達可能であり、コンテナをスイープしてしまえばメンバーを見つける手段がなくなってしまう。コレクタを先に実行することは、死んだオブジェクトがそもそもコンテナに入ることがないという意味でもあり、これが複利で効いてくるサイズ削減効果の源である。パッキングはほかのサイズ削減手段を置き換えるのではなく補完する。PDFファイルサイズ最適化とフォントサブセット化の解説はストリームペイロードに作用する手段を扱っており、オブジェクトストリームは構造に作用する
有効化する前に知っておくべき境界
インクリメンタル保存では決してパックされない。インクリメンタル更新は以前のリビジョンを物理的にそのまま残しつつ新しいオブジェクトと新しいクロスリファレンスセクションを追記するため、既存のオブジェクトを新しいコンテナへ詰め直せば、以前のリビジョンが今も参照しているtype-1エントリが孤立してしまう。losLab PDF Libraryはアペンドモードが有効な間はパッキングを常に無効化しており、インクリメンタル更新とアペンドモードストリーミングに関する記事がその経路を詳しく扱っている。PDF 1.5未満の文書は無条件で平文のクロスリファレンステーブルを保持する:1.4のコンシューマは/ObjStmが何を意味するのか一切知らず、ライターがより小さいファイルを好んだという理由だけで文書を黙って昇格させることは、呼び出し側に代わって行うべき判断としては誤っている。私たちが意図的に出力していない任意キーが1つある。それは/Extendsであり、ISO 32000-1 §7.5.7はこれによってコンテナが先行コンテナを名指しでき、リーダーがコンテナの連鎖を論理的な1グループとして扱えるようにしている。これは正真正銘の任意項目であり、私たちが書くすべてのコンテナは自己完結していて独立してデコード可能であるため、これを省くことでライターから循環参照とぶら下がり参照というバグの一群がまるごと取り除かれる——とはいえリーダーの側は、他のプロデューサーが作ったファイルの中で/Extendsに出会ったなら当然それを尊重しなければならない
オブジェクトストリームのパッキングとクロスリファレンスストリームの出力は、それらと組み合わさるガベージコレクタやコンテンツストリームオプティマイザとともに、DelphiとC++Builder向けのlosLab PDF Libraryの一部として提供される。製品ページには保存オプション全体のリファレンスが掲載されている