技術文章

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

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

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 個位元組。讀取側有鏡像對稱的陷阱:每 64 KB 壓縮輸入呼叫一次 ZFPCInflate,每次呼叫都重新 inflateInit2。第二個分塊是從 deflate 位元流的中間開始、沒有 zlib 標頭的,新的解壓器直接拒收,而別家產品寫出的再正常不過的單一成員串流,也只能被解到前 64 KB 壓縮位元組帶得動的地方為止

FPC 上分塊寫入端的陷阱:當每個 64 KB 分塊都經 ZFPCCompress 變成完整的 zlib 成員,1 MiB 的附件裝了十六個相接成員,閱讀器在 65,536 位元組後的第一個結尾就停,而 GetEmbeddedFileContentToStream 照樣回報成功
損害一直沒被看見,因為每一層都成功了:附件字典宣稱的是完整的 /Params /Size,解碼不丟錯,只有真正打開附件的人才會發現被截斷
// 天真的分塊版 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 上的大型內嵌檔案
// 單一 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 出來一樣逐位元組完整