技術文章

在 Delphi 中轉譯 Separation 與 DeviceN 特別色

HotPDF 藉由透過 HPDFResolveColorSpace 解析色彩空間、使用 HPDFEvalTintTransform 求值濃淡轉換函式,並將結果透過替代空間轉換為螢幕所用的 RGB,來轉譯已載入 PDF 頁面上的 Separation 與 DeviceN 特別色;自 v2.375.0 起,該管線可對所有四種 PDF 函式類型進行求值,包括 Type 4 PostScript 計算器,因此帶有 Pantone 顏料的印刷準備就緒檔案可以顯示其實際色彩而非預留位置;本篇文章將引導您瞭解該管線的運作方式,同樣有用的是,它將說明我們在建置它時是如何失敗的

觸發的情境每次都相同;客戶從印刷廠收到一個 PDF:標題設為具名的特別色顏料,包裝插圖使用雙顏料 DeviceN 混合,而正文則是純黑色;在 Acrobat 中它看起來很完美;在您的 Delphi 應用程式中,標題卻轉譯為黑色,或者更糟的是完全沒有轉譯,於是客戶針對您的軟體而不是檔案提交了錯誤回報;檔案沒有問題,只是轉譯器不支援該檔案所使用的色彩空間

為什麼特別色在 PDF 檢視器中會轉譯成黑色?

當轉譯器僅實作了裝置色彩運算子(rggk)而忽略通用運算子時,特別色就會轉譯成黑色或直接消失;ISO 32000-1 §8.6 定義了三組色彩空間:裝置空間(DeviceGray、DeviceRGB、DeviceCMYK)、基於 CIE 的空間(CalGray、CalRGB、Lab、ICCBased),以及特殊空間(Indexed、Separation、DeviceN, Pattern);裝置群組之外的任何內容都是使用通用運算子選取的:csCS 依名稱從頁面資源字典中挑選一個空間,然後由 scSCscnSCN 提供成分值;略過這些運算子的轉譯器會保留最後設定的任何色彩,對於以特別色標題為開頭的頁面而言,這就是初始的 DeviceGray 黑色

HotPDF 在 v2.333.0 中將通用運算子集新增至其頁面轉譯器,同時提供了統一的解析路徑:每個 /ColorSpace 資源項目,不論是裸名稱、行內陣列還是間接參照,都會被解析成一個 THPDFColorSpace 紀錄,且每個填滿或描邊色彩請求都會經由單一的 HPDFResolveColor 呼叫進行處理;家族列舉一目了然地顯示了支援的範圍

type
  THPDFColorSpaceFamily = (csfDeviceGray, csfDeviceRGB, csfDeviceCMYK,
                           csfIndexed, csfCalGray, csfCalRGB, csfLab,
                           csfICCBased, csfSeparation, csfDeviceN,
                           csfUnsupported);

function HPDFResolveColorSpace(Obj: THPDFObject): THPDFColorSpace;

function HPDFResolveColor(const CS: THPDFColorSpace;
  const Comps: THPDFColorComps; CompCount: Integer): THPDFRenderColor;

一個設計決策再三證明了其價值:csfUnsupported 是一個一等家族,而不是一個錯誤;轉譯器無法解譯的空間會降級為定義好的後備方案,而不是中止頁面載入,這與主流檢視器的行為相符,並可防止單個奇特的填滿導致原本可轉譯的檔案變成空白

濃淡轉換如何將一個顏料值轉換為實際色彩?

Separation 空間包含三項資訊:顏料名稱、替代色彩空間,以及濃淡轉換函式;陣列 [/Separation /PANTONE485 /DeviceCMYK f] 表示:當內容資料流寫入 0.8 scn 時,將濃淡值 0.8 傳送至函式 f 中,並繪製產生的 CMYK 四元組;DeviceN 透過 N 輸入的函式將此推廣到 N 種顏料;顏料名稱本身在螢幕上僅具備參考性質,濃淡轉換才是整個轉譯語義,因此僅解析空間但略過函式的轉譯器實際上還沒有執行任何有用的操作

HPDFEvalTintTransform 是該步驟背後的函式引擎;它在 v2.334.0 中引入,可對 Type 2 指數函式(具有 Domain 與 Range 限制的 C0 + x^N * (C1 - C0))、Type 3 拼接函式(具有 Encode 重新對應的 Bounds 選擇子函式遞迴),以及具有 8、16 和 32 位元取樣的 Type 0 取樣函式進行求值;Type 0 是我們在 建置 Type 0 色彩 LUT 的文章 中從編寫端介紹過的相同查表機制;轉譯端以相反方向遍歷相同的結構,從解碼的取樣位元組回到成分值

Type 4 PostScript 計算器函式是最後完成的部分;在 v2.375.0 之前,它們會正常降級為中性的預留位置;自 v2.375.0 起,HPDFEvalPostScriptCalculator 在受限的運算元堆疊上執行完整的 ISO 32000-1 §7.10.5 運算子集:算術、比較、布林與位元運算子、包括 roll 在內的堆疊操作,以及 if/ifelse 條件式;邊角語義比看起來更嚴格:PostScript 的 round 將半數向較大值四捨五入,因此不能使用 Delphi 的銀行家捨入法 Round;三角函數運算子以度為單位運作,其中 atan 傳回 [0, 360) 之間的值;而 exp 是雙運算元的乘冪,而不是自然指數;同一個評估器也驅動基於函式的漸層填滿,這就是為什麼 軸向與徑向著色轉譯 在同一個版本中獲得了計算器驅動的漸層坡度

// Evaluate a Separation/DeviceN tint transform (Type 0/2/3/4).
function HPDFEvalTintTransform(FuncObj: THPDFObject;
  const Inputs: THPDFColorComps; InputCount: Integer;
  out AltComps: THPDFColorComps): Boolean;

// Type 4 PostScript calculator, ISO 32000-1 7.10.5 operator set.
function HPDFEvalPostScriptCalculator(const Prog: TBytes;
  const Inputs: THPDFColorComps; InputCount: Integer;
  var Outputs: THPDFColorComps; OutCount: Integer): Boolean;

坦白面對精確度問題:以雙精確度求值的軟體計算器不會與 RIP 的位元完全一致,且在 Range 邊界處的限制值可能與其他實作相差一個最低有效位元;對於螢幕顯示和迴歸轉譯,這是無關緊要的;如果您正在建置色彩管理的打樣系統,濃淡轉換痕跡只是第一階段,您仍然需要在下游配備一個真正的 CMM

無 ICC 引擎的 CalGray、CalRGB、Lab 和 ICCBased 處理

基於 CIE 的家族則走同一個解析器的另一個分支;HotPDF 藉由標準的 Lab 到 XYZ 到 sRGB 鏈轉換 Lab 值,包括反向轉換函式中的 6/29 分界點立方,並處理具有每通道 gamma 加上 3x3 線性矩陣的 CalRGB,以及具有單一 gamma 的 CalGray;與效能相關的細節是白點處理:XYZ 到 sRGB 的轉換矩陣會根據色彩空間中宣告的白點進行 Bradford 適應,並快取在已解析的 THPDFColorSpace 紀錄中,因此不論宣告的光源有多奇特,像素級的工作仍然維持單一的 3x3 乘法

ICCBased 空間得到了刻意務實的處理;PDF 規格要求每個 ICCBased 資料流宣告一個 /Alternate 空間,或者透過其成分計數 /N 暗指一個空間,這正是為了讓沒有色彩管理引擎的檢視器仍然能夠合理地轉譯;HotPDF 藉由該替代空間解析 ICCBased,或者在該項目不存在時,根據 /N 為 1、3 或 4 來猜測 DeviceGray、DeviceRGB 或 DeviceCMYK,且從不解析設定檔位元組;這意味著沒有 lcms 相依性,也沒有設定檔尋找成本,代價是色度精確度:設定檔與其替代空間差異極大的 ICCBased 空間將會顯示替代的轉譯效果;對於螢幕檢視與縮圖而言,這是每個輕量級檢視器都會做出的相同折衷,也是在您自己的維護文件中應該清楚說明的限制

導致每次具名色彩空間尋找都失敗的錯誤

v2.333.0 運算子管道出貨時帶有一個缺陷,在 42 個版本中都未被發現:csCS 處理常式在尋找其運算元時保留了前導斜線(/CS0),而資源字典鍵的儲存則沒有斜線(CS0);每次具名色彩空間的尋找都會失敗,機率高達百分之百,且程式碼會默默地退回到預設的 DeviceGray;可見的症狀以最糟糕的方式保持隱蔽:Separation 填滿 1 scn 變成了 DeviceGray 1.0(即繪製白色),而白頁上的白色顏料並不是任何人會截圖的轉譯錯誤;在 v2.375.0 中的修正方法是在每次運算元到資源的尋找中套用共用的名稱標準化協助工具

同一項調查中還發現了兩個相關的缺陷;第一,陣列值物件的間接參照在傳回時未被解析:轉譯器的檔案存取僅具有針對資料流與字典的類型解析器,因此指向獨立 [/Separation ...] 陣列的 /CS0 5 0 R 會以原始參照形式傳回,且該空間被解析為不支援;第二,HPDFReadNumericArray 強制執行嚴格的長度語義,要求 PDF 陣列的長度至少要與提供的緩衝區一樣長;將 Type 2 函式的 /C0/C1 讀取至四元素緩衝區時,針對一成分與三成分的替代空間會失敗,導致兩個陣列皆保持零值,且每個非 CMYK 指數濃淡都會轉譯成黑色,直到 v2.376.0 引入了寬鬆的 HPDFReadNumericArrayUpTo 讀取器;可借鑑的教訓是:任何記載為持有可變長度數值陣列的 PDF 鍵都必須使用「填充現有內容」的讀取器來讀取,因為具有嚴格比對的固定大小緩衝區會將有效的檔案變成無聲的零值

如何測試特別色轉譯而不自欺欺人?

令人不安的問題是:為什麼測試套件在這一切過程中都保持綠燈;原始的迴歸測試 SeparationRendersDistinguishable 僅斷言轉譯的點陣圖不完全是黑色;將每個特別色都降級為 DeviceGray 的管線產生了灰色和白色的輸出,這不是黑色,因此當整個功能失效時,斷言仍然通過了;形如「非空白」、「不全黑」或「摘要值非零」的弱斷言無法區分運作中的轉譯器與損壞的轉譯器,因為幾乎任何失敗模式仍然會產生某些像素

真正能捕獲這些失敗的斷言樣式會鎖定預期的色彩:轉譯一個手動建立的極小 PDF,其 Separation 顏料會解析為已知的色調,然後計算在該色調中佔主導地位的像素;轉譯為點陣圖以供檢查時,使用的是 頁面到點陣圖轉譯指南 中描述的相同 RenderLoadedPageToBitmap 進入點

var
  Pdf: THotPDF;
  Bmp: TBitmap;
  X, Y, RedHits: Integer;
  Px: TColor;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('spot-red-fixture.pdf', '') > 0 then
    begin
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 96);
      try
        RedHits := 0;
        for Y := 0 to Bmp.Height - 1 do
          for X := 0 to Bmp.Width - 1 do
          begin
            Px := Bmp.Canvas.Pixels[X, Y];
            if (GetRValue(Px) > 180) and (GetGValue(Px) < 100) and
               (GetBValue(Px) < 100) then
              Inc(RedHits);
          end;
        // Lock the expected ink: demand a real area of red-dominant
        // pixels, never settle for "not all black".
        Assert(RedHits > 500);
      finally
        Bmp.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

測試配件 (fixture) 與斷言同等重要;一個長度僅幾百位元組、具有單個 Separation 填滿且無其他內容的手動建置 PDF,對於預期的輸出沒有任何歧義;真實世界的檔案會執行更多程式碼,但無法告訴您是哪個階段失敗的;我們現在將預期色彩像素計數視為任何轉譯冒煙測試的最低標準,因為它是唯一強迫這些缺陷暴露出來的斷言樣式

特別色與 CIE 色彩空間轉譯是 Delphi 與 C++Builder 的 HotPDF 元件 中已載入檔案管線的一部分,與函式引擎、著色轉譯和上方顯示的點陣圖匯出路徑並存