技術文章

字型子集閉包:Delphi PDF 中遺失的成形字符

當字型子集化工具只保留從已輸出碼位可抵達的字符時,經過成形(shaping)處理的字符就會顯示為 .notdef 方框。HotPDF 是 Delphi 與 C++Builder 適用的原生 VCL PDF 元件,在 2.435.0 版之前,正好帶有這個缺陷:OpenType GSUB 的輸出結果,被記錄進一個內部使用點陣圖中,子集化工具宣稱會遵循它,實際上卻從未讀取過它

這與 悄悄停用字型子集化的 EndDoc 錯誤 所描述的失敗不同。那個錯誤的重點在於子集化相對於序列化何時執行,而它會讓子集化整體失效。這次的問題則在於:當子集化按表操課、完全正常執行時,子集裡究竟包含了什麼。整個處理管線在正確的時機觸發,六字母的子集前綴也正確出現在 /BaseFont 上,完全符合 ISO 32000-1 §9.6.4 的要求,檔案也確實變小了,每一頁拉丁文都校對無誤,然而一頁阿拉伯文卻變成了一整排空白矩形。順序類錯誤一旦你去查看就會很明顯;閉包類錯誤卻會永遠保持沉默,因為子集在結構上是合法的,只是它自己的成員清單有誤

為何成形後的字符會顯示為 .notdef?

因為一份文件輸出的碼位集合,並不等於這份文件繪製出來的字符集合,而一個把兩者混為一談的子集化工具,會把所有由成形過程產生的字符全部捨棄。文字成形會把一段邏輯字元序列,轉換成一段已定位的字符序列,它整個存在的目的,正是為了產生沒有任何單一輸入字元能對應到的字符:一個阿拉伯文的字中形 heh、一個 fi 連字、一個天城文的複合字(conjunct),或是由 rclt 特性所選出的語境替代字符。這些每一個都是由 GSUB 查找表產生出來的字符 ID,而不是 cmap 表能為字串中任何字元直接給出的那種。因此,一個純粹以 cmap 為驅動的子集化工具,走訪的其實是錯誤的索引。它會忠實保留文字在成形之前可能用到的每一個字符,卻恰好捨棄了文字在成形之後真正用到的那些字符。渲染器接著向嵌入字型索取 GID 1847,子集卻已把 loca 裡對應那個條目歸零,於是回傳的是字符索引 0。依照 OpenType 定義,字符索引 0 就是 .notdef,這正是為何失敗的特徵是一個空白方框,而不是一個錯字或當機。PDF 裡沒有任何東西是格式錯誤的;字型只是單純不包含內容串流所要求的那個字符

碼位不是字符:子集的三個來源

一個正確的子集閉包,必須將三個獨立來源做聯集,且每個來源都有自己的累積器。第一個是由碼位推導出的集合:HotPDF 會在輸出 BMP 字元時累積 FUnicodeUsedCps,並為透過代理對(surrogate pair)抵達的輔助平面字元累積 FUnicodeSmpUsed,接著透過 FUnicodeCpToGid 把每一個都映射成一個字符 ID。第二個是由成形推導出的集合,也就是 GSUB 替換所產生的字符 ID,透過 MarkUnicodeGlyphUsedEnableShapingFeatureForSubset 記錄進 FUnicodeExtraUsedGlyphs。第三個是複合閉包:在 glyfnumberOfContours 為 -1 的字符,是由多個組成字符 ID 組裝而成,如果保留了複合字符本身,卻捨棄了它的組成部件,就會得到一個空的輪廓,而不是一個 .notdef,這可以說更糟糕,因為它讀起來像是一個間距錯誤

HotPDF 一直以來都有正確處理第一個與第三個來源。BuildAndApplyUnicodeFontSubsetEndDoc 在序列化之前呼叫的子集化進入點,它會用 GID 0 為已使用字符陣列做初始化,走訪 BMP 碼位、走訪 SMP 使用清單,再把陣列交給一個會在內部解析複合組件的子集建構器。第二個來源則是寫了程式碼、卻從未被消費使用,而由於這三個來源會在不同的內容上出錯,這個缺口能在一個回歸測試語料庫幾乎全是拉丁文的程式碼庫裡藏上好幾年

那個被寫入、卻從未被讀取的陣列

這份契約在三個地方都有文件記載,卻沒有一處被真正遵守。FUnicodeExtraUsedGlyphs 的宣告寫明,EndDoc 子集化工具會把它與由碼位推導出的使用情況做聯集;ApplyArabicGSUBRefinement 的標頭註解則承諾,每一個輸出的替代字符 GID 都會經過 MarkUnicodeGlyphUsed,好讓子集化工具把該字符拉進嵌入字型中;ApplyArabicGSUBContextualRefinement 針對 rclt 路徑,也一字不差地做出了相同的承諾。兩個呼叫端都各自履行了自己的那一半。用 grep 搜尋這個欄位的每一處參照,大約九十秒就能查清另一半的狀況:一處宣告、RegisterUnicodeTTF 裡的一次 SetLength 配置,以及兩個標記例程裡的寫入動作。沒有任何一處讀取。這是一個值得內化的診斷方法,因為它的適用範圍遠遠超出字型領域。當一個欄位被好幾個呼叫端寫入、卻沒有任何地方讀取它時,無論注釋寫得多麼詳盡,它所代表的功能都不存在。子集化工具的第一步小到能在一個螢幕內看完,一旦你知道該往哪裡找,這個缺口就顯而易見

// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
  UsedGlyphs[I] := False;
UsedGlyphs[0] := True;                       // .notdef is always present

for Cp := 0 to $FFFF do                      // source 1a: BMP code points
  if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
     and (Cp < Length(FUnicodeCpToGid)) then
  begin
    GID := FUnicodeCpToGid[Cp];
    if (GID > 0) and (GID < FUnicodeNumGlyphs) then
      UsedGlyphs[GID] := True;
  end;

for I := 0 to High(FUnicodeSmpUsed) do       // source 1b: SMP code points
begin
  GID := FUnicodeSmpUsed[I].GID;
  if (GID > 0) and (GID < FUnicodeNumGlyphs) then
    UsedGlyphs[GID] := True;
end;

// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs

一個迴圈就能修好,以及如何自行標記字符

這個修復方式是一次聯集運算,它的安全性論證來自操作的方向性:它只會設定位元,絕不會清除位元,所以原本能在子集中存活下來的字符,不會因此開始被捨棄

// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
  if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
    UsedGlyphs[I] := True;

有三個特性讓這項變更成為一次低風險的修改,而不是一次字型引擎的重寫。第一,如上所述,它是單調的。第二,對於從未做過任何成形處理的字型而言,它等同無操作,因為 FUnicodeExtraUsedGlyphs 會保持全為 False,純拉丁文文件的位元組輸出完全不受影響。第三,它落在第二步之前,所以兩個子集建構器都能繼承這項修復:一個是保留原始 GID 編號的稀疏建構器,另一個是 HotPDF 在 PDF/A 模式下選用的緊湊建構器 _BuildCompactSubsetTTF,它會把保留下來的字符重新編號為連續範圍、縮減 maxp.numGlyphs,並把新舊編號對照表輸出為 ISO 32000-1 §9.7.4.2 所要求的 /CIDToGIDMap 串流。兩者內部都會呼叫 _TTFWalkCompositeClosure,所以一個恰好是複合字符的成形字符,現在也會把它的組成部件一併帶入。複合閉包從來就沒有壞過;它只是從未對這些字符 ID 起作用過,因為這些字符 ID 根本不在它走訪的集合裡。如果你是直接驅動 GSUB 引擎,而不是依賴內建的精修流程,那麼閉包處理就變成你自己的責任,你所輸出的每一個替代字符 ID,都必須在 EndDoc 凍結已使用字符集合之前完成標記

EnableShapingFeatureForSubset 是單一字符呼叫的批次版本,設計上刻意保守。它會走訪 GSUB 查找清單中,繫結在目前所選 script 與 language 路徑下、對應某個四字元 feature tag 的那些查找項,並標記這些查找項可能產生的替代字符 ID。當字型不帶 GSUB 表、或該路徑不存在該 feature 時,它會是防禦性的空操作,因此無條件呼叫也是安全的。它在設計上同時是一種過度涵蓋:可能會保留某份文件實際上從未繪製的字符。就子集化而言,涵蓋過多只是耗費位元組,涵蓋不足卻會傷及正確性,這筆取捨很容易做。這些查找項的結構,以及決定哪些字符參與的涵蓋表,純 Delphi 環境下 GSUB stylistic alternates 完整解析一文已有說明

var
  Pdf: THotPDF;
  GIDs: array[0..1] of Word;
  LigGID: Word;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'shaped.pdf';
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
    Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
                            sfContextualAlternates];

    GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644);   // lam
    GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627);   // alef
    if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
      Pdf.MarkUnicodeGlyphUsed(LigGID);   // omit this and you get .notdef

    Pdf.EnableShapingFeatureForSubset('rclt');
    Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

你要如何證明該字符真的存在於子集中?

靠的是去讀取輸出出來的字型本身,而不是用肉眼看某個檢視器裡的頁面──那個檢視器可能已經在背地裡幫你換成系統字型了。能抓出這整類錯誤的檢查方式是機械式的:從輸出的 PDF 中擷取 /FontFile2 串流、解析 loca,並確認你預期的那個字符 ID 帶有一個非空的條目,也就是它的起始與結束偏移量不相同。一個空的條目,就是子集化工具已經判定該字符未被使用。此後養成兩個習慣,能讓這類失敗更難再度出貨。一是在自動化冒煙測試語料庫裡保留一頁成形文字的內容,而不是只放在人工校對集合中,因為阿拉伯文、天城文與高棉文所觸發的閉包路徑,再多的拉丁文覆蓋率也碰不到。二是只要存在一個累積器,就要斷言有東西真的在消費它,因為一個只寫不讀的欄位,是一項能編譯通過、在錯誤語料庫上測試綠燈、卻什麼也沒做的功能

這次修復到此為止,還沒解決什麼

子集閉包是成形字符得以渲染的必要條件,但並非充分條件。該字符還必須能從內容串流中被定址,這是另一個有自己邊界的獨立問題。HotPDF 內建的阿拉伯文精修流程,只有在每一個替代字符 ID 都能透過反向 cmap 掃描──掃描範圍大約涵蓋 U+FB50 到 U+FDFF 及 U+FE70 到 U+FEFF 這兩段共約 690 個碼位──經由某個 Unicode 呈現形式碼位抵達時,才會確認提交這次替換。當某個替代結果落在該範圍之外的字符 ID 上時,輸入視窗會原樣通過,而不會輸出讀取端無法定址的內容;任意字符 ID 上的字型專屬替代字符,則需要在 U+E000 到 U+F8FF 之間配置一個合成的私用區碼位,才能讓它們順利通過輸出路徑。所以老實說,2.435.0 版的這次修復,移除的是一個硬性阻礙,而不是把整個故事講完。在修復之前,一個字符可以被正確地成形、正確地輸出,卻仍然會在子集化階段憑空消失,這意味著無論其查找表寫得多好,整個成形引擎都無法被端到端地信任。剩下尚待解決的是可定址性問題,而這個限制至少會在輸出的那一刻就明顯失敗,而不是悄悄地失敗在你原本正在盯著看的一切之後才執行的某個建置步驟裡。若想了解這條管線輸出端的內容,請參閱 Delphi PDF 阿拉伯文與從右到左文字成形指南

本文所描述的字型子集化、GSUB 引擎與複雜文字成形功能,隨附於標準版 HotPDF Component(適用於 Delphi 與 C++Builder)之中;產品頁面提供上述 Unicode 字型與成形呼叫的完整 API 參考文件