技術文章

可插拔文字塑形:Delphi 中的 Uniscribe 與 HarfBuzz

PDFium 元件的文字塑形經過一個可安裝的物件。ConfigureTextShaper 安裝所有塑形進入點都會走經的塑形器,替換並釋放原本那個;ActiveTextShaper 回傳已安裝的塑形器,首次使用時建立平台預設;ActiveTextShaperName 回報目前活著的後端;ClearTextShaper 撤除安裝,讓預設可以重建。Windows 上的預設是 TPdfUniscribeTextShaper。Free Pascal 下則有 TPdfHarfBuzzTextShaper,它在執行期繫結 libharfbuzz,所以程式庫缺席是一個被回報的狀況,而不是載入失敗

PDFium Delphi 元件的可插拔文字塑形架構:ConfigureTextShaper、ActiveTextShaper 與 ClearTextShaper 管理單一已安裝後端,Windows 用 Uniscribe,Free Pascal 用執行期繫結的 HarfBuzz
每個塑形呼叫都走經單一已安裝的塑形器物件,每個目標各有一個平台預設

一個介面,兩個分工方式完全不同的後端。理解這個不對稱,才能讓可攜路徑不至於產出塑形正確、定位卻錯誤的文字

為什麼 Windows 後端是一個類別,可攜版卻是三塊?

因為 Uniscribe 是假裝成一個的四個 API。ScriptItemize 按文字系統把字串分段並解析雙向層級;ScriptShape 把字元對應到字形;ScriptPlace 計算前進量與偏移;ScriptLayout 把產生的文字段排進視覺順序。以它為基礎的後端沒有什麼可補,所以 Windows 塑形器是帶單一方法的單一類別

HarfBuzz 覆蓋中間兩步。它對呼叫端已決定方向與文字系統的 run 做塑形與定位,而對段落怎麼切成 run、run 以什麼順序出現,它沒有意見。所以可攜後端供應其餘部分:雙向演算法解析內嵌層級,HarfBuzz 的 Unicode 函式按文字系統切分文字,文字段按 UAX #9 規則 L2 產出的視覺順序排版。雙向那一半內容足以自成一個單元,見UAX #9 內嵌層級文章

PDF 文字的塑形管線比較:Uniscribe 在單一類別內供應 ScriptItemize、ScriptShape、ScriptPlace 與 ScriptLayout,HarfBuzz 只涵蓋塑形與定位,周圍是元件自己的 UAX #9 階段
Uniscribe 涵蓋全部四個階段;可攜路徑必須自行供應分段與視覺順序

塑形器不解析字型,這是刻意的

Uniscribe 從 GDI 裝置內容讀取字型二進位。可攜世界裡沒有對應物,而在塑形單元裡發明一個,等於替每個應用程式決定字型要來自 fontconfig、CoreText、應用程式字型資料夾,還是資料庫。所以 HarfBuzz 後端接受一個解析器:一個把字型名稱對應到 TrueType 或 OpenType 位元組的回呼。回傳 False 讓塑形請求失敗,與 Windows 上讀不到的 GDI 字型讓它失敗的方式相同

uses
  FPdfTextShaping
{$IFDEF FPC}
  , FPdfTextShapingHb
{$ENDIF}
  ;

function TFontCatalogue.Resolve(const FontName: WideString;
  out FontData: TBytes): Boolean;
var
  Path: string;
begin
  // 您的政策:fontconfig、CoreText、應用程式字型資料夾或資料庫
  Result := FLookup.TryGetValue(LowerCase(FontName), Path);
  if Result then
    FontData := TFile.ReadAllBytes(Path);
end;

procedure InstallShaper(Catalogue: TFontCatalogue);
begin
{$IFDEF FPC}
  // 所有權移交給單元;在啟動時呼叫一次,
  // 在任何文字塑形發生之前
  ConfigureTextShaper(TPdfHarfBuzzTextShaper.Create(Catalogue.Resolve));
{$ENDIF}
  // 在 Delphi 上,平台預設(Uniscribe)按需求建立,
  // 完全不需要安裝
  LogInfo('shaping backend: ' + ActiveTextShaperName);
end;

把字型探索留在塑形器之外還有第二個在伺服器上顯現的好處:同一個行程可以用與機器安裝無關的內嵌字型集塑形,正是輸出需要跨主機位元組級可重現時想要的。元件也為確實想用已安裝字型的場景暴露一個主機系統字型提供者,見系統字型提供者文章

結果記錄與後端無關,叢集是原因

兩個後端填的是同一個 TPdfShapedText:來源文字、字型名稱、大小、字型位元組、文字段陣列、總寬度、字形數與邏輯字元數。每個 TPdfShapedRun 攜帶它在來源文字中的區間、視覺 X 位置、寬度、雙向層級與由右至左旗標,外加它的字形。每個 TPdfShapedGlyph 攜帶字形識別碼、前進量、X 與 Y 偏移,以及它所屬的叢集——以來源文字中的起點與長度表示

那些叢集欄位正是讓記錄可用而不只是有資訊的原因。塑形不是一對一對應:一個天城文音節從四個字元變成一個字形,一個阿拉伯連字合併兩個,一個字元可以產出多個附加符號。沒有叢集區間,您無法放置游標、對點擊做命中測試、或標示選取範圍,因為您說不出一個字形屬於哪些字元。有了它們,算術是局部的,同一份程式碼對兩個後端都成立

TPdfShapedText 中的字形叢集區間:四個字元的天城文音節字形、兩個字元的阿拉伯連字、一個字元的基底加附加符號,各自經 ClusterStart 與 ClusterLength 對回
叢集區間把每個字形對回來源字元,游標、命中測試與選取才有得做
var
  Shaped: TPdfShapedText;
  R, G: Integer;
begin
  if ShapePdfText(Line, 'Noto Sans Arabic', 14, ptdAuto, Shaped) then
    for R := 0 to High(Shaped.Runs) do
    begin
      // 文字段到達時已是視覺順序,VisualX 已填好
      X := Shaped.Runs[R].VisualX;
      for G := 0 to High(Shaped.Runs[R].Glyphs) do
      begin
        EmitGlyph(Shaped.Runs[R].Glyphs[G].GlyphID,
          X + Shaped.Runs[R].Glyphs[G].OffsetX,
          Shaped.Runs[R].Glyphs[G].OffsetY);
        X := X + Shaped.Runs[R].Glyphs[G].Advance;
      end;
    end;
end;

預算屬於選項記錄

TPdfTextShapingOptions 攜帶一個方向加三個上限:最大字元數、最大字形數與最大文字段數,並以填入合理值的 Default 類別函式相伴。這些上限不是對畸形輸入的偏執,而是算術。塑形會膨脹:帶激進脈絡替代的字型可以發出比輸入字元更多的字形,每隔幾個字元就切換文字系統的段落,每次切換產生一個文字段。一份為把兩者同時放大而組裝的文件,能把一段普通字串變成一筆大配置,而處理來自不受信任 PDF 文字的服務,需要的是自己選的界限,而不是機器施加的界限

在已經知道方向的場合,明確設定方向而不是留在自動,值得做。自動套用段落方向規則、從第一個強字元猜測,這對自由文字是對的,對一個方向屬於欄位本身、而不是屬於別人填進去的值的表單欄位是錯的

執行期繫結,而不是建置相依

HarfBuzz 後端動態載入程式庫。這是帶真實後果的部署決策:同一個二進位檔在有 HarfBuzz 的機器與沒有的機器上都能跑,後者回報能力縮減而不是啟動失敗。對一個交付給其他開發者的程式庫,這是唯一可行的安排,因為您不能要求 PDF 元件的每個使用者都去取得並對齊版本一個他們可能根本不需要的塑形程式庫

對呼叫端的對應規則是檢查。平台沒有預設、也沒有人設定時,ActiveTextShaper 回傳 nil,塑形進入點把這個回報為塑形器不可用,而不是塑形失敗。兩者是不同的問題,值得不同的訊息:一個是部署缺口,另一個是字型或文字問題

安裝一次,在任何塑形之前

安裝會替換並釋放前一個塑形器,所以重複呼叫安全但無意義,而在另一個執行緒正在塑形時呼叫則完全不安。在啟動時做。如果之後需要回退到平台預設,傳 nil,這也是測試結束時拆除測試替身的方式

後端安裝後,量測與換行在兩個平台上行為一致,因為它們消費的是文字段與字形度量,而不是直接呼叫平台;換行模型在文字量測與自動換行文章中描述。元件支援的平台與工具鏈列於 PDFium Delphi component 產品頁