技術文章

PDF 算繪中的混合模式群組與明度遮罩

HotPDF 算繪以非 Normal 混合模式繪製的 PDF 透明度群組時,讓群組自己算自己的混合:Cb(1 − α) + α·B(Cb, Cs),用的群組 alpha 是它追蹤的,或從黑底與白底兩遍還原的。自 v2.770.0 起,THotPDF.RenderColorimetricLuminosityMasks 屬性還能選擇明度 soft mask 讀顏色的方式:像常見檢視器那樣用加權 RGB,或像 ISO 32000-1 描述的那樣用 CIE Y

兩項改動都來自同一類檔案:設計工具匯出的頁面,產品照片底下墊著柔和的陰影。陰影是一個以透明度群組繪製的 Form XObject,帶 /BM /Multiply,淡出則靠明度 soft mask。在別的檢視器裡,這道陰影是淺淺的中性灰;在舊版 HotPDF 卻幾乎是黑的,還染上背景色。兩個互不相干的 bug 疊在一起,值得拆開來看,因為任何從「先畫不透明物件」設計長出來的算繪器都會同時踩到這兩個

Multiply 陰影為什麼會算繪得太深?

當算繪器拿群組合成後的結果去混合,而不是拿群組自己的顏色與 alpha,Multiply 陰影就會太深。那份結果的半透明像素裡已經含有背景,再拿去跟背景混合,等於讓背景自己乘自己。帶不透明度的 Normal 混合是線性的,犯這個錯看不出來;Multiply、Screen、Darken 與其他所有非線性模式就藏不住

HotPDF 的算繪管線最初用一個簡單快速的模型,按繪製物件處理混合模式。物件繪製之前,算繪器先擷下受影響像素的快照;繪製之後,比較「前」「後」兩張點陣圖,把「後」的顏色當成不透明的來源色 Cs,算出 B(Cb, Cs) 再以目前的不透明度合成。對填色路徑、字形或不透明影像來說,這完全正確:來源在它的形狀內真的不透明,而形狀正是像素變化的地方

透明度群組打破了這個假設。ISO 32000-1 §11.4 把群組定義成:先以自己的顏色、自己的 alpha 合成出自己的結果,然後才當成單一物件與背景混合。陰影 30% 不透明的地方,「後」的點陣圖裡大約是 0.3 的陰影色加 0.7 的底頁。把這個當成不透明來源丟進 Multiply,公式就變成背景乘以(一個已經含有背景的值):陰影加深,還染上背景的色相。什麼都不崩、沒有任何診斷響起,頁面就是看起來不對——這是算繪器最糟的失敗方式

HotPDF 怎麼讓透明度群組自己做混合

自 v2.769.3 起,HotPDF 把以非 Normal 混合模式繪製的群組的混合,從通用的逐物件路徑移進群組算繪器本身。群組直接用自己的顏色、自己的 alpha、以及為 Do 運算子擷下的背景快照,算出 Cb(1 − α) + α·B(Cb, Cs),然後把結果標成「已混合」,外層的 Do 就只用目前的不透明度合成它,不再混合第二次。普通物件保留快速的前後對比模型,Normal 混合下的群組也不受影響,因為線性情況從來就沒錯

難的是 alpha。隔離群組(/I true)從完全透明的背景出發,算繪器本來就為它追蹤一個獨立的 alpha 平面,可以直接讀 α。非隔離群組則合成到頁面上已有的東西上,自己沒有 alpha 平面。HotPDF 的還原辦法是把群組畫兩次,一次畫在黑底上、一次畫在白底上:

  • 畫在黑底上,每個像素最後是 α·Cs
  • 畫在白底上,同一像素最後是 α·Cs + (1 − α)
  • 相減得 α = 1 − (white − black),再得 Cs = black / α

這招成立,是因為算繪器執行的每一個 Normal 合成——包括替帶 soft mask 與抗鋸齒邊緣的影像執行的 GDI AlphaBlend——對背景都是線性的。不過這招有個前提:群組內容本身必須正常混合。如果群組自己的 ExtGState 資源設了非 Normal 的 /BM,兩遍之間就不再差一個乾淨的線性項,HotPDF 便不拆這個群組,退回原本的行為。這項檢查只涵蓋群組自己的資源,不含巢狀的 form。代價也是真的:這類頁面會稍慢,因為群組畫了兩次。群組內建出的 soft mask 會保留到兩遍都結束,所以第二遍直接命中遮罩快取,不必重建

// 逐通道還原 alpha 的示意(數值 0..1)。
// Black = 群組畫在黑底上,White = 畫在白底上。
procedure RecoverGroupPixel(Black, White: Double;
  out Alpha, Source: Double);
begin
  Alpha := 1.0 - (White - Black);
  if Alpha < 0.0 then Alpha := 0.0;
  if Alpha > 1.0 then Alpha := 1.0;
  if Alpha > 0.0 then
    Source := Black / Alpha   // 還原預乘
  else
    Source := 0.0;            // 完全透明:顏色無關緊要
end;

// Multiply 混合,再套群組合成式 Cb(1 - a) + a * B(Cb, Cs)
function CompositeMultiply(Backdrop, Source, Alpha: Double): Double;
begin
  Result := Backdrop * (1.0 - Alpha) + Alpha * (Backdrop * Source);
end;

上面的程式碼是數學示意,不是 HotPDF 的 API;算繪器在 THotPDF.RenderLoadedPageToBitmap 內部對每個通道、每個像素做的是同一件事。實務上,從兩遍 8 位元還原的 alpha 在趨近零時會失去精度,所以非常淡的邊緣帶有一些量化雜訊——這也是為什麼只有在 alpha 明顯大於零的地方,那個除法才有意義

群組內部的混合模式會重設為 Normal

混合模式和固定 alpha、soft mask 一樣,作用於透明度群組整體,而不是群組內的每一個物件,所以群組內容必須從 Normal 混合起步(ISO 32000-1 §11.6.6)。HotPDF 過去在進入群組時會重設不透明度與 soft mask,卻放著混合模式不動。隔離群組裡的後果很戲劇性:內容把自己乘進群組空空的初始背景——那是黑的——整個群組就畫成一片黑

建 soft mask 時也適用同樣的重設。如果同一個 ExtGState 同時設了 /BM /Multiply 與 /SMask,遮罩群組必須以 Normal 混合繪製;否則白色的遮罩群組會變黑,正好遮住遮罩本該揭示的東西。兩項重設都是 v2.769.3 的一部分,而且在簡單文件上完全看不出來——這就是它們能活這麼久的原因

中灰的明度 soft mask 該取什麼值?

照 ISO 32000-1,群組使用 CIE 基混合空間的明度 soft mask 應取 CIE 1931 Y,而 sRGB 中灰 0.5 的 Y 只有約 0.21,因為 Y 是線性光。常見的 PDF 檢視器則把群組顏色解析成 RGB,對編碼值大致按 0.30 / 0.59 / 0.11 加權,同一個灰算出來約 0.5。自 v2.770.0 起,HotPDF 預設採用與檢視器相容的加權,並提供 THotPDF.RenderColorimetricLuminosityMasks 給想要規格解讀的人

這個差距不是學術問題。設計工具匯出裡的淡出,通常是 ICCBased 或校正過群組裡的漸層,而中間調正是線性 Y 與 gamma 編碼灰階分歧最大的地方。用 CIE Y,中灰遮罩只放行約五分之一的陰影而不是一半,陰影與暗角就會明顯比客戶手邊所有其他檢視器都淡。對預設值來說,「按標準字面是對的、按所有人的眼睛是錯的」是筆壞交易,所以預設跟著檢視器走,嚴格模式只隔一個屬性

var
  Pdf: THotPDF;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('catalog-page.pdf') > 0 then
    begin
      // 預設(False):明度遮罩對解析出的顏色加權
      // 方式同 DeviceRGB,與常見檢視器的顯示一致。
      // True:遮罩群組帶 CIE 基空間時改取 CIE Y。
      Pdf.RenderColorimetricLuminosityMasks := False;
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 150);
      if Bmp <> nil then
      try
        Bmp.SaveToFile('catalog-page-viewer.bmp');
      finally
        Bmp.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

這個屬性只在遮罩群組的混合空間是 CIE 基時才有意義:CalGray、CalRGB、Lab 或 ICCBased。群組用 DeviceGray、DeviceRGB 或 DeviceCMYK 的遮罩,兩種設定下都與以前完全一樣,因為設備空間轉灰階在兩種模式裡是同一套

陷阱:/BC、/CS 與算繪快取

明度 soft mask 的一致性取決於它的背景:填滿群組之外遮罩區域的 /BC 顏色,必須用與群組內上色相同的規則轉換。HotPDF 在 v2.770.0 把兩者一起改了。上色用加權 RGB、背景卻用 CIE Y 轉換,會在群組包圍框的邊緣留下一道看得見的接縫——當背景與內容走的是不同的輔助函式時,這種 bug 很容易引入

在這一切發生之前,群組屬性本身也必須有效。HotPDF 從群組的 /CS 條目讀混合空間,並要求 /BC 為該空間的每個分量恰好帶一個數:RGB 基空間三個、CMYK 四個、灰階一個。/CS 缺失或 /BC 數量不對,遮罩會在群組執行之前就失效,上色內容隨後以無遮罩的方式繪出,而不是套一個用猜的背景。Separation、DeviceN 與 NChannel 不是合法的混合空間,所以遮罩群組裡的特別色上色會先經過 tint transform 轉到替代空間,才計算明度

最後,這個設定會改變像素,所以它屬於快取鍵的一部分。RenderColorimetricLuminosityMasks 開與關算繪出的點陣圖,在記憶體與磁碟兩個算繪快取裡分開存放,在呼叫 THotPDF.RenderLoadedPageToBitmapCached 之間切換屬性,也絕不會拿回另一種模式的點陣圖。一則維運備註:v2.769.3 引入過一個只在 Win64 上出現的迴歸,帶透明度群組、自己卻沒設 soft mask 的頁面會算繪失敗;已在 v2.770.1 修復,64 位元應用程式請用該版本或更新

// 並排比對:兩種模式各算繪同一頁一次。
// 每種模式各自有快取鍵,重複呼叫很便宜。
Pdf.RenderColorimetricLuminosityMasks := False;
ViewerBmp := Pdf.RenderLoadedPageToBitmapCached(PageIndex, 96);

Pdf.RenderColorimetricLuminosityMasks := True;
StrictBmp := Pdf.RenderLoadedPageToBitmapCached(PageIndex, 96);

// ... 兩張都顯示後釋放:快取呼叫傳回的是呼叫者所有的複本
ViewerBmp.Free;
StrictBmp.Free;

更完整的算繪 API——含 DPI 選擇、頁面快取與算繪器仍在近似的部分——見用 HotPDF 把 PDF 頁面算繪成 Delphi TBitmap。如果您的問題是反方向——為列印或封存而移除檔案裡的透明度,而不是繪製它——PDFlibPas 的 soft mask 與透明度平面化講的就是那一側

FAQ

HotPDF 能正確算繪透明度群組內的 PDF 混合模式嗎?

可以,自 v2.769.3 起。以 Multiply 或其他非 Normal 混合模式繪製的群組,會拿自己的顏色與 alpha 跟背景混合,群組內容則按 ISO 32000-1 §11.6.6 的要求從 Normal 混合起步

HotPDF 為什麼要把非隔離群組畫兩次?

非隔離群組自己沒有 alpha 平面。HotPDF 把它畫在黑底與白底上,以 1 − (white − black) 還原 alpha,代價是一些速度,換來正確的 Multiply 陰影

該把 RenderColorimetricLuminosityMasks 設成 True 嗎?

想跟常見 PDF 檢視器的顯示一致,就留在 False。只有當您需要 ISO 32000-1 對 CIE 基遮罩群組的 CIE Y 解讀時——例如合規比對——才設成 True

混合模式透明度群組、明度 soft mask 與繪出它們的頁面算繪器,都是 HotPDF Delphi Component 的一部分;這是給 Delphi 與 C++Builder 用的原生 VCL 函式庫,建立、編輯與算繪 PDF 文件都不需要外部 DLL