用一次普通的文字呼叫把咧嘴笑 emoji 寫進 PDF、字型用 Segoe UI Emoji,出來的是扁平的黑色剪影:形狀對,沒有黃色,沒有漸層。換成 CBDT emoji 字型,同一個呼叫可能什麼都畫不出來,因為那種字型把圖存成 PNG 檔,幾乎不帶外框資料。從檢視器把黑色形狀複製出去,剪貼簿裡仍然是 U+1F600:字元是對的,只是彩色表從頭到尾沒人讀過
為什麼彩色 emoji 在 PDF 裡印成黑色剪影?
因為 PDF 字型程式裡沒有「彩色字形」這個概念。ISO 32000-1 把每個字形當成用目前填色塗色的形狀,OpenType 後來加的彩色表(COLR/CPAL、SVG 、CBDT/CBLC 與 sbix)不屬於 PDF 成像模型,所以檢視器沒有任何理由去讀內嵌字型裡的這些表。emoji 的顏色必須在寫 PDF 的當下、在產生器還握著字型位元組、也確切知道要哪個字形的時候,就翻譯成頁面內容。每種格式的翻法都不同,而真實的 emoji 字型四種都用得到:堆疊的向量圖層、漸層塗繪圖、整份 SVG 文件與 PNG 點陣
HotPDF 在 THotPDF.DrawRegisteredColorGlyph 裡做這個翻譯。它以 THPDFOpenTypeColorFormat 回報字形解到了哪種格式,取值有 otcfNone、otcfCOLRv0、otcfCOLRv1、otcfCBDT、otcfSVG 與 otcfSBIX,並按固定順序探測字型:先 COLR,再 SVG,再 CBDT,最後 sbix。因此同時帶向量與點陣的字型會走向量那條路。對任何要放大或列印的東西來說,這都是對的順序
解析並繪製單一代碼點
THotPDF.GetRegisteredColorGlyphInfo 告訴你某個代碼點會走哪條路;DrawRegisteredColorGlyph 負責畫。兩者都在最近一次傳給 RegisterUnicodeTTF 的字型的字元映射表裡查這個代碼點,所以呼叫當下註冊的 Unicode 字型必須是那個 emoji 字型。字形沒有彩色資料、或沒有任何路徑畫得出來時,繪製函式回傳 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';
// 調色盤項目 0xFFFF(「文字前景」)會解到這個顏色
Pdf.ColorGlyphForegroundColor := clNavy;
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 調色盤,字型若附了深色背景用的調色盤,不必碰字形就能切換。調色盤項目 0xFFFF 在 OpenType 規格裡很特別:它代表「文字用什麼顏色我就是什麼顏色」,HotPDF 用 ColorGlyphForegroundColor 與 ColorGlyphForegroundAlpha 填它(預設黑與 255)。emoji 字型很少用到這個項目,圖示字型倒是常用;如果你的字型靠它著色,畫之前先把屬性設成你的文字顏色
TargetPixelsPerEm 只對點陣字型有意義,而它的預設值正是最值得覆寫的參數。留 0 時它變成 Round(FontSize * 96 / 72),一個螢幕解析度:36 點的 emoji 是 48 像素。字型若宣告了 20、32、40、48、64、96、160 ppem 的 sbix strike,這個預設會選中 48 像素那個 strike,600 dpi 印表機接手後得把它放大約六倍。開 300——36 點在 600 dpi 需要的值——同一個呼叫就會拿下 160 strike,字型裡最大的那個
簽章也把範圍定死了:一次進一個代碼點,經 cmap 映射。ZWJ 家庭組合、膚色修飾符與區域指示符旗幟都是 GSUB 連字,得先塑形成一個字形,那類工作在OpenType GSUB 替代字形一文裡
COLR 圖層與塗繪圖變成 PDF 向量
COLR v0 是簡單格式。每個基礎字形列出一疊圖層字形,各帶一個 CPAL 顏色,HotPDF 按表序填每一層的外框。CPAL 顏色 alpha 低於 255 的圖層會得到自己的 /ca,半透明圖層因此能與下層圖層按字型設計者的本意混合。一般文件裡,整疊圖層隨後被收進一個隔離的透明群組(ISO 32000-1 §11.4)。這個群組管的是字形外面的事:你在 emoji 周圍設的不透明度、混合模式或軟遮罩,只會對畫好的成品套用一次,而不是每層重複套用、在圖層交疊處加倍
COLR v1 是另一種動物:由塗繪記錄構成的有向非循環圖,帶漸層、變換、巢狀字形剪裁與合成模式,節點還能透過 PaintColrLayers 與 PaintColrGlyph 共用。HotPDF 先把它剖析成有界圖,上限 4096 個塗繪節點、96 層深度、1024 個色標,並在走訪每個節點時追蹤它,所以指回當前路徑的引用(惡意字型用圖層重用拼出的環)會被拒絕,而不是遞迴下去
手寫 COLR v1 剖析器最常折在位移上。BaseGlyphPaintRecord 的位移相對 BaseGlyphList 開頭,LayerList 的塗繪位移相對 LayerList,塗繪表裡的每個 Offset24 相對那個塗繪表自己。三種基準拿同一個去解,完全合法的字形也會過不了邊界檢查,看起來就像字型損毀。圖建好之後,對映到 PDF 大致直觀:
PaintGlyph把字形外框變成剪裁路徑,子塗繪填滿被剪裁的區域- 線性漸層變成多站點軸向 shading,放射漸層變成放射 shading(§8.7.4.5)
- 掃掠漸層沒有對應的 PDF shading 類型,因此以圍繞中心的 96 片純色扇形繪製,每片從色線取樣
- 變換變成
cm運算子,平移量乘上FontSize / UnitsPerEm PaintComposite模式 13 到 27 對映到同名 PDF 混合模式,/Multiply、/Screen、/Luminosity等(§11.3.5),經由 ExtGState 的/BM項目
帶半透明色標的漸層還藏了一個陷阱。COLR v1 以 alpha 預乘的方式插值顏色,PDF 期待的卻是直色、alpha 由它自己套用,把預乘值直接交給 PDF 的轉換器,會讓每一段半透明色帶暗兩次。顏色必須在插值之後、寫出之前先解除預乘
PDF/A-1 或 PDF/X-1a 之下,COLR v1 合成模式會怎樣?
PaintComposite 裡的 Porter-Duff 模式(5 到 12:src_in、dest_out、src_atop、xor、plus 及其同伴)沒有同名的 PDF 混合模式,照 PDF 規格最直觀的讀法,它們無法表達。這個讀法太悲觀。in/out 模式就是對一個運算元套 alpha 軟遮罩;atop、xor 與 plus 可以用隔離形式、群組 alpha、亮度遮罩與混合算術拼出來。HotPDF 就是這樣建構的,這些字形得以維持向量
代價是這些構造大多需要 PDF 透明度,而 PDF/A-1、PDF/X-1a 與 PDF/X-3 禁止透明度。當 PDFACompliance 是 PDF/A-1 的值,或 PDFXCompliance 是 'X-1a' 或 'X-3' 時,HotPDF 在繪製前檢查字形,把離開透明度就畫不出來的情況導向光柵事件:atop、xor 與 plus 合成;被遮罩運算元不只是簡單不透明塗繪的 in/out 合成;帶部分 alpha 的純色塗繪;帶半透明色標的漸層;以及 alpha 低於 255 的 COLR v0 圖層。被遮罩那一側是單純的純色或漸層時,in 與 out 模式維持向量,可以再包一層字形剪裁或變換,因為那種情況只需要不透明的灰階遮罩。StrictVersionLock 把檔案釘在 PDF 1.4 以下時,COLR v0 也比照辦理
Pdf.PDFACompliance := 'B'; // PDF/A-1b:不允許透明度
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF(EmojiFontPath);
if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F308,
72, 200, EmojiFontName, 36, 0, 300) then
// 沒有 OnColorGlyphRasterize 處理器時,半透明
// 或合成的字形在 PDF/A-1 下就停在這裡
LogMissingColorGlyph($1F308); // 你自己的記錄程序
歸檔非得有彩色 emoji 不可的話,請改以 PDF/A-2b 為目標,別停在 PDF/A-1b。Part 2 允許透明度,所有 COLR 構造維持向量,後備處理器退居安全網,不必替每個半透明 emoji 充當主力渲染器。工作流程指名要求 PDF/A-1 的,就維持 PDF/A-1
還有一個讀取端設定值得放在這裡講。HotPDF 在線性光混合空間裡合成 COLR v1 群組;有些渲染器在這種群組裡會丟掉很暗的通道,有些則完全無視群組混合空間,所以 COLRv1DeviceSpaceGroupBlend(預設 False)讓群組改在普通設備空間合成。取捨細節在COLR v1 重複與反射漸層一文;使用者實際在用的檢視器裡暗色半透明色調會消失的話,就把它打開
SVG 字形與 bitmap strike
SVG 字形走的是 HotPDF 給匯入 SVG 檔用的同一個安全建構器,結果註冊成 Form XObject(§8.10),詳情見SVG 轉 Form XObject 一文。SVG 表裡的文件可能經 gzip 壓縮。壓縮輸入上限 8 MB,解壓以 8 KB 為單位進行,展開後會超過 32 MB 就立刻停手。腳本、foreignObject、javascript: URL 與 @import 樣式表會讓建構器直接拒收文件。form 會縮放到長邊等於字號。上線前值得一試的事:有些字型把許多字形塞進同一份共用 SVG 文件,建構器每個字形都會收到整份文件
點陣字型歸結為 strike 選擇與擺放。CBDT 方面,HotPDF 挑垂直 ppem 最接近 TargetPixelsPerEm 的 CBLC 尺寸,接受影像格式 17、18、19,格式 19 的度量從 CBLC 索引子表讀取,因為那個格式自己一個都不存。sbix 方面,實際含有該字形的最接近 strike 勝出;strike 位移相對表,字形位移相對 strike。dupe 記錄重用別個字形的影像,但保留自己的原點位移,讓查找覆寫外層原點的剖析器會把影像挪歪。PNG 與 JPEG 負載在內部解碼,按 FontSize / PixelsPerEmY 縮放而不是拉伸到字號,只要任何像素不是完全不透明,就以軟遮罩(§11.6.5.3)寫出。sbix 裡的 TIFF 負載交給事件
撰寫光柵後備處理器
沒有任何原生路徑可用時,HotPDF 觸發 OnColorGlyphRasterize,並放置處理器回傳的 RGBA 點陣圖。沒有處理器,或 Handled 留在 False,DrawRegisteredColorGlyph 回傳 False,什麼都不畫。處理器會收到格式、字型位元組、抽出的資產(SVG 文件,可能還壓著 gzip;或點陣圖位元組;COLR 為空)、字形 ID、調色盤與目標像素尺寸
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
// alpha 為直色(非預乘)
Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;
// 接線
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;
頁面看到結果之前,HotPDF 先驗證:零尺寸、長度不是剛好 Width * Height * 4 的緩衝區,或大到會溢位的尺寸都會被拒,呼叫回傳 False。可變彩色字型的話,處理器拿到的是註冊軸值下的靜態實體;如果哪天收到的是原始可變字型,對 Sender(轉型成 THotPDF)呼叫 GetRegisteredVariableFontAxes 就能拿到該用的座標。PixelSize 就是那個 TargetPixelsPerEm 值,所以前面印刷解析度的建議在這裡同樣適用
印任意使用者文字的管線需要知道兩種損失:字型從來沒有的字形,以及失去顏色的字形。缺字追蹤一文的繪製期涵蓋率統計報告第一種;DrawRegisteredColorGlyph 回傳 False 報告第二種
COLR、SVG 與點陣 emoji 渲染、OpenType 塑形與安全 SVG 建構器,都是支援 Delphi 與 C++Builder 的HotPDF component 的一部分