技術文章

PDF 中的網頁字型:Delphi 中的 WOFF2 與可變軸

PDF Library for Delphi 可直接接受網頁專案原本就已使用的字型。它能匯入 WOFF2 容器,把可變字型的設計軸以 JSON 形式列舉出來,並把選定的一組軸座標實例化為靜態字型,讓一般的內嵌、子集化與擷取路徑都能直接處理,完全不需要了解任何與變化軸相關的知識。TrueType 輪廓與 CFF2 輪廓皆涵蓋在內

這項功能要解決的情境既常見又惱人:品牌的字體排印定義在一套設計系統中,該設計系統以可變 WOFF2 檔案發布,而 PDF 產生器既讀不懂這個容器,也讀不懂變化資料。慣用的變通做法——請字型廠商為每一種實際用到的字重提供靜態 TTF 實例——往往要耗費數週時間,做出來的檔案也沒有人會持續同步更新

為何 WOFF2 不只是壓縮過的字型?

若把 WOFF2 當成一個 Brotli 包裝格式直接解壓縮,得到的位元組並不是一個有效的 sfnt。這種格式還允許表格經過轉換與重新排序:glyf、loca 與 hmtx 可能以重新排列過的形式儲存,必須先反向還原,字型才能被剖析。一般用途的解壓縮工具做不到這一點

在 Windows 上,作業系統平台自 Windows 10 版本 1703 起就提供了容器分析與解包功能,因此函式庫使用系統本身的實作,而不是自行附帶另一套解碼器。這項決定讓部署不必額外附加一個 DLL,也代表轉換還原是由作業系統自身文字堆疊所使用的同一套程式碼負責處理。在其他平台上,WOFF1 仍走純 Pascal 路徑,而 WOFF2 則會明確回報為不支援,這是一個誠實的答案,而不是一個悄悄損壞的字型

匯入路徑在解碼前後都會進行驗證:48 位元組的標頭、容器長度、保留欄位、宣告的 sfnt 大小,以及 512 MiB 的上限,全都會先行檢查;解碼後的串流大小,事後也會與宣告值進行比對。網頁字型本質上是下載來的內容,值得攔截的兩種失敗模式,就是大小不符與解壓縮炸彈

此管線示意圖顯示 Delphi PDF 函式庫如何透過標頭驗證、系統解碼、sfnt 轉換還原與解碼後大小稽核,匯入一個 WOFF2 網頁字型
WOFF2 容器本質上是下載來的內容,因此匯入過程會在解碼前後都驗證標頭與宣告的大小
uses
  PDFlibrary;

var
  Lib: TPDFlib;
  FontID, InstanceID: Integer;
  AxesJSON: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.SetPageSize('A4');
    Lib.NewPage;

    // WOFF2、WOFF1、TTF、OTF 與 TTC 全都透過同一個呼叫進入
    FontID := Lib.AddOpenTypeFontFromFile('Inter.woff2', 0);
    if FontID <= 0 then
      raise Exception.Create('font could not be imported');

    // 這個字型實際上能做什麼?
    if Lib.GetFontVariationAxes(FontID, AxesJSON) = 1 then
      Log(AxesJSON);   // 每個軸的標籤、最小值、預設值、最大值

    // 把一個設計位置烘焙成靜態字型
    InstanceID := Lib.CreateOpenTypeFontVariation(FontID,
      'wght=620,wdth=87.5');

    Lib.DrawOpenTypeText(56, 760, InstanceID, 24, 'Quarterly results', 0);
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

實例化必須重建什麼,又移除了什麼

可變字型為每個字形儲存一個預設輪廓,外加以設計空間中位置為索引的差值集合。要產生一個靜態實例,就必須在指定座標上內插這些差值、移動控制點,再重新計算所有從中推導出的內容

對 TrueType 輪廓而言,這代表要剖析 fvar、avar、gvar、HVAR 與 MVAR,以共用與私有的控制點編號套用元組差值,並對沒有明確差值的控制點進行內插。水平幻影點連同 HVAR,共同決定前進寬度與左側邊距。複合字形則必須先解析其元件相依關係與控制點對應,再套用平移、縮放、矩陣與附著偏移——同時設有深度上限與循環偵測,否則一個惡意的元件圖會無止盡地遞迴下去

中繼資料也需要一併更新,而這正是大多數實作會略過的部分。MVAR 會更新 hhea、OS/2 與 post 中的全域度量值,讓行距、底線位置與斜體角度符合實例,而不是預設值。wght、wdth、ital 與 slnt 這幾個軸還會進一步更新字重分類、字寬分類、選取旗標、macStyle 與斜體角度,讓檢視這個靜態字型的工具,回報的正是你所要求的樣式。一個幾何外觀是粗體、中繼資料卻寫著一般樣式的字型,會在下游各處被誤標

輸出接著會把複合輪廓攤平,並移除不再適用的表格:變化軸表、提示、裝置表以及任何簽章。字形邊界、hhea 摘要度量值、maxp 提示上限、各表校驗碼與整個字型的校驗碼,全部都會重新計算

此示意圖顯示可變字型實例化如何透過內插 gvar 差值、展開複合字形、重新計算前進量並改寫樣式中繼資料,重建一個 TrueType 字型面,最後再剝除變化軸表
實例化會重建差值所觸及的一切內容,再剝除變化軸機制,讓一般的內嵌流程只看到一個普通的靜態字型

CFF2 走不同的路徑,抵達相同的終點

CFF2 無法直接內嵌為一個 PDF 字型程式:它的 charstring 不帶寬度資訊,而是依賴周圍的 OpenType 表格來取得字形順序與度量值。因此實例化一個 CFF2 字型,產生的會是一個以 CID 為鍵值的 CFF OpenType 字型,而既有的繪製、內嵌與子集化路徑已經能夠處理這種格式

載入一個 CFF2 字型時,會立即為目前文件產生一個預設座標的靜態實例,同時原始的 CFF2 sfnt 仍會保留在字型物件上,讓軸列舉與之後的實例化仍能看到完整的設計空間。實例化過程中,每個字形都會被個別去子常式化,這會完全移除變化軸相關運算,讓既有的子集化工具無需修改即可運作。付出的代價是,靜態實例可能會比保留共用子常式的版本稍大一些——為了不必再實作第二套子集化機制,這是合理的代價

在信任一個實例之前先驗證它

實例化是那種可能以某種方式微妙出錯、卻不會有任何例外回報的功能。三項檢查幾乎能抓出所有問題

匯出這個實例,並在字型工具中開啟它。SaveFontToFile 會寫出保留下來的字型程式,因此一個記憶體中的實例,不必先產生 PDF 就能被匯出檢視。匯出的檔案應該能乾淨地被剖析、不含任何變化軸表,並回報你所要求的樣式中繼資料

在非預設位置比較前進量。若你的流程能以另一種方式產生相同的實例,就逐一比對每個字形的前進寬度與曲線邊界;本文所述的參考實作,與一套獨立實例化的雙軸、313 個字形字型比對後,每個字形的最大差異皆為零

並從產生出的 PDF 中把文字重新擷取出來。一個能內嵌、卻無法擷取出文字的實例,代表它遺失了 ToUnicode 對應,而這一點在視覺檢查通過很久之後,仍會影響搜尋與無障礙功能。更廣泛的擷取路徑,涵蓋於文字、影像與字型擷取一文

此示意圖顯示針對產生出的可變字型實例所做的三項驗證檢查:匯出字型檔案並檢視、比對字形前進量與獨立實例化結果,以及從 PDF 中重新擷取文字
一個微妙出錯的實例不會拋出任何例外,因此匯出的檔案、字形前進量與擷取出的文字,都需要依序逐一驗證
// 匯出一個已產生的實例,供檢視或重複使用
Lib.SaveFontToFile('Inter-620-87.ttf');

// 要求超出宣告範圍的座標時會被夾在範圍內,
// 因此請讀取軸的 JSON,而不要假設某個字型的限制
// {"axes":[{"tag":"wght","min":100,"default":400,"max":900}, ...]}

品牌字型流程的實務要點

依座標集合快取實例。每一組不同的座標,都是一個不同的字型程式,因此一份同時混用字重 400 與字重 620 的文件,會內嵌兩個子集,而不是一個共用的字型面。重複產生相同的實例是白費工夫;產生數十種幾乎相同的字重,則是白白浪費檔案大小

請用你實際要繪製的那個實例來測量。窄體實例的前進量比預設值窄,因此任何在實例化之前就先測量過的版面配置,測到的都是錯誤的寬度。這是「文字只在正式環境中溢出方框」這種問題最常見的單一原因

請為你的品牌字型缺少的字形保留一條備援路徑。可變品牌字型通常只涵蓋拉丁文,其他文字系統幾乎不涵蓋,一份含有西里爾字母客戶姓名的報告,否則就會顯示缺字符號;自動備援機制說明於遺失字形的自動字型備援一文。而當目標是縮小檔案,而非取得特定字重時,子集化所能發揮的效果,比實例化更大,說明於PDF 檔案大小最佳化與字型子集化一文

網頁字型匯入、可變字型實例化、子集化與備援,在 Delphi、C++Builder 與 Free Pascal 中同屬一套字型流程;完整功能清單請見PDF Library for Delphi 頁面