技術文章

Delphi 中的 Indexed PDF 調色盤:基底色彩空間與迴圈

/Indexed 查詢表儲存的是基底色彩空間的成份,不是 RGB 三色。DeviceGray 基底每筆塞一個位元組,DeviceCMYK 四個,Lab 三個但要範圍縮放,ICCBased 則看設定檔 /N 宣告幾個。THPDFColorSpace 公開的 RGB 調色盤,是 HotPDF 在剖析時把全部這些正規化出來的

那一句話就是整類 bug。/Indexed 空間幾乎每個調色盤渲染器一開始都假設三位元組步距,因為真實世界的 /Indexed 空間絕大多數坐在 /DeviceRGB 上,這個假設可以撐好幾年。然後一張掃描發票帶著 [/Indexed /DeviceCMYK 15 <...>] 來了,讀取器一次走三個位元組走過查詢字串,每個超過零的索引都落進別人的顏色中間。輸出不是空白,也不會當機。它是看起來合理的垃圾,而那更糟,因為沒人會對只是顏色看起來不對的 bug 開單

Indexed 調色盤為什麼會出來是錯的

步距錯了,因為步距不是常數。ISO 32000-1 §8.6.6.3 把查詢表定義為 m × (hival + 1) 個位元組,其中 m 是基底空間的成份數,而它對基底是 RGB 這件事隻字未提。同一節只明文禁止兩個基底:/Indexed 本身與 /Pattern。其他一切都可以,包括 §8.6.5.2 到 §8.6.5.5 全部四個 CIE 家族

HotPDF 中 Indexed PDF 查詢表示意圖,條目步距來自基底色彩空間:8 位元組的 DeviceCMYK 查詢表被錯誤的 3 位元組 RGB 走法切成垃圾條目,正確的 4 位元組分組得到兩筆正規化為 RGB 三色的 CMYK 條目,表格列出 DeviceGray、DeviceRGB、DeviceCMYK、Lab 與 ICCBased 每筆的成份數
HotPDF 先解析基底色彩空間,再問它每筆幾個成份,所以 CMYK 查詢表在正規化成 RGB 之前每筆走四個位元組

所以真正的工作不是讀位元組,而是先解析基底、問它一筆多寬。HotPDF 的 ResolveIndexedPalette 正是這麼做:把基底解析成 THPDFColorSpace,從解析出的 record 拿 ComponentCount,如果基底回來是 csfIndexed、csfPattern 或 csfUnsupported,就直接拒絕整個陣列。只有這樣它才把位元組預算定為 PaletteCount * Count,以 hival 255 的合法上限加四成份基底來算,頂多 1024 位元組。尺寸估算裡有兩個細節是惡意檔案會試探你的地方。HotPDF 依 §8.6.6.3 的限制先把 hival 夾進 0..255,所以宣稱 hival 100000 的查詢陣列不會變成 GB 級的配置;它還把預算截到查詢資料實際包含的長度,然後在第一批位元組越過結尾的條目停下填色。短的表格得到短的調色盤,而不是讀出緩衝區

把四種基底色彩空間正規化成一個 RGB 表

HotPDF 在剖析時就把調色盤轉成 RGB,而不是畫圖時,這個決定帶動了其餘設計。公開的 THPDFColorSpace.Palette 欄位是長度 3 * PaletteCount 的 RGB 三色 TBytes,而且在支援 CIE 基底很久以前就是這個形狀。改寫 record 版面去攜帶原生基底成份,會弄壞這個欄位的每個消費端。在剖析時轉換保住版面,而且附帶好處是讓 Gray、CMYK、Lab、CalGray、CalRGB 與 ICCBased 條目走渲染器對直接色彩運算子已經在用的同一條 HPDFResolveColor 路徑

縮放步驟是基底空間不再可互換的地方。查詢位元組永遠是 0..255,但那個位元組代表什麼取決於基底宣告的成份範圍。Device 與校準成份映射到 0..1。Lab 不是:L* 成份跑 0..100,a* 與 b* 則跑空間宣告的 /Range。HotPDF 從 THPDFCIEParams 的 AMin、AMax、BMin、BMax 欄位拉出這些界限,Lab 剖析器從字典 /Range 條目填入,條目缺漏時預設 −108..101,所以別憑記憶寫死對稱範圍。ICCBased 基底加上第三個真相來源:成份數來自設定檔串流 /N,以 ICCN 呈現,而 ICCBased 串流本身可能帶 /Range 陣列,HotPDF 用 HPDFReadNumericArrayUpTo 讀進同一張逐成份界限表。所以三成份 ICC 調色盤與三成份 Lab 調色盤以相同的步距走查詢字串,運算卻完全不同,這正是固定 RGB 步距做不到的結果

var
  CS: THPDFColorSpace;
  Entry: Integer;
  R, G, B: Byte;
begin
  // ColorSpaceObj 是影像 XObject 或資源字典的 /ColorSpace 條目;
  // 可能是名稱、陣列或間接參照
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if CS.Family <> csfIndexed then
    Exit;

  // 無論基底空間是什麼,Palette 都已經是 RGB
  for Entry := 0 to CS.PaletteCount - 1 do
  begin
    if (Entry * 3 + 2) >= Length(CS.Palette) then
      Break;                       // 查詢表太短,乾淨地停住
    R := CS.Palette[Entry * 3];
    G := CS.Palette[Entry * 3 + 1];
    B := CS.Palette[Entry * 3 + 2];
    UseColor(Entry, R, G, B);
  end;
end;

查詢表住在串流裡的時候

ISO 32000-1 §8.6.6.3 允許查詢表是字串或串流,而串流形式正是咬人的那種。串流可能帶 /FlateDecode、可能是與檔案無關部分共用的間接物件,而且你的剖析器碰到它時,它的 TStream 可能停在任意位置。HotPDF 用委派給解碼位元組 callback 的方式處理壓縮案例,那是以 HPDFSetColorStreamReader 註冊的 callback,與 Type 0 取樣函式引擎用的是同一個

沒有註冊讀取器時,回退刻意做窄而不做 best-effort。HotPDF 檢查串流字典有沒有 /Filter,有的話就整個拒絕調色盤,而不是把壓縮位元組餵進查詢表、產出信心十足的錯誤表格。沒有濾鏡就讀原始位元組,上限是依 hival 與基底成份數算出的位元組預算。整個串流分支坐在 try..finally 裡,每一條出口路徑都還原原本的 Stream.Position,包括拒絕的路徑,因為物件是共用的,下一個消費端不會感謝你移動了游標。回歸套件的 LookupStreamUsesDecodedReaderAndRestoresPosition 把這兩個行為釘死

HotPDF 以 PDF 串流儲存 Indexed 查詢表的決策圖:已註冊的 HPDFSetColorStreamReader callback 解碼帶濾鏡的位元組,未註冊且帶 /Filter 條目的串流直接被拒,無濾鏡串流則讀原始位元組到位元組預算為止,try..finally 在每條出口還原 Stream.Position
拒絕帶濾鏡的串流,好過把壓縮位元組餵進查詢表,而還原原本串流位置讓共用物件對下一個消費端保持安全
// 在解析任何色彩空間前先註冊兩個 callback,否則
// 間接基底與壓縮查詢串流只會被拒絕
HPDFSetColorSpaceResolver(MyResolveIndirectObject);
HPDFSetColorStreamReader(MyDecodedStreamReader);
try
  CS := HPDFResolveColorSpace(ColorSpaceObj);
  if (CS.Family = csfIndexed) and (Length(CS.Palette) = 0) then
    LogWarning('lookup table unreadable: filtered stream or no reader');
finally
  HPDFSetColorStreamReader(nil);
  HPDFSetColorSpaceResolver(nil);
end;

什麼阻止 Indexed 色彩空間永遠遞迴下去

把 /Indexed 拒絕為直接基底是必要的,但離充分還遠得很。有趣的攻擊走側面。拿一個合法的 [/Indexed /Separation ...],回想 §8.6.6.4 允許 Separation 指名替代空間、§8.6.6.5 允許 DeviceN 做同樣的事。讓那個替代空間成為指回原始 Indexed 陣列的間接參照。整條鏈沒有任何一跳違反直接基底規則:基底是 Separation,Separation 的替代是陣列,而那個陣列剛好就是你起點的那個。天真地解析它,剖析器就會繞著迴圈走,直到堆疊耗盡。那些空間依賴的色調變換機制是另一個話題,渲染 Separation 與 DeviceN 特別色那篇有講;在這裡它們只當迴圈中的一環

HotPDF 在每條路徑都匯集的唯一入口關上這個洞。HPDFResolveColorSpace 用受 HPDFColorSpaceMaxDepth(64)限制的深度計數器包住內部解析器。跨過界線,守衛就設旗標,而旗標才是關鍵:回溯時任何看到旗標被設的巢狀層級都會丟棄自己解析到一半的 record 並回傳 csfUnsupported,所以整個圖被拒絕而不是留下一半,兩個計數器在最外層呼叫回傳時重設。那些計數器宣告為 threadvar,不是單元全域變數,這不是順手的整潔:GResolveObj 與 GReadStream 也為同樣的理由活在執行緒區域儲存裡,因為頁面是並行渲染的。共用的全域變數會讓一個深陷巢狀 DeviceN 鏈的執行緒,替另一個正在解析普通 [/Indexed /DeviceRGB 255 <...>] 的執行緒觸發上限,產生非確定性、隨負載而變的失敗——這是最難除錯的那種。對合法內容來說 64 是充裕的餘裕,合法內容很少巢狀超過四五層,所以把結構上看起來合法的陣列回傳 csfUnsupported 當成值得記錄的事:實務上它代表迴圈,或真實產生端不會有的深度。以 Separation 與 DeviceN 參數化的 IndirectAlternateCycleIsUnsupported 與 ExcessivePatternDepthIsUnsupported 覆蓋兩種情況

HotPDF 色彩空間的迴圈圖與守衛:Indexed 陣列的基底是 Separation,其替代是回到同一個陣列的間接參照,由 HPDFResolveColorSpace 用受 HPDFColorSpaceMaxDepth 以 64 封頂的深度計數器解析,超過後每一層回溯都丟棄自己的 record 並回傳 csfUnsupported
單一跳沒有任何東西違反直接基底規則,所以 HotPDF 把解析深度限制在 64 層,拒絕整個圖而不是留下一半
function TryResolveImageSpace(Obj: THPDFObject;
  out CS: THPDFColorSpace): Boolean;
begin
  CS := HPDFResolveColorSpace(Obj);
  // 這裡的 csfUnsupported 涵蓋真正未知的家族、穿過
  // 間接替代的迴圈,以及超過 64 層的深度
  Result := CS.Family <> csfUnsupported;
  if not Result then
    CS := HPDFDefaultColorSpace;   // DeviceGray,PDF 的初始空間
end;

一個值得寫進文件給呼叫端看的 API 界線

HotPDF 在建 Indexed 調色盤時套用剖析當下生效的 ICC 轉換設定,之後呼叫 HPDFSetColorSpaceRenderingIntent 不會重新產生已經解析好的調色盤。那個函式走你交給它的 record 的 ICCBased 與替代轉換,重建那些管線;Palette 裡攤平的 RGB 位元組是剖析時的產物,維持原樣。這是提早正規化的後果,不是疏忽,值得寫進你自己的 API 文件,而不是留給客戶去發現。如果你的應用程式讓使用者在同一場工作階段中途切換渲染意圖,切換後重新解析色彩空間;如果在管線設定時設一次,也就是壓倒性的常見情況,那什麼都不用做。同樣的順序也適用在 HPDFSetICCColorPipeline 安裝的打樣設定檔:在你開始解析之前設定,不要在之後

寫入端的鏡像 bug

以上全部是關於讀取。寫入端對稱的缺陷更容易出貨,也更容易漏掉。HotPDF 從 Windows 8 位元點陣圖建 /Indexed 陣列時呼叫 GetPaletteEntries,而這個 API 只填調色盤實際持有的條目數並回傳那個數字。原始程式把結果測成 = 0,然後對堆疊陣列疊代固定的 256 筆。對 16 色點陣圖來說,這把 240 筆未初始化的堆疊記憶體直接寫進你要交給客戶的 PDF 查詢字串,hival 寫死成 255,所以每個讀取器都乖乖把殘渣當成真實顏色資料。它是正確性 bug,也是資訊洩漏 bug,穿同一件衣服。修法樸素而完整:呼叫前把陣列歸零,然後循環上限與 hival 都尊重回傳的數字。如果你在自己的程式碼裡任何地方產生 /Indexed 影像,現在就去檢查 hival

兩塊相鄰的拼圖補全畫面:Indexed 調色盤與 Type 0 取樣函式共用解碼串流讀取器與同一套夾制紀律,Type 0 函式色彩查詢表那篇有講;管轄查詢串流的濾鏡規則與透過解碼濾鏡提取已載入影像裡管轄影像資料的同一套。這裡描述的色彩空間解析器,以 HotPDF Delphi Component 的一部分出貨,給 Delphi 與 C++Builder 用,完整的 THPDFColorSpace 參考在產品頁上