PDF 裡缺一個字形不是錯誤。產出者請求了所選字型無法對應的字元,字型回傳字形索引零,產出的檔案結構有效、到處能開,卻在姓名或金額該出現的地方顯示一個空框。產生管線裡沒有人發現,收件人發現了。HotPDF 用 TrackUnresolvedGlyphs 閉合這個迴圈:開啟它,文字繪製路徑就會記錄每個字形查詢解析為索引零的字碼點,對每個唯一發現觸發一次 OnUnresolvedGlyph,附上字碼點、失敗的字型、所屬的文字系統,以及能涵蓋它的字型建議
偵測只是答案的一半。另一半是 SetFontFallbackChain,它為每個文字系統註冊一份有序字型清單,讓常見情況自行解決,只有真正的缺口才會到達您的處理器。兩者合力,把過去由客戶回報的一類缺陷,變成建置期檢查
為什麼缺失字形不會引發任何錯誤?
因為 ISO 32000 沒有課予產出者驗證涵蓋的義務,而字形索引零是合法字形。它是 .notdef,外框由字型設計者決定:通常是空或空心的矩形,有時什麼都沒有。把它畫出來的檢視器行為正確。文字擷取甚至可能回傳正確字元,因為 /ToUnicode 對應是從來源文字寫出而不是從外框,所以自動來回檢查會愉快地通過一份可見文字有洞的文件
實際後果是:涵蓋必須在繪製當下檢查,那時程式庫還知道請求的是哪個字碼點、字型實際提供的是哪個字形。之後資訊就沒了
偵測器必須盯著子集狀態,而不是裝置內容
這是第一版實作出錯的地方,原因值得理解,因為它適用於任何加掛在文字管線上的涵蓋檢查。HotPDF 有兩條文字路徑。一條透過註冊的 Unicode TrueType 字型發出,註冊時就在記憶體中建好字元對應表。另一條是舊式 GDI 路徑,每個字元連續段都建立新的裝置內容與字型 handle
從 GDI 路徑判斷涵蓋是沒望的。它的對應不是最終進入輸出內容串流的那份對應,兩者也不同步,所以讀取 GDI 結果的偵測器會把整個可列印 ASCII 範圍都報成未解析。權威答案在註冊字型裡:RegisterUnicodeTTF 解析出的字元對應表,經 GetUnicodeGlyphForCodepoint 查詢。因此偵測器以子集就緒狀態為閘,而不是以任何 GDI 條件,並且在從未註冊 Unicode 字型的文件上乾脆不執行,這是正確的,因為那些文件本來就受限於標準編碼
旁邊還有一個陷阱。字型的 GDI 家族名稱與註冊時從字型二進位擷取的 PostScript 名稱是不同的字串,而且無法正規化:名為 Arial Unicode MS 的家族,PostScript 名稱是 ArialMT。任何以名稱比較、寫成「目前選中的字型是否為我們註冊的那個」的閘,都是永遠不會觸發的死程式碼。以狀態設閘,永遠不要以字型名稱
不要用 emoji 測試字形偵測器
顯而易見的測試案例是一張笑臉,而它會讓您相信偵測器壞了。增補平面的常見 emoji 字碼點,會經由把它們直接對應到字形索引的私用區合成路徑解析,所以永遠到不了一般涵蓋分支。偵測器行為正確,是測試量錯了路徑
改用未指派的字碼點。U+0378 在 Unicode 中永久未分配,沒有任何字型能合法對應它,而它恰好演練您想驗證的那個分支。「功能壞了」與「測試挑了一個繞過功能的輸入」之間的這個區別,要花掉真實的工時,而未指派字碼點是避免它的最便宜方式
type
TCoverageAudit = class
private
FFindings: TStringList;
public
procedure Handle(Sender: TObject;
const Info: THPDFUnresolvedGlyphInfo);
property Findings: TStringList read FFindings;
end;
procedure TCoverageAudit.Handle(Sender: TObject;
const Info: THPDFUnresolvedGlyphInfo);
begin
// 每個唯一字碼點只觸發一次,而不是每次出現都觸發
FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
[Info.CodePoint, String(Info.FontName), Ord(Info.Script),
String(Info.SuggestedFonts)]));
end;
// 把它接進一個產生文件的工作
Pdf := THotPDF.Create(nil);
try
Pdf.TrackUnresolvedGlyphs := True;
Pdf.OnUnresolvedGlyph := Audit.Handle;
Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
Pdf.EndDoc;
if Audit.Findings.Count > 0 then
// 讓工作失敗,而不是把畫滿方框的頁面出貨
raise Exception.Create(Audit.Findings.Text);
finally
Pdf.Free;
end;
回退鏈按文字系統劃分,而不是按字型
回退按文字系統而不是按來源字型劃分的原因,是涵蓋缺口按書寫系統聚集。一個拉丁文字型同時缺天城文、泰文、漢字與 emoji,而每者的替代字型都不同。因此每個文字系統一條鏈,描述的正是真實部署:一個拉丁字型做正文、一個 CJK 字型、一個 emoji 字型、一個兜底
// THPDFFontScript 涵蓋 hfsCommon、hfsLatin、hfsGreek、hfsCyrillic、
// hfsHebrew、hfsArabic、hfsIndic、hfsSoutheastAsian、hfsCJK、hfsKana、
// hfsHangul、hfsEmoji 與 hfsOther
Pdf.SetFontFallbackChain(hfsCJK,
['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);
回退與偵測是互補而不是二選一。鏈處理您預期到的涵蓋;偵測器回報您沒預期到的,而在處理任意客戶資料的系統上,後者才是有趣的一半。注意替換字型會改變度量,所以發生回退的段落可能重新換行;如果版面重要,替代字型的閉包與子集化行為值得在字型子集閉包文章中深讀,需要重排或連寫的文字系統由複雜文字塑形所述的塑形階段處理
如何在不冒現有路徑風險的前提下補上行為
同一版還為字對間距加了舊式 kern 表回退,它的劃分方式是一個值得複製的模式。它不在字距調整邏輯裡加新的決策點,而是掛在已存在的提前離開分支上——那個本來就為沒有 GPOS 表的字型準備的分支。帶 GPOS 的現代字型永遠到不了那裡,所以它的行為在構造上就沒有改變,而不需要靠測試保證。不註冊 Unicode 字型的路徑產出兩個零偏移,所以它們也沒有改變
這就是成熟渲染程式庫裡低風險補強的一般形狀:找到目前什麼都不產出的分支,把新行為放那裡。它把「我們相信這沒有造成任何退化」變成「這在構造上不可能造成任何退化」,對一個承載別人發票的文字引擎來說,後者是說得出口的話
讓它成為閘門,而不是日誌
涵蓋發現只有在有東西因此失敗時才有用。在產生文件的服務裡,有生產力的安排是在針對真實客戶姓名、地址與產品描述語料的每夜迴歸工作中保持追蹤開啟,並讓任何發現都使工作失敗。因為事件對每個唯一字碼點只觸發一次而不是每次出現都觸發,即使整個文字系統都缺失,輸出也小到讀得完
在正式環境,同一個處理器更適合作為遙測:記錄字碼點與字型,繼續供應文件,讓累計資料告訴您下一個該把哪個文字系統加進部署字型集。內嵌與替代字型的渲染行為在渲染內嵌字型字形中進一步涵蓋,包括 TrackUnresolvedGlyphs 在內的完整屬性清單記錄於 HotPDF Delphi PDF component 產品頁