在 Free Pascal 上寫分塊的 FlateDecode,必須讓單一 zlib 狀態貫穿每一個分塊。最誘人的捷徑是每 64 KB 分塊各自壓縮,結果每個分塊都自帶 zlib 標頭與檢查碼,一個 1 MiB 的附件就變成十六個首尾相接的 zlib 成員。ISO 32000-1 的 FlateDecode 只認一條 zlib 串流,標準解碼器停在第一個串流結尾標記,也就是說前 65,536 個位元組之後的內容全數無聲消失。PDF Library for Delphi 在 DeflateStream 與 InflateStream 兩側都讓單一 zlib 狀態貫穿每個分塊
這個陷阱只存在跨編譯器單元的 FPC 側,而且只在分塊串流路徑上,這也正是它容易漏掉的原因:Delphi 分支與字串版輔助函式各自都只產出一條 zlib 串流,小型測試資料根本走不到分塊程式碼。它跟Delphi 常替你掩護的五個 FPC 移植陷阱是近親,差別在於這裡 Delphi 並沒有掩護什麼,這個捷徑純粹是建立在對 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 個位元組。讀取側有鏡像對稱的陷阱:每 64 KB 壓縮輸入呼叫一次 ZFPCInflate,每次呼叫都重新 inflateInit2。第二個分塊是從 deflate 位元流的中間開始、沒有 zlib 標頭的,新的解壓器直接拒收,而別家產品寫出的再正常不過的單一成員串流,也只能被解到前 64 KB 壓縮位元組帶得動的地方為止
// 天真的分塊版 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,所以寫入端在把資料拷去目標之前,從不會先在記憶體裡多養一份壓縮資料的完整副本
// 單一 zlib 狀態的 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 的收尾。有一個副作用值得知道:因為寫入端自己呼叫 deflateInit2、不走寫死等級的輔助函式,它尊重 PLDeflateLevel,所以透過 TPDFlib.SetCompressionLevel(1..9) 設定的等級,也作用在 FPC 的大型內嵌檔案上。如果您已經照在 Delphi 中縮減 PDF 檔案體積的說明為歸檔輸出調過壓縮,這一點就有差
怎麼驗證一條 Flate 串流是單一 zlib 成員?
用普通的 zlib 解碼器去解,回傳 Z_STREAM_END 時檢查兩件事:解出的長度等於來源長度,而且 avail_in 為零。結尾標記之後還有剩餘輸入,就是串接串流的指紋。下面的常式就是這個檢查的 FPC 版,直接建立在 paszlib 上,免得去信任被測的程式碼本身;後面還附上一個探針,把 200,000 個位元組(四個 64 KB 分塊)灌過 DeflateStream
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 產生器就是會送出被截斷或檢查碼壞掉的串流。已經含有串接成員的 PDF,不管是誰寫的,在每個閱讀器裡都停在第一個 Z_STREAM_END。別想在函式庫裡把這種檔案解了再重編來治好它,那只是把 64 KB 截斷變成永久。要做的是把附件從原始來源重新內嵌。FPC 迴圈把讀不滿整塊的讀取視為資料結束,這對 TFileStream、TMemoryStream 這類串流才成立,所以傳給 AddAssociatedFileFromStream 的自訂 TStream,最保險的作法是先拷進 TMemoryStream
單一狀態的 FPC DeflateStream 與 InflateStream 隨 PDF Library for Delphi 出貨,這套函式庫用同一份原始碼樹面向 Delphi、C++Builder 與 Free Pascal,大型附件從 FPC 版出來,就跟從 Delphi 出來一樣逐位元組完整