技術文章

COLRv1 repeat/reflect 漸層:Delphi PDF shading

HotPDF Component 把 COLRv1 的 repeat 與 reflect 漸層畫成向量 PDF shading,而不是把字形點陣化。幾何被拉伸到涵蓋整數個色彩線週期,每個週期各有一個關閉 Extend 的軸向或徑向 shading,顏色在線性光 CalRGB 裡內插,Porter-Duff 的 in/out 合成模式則用亮度軟遮罩表達

我們寫HotPDF 在 Delphi 中如何繪製 COLR v1 與 SVG 彩色 emoji 字型時,repeat 與 reflect 擴展模式還列在原生路徑不輸出的清單上,這類字形只能走向點陣回退。較新的 HotPDF 版本把它們畫成向量。走到這一步繞了好幾次遠路,多半起因於某個看起來很合理的 PDF 假設,事後證明在至少一款廣泛使用的閱讀器裡是錯的。本文把這些假設攤開講,因為任何把 CSS、SVG 或 OpenType 漸層映射到 PDF 的人都會撞上同一批

為什麼 PDF 閱讀器不能替您重複漸層?

PDF shading 沒有 repeat 或 reflect 模式,所以生產端得自己把每個週期鋪出來。ISO 32000-1 §8.7.4.5.3 與 §8.7.4.5.4 為軸向與徑向 shading 定義了 /Extend,它做的事只是把 t0 或 t1 處的顏色沿軸延續到端點之外。那正是 COLRv1 的 EXTEND_PAD,而且僅此而已。OpenType COLR v1 還定義了 EXTEND_REPEAT——參數每越過一個整數,色彩線就從頭來過——與 EXTEND_REFLECT——隔個週期反向跑一次

Delphi 中 HotPDF 的 COLRv1 到 PDF shading 對映:EXTEND_PAD 對上軸向與徑向 shading 的 /Extend 延續旗標,EXTEND_REPEAT 與 EXTEND_REFLECT 在 PDF 沒有對應物,必須用逐週期的 shading 幾何搭建
PDF shading 只能在 t0 與 t1 補色,所以 COLRv1 的 repeat 或 reflect 漸層得由生產端逐週期鋪設,而不是替閱讀器打個開關

HotPDF 的第一次嘗試想讓函式自己完成週期性工作。它註冊了一個域很寬的 Type 0 取樣函式(§7.10.2),25 個週期的跨度大約 [-12.73, 12.73],填入摺疊後的樣本,並打開 Extend。在第一個週期之內,最小測試檔渲染正確;出了週期,MuPDF 塗出一種平均色,Poppler 也好不到哪去。兩家閱讀器都沿 shading 參數求函式值並在軸外補色,沒有誰會回頭在擴展區查詢週期函式。寬域也是對規範的誤讀:shading 參數 t 原樣傳給函式,而按 §7.10.1,落在函式域外的輸入會被裁剪,所以域與 shading 的 /Domain(預設 [0 1])不匹配的函式,永遠只有一部分樣本被讀到

行得通的設計有三個部分,全部對照 MuPDF 與 Poppler 的渲染驗證過。Shading 幾何實際涵蓋整數 N 個週期,所以沒有任何像素依賴 Extend。取樣函式保持預設 [0 1] 域,與閱讀器實際傳入的值相符。樣本序列本身逐週期摺疊,每週期 256 個區間,所以函式自己裝著 repeat 或 reflect 的顏色資料,不指望任何閱讀器特性

把色彩線摺疊成 Type 0 樣本

摺疊的意思是每個週期對色彩線取樣一次,reflect 模式下奇數週期鏡射該序列。下面的輔助函式是純 Delphi,不是 HotPDF API,它做對了兩件天真的寫法會搞砸的事:在線性光裡內插而不是對儲存的 sRGB 位元組內插,以及把硬性停頓點(同一偏移上的兩個 stop)解析到遠側的顏色,這是 OpenType 與 CSS 的語意

uses
  System.SysUtils, System.Math;

type
  TColorStop = record
    Offset: Single;  // 單一週期內 0..1,升冪排序
    R, G, B: Byte;   // CPAL 風格 sRGB 位元組
  end;

function SRGBToLinear(V: Byte): Double;
var
  C: Double;
begin
  C := V / 255;
  if C <= 0.04045 then
    Result := C / 12.92
  else
    Result := Power((C + 0.055) / 1.055, 2.4);
end;

function LinearToSRGB(L: Double): Byte;
var
  C: Double;
begin
  L := EnsureRange(L, 0, 1);
  if L <= 0.0031308 then
    C := L * 12.92
  else
    C := 1.055 * Power(L, 1 / 2.4) - 0.055;
  Result := Round(C * 255);
end;

// 色彩線一個週期的 8 位元 RGB 三元組。Reverse = True
// 鏡射該週期,即 reflect 模式在奇數週期做的事
function SampleCycle(const Stops: array of TColorStop; Count: Integer;
  Reverse: Boolean): TBytes;
var
  I, S: Integer;
  T, F, Span: Double;
begin
  SetLength(Result, Count * 3);
  for I := 0 to Count - 1 do
  begin
    T := I / (Count - 1);
    if Reverse then
      T := 1 - T;
    S := 0;
    // >= 取硬性停頓點的後側
    while (S < High(Stops) - 1) and (T >= Stops[S + 1].Offset) do
      Inc(S);
    Span := Stops[S + 1].Offset - Stops[S].Offset;
    if Span > 0 then
      F := EnsureRange((T - Stops[S].Offset) / Span, 0, 1)
    else
      F := 1;
    Result[I * 3] := LinearToSRGB(SRGBToLinear(Stops[S].R) +
      (SRGBToLinear(Stops[S + 1].R) - SRGBToLinear(Stops[S].R)) * F);
    Result[I * 3 + 1] := LinearToSRGB(SRGBToLinear(Stops[S].G) +
      (SRGBToLinear(Stops[S + 1].G) - SRGBToLinear(Stops[S].G)) * F);
    Result[I * 3 + 2] := LinearToSRGB(SRGBToLinear(Stops[S].B) +
      (SRGBToLinear(Stops[S + 1].B) - SRGBToLinear(Stops[S].B)) * F);
  end;
end;

HotPDF 內部的 COLRv1 渲染器比這個示意走得更遠。它把 big-endian 16 位元線性樣本寫進 CalRGB 函式,把 alpha 存成獨立的 16 位元灰階樣本,而接下來展示的公開輔助收的是 8 位元 DeviceRGB 三元組,這也是示意碼轉回 sRGB 位元組的原因。想知道閱讀器拿這些樣本做什麼,作為 3D 色彩 LUT 的 Type 0 取樣函式一文詳述了內插模型

為什麼每個週期需要自己的 shading?

每個週期需要自己的 shading,是因為閱讀器不會精確求取樣函式的值;它們預先算好一張色彩表,而這張表會在週期收尾處跨過環繞點、從上一個週期的最後一色插到下一個週期的第一色。MuPDF 把 shading 的一維函式存成 256 項色彩表。把 11 個週期塞進一個 2816 樣本的函式,每個保存的樣本都可以是精確的,MuPDF 卻仍顯示 12 到 14 個層級的通道誤差,因為 256 個表項攤在整個跨度上,硬性重複接縫被抹成斜坡。Poppler 與同一檔案的解析內插都正確,所以這是閱讀器的精度極限,不是檔案錯誤,加樣本也救不了

Delphi 的 HotPDF COLRv1 repeat 漸層接縫示意:一個跨 11 週期的取樣函式會讓 MuPDF 的 256 項色彩表把每條重複接縫抹成斜坡,而每週期一個軸向 shading 讓每張表只待在單一週期內,保住硬性色彩跳變
閱讀器為每個 shading 預先算好固定色彩表,所以修法是幾何上的:每週期一個 shading、關掉 Extend,把重複接縫搬到任何表都不會跨越的邊界上

修法是讓每張閱讀器色彩表都待在一個週期之內。HotPDF 把週期性線性漸層輸出為每個原始週期一個獨立的軸向 shading,兩個 Extend 旗標都是 false,於是尾色到首色的跳變落在兩個 shading 之間的邊界上,而不是落在一張表內部。reflect 的奇偶判斷用原始帶號週期編號,包括色彩線起點之前的負週期,方向用調換 shading 走向解決,而不是重算樣本。所有週期畫進同一個線性光 isolated surface,繪製 alpha 只在最後套用一次,半透明漸層因此不會沿接縫把不透明度翻倍

公開的 THotPDF.RegisterSampledAxialGradient 與 THotPDF.RegisterSampledRadialGradient 方法把取樣 shading 積木暴露給您自己的繪圖。兩者在幾何參數之後都收 (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer),要求 PDF 1.3,Samples 不是恰好 SampleCount * 3 位元組就丟例外,並回傳一個供 THPDFPage.SetFillPattern 使用的 pattern 名稱。它們在兩端設 Extend,並把 Cycles 記成自訂的 /Cycles pattern 鍵,閱讀器會忽略它。想跟著一週期一 shading 的規則走,就每週期註冊一個 pattern,再用色帶矩形裁剪

// 由每週期一個 shading 組成的水平 repeat 或 reflect 漸層。
// Pattern 座標在預設 PDF 空間;水平軸讓這個範例
// 不依賴頁面自頂向下的 Y 慣例
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
  X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
  SamplesPerCycle = 257;  // 每週期 256 個區間
var
  Cycle, CycleCount: Integer;
  X0, X1: Single;
  Samples: TBytes;
  PatternName: AnsiString;
begin
  CycleCount := Ceil(Width / CycleLength);
  for Cycle := 0 to CycleCount - 1 do
  begin
    X0 := X + Cycle * CycleLength;
    X1 := X0 + CycleLength;
    Samples := SampleCycle(Stops, SamplesPerCycle, Reflect and Odd(Cycle));
    // Domain [0, 1]:shading 參數原樣抵達函式
    PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
      Samples, SamplesPerCycle, 1);
    if PatternName = '' then
      Exit;  // 文件版本鎖在 PDF 1.3 以下
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // 色帶把 shading 裁剪到恰好一個週期,Extend 因此永不現形
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

很長的跨度需要另一招,因為一週期一 shading 就是一週期一個資源。對週期性線性 COLRv1 漸層,HotPDF 改用倍增方案:一個葉表單持有單週期 repeat 或雙週期 reflect 的原語,每層 Form XObject 把上一層畫兩次、按寬度平移。表單數量隨跨度對數成長,閱讀器仍會畫出每個可見週期,相位對齊後覆蓋上限為 65536 個週期單位。更早的版本曾把原語放進 Type 1 平鋪 pattern,後來放棄了,因為閱讀器會把磚面間距對齊裝置網格,當週期不是整數個像素時 Poppler 會漂移出相位

為徑向圓族解出參數範圍

徑向漸層要蓋住字形,前提是週期範圍按正確的圓族計算,而 COLRv1 徑向繪製分屬三個數學不同的族。記 D = c1 - c0、dr = r1 - r0:嚴格嵌套族有 |dr| > |D|,非嵌套錐有 |D|² > dr²,相切族有 |D| = |dr|。HotPDF 從原始族的零半徑點起量參數,讓每個輸出圓的圓心與半徑都綁在同一個原始參數上,重新映射的負半徑因此絕不會被各自夾住

Delphi 的 HotPDF COLRv1 徑向漸層決策樹:比較 dr 與 D,區分出用二分法求解的嵌套圓族、用逐週期短徑向 shading 求解的錐族,以及化約為線性根 q = |W|^2 / (2(W*V)) 的相切族
|dr| 與 |D| 之間的不等式決定週期數學:嵌套族二分搜尋第一個碰到填充框的圓,錐族每週期一個短 shading,相切線留下一條透明的奇異帶
  • 嵌套族單調地與填充框相交,所以參數範圍來自四個角落加上一次二分搜尋,找出第一個碰到框的圓。最多 64 個週期以每週期 256 樣本取進一個 shading,上限 4096 樣本;更深的跨度改用逐週期切片
  • 錐與等半徑帶在原始週期邊界上用一個個短徑向 shading,各 257 個樣本且關閉 Extend,在 isolated、非 knockout 的群組內按原始參數順序合成,圓重疊處由最大的有效參數勝出
  • 相切族把圓方程式化約成線性。相對共用切點,根為 q = |W|² / (2·(W·V)),覆蓋區域是該點前方的開半平面

相切情況有一個貨真價實的奇點。當切線穿過填充框,要到達切線附近的像素所需的週期數是無界的,多少個 shading 都蓋不滿。HotPDF 從切點花掉 64 個週期的預算,剩下的細帶保持透明。當框的四個角落都落在切點之後,整個繪製就是透明的,字形其餘部分照畫。有限錐路徑最多接受 4096 個必需週期,覆蓋上限 1024 個切片,超出的部分不畫;一個 1024 切片的字形大約 1.8 MB

線性光色彩與兩位小數的陷阱

COLRv1 在線性光裡內插與合成,所以 HotPDF 用 sRGB 逆傳輸函式解碼每個 CPAL 位元組,並以單一 CalRGB 空間(D65 白點、sRGB 原色、/Gamma [1 1 1],§8.6.5.3)繪製純色、shading 與內部群組。只有完成的根表面才轉換到呼叫方的混合空間。對儲存的 sRGB 位元組直接內插,中點會明顯偏暗,這是每個漸層引擎都犯過一次的經典 gamma 錯誤

轉向線性光暴露了一個老格式化 bug。CPAL 通道暗值 12 在線性光約為 0.0036765,而 scn 背後共用的數字格式化器只留兩位小數,於是這個分量在任何閱讀器碰到檔案之前就被寫成 0。症狀看上去與閱讀器量化一模一樣,第一反應是怪 MuPDF 的 8 位元群組緩衝。填色與描邊分量現在走一個不使用指數的十進位格式化器,保住小值、無視程序 locale。教訓可以推廣:暗色消失時,先傾印內容流、檢查寫出的運算元,再去查閱讀器

修完之後閱讀器仍然要緊。MuPDF 1.28.2 在合成帶顯式混合色彩空間的 isolated 透明群組時會丟掉近零的線性通道,Poppler 26.07.0 的 Splash 則完全無視群組混合空間。選用的 THotPDF.COLRv1DeviceSpaceGroupBlend 屬性(預設 False)輸出不帶混合色彩空間的 COLRv1 群組,讓它們在繼承的裝置空間裡合成,而內容顏色、shading 與函式維持線性光 CalRGB。代價是,在確實尊重群組色彩空間的閱讀器裡,重疊的半透明層改在裝置空間混合

哪些 COLRv1 合成模式 PDF 能精確表達?

HotPDF 原生渲染 COLRv1 合成模式 5 到 8(SRC_IN、DEST_IN、SRC_OUT、DEST_OUT),模式 9 到 12(SRC_ATOP、DEST_ATOP、XOR、PLUS)則送 OnColorGlyphRasterize。in 與 out 模式是一個表面與另一個表面 alpha 的乘積,這正是軟遮罩計算的東西(§11.6.5)。存活的運算元以普通 form 捕捉;被遮罩的運算元在 alpha pass 裡以扁平內容捕捉進一個 isolated 的 DeviceGray 群組,用作帶 /BC [0] 的 /Luminosity 遮罩。out 模式對每個 alpha 寫 1 − α,並在遮罩內容底下墊一層不透明白色蓋板,存活者在另一運算元從未著色的地方保持可見

其餘四個模式需要兩個表面的預乘相加。PDF 的 source-over 模型裡,背景項永遠被來源的 (1 − α) 衰減,也沒有哪個原語能把兩個表面相加、或只按來源覆蓋率縮放背景,所以 atop、xor 與 plus 寫不成向量。還有一條限制來自裸測試 PDF:兩家閱讀器都會算錯內容巢狀 form Do 的軟遮罩群組,所以被遮罩的運算元必須是扁平的。純色、pad 漸層與包著它們的 PaintGlyph 包裝符合資格;層、變換與週期漸層都會產生內部 form,字形因此送往回退

var
  Pdf: THotPDF;
  Fallback: TEmojiFallback;  // 事件處理類別,簽名見下方
begin
  Pdf := THotPDF.Create(nil);
  Fallback := TEmojiFallback.Create;
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'periodic-icons.pdf';
    // 選用:為 MuPDF 與 Poppler 啟用裝置空間群組合成
    Pdf.COLRv1DeviceSpaceGroupBlend := True;
    // 接收 atop/xor/plus 字形與子集以外的一切:
    // (Sender; Format; const FontBytes, AssetData: TBytes; GlyphID: Word;
    //  PaletteIndex, PixelSize: Integer; out Width, Height: Integer;
    //  out RGBA: TBytes; out Handled: Boolean)
    Pdf.OnColorGlyphRasterize := Fallback.Rasterize;
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('Fonts\GradientIcons.ttf');
    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $E001,
      72, 144, 'Gradient Icons', 48) then
      Writeln('U+E001 was neither drawn natively nor rasterized');
    Pdf.EndDoc;
  finally
    Fallback.Free;
    Pdf.Free;
  end;
end;

向量路徑仍然不及之處

向量輸出不等於每家閱讀器像素一致,HotPDF 的驗收記錄把這點寫得明明白白。離週期邊界一兩步以內的樣本會被閱讀器內插抹平,所以像素檢查避開接縫取樣,函式流改用解碼方式驗證。分開儲存的直 alpha RGB 與 alpha 樣本的內插,在低 alpha 端點與硬性停頓點附近是近似;實測誤差在每週期 256 樣本下是二階的,但不是精確。Poppler Splash 在像素左緣而不是中心取樣,最陡的斜坡上會現出小差異

預算也是真實的極限。嵌套徑向族超過 4096 週期跨度就婉拒,錐與相切族超過 4096 個必需週期也一樣,奇異線附近的切線帶保持空白。漸變的 stop alpha 需要 PDF 1.4 透明度,所以在 PDF/A-1、PDF/X-1a 與 PDF/X-3 之下不啟用。字形落在任何一條限制之外時,DrawRegisteredColorGlyph 在寫出任何運算子之前婉拒整張圖,與之前的全有或全無行為一致

FAQ

HotPDF 會把 COLRv1 repeat 與 reflect 漸層點陣化嗎?

不會。較新的 HotPDF 版本把它們輸出為涵蓋整週期的向量軸向與徑向 shading。只有支援子集之外的圖,例如 atop、xor 與 plus 合成,才送 OnColorGlyphRasterize

為什麼不給所有週期用一個取樣函式?

閱讀器會從函式預先算出一張色彩表,MuPDF 的表有 256 項。攤到許多週期上,它會在每條重複接縫跨越內插。一週期一 shading 讓每張表只待在單一週期裡

什麼時候該開 COLRv1DeviceSpaceGroupBlend?

當 COLRv1 字形裡的近黑通道在 MuPDF 中掉到 0 時就打開。若重疊的半透明層必須在尊重群組色彩空間的閱讀器裡以線性光混合,就別開

原生 COLRv1 漸層、彩色 emoji、取樣 shading 與透明群組都是HotPDF Component for Delphi and C++Builder 的一部分,它直接從 Delphi 與 C++Builder 程式碼產生 PDF 文件