エラーなく展開されるのに依然としてノイズとして読めるPDFオブジェクトストリームは、たいてい1つのステップを欠いている:ISO 32000-1のPredictor(予測子)を逆転させることだ。ストリームの/DecodeParms辞書が/Predictor 2以上を運んでいる場合、FlateDecodeが返すバイトは元のデータではない——それらはPNG形式の行差分化、あるいはTIFF形式の水平差分化された値であり、どんな辞書のルックアップも意味をなす前に2回目の再構築パスを必要とする。DelphiおよびC++Builder向けネイティブVCL PDFコンポーネントライブラリであるPDFiumPasは、その再構築パスをv2.16.0で追加した。それはまさに、PDF 1.5以降のオブジェクトストリームが、どんな辞書パーサーも読めない差分化されたバイトへ展開されていたためである
なぜFlateDecodeだけでは足りないのか
FlateDecode自体は単なるDEFLATE展開(ISO 32000-1 §7.4.4.1)にすぎない:それはエンコーダが圧縮器に渡したバイトをそのまま再現するだけであり、それ以上のことは何もしない。Predictorはその1層上、ストリームの/DecodeParms辞書の中に存在し、それはエンコーダが圧縮の前に適用した変換を記述する——差分化は、相互参照ストリームやオブジェクトストリームの内部にぎっしり詰まった整数のような、類似した構造的な値の長い連なりを、DEFLATEがはるかによく圧縮できる小さな数値の長い連なりに変える。ISO 32000-1 §7.4.4.3(表8)は、この変換を元に戻すことがフィルタ済みストリームをデコードすることの一部であり、任意のクリーンアップパスではないと明記しているが、それでも展開を呼ぶだけでそこで止まるFlateDecodeヘルパーを書いてしまうのは容易である
その症状は、それを探すべきだと知っていれば特徴的である。Predictorで差分化されたバイトはランダムなノイズではない——それらは依然として圧縮されたストリームの形を保っているため、素朴なパーサーはしばしば有効そうに見えるいくつかのトークンを通り過ぎてから、PDFの名前・数値・区切り文字にはなりえないバイト列にぶつかる。そして異なる行は、基礎となる値が隣接する値とたまたまどれだけ異なっていたかによって、異なるオフセットで失敗する。この一貫性のなさこそが、このバグを単一の失敗したファイルから特定しにくくしている:同じ生成元からの2つのPDFは、どの値がたまたま繰り返されるかという点だけで異なることがあり、一方はほとんど偶然に解析でき、もう一方は完全に失敗する
PDFのPredictorパラメータは実際に何をするのか
/DecodeParms内の/Predictorエントリは、準拠したリーダーにどの逆転を実行すべきかを伝える。ISO 32000-1の表8は実際に重要な値を定義している:1は予測が適用されていないことを意味し、2はTIFF Predictor 2(水平差分化)を選択し、10から15までのどの値もPNG形式の予測を選択する。それとともに3つのキー——/Colors、/BitsPerComponent、/Columns——がさらに旅をし、それらは一緒になって、たとえストリームが画像データを一切保持していなくても、差分化がどの行のジオメトリに対して計算されたかを記述する:オブジェクトストリームは画像ではないが、PDFライターは同じ行ベースのpredictorの仕組みをそれに再利用する。なぜなら差分化してからdeflateする方が、ぎっしり詰まった整数やオブジェクトのオフセットを生のままdeflateするよりもきつく圧縮できるからだ
TIFF Predictor 2はより単純な方式である:すべての成分は、同じ行の前のピクセルの同じ成分との差として保存され、各行はその左端でリセットされ、上の行からの差を持ち込まない。PNG予測はより特殊である。なぜなら実際のフィルタは行ごとに変わりうるからだ:すべての行は単一のタグバイトで始まる——0はNone、1はSub、2はUp、3はAverage、4はPaeth——そしてそのタグこそが、宣言された/Predictor値ではなく、その特定の行がどう再構築されるかを決める。/Predictorが12であることは、実際にはエンコーダがUpフィルタを好んだというヒントにすぎない。そこでは各バイトは前の行の真上にあるバイトを加算することで復元されるが、正しいデコーダは、すべての行を通してUpだと仮定するのではなく、それでもすべての行のタグを読まなければならない
なぜオブジェクトストリームは見逃されたPredictorを見えなくしてしまうのか
オブジェクトストリームは、単に問題を繰り返すのではなく複合させる。ISO 32000-1 §7.5.7は、PDF 1.5以降のライターが複数の間接オブジェクトを単一の圧縮コンテナ、すなわち/ObjStmにまとめることを許しており、バリデータが最も必要とするまさにそのオブジェクト——カタログ、/OutputIntents、あるいはXMPの/Metadataストリーム——が、行の差分化から恩恵を受けるほど短く反復的であるため、/Predictor 12を付けてそのコンテナを通って旅をすることは珍しくない。predictorのステップが欠けている場合、オブジェクトストリームを展開してもエラーは発生しない:それは表面的にはもっともらしく見えるが期待されるオブジェクトへとトークン化されないバイト列を生成する。そのため内部にまとめられていたものは単純に現れない。レンダリングはめったにそれに気づかない、なぜなら準拠したレンダリングエンジンは、それがレイアウトに到達する前にすでにpredictorで差分化されたデータを再構築しているからだ。気づくのはまさにこのバグが内部に隠れていた類のコードである——生のPDFバイトを自分で歩いて構造的な質問に答えるバリデータ、署名機能、あるいはバージョンチェッカーであり、オブジェクトストリームに対する自身のビューが間違って戻ってきても代替手段が一切ない
PDFiumPasはv2.16.0より前、まさにこの失敗にぶつかっていた。/Predictor 12で構築されたオブジェクトストリーム(PDF 1.5以降のライターにとってよくあるケース)は、PdfExpandObjectStreamsを通じて構造スキャナが解析できない差分化されたバイトへ展開されていた。そのため内部にまとめられたカタログ、/OutputIntents、/Metadataオブジェクトは、コンプライアンススキャンから事実上見えなくなっていた——例外もなく警告もなく、単にそれらのオブジェクトが存在しないかのように静かに振る舞うスキャンだけがあった。ハイブリッドや純粋なxrefストリームのケースを含め、PDFiumPasがアクティブな相互参照テーブルに対してオブジェクトストリームをどう解決するかというより深い仕組みは、PDFiumPasによるオブジェクト・xrefストリームの検証に関する記事で別途扱われている;ここで説明するpredictorのステップは、その解決の後、各圧縮オブジェクトが実際に含むバイトに対して実行される
PascalでPNGとTIFFのPredictor行を逆転させる
PDFiumPasは、この差分化を単一のルーチンPdfApplyPredictorで逆転させる。その幾何学の計算は、あなたがそれを呼ぶにせよ、自分のDelphiコードでその考え方を再実装するにせよ知っておく価値がある。バイト単位の行幅はceil(Columns × Colors × BitsPerComponent ÷ 8)であり、両方のアルゴリズムが使うピクセル当たりのバイト幅はceil(Colors × BitsPerComponent ÷ 8)である——どちらかの丸めを間違えると、再構築は1つの行の中ではなく行の境界をまたいで読んでしまう。2未満の/Predictorは手を付けられないままにされる、なぜなら1はエンコーダが一切変換を適用しなかったことを意味するからだ;2は以下に示すTIFFの分岐を選択し、10以上のものはすべてPNG行フィルタの再構築へ落ちる。そこでは各行の先頭にあるタグバイトが——宣言された/Predictor値ではなく——その特定の行がどう元に戻されるかを決める
function PdfApplyPredictor(const Src: TBytes;
Predictor, Colors, Bpc, Columns: Integer): TBytes;
var
RowLen, Bpp, R, I: Integer;
begin
Result:= Src;
if Predictor< 2 then
Exit; // 1 = no prediction, nothing to undo
if Colors<= 0 then Colors:= 1;
if Bpc<= 0 then Bpc:= 8;
if Columns<= 0 then Columns:= 1;
if (Colors> 64)or (Bpc> 32)or (Columns> 1 shl 24) then
Exit; // reject hostile row geometries
RowLen:= (Columns* Colors* Bpc+ 7) div 8; // ceil(), per ISO 32000-1 Table 8
Bpp:= (Colors* Bpc+ 7) div 8;
if Predictor= 2 then
begin
if Bpc<> 8 then
Exit; // only the 8-bit layout is reconstructed
Result:= Copy(Src, 0, Length(Src));
R:= 0;
while R+ RowLen<= Length(Result) do
begin
for I:= R+ Bpp to R+ RowLen- 1 do
Result[I]:= Byte(Result[I]+ Result[I- Bpp]);
Inc(R, RowLen);
end;
Exit;
end;
// Predictor >= 10 falls through to PNG row-filter reconstruction below
end;
// Continues inside PdfApplyPredictor once Predictor>= 10 (PNG row filters).
// Rows:= Length(Src) div (RowLen+ 1); each row is a 1-byte filter tag
// followed by RowLen data bytes, decoded left to right.
for R:= 0 to Rows- 1 do
begin
SrcOfs:= R* (RowLen+ 1);
DstOfs:= R* RowLen;
Tag:= Src[SrcOfs];
Inc(SrcOfs);
for I:= 0 to RowLen- 1 do
begin
if I>= Bpp then A:= Result[DstOfs+ I- Bpp] else A:= 0; // byte to the left
if R> 0 then B:= Result[DstOfs+ I- RowLen] else B:= 0; // byte above
case Tag of
1: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ A); // Sub
2: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ B); // Up
3: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ (A+ B) div 2); // Average
// Paeth (tag 4) adds whichever of A, B or the byte above-left sits
// closest to the linear predictor A+ B- C; tag 0 (None) copies the
// filtered byte through unchanged
else Result[DstOfs+ I]:= Src[SrcOfs+ I];
end;
end;
end;
PDFiumPasがv2.16.0で変更したこと
PDFiumPas v2.16.0で出荷された修正は、PdfReadAndDecodeStreamの内部に存在する。これはストリームの生バイトを読み、オブジェクトストリームの展開を含む、バイトレベルでPDF構造を検査する必要があるすべての呼び出し元のためにそれをデコードするルーチンである;それは/Filterが決して連鎖ではなく単純なFlateDecodeであることを確認した後にのみ再構築を試みる。なぜなら連鎖されたフィルタは、この層で安全にPredictor補正できないからだ。ストリーム辞書から/Predictor、/Colors、/BitsPerComponent、/Columnsを読み戻すことも、一般的な辞書パーサーを必要としない:PdfDictRefNumは、その1つの辞書のバイト範囲内で直接名前トークン検索によって各キーを見つける。これはここでは安全である、まさにこの4つのキーが単一のストリーム辞書内で繰り返したりネストしたりできないからだ。同じ名前トークン検索は、PDFファイルのより大きい、あるいはより境界が定まっていない領域に向けられると、はるかに危険になる。これはPDF辞書を安全に解析することに関する関連記事の主題である
// Inside PdfReadAndDecodeStream, right after PdfInflate() has already run:
if PdfFilterIsPureFlate(DictTxt) then
begin
Inflated:= PdfInflate(Raw);
Predictor:= PdfDictRefNum(Data, DS, DE, 'Predictor');
if Predictor>= 2 then
begin
PColors:= PdfDictRefNum(Data, DS, DE, 'Colors');
PBpc:= PdfDictRefNum(Data, DS, DE, 'BitsPerComponent');
PColumns:= PdfDictRefNum(Data, DS, DE, 'Columns');
Result:= PdfApplyPredictor(Inflated, Predictor, PColors, PBpc, PColumns);
end
else
Result:= Inflated;
end;
v2.16.0より前は、/Predictor 12で構築されたオブジェクトストリームはエラーを発生させることなく差分化されたバイトへ展開されていた。そのため内部にまとめられたカタログ、/OutputIntents、あるいは/Metadataオブジェクトは、何の警告もなくPDFiumPasの構造スキャンから欠落していた。この修正の後、同じオブジェクトストリームは展開され、その後正しく再構築され、内部にまとめられたオブジェクトはそれらのスキャンに対して再び見えるようになる。防御的な範囲チェックもこの修正と一緒に持ち込まれた:PdfApplyPredictorは今や、64を超える/Colors、32を超える/BitsPerComponent、2^24を超える/Columnsをきっぱりと拒否する。なぜなら、これらの組み合わせは本物のPDF生成器が必要とするどんな行のジオメトリも記述せず、主にデコーダに入力バイトが正当化する以上のメモリをはるかに多く割り当てさせるために存在するからだ
var
Pdf: TPdf;
Report: TPdfAValidationResult;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'incoming.pdf';
Pdf.Active := True;
Report := Pdf.ValidatePdfA;
if not Report.IsCompliant then
LogNonCompliance(Report); // caller-supplied handler
finally
Pdf.Free;
end;
end;
知っておく価値のある限界
PDFiumPasのpredictor再構築には、それに頼る前に知っておく価値のある2つの端がある。TIFF Predictor 2の再構築は、成分当たり8ビットのケースだけをカバーする;PDFはより狭いパッキングを許可しているが、バイト未満のTIFF差分化データは、推測されるのではなく再構築されずに通過する。そのため/BitsPerComponentが1、2、あるいは4を伴う/Predictor 2を宣言するストリームは、今日のところこの経路を通じて正しくデコードされない。PNG予測にはそのような制限はない——すべての行が自身のフィルタタグを供給し、宣言された/Predictor値が10から15のどれであろうと、定義されている5つのタイプすべてが再構築される。これはPNG形式のフィルタリングが実際にどう機能するかと一致している:宣言された値は、すべての行についての約束というより、エンコーダが主に何を使ったかについてのヒントに近い
PDFiumのネイティブレンダリングエンジンは、Predictorで差分化された画像・コンテンツストリームのデータをすでに正しく再構築している。これがまさに、その上に構築されたバイトレベルのバリデータ、署名機能、あるいはバージョンチェッカーが同じバイトを間違って読む一方で、ファイルがどんな通常のビューアでも完璧にレンダリングされうる理由である。ここで説明したPredictorを意識したデコードは、DelphiおよびC++Builder向けネイティブVCL PDFiumコンポーネントであるPDFiumPasのPDF/A検証、構造スキャン、署名機能を支えている