v3.539.24 之前,PDF Library for Delphi 的 Free Pascal 版會把大型串流按 64 KB 分塊壓縮,而且每個分塊都自帶 zlib 標頭與檢查碼,於是一個 1 MiB 的附件變成十六個首尾相接的 zlib 成員。ISO 32000-1 的 FlateDecode 只認一條 zlib 串流,標準解碼器停在第一個串流結尾標記,也就是說前 65,536 個位元組之後的內容全數無聲消失。修法是讓單一 zlib 狀態在所有分塊之間保持存活,DeflateStream 與 InflateStream 都是
這個 bug 只存在於單元的 FPC 側,而且只在分塊串流路徑上,這也正是它能存活下來的原因:Delphi 分支一直都是對的,字串版的輔助函式一直都是對的,而小型測試資料根本走不到分塊程式碼。它跟Delphi 一直替它掩護的五個 FPC 移植 bug 是近親,差別在於這裡的 Delphi 版並沒有掩護什麼,FPC 分支只是從一開始就抱著對 zlib 串流的錯誤想像在寫
為什麼分塊的 zlib 串流會被其他 PDF 閱讀器截斷?
因為 RFC 1950 定義的 zlib 串流是一個容器,不是一排容器,合規的解壓器會把第一個 Adler-32 結尾當成資料的終點。格式是一個兩位元組標頭、一條連續的 RFC 1951 deflate 位元流(最後一個區塊帶著 final-block 旗標),外加對全部未壓縮位元組算出的四個位元組 Adler-32 檢查碼。ISO 32000-1 §7.4.4 就是用這些術語定義 /FlateDecode 的。當 inflate 走到結尾,它回傳 Z_STREAM_END 並把剩下的輸入原封不動留在 avail_in 裡。從它的角度來看什麼都沒錯,所以不丟任何錯誤,結尾之後的位元組就被直接無視。串接多個成員在 gzip 裡是合法玩法(RFC 1952 允許一個檔案裝多個成員),那個直覺八成是從這裡來的,但 zlib 沒有這條規則,PDF 也從來沒要求過
舊的 FPC DeflateStream 每次從來源讀 64 KB,把每個分塊交給 ZFPCCompress,這個輔助函式自己跑 deflateInit2、用 Z_FINISH 壓縮、再呼叫 deflateEnd。於是每個分塊出來都是一條完整、合法、自我終結的 zlib 串流,函式把它們串接成一個 AnsiString 再寫出。結果看起來像 Flate 資料、標頭正確、解碼也不丟錯,但解出來的只有前 65,536 個位元組。讀取側的 InflateStream 有鏡像對稱的缺陷:每 64 KB 壓縮輸入呼叫一次 ZFPCInflate,每次呼叫都重新 inflateInit2。第二個分塊是從 deflate 位元流的中間開始、沒有 zlib 標頭的,新的解壓器直接拒收,而別家產品寫出的再正常不過的單一成員串流,也只能被解到前 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 串流,只要壓縮後大小超過一個分塊就會被讀錯。TPDFStream.ReadFromStream 決定輸入資料怎麼編碼。當 Deflate 為真且 Stream.Size >= 1048576 時,它走 DeflateStream 串流處理;低於這個門檻則把整個來源讀進記憶體、呼叫 DeflateStr,那個一次到位的輔助函式從頭到尾都沒事。ASCII85 加 Flate 的濾鏡鏈跳過大小測試、一律走 DeflateStream,所以在這條路徑上,任何超過 64 KB 的資料早就被拆成多個成員。開著壓縮餵 ReadFromStream 的公開入口是內嵌檔案寫入器:
TPDFlib.EmbedFile與TPDFlib.AddEmbeddedFile,把磁碟上的檔案讀進/EmbeddedFile串流TPDFlib.AddAssociatedFileFromStream與TPDFlib.AddAssociatedFileFromFile,PDF/A-3 關聯檔案寫入器,用在電子發票 XML 之類的原始資料- 讀取側是
TPDFlib.GetEmbeddedFileContentToStream與GetEmbeddedFileContentToFile,它們先經TPDFStream.WriteDecodedToStream解碼,再往下經InflateStream
每一層的失敗都很安靜。寫入端會儲存 /Params /Size 以及從原始檔案算出的 MD5 /CheckSum,所以附件字典宣稱的是完整大小,串流裡裝的卻是十六個成員。同一份函式庫的 Delphi 版去讀這個檔案,會乾淨地停在第一個 Z_STREAM_END、剛好回傳 65,536 個位元組。GetEmbeddedFileContentToStream 回傳 1,因為它回報的是解碼有沒有丟錯,不是輸出符不符合 /Size。追過合併與拆分上 GB 的 PDF 的人都認得這個劇本:檔案開得了、頁數也對,損害只在有人打開附件時現形
一個 deflate 狀態貫穿每個分塊
PDFlibZLib.pas 裡修好的 DeflateStream 只初始化一個 paszlib.TZStream,每個分塊都以 Z_NO_FLUSH 餵給 deflate,直到最後才用 Z_FINISH 排空壓縮器,直到它回傳 Z_STREAM_END。這樣產出的是剛好一個標頭、一條 deflate 位元流(它的回引用可以跨過分塊邊界),以及對整個輸入算出的一份 Adler-32。FPC 分支現在有了 Delphi 分支一直以來的結構。它也改成邊壓邊寫,而不是先把整個壓縮結果串成一個 AnsiString,所以寫入端不再在把資料拷去目標之前,先在記憶體裡多養一份壓縮資料的完整副本
// 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 // 整個輸入只留一份結尾
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 拿到了對稱的重寫:一個 inflateInit2,一個持續呼叫 inflate 的內層迴圈,直到目前分塊耗盡、輸出緩衝區不再填滿為止,以及一個停在 Z_STREAM_END 的收尾。有一個副作用值得知道:舊的分塊寫入端寫死壓縮等級 6,新的則尊重 PLDeflateLevel,所以透過 TPDFlib.SetCompressionLevel(1..9) 設定的等級,現在也作用在 FPC 的大型內嵌檔案上。如果您已經照在 Delphi 中縮減 PDF 檔案體積的說明為歸檔輸出調過壓縮,這一點就有差
怎麼驗證一條 Flate 串流是單一 zlib 成員?
用普通的 zlib 解碼器去解,回傳 Z_STREAM_END 時檢查兩件事:解出的長度等於來源長度,而且 avail_in 為零。結尾標記之後還有剩餘輸入,就是串接串流的指紋。修法就是這樣驗證的:200 KB 測試資料,橫跨四個 64 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;
// 單一成員應正好在最後一個輸入位元組處結束
Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
finally
inflateEnd(strm);
end;
end;
// 用預設 64 KB 分塊把 200,000 位元組灌過 DeflateStream
// 並要求解回來是單一成員、長度等於原始全長
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 比一比。GetEmbeddedFileIntProperty 用 tag 5 取回那個記錄的大小,內嵌檔案索引從 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;而當 inflate 回報資料錯誤時,FPC 仍接受部分解出的輸出,因為有些 PDF 產生器就是會送出被截斷或檢查碼壞掉的串流。v3.539.24 之前的 FPC 版寫出的 PDF,裡面仍然是串接的成員,修好的讀取器跟所有其他閱讀器一樣,停在第一個 Z_STREAM_END。別想在函式庫裡把這種檔案解了再重編來治好它,那只是把 64 KB 截斷變成永久。要做的是把附件從原始來源重新內嵌。FPC 迴圈也照舊在第一個讀不滿整塊的讀取處收工,而這對 TFileStream、TMemoryStream 這類串流才是資料結束的訊號,所以傳給 AddAssociatedFileFromStream 的自訂 TStream,最保險的作法是先拷進 TMemoryStream。Delphi 分支、DeflateStr 與 InflateStr 輔助函式,以及純 Flate 路徑上小於 1 MiB 的所有串流,行為都與之前完全相同
修好的 FPC DeflateStream 與 InflateStream 隨 PDF Library for Delphi v3.539.24 出貨,這套函式庫用同一份原始碼樹面向 Delphi、C++Builder 與 Free Pascal,而大型附件從 FPC 版出來,從此應該與 Delphi 版一直以來的表現一致,逐位元組完整回歸