PDFlibPas 以手寫的 Object Pascal 解析器解碼 TIFF,而非繫結 libtiff,而 3.534.1 版收緊的正是這個解析器拒絕輸入的位置。BigTIFF magic 43 現在會被具名拒絕,TileOffsets 與 TileByteCounts 在標籤解析階段即被拒收,而且每個緩衝區都透過 Int64 運算決定大小,解碼上限為 256 MiB
這次堵上的缺陷在實驗室裡永遠不會出現。它出現的形式是一台安靜運作了三年的掃描閘道,直到某個客戶把地理空間封存檔或全切片醫學影像送進來。檔案帶著合法的 TIFF 標頭,解析也通過了。結果卻是一頁條紋雜訊,或是一個數 GB 的配置直接把服務拖垮,而整個過程沒有任何環節宣告過輸入無效。這才是值得針對設計的失敗形態:不是當機,而是自信滿滿地交出錯誤答案
為什麼 II 或 MM 不能證明您手上是傳統 TIFF?
因為位元組順序標記是兩種方言共用的。傳統 TIFF 與 BigTIFF 都以 II 或 MM 開頭,真正區分兩者的是緊接在後的 16 位元 magic:42 代表 TIFF 6.0 規格定義的傳統 TIFF,43 代表採用 64 位元偏移的 BigTIFF。寫成 FValidTIFF := PopWord = 42 的載入器對傳統 TIFF 的判斷沒有錯,但它把兩種截然不同的拒絕壓縮成一個沉默的布林值,於是一個 BigTIFF 和某個被改名的截斷 JPEG 就變得無法區分。PDFlibPas 現在把這些情況分開,並逐項記錄在 TPDFTIFF.LastError:標頭不足四個位元組、位元組順序標記無效、magic 43,以及其他任何 magic 值,各自產生不同的訊息文字。函式庫仍然不解碼 BigTIFF,而把這一點講清楚正是重點。呼叫方能分辨「這不是 TIFF」與「這是 TIFF,但內建解碼器未實作其 64 位元偏移配置」,也就是一封回覆就能結案的支援工單,與一個拖上一週的猜謎之間的差別
var
Tiff: TPDFTIFF;
Page: Integer;
begin
Tiff := TPDFTIFF.Create;
try
Tiff.LoadFromFile('inbox\scan-0417.tif');
if not Tiff.ValidTIFF then
raise Exception.Create('TIFF rejected: ' + Tiff.LastError);
if Tiff.PageCount < 1 then
raise Exception.Create('TIFF carries no decodable page');
for Page := 1 to Tiff.PageCount do
Writeln(Format('page %d: %dx%d, %d spp',
[Page,
Tiff.PageInfo[Page].Width,
Tiff.PageInfo[Page].Height,
Tiff.PageInfo[Page].SamplesPerPixel]));
finally
Tiff.Free;
end;
end;
Tile 是另一種幾何結構,不是另一組偏移陣列
PDFlibPas 在解析標籤時就拒絕 tiled TIFF,此時尚未觸碰任何像素資料。引來臭蟲的捷徑很容易看出來:tag 324(TileOffsets)與 tag 325(TileByteCounts)是檔案偏移與位元組計數的陣列,結構上與 strip 陣列完全相同,所以把既有的 strip 欄位指向它們只要兩行程式碼,還能乾淨編譯。但這是錯的。Tile 構成帶有邊緣填補區塊的二維網格,每個 tile 內有自己的列步距,而且完全沒有 RowsPerStrip 語意,TIFF 6.0 的 tiled-image 章節寫得很清楚。因此把 tile 資料餵給 strip 解碼器並不會大聲失敗。SimpleExtract 與 CompDecode 會用錯誤的步距走訪資料,產出一張尺寸正確、像素全錯的影像。舊程式碼還讓問題更嚴重:在 TTIFFPage 裡保留了 StripsAreTiles、ColumnsPerTile 與 RowsPerTile——由一個背後沒有 tile 組裝器的解碼器記錄 tile 幾何資訊。在 3.534.1 中,tag 324 與 325 的處理常式會拋出 tile 錯誤並立即放棄該 IFD,所以拒絕訊息帶著「tiled」字樣,而不是幾週後才以算繪客訴的形式浮現
單一維度箝制不是記憶體預算
把寬與高各自箝制在 65,535 是必要的,但遠遠不夠,因為驅動配置量的是乘積。RowsPerStrip * Width * SamplesPerPixel 在任何一邊碰到自身上限之前就可能讓 32 位元運算溢位,即使不溢位,也可能指名一個任何服務都不該嘗試的配置量。PDFlibPas 以 Int64 計算列位元組數,並同時強制三道上限:每個維度 65,535、32 個色彩分量,以及 256 MiB 的解碼位元組數
const
PDFLIB_TIFF_MAX_IMAGE_DIM = 65535;
PDFLIB_TIFF_MAX_COLOR_COMPONENTS = 32;
PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES = 256 * 1024 * 1024;
// 位於 TPDFTIFF.ValidatePageForDecode 內
BitsPerPixel := Int64(P.BitsPerSample) * P.SamplesPerPixel;
RowBytes := (Int64(P.Width) * BitsPerPixel + 7) div 8;
if (RowBytes < 1) or
(RowBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
(Int64(P.Height) > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES div RowBytes) then
Exit(False);
DecodedBytes := RowBytes * P.RowsPerStrip;
if (DecodedBytes < 1) or
(DecodedBytes > PDFLIB_TIFF_MAX_DECODED_IMAGE_BYTES) or
(DecodedBytes > MaxInt) then
Exit(False);
其中三個細節比常數本身更重要。高度測試寫成除法而非乘法,所以那個過大的乘積根本不會被構造出來。低於 1 或高於影像高度的 RowsPerStrip 會先被正規化為影像高度,這正是 TIFF 6.0 本就隱含的單 strip 解讀,也擋住了惡意標籤灌大 strip 緩衝區的可能。而且這個常式是共用的:ValidatePageForDecode 在標籤解析結束時執行一次,並在 SimpleExtract 與 CompDecode 兩個入口各執行一次,所以直接抵達解碼器的程式碼也無法繞過預算。這與 PDFlibPas 解析不受信任的 PDF 物件圖時遵循的是同一條規則,因為只在三扇門中的一扇執行的上限就不算上限
呼叫方在讀取 PageInfo 之前必須檢查什麼?
先檢查 ValidTIFF,再檢查 PageCount,然後才索引 PageInfo。被拒絕的檔案可能讓 PageCount 留在零,而 GetPageInfo 對越界索引的回應是一個未初始化的 TTIFFPage 記錄,所以在回報失敗的路上順手讀取解析度或取樣數的錯誤路徑,最後讀到的是雜訊。3.534.1 版修正了函式庫內部的兩個呼叫方:影像匯入路徑只在有效分支內讀取 XRes 與 YRes,而 TPDFlib.GetImagePageCount 要求 ValidTIFF 成立,不再只憑非零頁數就採信。在下游,AddImageFromFile 的 Options 引數是多頁 TIFF 的 1 起始頁碼,所以 GetImagePageCount 必須在迴圈開始前就可信,而不是事後才驗證。零頁現在是一個真正的答案,意思是「這裡沒有可解碼的內容」,而不是提早回傳造成的意外,這在您整併與交錯雙面掃描批次時最重要,因為一張靜靜解錯的紙面會落到錯誤的位置
var
Pdf: TPDFlib;
Pages, I, ImageID: Integer;
begin
Pdf := TPDFlib.Create;
try
Pages := Pdf.GetImagePageCount('inbox\scan-0417.tif');
if Pages < 1 then
Exit; // 標頭損毀、BigTIFF、tiled 版面或超出預算
Pdf.NewDocument;
for I := 1 to Pages do
begin
Pdf.NewPage;
ImageID := Pdf.AddImageFromFile('inbox\scan-0417.tif', I);
if ImageID > 0 then
begin
Pdf.SelectImage(ImageID);
Pdf.DrawImage(0, 0, 595, 842);
end;
end;
Pdf.SaveToFile('scan-0417.pdf');
finally
Pdf.Free;
end;
end;
自建解碼器,還是連結 libtiff?
PDFlibPas 保留內建解碼器,決定性因素是平台覆蓋面,而非出自誰手。大約 1,873 行 Object Pascal 能編譯到編譯器所及的任何地方:Win32、Win64、macOS、iOS、Android,以及 Linux 上的 FPC。libtiff 4.7.1 則是約 30,000 行 C,分散在 34 個 tif_*.c 編譯單元裡,而目前存在的預建目的檔只涵蓋 Windows。採用它等於用完整的 TIFF 覆蓋率,換來一份縮水到「能跑 C 工具鏈的機器」的支援平台清單,外加一輪沒人走過的連結流程
其代價值得不加修飾地說清楚。內建解碼器處理掃描文件工作實際會產生的東西:CCITT Group 3 一維與二維、Group 4、LZW、Deflate、PackBits 與 JPEG-in-TIFF,涵蓋 WhiteIsZero、BlackIsZero、RGB、色盤與 CMYK 光度解讀,以及 Predictor 1 與 2。這些負載與 ISO 32000-1 §7.4.4 與 §7.4.6 的 PDF 濾鏡一一對應,這正是 TIFF 前端在掃描管線中承載如此重量的原因。它不處理的是 BigTIFF、tile、浮點 Predictor 3、PixarLog 與 SGILog、舊式 JPEG compression 6,以及 sub-IFD 金字塔。自 3.534.1 起,這些每一項都是具名拒絕而非錯誤影像,而且函式庫保留了一份書面的觸發條件清單,用於重新開啟 libtiff 決策:
- 客戶回報 BigTIFF 檔案,且需要原生支援而非轉換步驟
- 客戶回報來自醫療、GIS 或工業來源的 tiled TIFF,且需要就地解碼
- 客戶回報浮點 Predictor 3 TIFF
- 已公開的漏洞落在內建 CCITT 或 LZW 解碼路徑上
- 跨平台論證不再成立,原因可能是 macOS、iOS 與 Android 支援被捨棄,或已有可複用的 libtiff 整合涵蓋 macOS 與 Linux
遷移本身的範圍是明確的,而非假設性的:一個 USE_LIBTIFF 條件編譯會保持 TPDFTIFF 公開介面不變,把 LoadFromStream 改道經過 TIFFClientOpen 與串流回呼,並保留 Pascal 解析器作為非 Windows 平台的後備。在那些觸發條件真正啟動之前,維護兩個解碼器與加倍的測試矩陣買不到任何客戶感覺得到的東西。把退路寫好再延後一項成本,與無視它,是兩回事
這對掃描文件管線意味著什麼
把 TPDFTIFF 當成閘門,而不是轉換器。載入檔案,讀取 ValidTIFF,並在它為 false 時逐字記錄 LastError,因為這個字串現在是從現場回報到診斷之間最短的路徑。未能過閘的檔案仍可透過在上游轉換來挽回,這是今天對 BigTIFF 與 tiled 來源的實際答案。對於完全不屬於 TIFF 的輸入,PDFlibPas 另有一條路徑,經由 AVIF、HEIF 與 JPEG XL 影像輸入處理,所以哪個解碼器擁有哪種格式的問題保持明確,而不是自然浮現
這一切都藏在平常的影像 API 之後,所以文件管線無需更動任何一行程式碼就能獲得更緊的邊界,頂多補上它本來就該做的頁數檢查。如果您正在評估 Delphi 或 C++Builder 的原生 TIFF 轉 PDF 路徑,完整元件及其影像處理都記錄在 PDF Library for Delphi 產品頁上