一個標記為 /Predictor 12 的串流,並不代表每一列都使用 PNG 濾鏡 2。HotPDF 是 Delphi 與 C++Builder 適用的原生 VCL PDF 元件,把 predictor 值 10 到 15 視為同一個家族:真正的濾鏡標記是 0 到 4,位於每一個已編碼列的第一個位元組,HPDFDecodePredictor 會逐列讀取並驗證這個標記。這個區別正是這個 PDF 角落幾乎所有錯誤的成因,因為一旦搞錯,什麼都不會拋出例外。濾鏡鏈照樣執行,點陣圖大小也符合預期,但影像卻變成對角線雜訊,或是一段隨著每條掃描線愈飄愈遠的漸層。/DecodeParms(ISO 32000-1 §7.4.4)裡的五個數字,大多改變的是位元組的意義,而不是位元組的長度,所以錯一個數字,產生的往往是看起來合理的垃圾資料,而不是錯誤訊息
為何 /Predictor 12 不代表每一列都是 PNG 濾鏡 2?
因為 predictor 數值只表示「正在使用 PNG 式預測」,並沒有指明是哪一種濾鏡。PNG 編碼器會逐條掃描線各自選擇濾鏡,PDF 濾鏡也繼承了這一點,所以 predictor 值 10(無)、11(Sub)、12(Up)、13(Average)、14(Paeth)與 15(Optimum)的解碼方式全部相同:解碼器必須遵循的是每一列開頭的那個標記位元組。版面配置上的後果,和語意本身同樣重要。每個已編碼列的長度都是 1 + RowBytes 個位元組,因此輸入長度必然比輸出多出恰好列數那麼多,而長度不是 RowBytes + 1 整數倍的串流,依定義就是被截斷了。HotPDF 會在觸碰任何位元組之前先檢查這個邊界,任何超過 4 的標記都會以 Invalid PNG predictor row tag 拒絕,而且會直接從單一輸出緩衝區中讀取前一列,而不是實體化一個二維列陣列。濾鏡 1 與 3 會在目前列中往回讀取 BytesPerPixel 個位元組,濾鏡 2 直接向上讀取,濾鏡 4 則對左、上與左上三者執行 Paeth 選擇──這四種濾鏡全部作用於已經重建完成的輸出資料,這正是為何上一列必須是已解碼列、絕不能是尚未過濾的輸入資料
uses
HPDFPredictor;
var
Filtered, Raster: AnsiString;
ErrorText: string;
begin
// /DecodeParms << /Predictor 12 /Colors 3 /BitsPerComponent 8 /Columns 1024 >>
if HPDFTryDecodePredictor(Filtered, 12, 3, 8, 1024,
Int64(1024) * 3 * 8192, Raster, ErrorText) then
ConsumeRaster(Raster)
else
LogStreamDefect('predictor', ErrorText); // no exception, no partial raster
end;
MaxOutputBytes 這個參數不是裝飾用的。predictor 階段實質上是偽裝過的解壓縮階段,一個惡意或只是單純錯誤的 /Columns 值,能把幾千位元組的輸入變成一個高達數 GB 的配置請求。HotPDF 會先以 Int64 計算每列位元數、每列位元組數與點陣圖總大小,拒絕會溢位的幾何配置,並遵循呼叫端提供的上限。只要傳入一個從影像字典推導出的真實邊界,失敗模式就會變成一則日誌訊息,而不是客戶機器上一個記憶體不足的對話框
為何 TIFF Predictor 2 會損毀 4 位元影像?
因為 Predictor 2 是以取樣(sample)為單位做水平差分,而不是以位元組為單位,而在每分量 1、2 或 4 位元的情況下,好幾個取樣會共用同一個位元組。常見的實作方式是把位元組 N-Colors 加到位元組 N 上,這在每分量 8 位元時剛好是對的,在其他所有情況下卻悄悄地出錯。一份 8 位元 RGB 掃描能完美解碼,然後同一段程式碼,在正式環境第一次遇到 4 位元索引影像時,就把它給毀了
正確的運算必須在位元欄位內部進行。HotPDF 會走訪索引 Colors 到 Colors * Columns - 1 之間的每一個取樣,用適當位移的遮罩 (1 shl BitsPerComponent) - 1 取出該取樣及其同分量的左鄰值,將兩者以該遮罩為模做加法,再把結果寫回,同時不擾動同一個位元組裡打包的其他取樣。結尾部分也很重要:一列會被補齊到位元組邊界,所以最後一個取樣之後的填補位元,必須維持原樣、不能被捲入運算之中。在每分量 16 位元時,每個取樣是一對大端序(big-endian)位元組,加法運算是跨這一對位元組在 $FFFF 處回捲,而不是位元組之間各自獨立進位;在 8 位元時,簡單的逐位元組遞迴就是對的,並以 Colors 為步進,讓紅色分量對紅色分量累加、alpha 對 alpha 累加。在每一種變體中,一列的第一個像素永遠是字面值,絕不是差值,而遞迴會在每個列邊界重新開始──TIFF 預測從不讀取上一列,這正是它與 PNG 家族之間唯一的差別
EarlyChange 在 LZWDecode 中實際控制的是什麼?
它控制的是讀取端何時把編碼寬度加寬一個位元,而只要偏差一個編碼,後面的內容就會全部損毀。HotPDF 把這條規則表達成單一不變式:在加入一個字典條目之後,下一次讀取會在 NextCode 到達 (1 shl CodeSize) - Ord(EarlyChange) 時加寬。當 /EarlyChange 1(ISO 32000-1 §7.4.4 的預設值)時,切換會提早一個編碼發生;當 /EarlyChange 0 時,切換則恰好發生在邊界處。兩種寫法在真實檔案中都會出現,而位元串流本身完全不會告訴你編碼器用的是哪一種。狀態機的其餘部分必須同步移動:一個清除碼(clear code)會一併重置編碼寬度、位元遮罩、下一個空閒編碼與片語儲存區,而結束資訊碼則是以那一刻當下的寬度來讀取,而不是以初始的 9 個位元讀取。HotPDF 從 InitialCodeSize 9 開始,把編碼寬度上限訂為 12、字典上限訂為 4096 個條目,並把 FillOrder 預設為 foTop,因為 PDF 是以高位元優先的方式打包編碼──foBottom 則是為那些不這麼做的 TIFF 式串流而存在的
uses
HPDFLZW;
var
Decoder: TPDFLZWDecompressor;
Parms: TPDFLZWParms;
Plain: AnsiString;
begin
Decoder := TPDFLZWDecompressor.Create;
try
Decoder.EarlyChange := True; // /EarlyChange 1 is the PDF default
Decoder.FillOrder := foTop; // high-order bit first
Decoder.MaxOutputBytes := 256 * 1024 * 1024;
Decoder.RequireInitialClear := False;
Decoder.RequireEndOfInformation := False;
Parms.Predictor := 12;
Parms.Colors := 3;
Parms.BitsPerComponent := 8;
Parms.Columns := 1024;
Parms.ExpandedTo8Bit := False;
Parms.ColorSpace := 'DeviceRGB';
if Decoder.TryDecompress(RawStreamBytes, Parms, Plain) then
LogDecodeStats(Decoder.PeakCodeSize, Decoder.DictionaryAdds,
Decoder.KwKwKExpansions, Decoder.OutputBytes)
else
LogStreamDefect('lzw', Decoder.LastError);
finally
Decoder.Free;
end;
end;
這些統計數據的存在是為了排查問題,不是為了炫耀。當一份檔案解碼出正確的長度、卻是錯誤的像素時,PeakCodeSize 與 DictionaryAdds 能立刻告訴你,讀取端是否確實在寫入端加寬的那個位置也跟著加寬了。翻轉 EarlyChange、再解碼一次,比較兩次結果:如果數字有變動,你只需跑一次就能得到答案,不必逐步追蹤位元讀取器
KwKwK 分支,以及串流何時該直接判定失敗
唯一一種看起來不合法、實際上合法的情況是 Code = NextCode,HotPDF 的處理方式是先建構出該條目、再把它輸出。編碼器有可能在同一個步驟裡,就輸出它正在定義的片語所對應的編碼,只要輸入內容中出現形如 K w K w K 的樣式,就會發生這種狀況;解碼器無法查到這個編碼,因為它此刻根本還不存在,所以必須自行建構出 Previous + First(Previous)、把它加為新條目,再輸出這個剛剛建立好的條目。HotPDF 會在 KwKwKExpansions 中計算這類情況的次數,並交叉檢查它新增的編碼,是否正好就是被要求的那個編碼。任何超出 NextCode 的情況都屬於資料損毀,這時解碼器該做的是停止,而不是即興發揮:遇到未來編碼、遇到指向片語儲存區之外的字典前綴、遇到字典已滿,或遇到第一個編碼不是字面值時,HotPDF 都會拋出例外。有兩個嚴謹度開關預設是刻意關閉的,RequireInitialClear 與 RequireEndOfInformation,因為許多正式環境中的 PDF 都省略了開頭的清除碼,或是資料用完卻沒有結束符。驗證自己的輸出時把它們打開,讀取來路不明的檔案時則讓它們保持關閉
在已載入文件端,/DecodeParms 實際上是在哪裡被讀取的
HotPDF 會在影像串流字典上解析 /DecodeParms 或它的縮寫 /DP,接受字典或陣列兩種形式,若是陣列則取用最後一個元素,接著把 Predictor、Colors、BitsPerComponent、Columns 與 EarlyChange 帶入點陣圖處理路徑。陣列這種情況是大家容易忘記的:一個以 [/ASCII85Decode /FlateDecode] 過濾的串流,會帶有一個並行的參數陣列,而 predictor 的設定屬於最後一個濾鏡,而不是第一個
var
Pdf: THotPDF;
Info: THPDFLoadedImageInfo;
Bmp: TBitmap;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('scanned.pdf', '') > 0 then
for I := 0 to Pdf.GetLoadedImageCount - 1 do
if Pdf.GetLoadedImageInfo(I, Info) then
begin
Bmp := Pdf.ExtractLoadedImage(I); // nil when the raster is unusable
if Bmp <> nil then
try
Bmp.SaveToFile(Format('image-%d.bmp', [I]));
finally
Bmp.Free;
end;
end;
finally
Pdf.Free;
end;
end;
這條路徑上有一個歷史缺陷值得一提,因為這類錯誤會反覆出現。舊版的「帶參數 Flate」例程,會先建立一個解壓縮串流,再從原始壓縮輸入複製資料,導致 predictor 階段收到的其實是壓縮過的位元組,卻盡忠職守地對它們做反預測處理:結果永遠是錯的,卻從不拋出例外。目前的程式碼只會從解碼器讀取資料,再把結果交給共用的 predictor,而且會拒絕一個比計算出的大小還短的點陣圖,而不是退回去使用仍處於壓縮狀態的位元組──那種退回機制過去曾經把一次解碼失敗,變成一張損毀的點陣圖。同一套 predictor 實作,現在也用於交互參照串流,如果你也在處理 物件串流與增量更新,這種一致性會很有幫助,而周邊的擷取機制則涵蓋在姊妹篇 擷取已載入影像及其解碼濾鏡 中。以 DCTDecode 或 JPXDecode 形式抵達的影像,則完全不會進入 predictor;它們攜帶的是自己的壓縮像素模型
輸送量:連續片語儲存區對比逐條目字串
把逐條目字串字典換成連續的片語儲存區,在一個病態輸入上測得大約快了 1.61 倍:在一個單一最長片語達 7,370,880 位元組的基準測試中,速度是 1558 MiB/s 對比 969 MiB/s。這個輸入的形狀,正好說明了差距的成因,因為傳統實作只能在兩種糟糕的取捨之間二選一。一個由 AnsiString 值組成的字典,會為多達 4096 個條目中的每一個,都配置並複製一份全新的字串,每個新條目都要把它的父片語整個複製一遍;而前綴/後綴堆疊雖然完全避開了這種記憶體開銷,卻要靠沿著鏈結逐一位元組往回走、再反轉,才能重建出每個片語,這對一般文字沒問題,但當某個片語長達數百萬位元組時就會很痛苦。HotPDF 把每個片語連續附加到一個以幾何級數成長的儲存區中,以偏移量與長度為條目建立索引,並用單一一次 Move 把片語輸出到輸出緩衝區。老實說這樣做要付出記憶體上的代價:一個完整存放所有片語的儲存區,其上限取決於所有片語長度的總和,而不是條目數量,這正是為何解壓縮器與 predictor 都設有 MaxOutputBytes 的原因。把這個上限從影像字典所宣稱的點陣圖大小推導出來,一個說謊的串流就會很快失敗
本文所示的 LZW 解壓縮器、共用 predictor 與已載入影像擷取路徑,隨附於標準版 HotPDF Component(適用於 Delphi 與 C++Builder)之中,完整的濾鏡與 DecodeParms 參考文件請見產品頁面