Technical Article

純 Delphi 中實現 OpenType GSUB 樣式替換字

設計師為標題選擇了帶有單層 a 的字型,或為表格選擇了斜線零,或為封面選擇了一組花體大寫字母。這些字形已經存在於字型中。它們只是不是預設值。預設的 a 從字元透過 cmap 表對應到一個字形,而替換字字形識別碼相隔不遠,只能透過替換規則存取。在 PDF 中產生該替換字意味著讀取該規則並在內容資料流中發送替代字形。本文介紹在沒有底層原生塑形函式庫的情況下,如何在 Object Pascal 中讀取這些單一替換類型的規則。

範圍是有意縮小的。樣式集和替換字是單個字形輸入、單個字形輸出的替換。它們是 OpenType 佈局中您可以使用小型、確定性的表遍歷來解析的部分,這使其非常適合想要保持免於 C 相依性的 Pascal 引擎。

為什麼選擇純 Delphi 而不是 HarfBuzz

HarfBuzz 是「塑造此文字」的顯而易見的答案,且對於完整的雙向、印度語或阿拉伯語塑形,它是正確的答案。它也是一個 C 函式庫。將其繫結到 Delphi 或 C++Builder 產品中意味著為每個目標平台和架構出貨一個原生權杖、匹配其呼叫約定、追蹤其發行步調,以及對照您自己的授權條款來閱讀其授權條款。單獨來看,這些都不難。所有這些都是永遠不會消失的摩擦,且當實際需求只是「給我這封信的 ss01 形式」時,它什麼也買不到。

單一替換不需要塑形引擎。它需要一個適用於少數 GSUB 子表格式的剖析器以及一兩個二進位搜尋。用 Pascal 編寫它可使整個工具鏈保留在一個編譯器中。誠實的局限是,這種方法處理字形替換查閱,不處理其他任何內容。它不是雙向解析,不是印度語重新排序,也不是自動上下文塑形。在需要這些的地方就是需要,而單一替換查詢無法替代它們。

GSUB 階層,從上到下

字形替換表被組織為間接鏈,且替換查詢從頂部遍歷該鏈。頂部是 ScriptList。諸如 latn 之類的指令碼標記會選擇一個項目,而特殊標記 DFLT 是在沒有更具體的指令碼相符時套用的預設指令碼。指令碼項目指向 LangSys(語言系統),其中包含適用於常見情況的預設 LangSys以及適用於需要不同行為的語言的可選具名項目。土耳其語是通常的例子,其中帶點和不帶點的 i 需要各自的處理。

LangSys 命名一組功能索引。每個索引都指向 FeatureList,其中功能記錄攜帶一個四位元組標記(其中包括 ss01)以及一個查閱索引清單。這些索引最終指向 LookupList,實際的替換子表存放在此處。因此,解析 ss01 意味著:尋找指令碼、尋找其 LangSys、尋找標記為 ss01 的功能、收集其命名的查閱,然後套用它們。HotPDF 預設為 DFLT 指令碼和預設 LangSys,這是絕大多數拉丁文字設計出貨的內容,並且當字型在特定指令碼下連接其功能時,它公開了一種覆寫指令碼標記的方法。

覆蓋表決定誰參與

每個替換子表都以相同的問題開始:此輸入字形是否參與此規則,如果是,它在規則自身的索引中位於何處。該問題由 Coverage 表回答,答案是覆蓋索引(一個小子序數),子表的其餘部分使用該索引來查閱字形變成什麼。

覆蓋範圍有兩種格式。格式 1 是按遞增順序排序的字形識別碼清單。您透過二進位搜尋尋找字形,且其在清單中的位置就是其覆蓋索引。格式 2 是範圍記錄的清單,每個記錄都有一個起始字形、一個結束字形,以及起始字形對應到的覆蓋索引。範圍內的字形藉由與範圍起點的偏移量來取得其覆蓋索引。當參與的字形分散時,格式 1 很緊湊,而當它們落入連續的執行單元時,格式 2 很緊湊。兩者都是排序過的,因此兩者都在對數時間內進行搜尋,且兩者都傳回覆蓋索引或乾淨的「未覆蓋」,從而讓引擎保留字形不變。

單一替換,兩種格式

單一替換是 LookupType 1,它將一個字形精確對應到一個替換。它也有兩種格式,且這種劃分是一種空間最佳化。格式 1 儲存單個有號差異值 (delta)。輸出字形識別碼是輸入字形識別碼加上該差異值對 65536 取模。這就是字型對替換進行編碼的方式,其中每個參與字形與其替換字字形都位於相同的固定偏移量處(例如,放置在與相符的舊式數字相距恆定距離的等高數字塊)。Coverage 表指出哪些字形符合條件,且單個差異值適用於所有這些字形。

格式 2 儲存替代字形識別碼的明確陣列。來自 Coverage 表的覆蓋索引是進入該陣列的索引,因此覆蓋索引 0 處的字形變為第一個陣列項目,覆蓋索引 1 變為第二個,依此類推。當替換字不處於統一偏移量時使用格式 2,這是手動建立樣式集的常見情況。無論哪種方式,從呼叫者的角度來看,查詢都是相同的。取得輸入字形,將其透過 Coverage 執行,如果它被覆蓋,套用差異值或讀取陣列插槽。

var
  Pdf: THotPDF;
  BaseGID, AltGID: Word;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Fonts\MyStylisticFace.ttf');
    Pdf.SetFont('My Stylistic Face', 12, []);

    // Default glyph for 'a' through the font's cmap.
    BaseGID := Pdf.GetUnicodeGlyphForCodepoint(Ord('a'));

    // Stylistic Set 1: resolve the alternate via GSUB LookupType 1.
    AltGID := Pdf.GetSingleSubstituteGlyph(BaseGID, 'ss01');

    // AltGID = BaseGID means the feature did not touch this glyph.
    if AltGID <> BaseGID then
      { emit AltGID in the content stream };
  finally
    Pdf.Free;
  end;
end;

值得注意的約定是直通 (pass-through)。GetSingleSubstituteGlyph 在每次未命中時都會原封不動地傳回輸入字形識別碼:無字型、無 GSUB 表、無相符功能、無覆蓋命中。這意味著該呼叫在無條件下執行是安全的。您要求替換字,如果沒有,您會得到與輸入完全相同的內容,因此呼叫程式碼絕不需要針對缺乏該功能的字型編寫特例。

樣式功能標記的意義

功能標記是您所要求的替換字的整個詞彙表,且與樣式工作相關的標記是一個簡短清單。最主要的一對是 salt(樣式替換字,存取字形替換形式的萬用入口)以及 ss01ss20(字型可以定義的二十個具名樣式集,每個都是設計師組合在一起的具名替換套件)。例如,字型可能將單層 a 和直腿 R 放在 ss03 下,因此啟用該單一集合會重新設計兩者的樣式。

圍繞這些的還有幾個單一替換標記。aalt 是 access-all-alternates,即字形所擁有的每個替換字的聯集,通常呈現為字形調色盤功能。titl 選擇為大尺寸裁切的標題大寫字母。subssups 交換真正的下標和上標數字,而不是縮小的預設值。ordn 產生序數形式,例如 1st 和 2nd 中的上升字母。frac 建立分數,儘管完整的對角線分數也依賴超出普通單一替換的連字和上下文邏輯。對於單字形情況,該機制與 ss01 相同:將標記傳遞給替換查詢並讀回替換字字形。

// Try a stylistic-set feature, then fall back to plain alternates.
function ResolveAlternate(Pdf: THotPDF; BaseGID: Word;
  const PreferredTag: AnsiString): Word;
begin
  Result := Pdf.GetSingleSubstituteGlyph(BaseGID, PreferredTag);
  if Result = BaseGID then
    Result := Pdf.GetSingleSubstituteGlyph(BaseGID, 'salt');
  // Still BaseGID if neither feature covers this glyph.
end;

cmap 格式 12 與輔助平面

在任何替換可以執行之前,字元必須變為字形,這是 cmap 表的工作。替換查詢從字形識別碼開始,因此路徑始終是字元透過 cmap 對應到字形,然後字形透過 GSUB 對應到替換字。cmap 的有趣之處在於其觸及範圍。格式 4 子表覆蓋基本多語言平面(前 65536 個碼點),這對於大多數拉丁文字已經足夠。它對於 U+10000 及以上的碼點(輔助平面,數學英數字元、許多符號和幾種活語文目前的所在地)是不夠的。

格式 12 是覆蓋完整 U+0000 至 U+10FFFF 範圍的子表。它是一個已排序的群組清單,每個群組都有一個起始碼點、一個結束碼點以及一個起始字形識別碼,因此連續的碼點執行單元會對應到連續的字形執行單元。HotPDF 採用與資料外觀相匹配的混合策略來解析碼點。BMP 中的碼點由透過碼點索引的直接陣列提供服務(單次查閱,無需搜尋)。輔助平面中的碼點由按碼點排序並使用二進位搜尋進行搜尋的稀疏表提供服務。結果是,GetUnicodeGlyphForCodepoint 接受完整的 Cardinal 並在整個範圍內正確回答,針對字型未對應的任何碼點傳回字形識別碼 0(即 .notdef 字形)。

var
  Pdf: THotPDF;
  Cp: Cardinal;
  GID, StyledGID: Word;
begin
  // A supplementary-plane code point: U+1D49C MATHEMATICAL SCRIPT CAPITAL A.
  Cp := $1D49C;
  GID := Pdf.GetUnicodeGlyphForCodepoint(Cp);  // format 12 lookup
  if GID <> 0 then
    StyledGID := Pdf.GetSingleSubstituteGlyph(GID, 'ss01')
  else
    StyledGID := 0;  // font has no glyph for this code point
end;

這些查詢在何處停止

單一替換 API 回答一種形式的問題,且值得明確指出它們不回答什麼。LookupType 1 是八種替換類型之一。查詢不處理 LookupType 2 複數替換(一個字形變為多個),也不處理 LookupType 4 連字替換(多個字形變為一個)。它不處理僅在字形出現在特定鄰近區域時才觸發的上下文和鏈結上下文類型(LookupType 5 和 6),也不處理擴充和反向鏈結類型。對角線分數、天城文字母組合或阿拉伯語初字-中字-終字串聯是一個順序問題,且每個字形的單一替換查閱無法表示它。

它也不執行自動塑形。此處沒有任何內容會檢查文字執行單元、決定開啟哪些功能,並按腳本要求的順序套用它們。呼叫者選擇功能標記並逐個字形地套用它。這正是樣式集和替換字(可選擇加入且為局部的)的正確工具,而對於需要重新排序的腳本則是完全錯誤的工具。保持邊界清晰正是使替換路徑保持小巧且可預測的原因。

對於確實需要順序級工作的情況,複雜腳本的故事將在我們關於 Delphi 中複雜腳本文字塑形的文章中展開。如果您的替換是較大報表工作的一部分,而該工作還在頁面上放置影像和其他字型,則字型與影像報表輸出指南會介紹這些部分如何組合在一起。所有這些都在同一個引擎上執行,即適用於 Delphi 和 C++Builder 的 HotPDF 元件,它攜帶 GSUB 替換查詢,以及本部落格其他地方介紹的字型內嵌、子集化和文字 API。