v3.539.24より前、PDF Library for DelphiのFree Pascal版は大きなストリームを64 KBのチャンクで圧縮し、チャンクごとに独自のzlibヘッダーとチェックサムを付けていました。そのため1 MiBの添付ファイルが、端から端まで継ぎ足された16個のzlibメンバーになっていたのです。ISO 32000-1のFlateDecodeはzlibストリームをちょうど1つだけ期待しており、標準のデコーダーは最初のend-of-streamマーカーで停止します。つまり最初の65,536バイトより後のデータは、音もなく消えていました。修正版では、DeflateStreamとInflateStreamのどちらでも、1つのzlib状態をすべてのチャンクにわたって生かし続けます
このバグはユニットのFPC側にだけ存在し、その中でもチャンク化ストリーミング経路にだけ存在しました。だからこそ生き延びたのです。Delphi側の分岐は常に正しく、文字列ベースのヘルパーも常に正しく、小さなテストペイロードはチャンク化コードにそもそも届きませんでした。Delphiが隠していた5つのFPC移植バグの近縁と言えますが、今回はDelphiビルドが何かを覆い隠していたわけではありません。FPC側の分岐は、zlibストリームとは何かというメンタルモデルが間違ったまま書かれていただけです
チャンク化zlibストリームが他のPDFリーダーで切り詰められるのはなぜか
RFC 1950で定義されるzlibストリームは1つのコンテナであって、その連続ではないからです。仕様に従うinflaterは最初のAdler-32トレーラーをデータの終わりとして扱います。フォーマットは、2バイトのヘッダー、最終ブロックがfinal-blockフラグを運ぶ1本の連続したRFC 1951 deflateビットストリーム、そして展開済み全バイトにわたる4バイトのAdler-32チェックサムです。ISO 32000-1 §7.4.4は/FlateDecodeをまさにその言葉で定義しています。inflateがトレーラーに到達するとZ_STREAM_ENDを返し、残りの入力はavail_inに未読のまま置かれます。inflaterの視点では何も悪くないのでエラーは上げず、トレーラー以降のバイトは単に無視されます。メンバーの連結はgzipでは正当な考え方で(RFC 1952は1つのファイルに複数メンバーを許します)、直感はおそらくそこから来たのでしょうが、zlibにそのような規則はなく、PDFもそれを要求したことはありません
旧FPC版のDeflateStreamはソースを一度に64 KB読み込み、各チャンクをZFPCCompressに渡していました。このヘルパーは自分のdeflateInit2を実行し、Z_FINISHで圧縮し、deflateEndを呼びます。そのためすべてのチャンクが完全で、有効で、自分で終端を持つzlibストリームとして出てきて、関数はそれらを書き出す前に1つのAnsiStringに連結していました。結果はFlateデータに見え、ヘッダーは正しく、エラーなしで復号できました。しかし復号されるのは最初の65,536バイトだけです。読み込み側のInflateStreamには鏡写しの欠陥がありました。圧縮入力64 KBごとにZFPCInflateを1回呼び、そのたびに新しいinflateInit2を始めていたのです。2番目のチャンクはzlibヘッダーのないdeflateビットストリームの途中から始まるので、新しいinflaterはそれを拒否します。そして他のプロデューサーが作ったまったく普通の単一メンバーのストリームも、最初の64 KBの圧縮バイトが届く範囲でしか復号されませんでした
// v3.539.24以前のFPC版DeflateStream(簡略化):
// ZFPCCompressはdeflateInit2 / deflate(Z_FINISH) / deflateEndを行い、
// 64 KBのチャンクごとに別々のzlibメンバーになってしまう
Repeat
ReadCount:= Source.Read(Input[1], ChunkSize);
If (ReadCount> 0) Then
Compressed:= Compressed+ ZFPCCompress(Copy(Input, 1, ReadCount), 6, False);
Until (ReadCount< ChunkSize);
If (Compressed<> '') Then
Target.WriteBuffer(PAnsiChar(Compressed)^, Length(Compressed));
PDF Library for Delphiのどの呼び出しがチャンク化経路に届いていたのか
FPCでは、1 MiB以上の埋め込みファイルはすべて間違って書かれ、ストリーミングAPI経由で取り出したFlateストリームは、圧縮サイズが1チャンクを超えた時点から間違って読まれていました。エンコード方法の決定はTPDFStream.ReadFromStreamが行います。Deflateが真でStream.Size >= 1048576ならDeflateStream経由でストリーミングし、閾値未満ならソース全体をメモリに読んでDeflateStrを呼びます。こちらは一度きりのヘルパーで、一度も影響を受けていません。ASCII85+Flateのフィルターチェーンはサイズ検査を飛ばして常にDeflateStreamを通るので、この経路では64 KBを超えるペイロードは既に複数メンバーに分割されていました。圧縮を有効にしてReadFromStreamに流れ込む公開エントリーポイントは、埋め込みファイルのライターです:
TPDFlib.EmbedFileとTPDFlib.AddEmbeddedFile。ディスク上のファイルを読んで/EmbeddedFileストリームに入れますTPDFlib.AddAssociatedFileFromStreamとTPDFlib.AddAssociatedFileFromFile。電子インボイスXMLなどのソースデータに使うPDF/A-3 associated-fileのライターです- 読み込み側では
TPDFlib.GetEmbeddedFileContentToStreamとGetEmbeddedFileContentToFile。TPDFStream.WriteDecodedToStream経由で、そこからさらにInflateStream経由で復号します
失敗はあらゆる層で静かでした。ライターは元のファイルから計算した/Params /SizeとMD5の/CheckSumを格納するので、添付ファイル辞書は完全なサイズを掲げ続け、その間ストリームは16個のメンバーを抱えていました。同じライブラリのDelphiビルドでそのファイルを読むと、最初のZ_STREAM_ENDできれいに停止し、ちょうど65,536バイトを返します。GetEmbeddedFileContentToStreamが1を返したのは、復号がエラーを上げたかどうかを報告するのであって、出力が/Sizeと一致するかどうかを報告するのではないからです。ギガバイト級PDFのマージと分割で大きな文書の問題を追いかけたことのある人なら、このパターンはお決まりだと知っています。ファイルは開き、ページ数は正しく、被害が見えるのは誰かが添付ファイルを開いたときだけです
すべてのチャンクにわたる1つのdeflate状態
PDFlibZLib.pasの修正済みDeflateStreamは、paszlib.TZStreamを1つ初期化し、すべてのチャンクをZ_NO_FLUSH付きでdeflateに供給し、最後にだけZ_FINISHでコンプレッサーを絞り切ってZ_STREAM_ENDが返るまで続けます。こうして出てくるのは、ヘッダーがちょうど1つ、バックリファレンスがチャンク境界をまたげる1本のdeflateビットストリーム、そして入力全体にわたる1つのAdler-32です。FPC側の分岐は、Delphi側がずっと持っていたのと同じ構造をようやく手に入れました。出力も生成されたその場で書き、圧縮結果全体をまずAnsiStringに連結するのをやめたので、ライターは圧縮データの完全なコピーをもう1つメモリに作ってからターゲットへ書き込む、ということをしなくなります
// v3.539.24からのFPC版DeflateStream(エラー経路は省略)
If (deflateInit2(strm, Level, Z_DEFLATED, 15, 8, Z_DEFAULT_STRATEGY)<> Z_OK) Then
Exit;
Try
Repeat
ReadCount:= Source.Read(Input[0], ChunkSize);
If (ReadCount> 0) Then
Begin
strm.next_in:= Pointer(Input);
strm.avail_in:= ReadCount;
While (strm.avail_in> 0) Do
Begin
strm.next_out:= Pointer(Output);
strm.avail_out:= ChunkSize;
Status:= deflate(strm, Z_NO_FLUSH); // 同じ状態、メンバー境界なし
Produced:= ChunkSize- strm.avail_out;
If (Produced> 0) Then
Target.WriteBuffer(Output[0], Produced);
If (Status<> Z_OK) Then
Break;
End;
End;
Until (ReadCount< ChunkSize);
Repeat // 入力全体で1つのトレーラ
strm.next_out:= Pointer(Output);
strm.avail_out:= ChunkSize;
Status:= deflate(strm, Z_FINISH);
Produced:= ChunkSize- strm.avail_out;
If (Produced> 0) Then
Target.WriteBuffer(Output[0], Produced);
Until (Status= Z_STREAM_END);
Finally
deflateEnd(strm);
End;
InflateStreamは対称の書き直しを受けました。1つのinflateInit2、現在のチャンクを使い切り出力バッファーが満杯でなくなるまでinflateを呼び続ける内側のループ、そしてZ_STREAM_ENDでの停止です。知っておく価値のある副作用が1つあります。旧チャンク化ライターはレベル6をハードコードしていましたが、新しいものはPLDeflateLevelを尊重します。つまりTPDFlib.SetCompressionLevel(1..9)で設定したレベルが、FPC上の大きな埋め込みファイルにも効くようになりました。DelphiでPDFのファイルサイズを減らすで説明したようにアーカイブ出力向けに圧縮を調整しているなら、これは効いてきます
Flateストリームが単一のzlibメンバーだとどう検証するか
素のzlibデコーダーで展開し、Z_STREAM_ENDが返ってきたときに2つを確認します。復号された長さがソースの長さと等しいこと、そしてavail_inがゼロであることです。終端マーカーの後に入力が残るのが、連結ストリームのシグネチャーです。修正はこの方法で検証しました。4つの64 KBチャンクにまたがる200 KBのテストデータを新しいDeflateStreamに通すと、534バイトの単一zlibストリームが出てきました。素のzlibデコーダーは200,000バイトすべてを復元し、入力の残りはゼロ。同じ検査はi386クロスコンパイルのFPCターゲットでも通りました。下のルーチンはこの検査のFPC版で、paszlibの上に直接作られているので、テスト対象のコードを信用しません
uses Classes, SysUtils, paszlib, PDFlibZLib;
function IsSingleZlibMember(Packed: TMemoryStream; out Decoded: Int64): Boolean;
var
strm: TZStream;
Buf: array[0..65535] of Byte;
Status: Integer;
begin
Result:= False;
Decoded:= 0;
FillChar(strm, SizeOf(strm), 0);
if inflateInit2(strm, 15) <> Z_OK then
Exit;
try
strm.next_in:= Packed.Memory;
strm.avail_in:= Cardinal(Packed.Size);
repeat
strm.next_out:= @Buf;
strm.avail_out:= SizeOf(Buf);
Status:= inflate(strm, Z_NO_FLUSH);
until Status <> Z_OK;
Decoded:= strm.total_out;
// 1つのメンバーは最後の入力バイトでちょうど終わる
Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
finally
inflateEnd(strm);
end;
end;
// 既定の64 KBチャンクで200,000バイトをDeflateStreamに通し、
// 全長へ復号されるメンバーが1つであることを要求する
Source.Position:= 0;
DeflateStream(Source, Packed);
if not (IsSingleZlibMember(Packed, Decoded) and (Decoded = Source.Size)) then
raise Exception.Create('DeflateStream produced more than one zlib member');
アプリケーションのレベルで有用なアサーションは、ライブラリが代わりにやってくれないものです。添付ファイルから出てきたものを、入れたときに記録された/Params /Sizeと比較することです。タグ5のGetEmbeddedFileIntPropertyがその記録されたサイズを返し、埋め込みファイルのインデックスは1ベースで、ストリーミング経路を通すにはペイロードが最低1 MiB必要です。出荷するすべてのコンパイラーで同じテストを走らせてください。元の欠陥はDelphiでは通り、FPCでだけ落ちたのですから
procedure CheckLargeAttachmentRoundTrip(const PayloadFile, OutFile: string);
var
PDF: TPDFlib;
Extracted: TMemoryStream;
I, Declared: Integer;
begin
PDF:= TPDFlib.Create;
try
PDF.NewDocument;
PDF.AddStandardFont(4);
PDF.DrawText(80, 100, 'Large attachment round trip');
// 1 MiB以上はReadFromStreamのチャンク化DeflateStream経路へ進む
if PDF.EmbedFile('Payload', PayloadFile, 'application/octet-stream') <> 1 then
raise Exception.Create('EmbedFile failed');
if PDF.SaveToFile(OutFile) <> 1 then
raise Exception.Create('SaveToFile failed');
finally
PDF.Free;
end;
PDF:= TPDFlib.Create;
Extracted:= TMemoryStream.Create;
try
if PDF.LoadFromFile(OutFile, '') = 0 then
raise Exception.Create('LoadFromFile failed');
for I:= 1 to PDF.EmbeddedFileCount do
begin
Extracted.Clear;
if PDF.GetEmbeddedFileContentToStream(I, Extracted) <> 1 then
raise Exception.CreateFmt('Attachment %d could not be decoded', [I]);
Declared:= PDF.GetEmbeddedFileIntProperty(I, 5); // /Params /Size
if Extracted.Size <> Declared then
raise Exception.CreateFmt('Attachment %d truncated: %d of %d bytes',
[I, Extracted.Size, Declared]);
end;
finally
Extracted.Free;
PDF.Free;
end;
end;
修正は何を変えないのか
FPC側の分岐は寛容な復号ルールを保っており、以前のFPCビルドが書いたファイルを修復するわけではありません。MaxOutputに達したとき、FPCのInflateStreamは上限で切り詰めて返りますが、Delphi側の分岐はERangeErrorを上げます。またFPCは、inflateがデータエラーを報告したときに部分的に復号された出力を受け入れ続けます。切り詰められたストリームやチェックサムの壊れたストリームを出すPDFプロデューサーが実在するからです。v3.539.24より前のFPCビルドが書いたPDFには今も連結メンバーが入っており、修正済みリーダーも他のリーダーと同じく最初のZ_STREAM_ENDで停止します。ライブラリ内でストリームを復号して再エンコードすれば直る、などと考えないでください。64 KBの切り詰めを恒久化するだけです。添付ファイルは元のソースから埋め込み直してください。FPCのループは、チャンクに満たない最初の読み込みで終わる点もそのままです。これはTFileStreamやTMemoryStreamのようなストリームにとってのみend-of-dataの合図なので、AddAssociatedFileFromStreamに渡すカスタムTStreamは、まずTMemoryStreamにコピーするのが安全です。Delphi側の分岐とDeflateStr/InflateStrヘルパー、そして素のFlate経路の1 MiB未満のストリームは、以前とまったく同じ振る舞いをします
修正済みのFPC版DeflateStreamとInflateStreamは、PDF Library for Delphiのv3.539.24に同梱されます。1つのソースツリーからDelphi、C++Builder、Free Pascalを対象にしているライブラリで、大きな添付ファイルはこれから、Delphiビルドと同じく、FPCビルドからもバイト単位でそのまま返ってくるはずです