技術文章

Delphi TIFF 解碼器強化:BigTIFF 與 Tiled TIFF

PDFlibPas 以手寫的 Object Pascal 解析器解碼 TIFF,而非繫結 libtiff,而 3.534.1 版收緊的正是這個解析器拒絕輸入的位置。BigTIFF magic 43 現在會被具名拒絕,TileOffsets 與 TileByteCounts 在標籤解析階段即被拒收,而且每個緩衝區都透過 Int64 運算決定大小,解碼上限為 256 MiB

這次堵上的缺陷在實驗室裡永遠不會出現。它出現的形式是一台安靜運作了三年的掃描閘道,直到某個客戶把地理空間封存檔或全切片醫學影像送進來。檔案帶著合法的 TIFF 標頭,解析也通過了。結果卻是一頁條紋雜訊,或是一個數 GB 的配置直接把服務拖垮,而整個過程沒有任何環節宣告過輸入無效。這才是值得針對設計的失敗形態:不是當機,而是自信滿滿地交出錯誤答案

為什麼 II 或 MM 不能證明您手上是傳統 TIFF?

因為位元組順序標記是兩種方言共用的。傳統 TIFF 與 BigTIFF 都以 IIMM 開頭,真正區分兩者的是緊接在後的 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 位元偏移配置」,也就是一封回覆就能結案的支援工單,與一個拖上一週的猜謎之間的差別

PDFlibPas TIFF 載入器分開讀取位元組順序標記與 16 位元 magic,因此短標頭、無效標記、BigTIFF magic 43 與其他任何 magic 值各自產生不同的 LastError 文字,而非單一沉默的布林值
傳統 TIFF 與 BigTIFF 以相同的位元組順序標記開頭,所以 PDFlibPas 把四種拒絕情況分開,並在 LastError 中逐一具名
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 解碼器並不會大聲失敗。SimpleExtractCompDecode 會用錯誤的步距走訪資料,產出一張尺寸正確、像素全錯的影像。舊程式碼還讓問題更嚴重:在 TTIFFPage 裡保留了 StripsAreTilesColumnsPerTileRowsPerTile——由一個背後沒有 tile 組裝器的解碼器記錄 tile 幾何資訊。在 3.534.1 中,tag 324 與 325 的處理常式會拋出 tile 錯誤並立即放棄該 IFD,所以拒絕訊息帶著「tiled」字樣,而不是幾週後才以算繪客訴的形式浮現

PDFlibPas 比較 strip 版面(全寬色帶共用同一列步距)與 tile 版面(帶邊緣填補區塊的二維網格),並在標籤解析階段、尚未觸碰任何像素資料前拒絕 tag 324 與 325
Tile 陣列在結構上看起來與 strip 陣列完全相同,這正是把它們餵給 strip 解碼器會得到尺寸正確、像素錯誤的原因

單一維度箝制不是記憶體預算

把寬與高各自箝制在 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 在標籤解析結束時執行一次,並在 SimpleExtractCompDecode 兩個入口各執行一次,所以直接抵達解碼器的程式碼也無法繞過預算。這與 PDFlibPas 解析不受信任的 PDF 物件圖時遵循的是同一條規則,因為只在三扇門中的一扇執行的上限就不算上限

PDFlibPas 以 Int64 運算決定每個 TIFF 緩衝區的大小,以除法測試影像高度使過大的乘積永不成立,並在標籤解析與兩個解碼器入口執行同一個 ValidatePageForDecode 常式
三道上限、Int64 列位元組運算,以及一個從三扇門都能抵達的共用驗證常式,因為只在三扇門中的一扇執行的上限就不算上限

呼叫方在讀取 PageInfo 之前必須檢查什麼?

先檢查 ValidTIFF,再檢查 PageCount,然後才索引 PageInfo。被拒絕的檔案可能讓 PageCount 留在零,而 GetPageInfo 對越界索引的回應是一個未初始化的 TTIFFPage 記錄,所以在回報失敗的路上順手讀取解析度或取樣數的錯誤路徑,最後讀到的是雜訊。3.534.1 版修正了函式庫內部的兩個呼叫方:影像匯入路徑只在有效分支內讀取 XResYRes,而 TPDFlib.GetImagePageCount 要求 ValidTIFF 成立,不再只憑非零頁數就採信。在下游,AddImageFromFileOptions 引數是多頁 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 產品頁