技術文章

FPC 上的分塊 zlib:FlateDecode 為什麼只能有一條串流

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 也從來沒要求過

PDFlibPas FlateDecode 串流中 RFC 1950 zlib 成員的解剖:一個兩位元組標頭、一條連續的 deflate 位元流(最後一個區塊帶 final-block 旗標),以及四個位元組的 Adler-32 結尾,inflate 在這裡回傳 Z_STREAM_END,剩下的輸入原封不動留在 avail_in,不丟任何錯誤
合規的解壓器把第一個 Adler-32 結尾當成資料終點,成員邊界就是硬停點,寫入端之後再黏上去的東西全是沒有任何閱讀器會去解的死重

舊的 FPC DeflateStream 每次從來源讀 64 KB,把每個分塊交給 ZFPCCompress,這個輔助函式自己跑 deflateInit2、用 Z_FINISH 壓縮、再呼叫 deflateEnd。於是每個分塊出來都是一條完整、合法、自我終結的 zlib 串流,函式把它們串接成一個 AnsiString 再寫出。結果看起來像 Flate 資料、標頭正確、解碼也不丟錯,但解出來的只有前 65,536 個位元組。讀取側的 InflateStream 有鏡像對稱的缺陷:每 64 KB 壓縮輸入呼叫一次 ZFPCInflate,每次呼叫都重新 inflateInit2。第二個分塊是從 deflate 位元流的中間開始、沒有 zlib 標頭的,新的解壓器直接拒收,而別家產品寫出的再正常不過的單一成員串流,也只能被解到前 64 KB 壓縮位元組為止

PDFlibPas 的 FPC DeflateStream 中分塊寫入端的缺陷:每個 64 KB 分塊都經 ZFPCCompress 變成完整的 zlib 成員,於是 1 MiB 的附件裝了十六個相接成員,閱讀器在 65,536 位元組後的第一個結尾就停,而 GetEmbeddedFileContentToStream 照樣回報成功
損害一直沒被看見,因為每一層都成功了:附件字典宣稱的是完整的 /Params /Size,解碼不丟錯,只有真正打開附件的人才會發現被截斷
// 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,所以寫入端不再在把資料拷去目標之前,先在記憶體裡多養一份壓縮資料的完整副本

PDFlibPas 中修好的 DeflateStream:只初始化一個 paszlib.TZStream,每個 64 KB 分塊以 Z_NO_FLUSH 餵入、最後以 Z_FINISH 排空,產出剛好一個標頭、一條連續的 deflate 位元流(回引用跨越分塊邊界),以及對整個輸入的一份 Adler-32
單一狀態也改變了輸出的寫法:壓縮位元組隨每個緩衝區填滿就送出,不再累積成第二份完整副本,PLDeflateLevel 從此也管得到 FPC 上的大型內嵌檔案
// 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 版一直以來的表現一致,逐位元組完整回歸