技術文章

PDFlibPas 自動字型後援:CJK 與 Emoji 缺字

PDFlibPas 會逐個叢集搜尋已安裝字型組成的後援鏈,解析所選字型無法繪製的字元,同時保留文字塑形與雙向文字流順序。啟用方式是呼叫 SetAutomaticFontFallback,並以 AddFontFallback 擴充後援鏈;只有實際用於輸出的後援字型才會被內嵌到檔案中

它所解決的問題,是每一套文件產生器遲早都會遇到的情境:客戶名稱以範本字型從未預期過的文字系統出現。這種失敗是無聲的,這也正是它代價高昂的原因

不支援的文字為何會消失而不是拋出錯誤?

因為 PDF 根本沒有「字型無法繪製某個字元」這種概念。簡單字型透過編碼把位元組碼對應到字型名稱;複合字型則透過 CMap 把碼位對應到字型索引。若要求一個字型不含的字符,得到的會是字型索引零,也就是 .notdef,大多數字型會把它畫成什麼都沒有,或是一個空白方框。檔案在結構上仍然有效,文字運算子格式正確,頁面也照樣渲染,只是本該出現名稱的地方是空白的

ISO 32000-1 並未要求產生器察覺這種情況。一個在不檢查涵蓋範圍就寫入文字的產生器,會輸出技術上合規、卻已悄悄遺失內容的 PDF,而這個遺失往往要等到數週後才會出現在客戶的螢幕上。這正是後援功能與缺字報告要一併出貨的原因:能解析的部分只完成了一半工作,把無法解析的部分回報出來才是另一半

後援以叢集為單位,而非個別碼位

粒度正是區分「能運作的實作」與「看似可行的實作」的關鍵細節。文字並不是一串各自獨立的字元。一個天城文字音節、一個帶膚色修飾符的 Emoji、一個帶組合符號的基礎字母:每一個都是必須由同一個字型繪製的單一叢集,因為其中的塑形決策取決於該字型內部的資料表

PDFlibPas 以叢集為單位解析,所以只要後援字型涵蓋某個叢集,該叢集就會完全由那個字型繪製。若在叢集中途拆分,一半用主要字型繪製、一半改用後援字型,得到的結果雖然技術上「有畫出東西」,視覺上卻是破碎的,某種程度上比一開始留白還糟。文字流順序同樣會被保留,因此右到左文字流中出現的後援字型不會打亂周圍文字的順序;同一套機制也支撐著 日文與中文直式書寫 中描述的直排版面

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetAutomaticFontFallback(1);

    // 搜尋順序:先比對到者優先,因此涵蓋範圍最廣的字型要放在最後
    Lib.AddFontFallback('Microsoft YaHei');   // 簡體中文
    Lib.AddFontFallback('Meiryo');            // 日文
    Lib.AddFontFallback('Segoe UI Symbol');
    Lib.AddFontFallback('Segoe UI Emoji');

    Lib.SetMissingGlyphPolicy(PDF_MISSING_GLYPH_REPORT);

    Lib.AddTrueTypeFont('Arial', 1);          // 1 = 內嵌字型
    Lib.SetTextSize(11);
    Lib.DrawText(72, 720, 'Invoice for 北京示例科技有限公司');
    Lib.DrawText(72, 700, 'Delivery status: on time');

    Lib.SaveToFile('invoice.pdf');
  finally
    Lib.Free;
  end;
end;

後援鏈的順序需要刻意安排。解析時會採用第一個涵蓋該叢集的字型,因此若把涵蓋範圍極廣的泛 Unicode 字型放在最前面,幾乎所有字元都會由它勝出,而你精心挑選的特定文字系統字型反而永遠不會被用到。應把特定用途的字型放前面,涵蓋範圍最廣的放最後

回報還是中止:你想要哪一種失敗?

SetMissingGlyphPolicy 可接受相容預設值 PDF_MISSING_GLYPH_REPORT,或是 PDF_MISSING_GLYPH_ABORT。在回報策略下,文字操作照常進行,無法解析的碼位一如既往被捨棄,同時每一個都會被記錄下來。在中止策略下,文字操作會在任何內容寫入之前就被拒絕,LastErrorCode 會被設為 521

選擇哪一種取決於文件的用途。一批內部報表應該持續渲染並記錄缺漏,因為今天略有缺漏的報表總比完全沒有報表要好。具法律效力的合約、發票,或任何載有姓名的文件,則應該中止,因為當事人姓名中悄悄遺失一個字元,是你希望在自家流程中就發現、而不是在爭議發生時才發現的缺陷。中止策略會在寫入之前就失敗,因此不會留下半成品的內容串流

var
  Lib: TPDFlib;
  Report: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetMissingGlyphPolicy(PDF_MISSING_GLYPH_ABORT);
    // ... 建構文件 ...

    if Lib.DrawText(72, 660, CustomerName) <> 1 then
      if Lib.LastErrorCode = PDFLIB_ERROR_MISSING_GLYPH then
      begin
        Report := Lib.GetMissingGlyphReportJSON;
        // {"valid":false,"policy":1,"eventCount":1,"events":[
        //   {"sequence":1,"documentIndex":0,"page":1,"utf16Index":12,
        //    "codePoint":21271,"unicode":"U+5317","fontName":"Arial",
        //    "fontType":"TrueType","operation":"DrawText"}]}
        EscalateToOperator(Report);
      end;
  finally
    Lib.Free;
  end;
end;

這份報告刻意設計成機器可讀且範圍有界。每一筆事件都記錄了頁碼、字串內的 UTF-16 索引、碼位的數值與 U+XXXX 兩種形式、被選中的字型、其類型以及觸發問題的操作,讓支援工單能夠指名確切的字元,而不是描述一個模糊的症狀。追蹤器只保留最近 256 筆事件,這個數量足以診斷一份文件,同時也小到不會讓一次異常執行把診斷功能變成記憶體問題

量測與繪製必須一致

寬度量測使用的是與繪製時相同、具叢集感知能力的後援決策。這聽起來理所當然,卻正是多數自製後援層最容易做錯的地方:它們只修補了繪製路徑,量測仍停留在主要字型上,結果每一個文字方塊、靠右對齊與表格欄位,最終都是根據與實際渲染結果不符的寬度計算出來的

因為兩條路徑共用同一套解析邏輯,一個在繪製前先量測過的字串,其占用寬度會與量測結果一致,包含後援字型所繪製的部分在內。這正是讓後援機制能夠全域啟用、而不必只在你手動稽核過的地方才啟用的原因

只有實際用到的字型才會被內嵌

後援字型是延遲內嵌的:後援鏈中若有字型從未解析過任何叢集,就完全不會出現在輸出中。一份文件若只含一個中文字與 5,000 個拉丁字元,並不會因此承載一整套完整的 CJK 字型;它承載的只是子集化步驟針對那一個字符所產生的結果,這正是 檔案大小最佳化與字型子集化 中所描述的行為

這種延遲特性讓設定一條涵蓋範圍很廣的後援鏈變得成本低廉。你可以把整個服務範圍內每個語系可能需要的字型都註冊進去,而每一份個別 PDF 只需要為它實際用到的部分付出成本。至於不是你自己產生的文件,其中缺少的字型早已存在於既有檔案內部,修復路徑則有所不同,詳見 在既有 PDF 中內嵌缺少的字型

有一個部署上的注意事項值得明講:後援機制是根據執行程式碼的機器上已安裝的字型來解析的。一台沒有安裝 CJK 字型的伺服器,將沒有任何字型可以後援,而報告會在第一份文件就告訴你這件事,不必等到第一次客訴才發現。務必隨部署一併提供你所依賴的字型,並確認內嵌這些字型的授權條件

PDFlibPas 是一套適用於 Delphi、C++Builder 與 Lazarus 的 PDF 函式庫,並提供對應的 DLL 與 ActiveX 介面,因此後援與缺字相關 API 同樣可供非 Pascal 呼叫端使用。完整文件請參見 PDFlibPas Delphi PDF 函式庫頁面