技術文章

PDF 彩色 emoji 字型:COLR v1、SVG 與點陣圖(Delphi)

HotPDF 透過 THotPDF.DrawRegisteredColorGlyph 把彩色 emoji 畫進 PDF:它讀取以 RegisterUnicodeTTF 註冊的字型裡的彩色資料,輸出成原生 PDF 圖形,COLR v0 圖層變成填色字形輪廓,COLR v1 繪製圖變成剪裁、漸層與混合模式,SVG 字形變成 Form XObject,CBDT 或 sbix 點陣變成影像。無法原生映射的東西,交給 OnColorGlyphRasterize 事件,而不是無聲地變成一團黑形

最後那半句是這段程式碼存在的全部理由。用普通方式內嵌 emoji 字型,檢視器拿到的是 glyf 或 CFF 的輪廓,用當下填色隨便什麼顏色填滿。笑臉來的時候是一團黑,國旗是一個矩形,管線裡沒有任何東西抱怨

為什麼彩色 emoji 在 PDF 裡印成黑色剪影?

PDF 字型程式沒有彩色字形的概念。ISO 32000-1 把字形當成用當前顏色塗色的形狀,而 OpenType 後來加的那些彩色表,COLR/CPAL、SVG 、CBDT/CBLC 與 sbix,都不是 PDF 影像模型的一部分,所以沒有任何檢視器有義務從內嵌字型裡讀它們。顏色必須在產生階段就翻譯成頁面內容,趁製造端還握著字型位元組、還知道要哪個字形的時候。翻譯方式因格式而異,而野外的 emoji 字型五種都用:分層向量、漸層繪製圖、內嵌 SVG 文件與 PNG strike。HotPDF 把結果回報成 THPDFOpenTypeColorFormat,取值 otcfNone、otcfCOLRv0、otcfCOLRv1、otcfCBDT、otcfSVG 與 otcfSBIX,並以固定優先順序探測字型:先 COLR,再 SVG,再 CBDT,最後 sbix。字型同時帶向量與點陣時,向量贏,這正是可縮放、可列印的文件想要的

HotPDF 彩色字形探測示意圖:PDF 字型程式用當前顏色塗字形輪廓,OpenType 彩色表 COLR、SVG、CBDT 與 sbix 必須在產生階段翻譯成頁面內容,HotPDF 以固定優先序探測已註冊字型:COLR、SVG、CBDT、sbix,並以 otcfCOLRv0 到 otcfSBIX 回報 THPDFOpenTypeColorFormat
字型同時帶兩種時向量贏過點陣,可縮放、可列印的文件要的正是這個;沒有彩色路徑的字形留給您的後備處理

一個呼叫、五種格式:解析與繪製彩色字形

THotPDF.GetRegisteredColorGlyphInfo 回答某個碼位會走哪條路,DrawRegisteredColorGlyph 負責走。兩者都在最近一次傳給 RegisterUnicodeTTF 的字型的字元對映表裡查這個碼位,所以呼叫當下,彩色字型必須正是那個已註冊的 Unicode 字型。字形沒有彩色資料、或沒有任何路徑能渲染它時,繪製函式回傳 False,後備留給您

const
  FormatNames: array[THPDFOpenTypeColorFormat] of string =
    ('none', 'COLR v0', 'COLR v1', 'CBDT', 'SVG', 'sbix');
var
  Pdf: THotPDF;
  Info: THPDFOpenTypeColorGlyphInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'emoji.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600,CPAL 調色盤 0,最接近 300 ppem 的 bitmap strike
    if Pdf.GetRegisteredColorGlyphInfo($1F600, 0, 300, Info) then
      Writeln(Format('GID %d via %s',
        [Info.GlyphID, FormatNames[Info.Format]]));

    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F600,
      72, 144, 'Segoe UI Emoji', 36, 0, 300) then
    begin
      // 沒有彩色資料:退回單色輪廓
      Pdf.CurrentPage.SetFont('Segoe UI Emoji', [], 36, DEFAULT_CHARSET);
      Pdf.CurrentPage.TextOut(72, 144, 0, WideString(#$D83D#$DE00));
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

兩個參數值得注意。PaletteIndex 選 CPAL 調色盤,自帶深色背景調色盤的字型不必動字形就能切換。TargetPixelsPerEm 只對點陣字型有意義;留 0 時預設 Round(FontSize * 96 / 72),這是螢幕解析度,所以範例為列印輸出指定 300。誠實的極限寫在簽名裡:這個呼叫只吃一個碼位、只透過 cmap 映射。ZWJ 序列、膚色修飾符與區域指示符國旗是 GSUB 連字,組合它們是 shaping 的問題,屬於OpenType GSUB 替代字形那篇的範疇,這個入口不替您做

COLR v0:用調色盤顏色堆疊字形圖層

COLR v0 是簡單情況,HotPDF 直接渲染:每個基底字形列出圖層字形與 CPAL 顏色條目,每個圖層變成一次帶自己填色的普通文字顯示操作,按表格順序堆疊。alpha 低於 255 的圖層拿到一個帶對應 /ca 與 /CA 的圖形狀態參數字典(ISO 32000-1 §8.4.5),每個圖層字形都標記為已使用,子集化器因此保留它的輪廓,即使沒有任何碼位直接映射到它。有一個細節讓人意外:OpenType 規格裡,調色盤條目索引 0xFFFF 的意思是「用文字前景色」,而 HotPDF 把它解析成黑色,不是當前頁面填色。對 emoji 字型這很少要緊;對靠前景條目上色的 icon 字型,先檢查輸出,再假設它會跟著您的文字顏色

HotPDF 怎麼把 COLR v1 繪製圖變成 PDF 運算元?

先把繪製表剖析成扁平、有界的圖,然後才把每個節點映射成 PDF 構件。COLR v1 字形不是圖層清單,而是繪製記錄的有向無環圖,節點可以透過 PaintColrLayers 與 PaintColrGlyph 共享。剖析器設了上限:4096 個繪製節點、64 層深度、1024 個色標,並把每個節點標記為進行中或已完成,所以指回進行中節點的參照,惡意字型用圖層重複利用能造出的環,會被拒絕而不是遞迴進去。位移基準是第一版實作會栽的地方:BaseGlyphPaintRecord 的位移相對 BaseGlyphList 起點,LayerList 的繪製位移相對 LayerList,繪製表裡的每個 Offset24 相對那個繪製表自己。三個都對同一個基準解析,再合法的字形也會栽在邊界檢查上,而那看起來跟字型損壞一模一樣。圖建好之後,映射很直接:

  • PaintGlyph 把字形輪廓設成剪裁,文字渲染模式 7(ISO 32000-1 §9.3.6),然後把子節點畫在裡面
  • 純色繪製填滿一個已剪裁的矩形;線性漸層變成多色標的軸向 shading,放射漸層變成雙色的放射 shading(§8.7.4.5)
  • 掃掠漸層在 PDF 裡沒有對應物,HotPDF 用 96 片純色扇形逼近,每片從色線上取樣
  • 變換以 cm 發出,繞字形基線原點共軛,平移量按 FontSize / UnitsPerEm 縮放
  • PaintComposite 模式 13 到 27 映射到 PDF 的可分離與不可分離混合模式,如 /Multiply、/Screen 與 /Luminosity(§11.3.5),透過 ExtGState 的 /BM 條目設定

邊界寫得明明白白。Porter-Duff 模式 5 到 12(src_in、xor、plus 等)在 PDF 混合模式裡沒有對應;線性與放射漸層的 repeat、reflect 延伸模式不發出;色標帶不同 alpha 值的漸層,不拿單一透明度硬湊。超過兩個色標的放射漸層只保留首尾兩色。HotPDF 在寫出任何運算元之前,先把整個圖對照這個受支援子集檢查,所以不支援的字形讓頁面保持原樣、轉向光柵後備,而不是在頁上留下半張圖

HotPDF 的 COLR v1 轉換示意圖:繪製圖先剖析成有界圖,上限 4096 節點、64 層深度、1024 色標並拒絕環,然後 PaintGlyph 變成模式 7 剪裁,線性與放射漸層變成軸向與放射 shading,掃掠漸層變成 96 片扇形,PaintComposite 模式 13 到 27 變成 PDF 混合模式
第一個運算元寫出之前,整個圖先對照受支援子集檢查,不支援的字形讓頁面原封不動、轉向光柵後備,而不是留下半張圖

SVG 字形與 bitmap strike

SVG 字形走 HotPDF 給匯入 SVG 檔用的同一個有界建構器,結果註冊成 Form XObject(§8.10),SVG 轉 Form XObject 那篇講的就是它。SVG 表裡的文件可能是 gzip 壓縮的;解壓縮按 8 KB 分塊跑,展開後大小一旦要超過 32 MB 就停,而不是先膨脹完再檢查,壓縮輸入本身上限 8 MB。這個設定檔刻意嚴格:腳本、內嵌影像、外部 URL、data: URI 與非本地參照一律失敗關閉。Form 會縮放到長邊等於字型大小、錨在基線上,把 y 向下的 SVG 座標系映射到 y 向上的 PDF 座標系。注意建構器拿到的是字形所在的整份 SVG 文件,不挑 glyphNNN 元素,把很多字形打包進同一份共用文件的字型,值得先測再依賴

點陣字型的問題是選 strike 與擺位。CBDT 方面,HotPDF 挑垂直 ppem 最接近 TargetPixelsPerEm 的 CBLC 尺寸,接受影像格式 17、18 與 19,而格式 19 的度量從 CBLC 索引子表讀,因為那個格式自己不帶。sbix 方面,strike 位移相對表、字形位移相對 strike,dupe 記錄重用另一個字形的圖形、但保留自己的原點位移;讓遞迴覆寫外層原點,影像就會跑位。PNG 與 JPEG 負載在內部解碼,按 FontSize / PixelsPerEmY 縮放、而不是硬拉到字型大小,只要有任何像素不是完全不透明,就配一個軟遮罩(§11.6.5.3)寫出。sbix 的 TIFF 負載不解碼,直接送事件

字形無法原生繪製時會怎樣?

HotPDF 觸發 OnColorGlyphRasterize,放上您的事件處理器回傳的任何 RGBA 點陣;沒掛處理器、或處理器把 Handled 留在 false,DrawRegisteredColorGlyph 就回傳 False,頁面保持原樣。事件為三種情況觸發:落在受支援子集外的 COLR v1 圖、被安全建構器拒收的 SVG 文件、以及內部解碼器不讀的點陣負載。處理器拿到格式、原始字型位元組、抽取出的資產(SVG 文件,可能還帶著 gzip;或點陣位元組;COLR v1 為空)、字形 ID、調色盤與目標像素尺寸

HotPDF 光柵後備示意圖:落在受支援子集外的 COLR v1 圖、被安全建構器拒收的 SVG 文件或解碼器不讀的點陣負載會觸發 OnColorGlyphRasterize,傳入格式、字型位元組、資產、GlyphID、PaletteIndex 與 PixelSize,回傳的 RGBA 緩衝區只有長度恰好是 Width 乘 Height 乘 4 時才被接受
零尺寸、緩衝區長度不對或會溢位的尺寸,都在碰頁面之前被拒;沒有處理器或 Handled 為 false 時,呼叫回傳 False,頁面原封不動
type
  TEmojiFallback = class
  public
    procedure Rasterize(Sender: TObject;
      Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
      const AssetData: TBytes; GlyphID: Word;
      PaletteIndex, PixelSize: Integer;
      out Width, Height: Integer; out RGBA: TBytes;
      out Handled: Boolean);
  end;

procedure TEmojiFallback.Rasterize(Sender: TObject;
  Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
  const AssetData: TBytes; GlyphID: Word;
  PaletteIndex, PixelSize: Integer;
  out Width, Height: Integer; out RGBA: TBytes;
  out Handled: Boolean);
begin
  Width := 0;
  Height := 0;
  RGBA := nil;
  // RenderWithOwnEngine 是您自己的光柵化器,不是 HotPDF API。
  // 它必須回傳恰好 Width * Height * 4 位元組的 RGBA。
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// 接線
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF 在碰頁面之前先驗證處理器輸出:零尺寸、長度不是恰好 Width * Height * 4 的緩衝區、或大到會溢位的尺寸一律拒收,呼叫回傳 False。光柵後備仍然是光柵,這樣渲染的 emoji 失去向量銳利度;把 PixelSize 要到與輸出解析度相符。把彩色路徑跟缺字追蹤那篇的繪製時涵蓋檢查配對起來,處理任意使用者文字的管線就能同時回報缺字與丟了顏色的字

彩色字形渲染器、OpenType shaping 堆疊與安全 SVG 建構器,都隨 HotPDF Delphi PDF component 出貨,支援 Delphi 與 C++Builder