技術文章

使用 HotPDF 在 Delphi 中轉譯內嵌 PDF 字型

HotPDF 在 Delphi 中轉譯內嵌 PDF 字型,無需在電腦上安裝任何東西:HPDFGlyphRender.pas 中的內嵌圖像轉譯管線會解析儲存在 PDF 本身內部的字型程式——來自 FontFile2 的 TrueType glyf 外框、來自 FontFile3 的 CFF Type 2 字元字串,以及 Type 3 圖像內容資料流——並將它們重新播放為填滿的 GDI 向量路徑;本篇文章是 使用 HotPDF 將 PDF 頁面轉譯為 TBitmap 背後的字型精確度深度探討:該部分涵蓋了轉譯器的整體結構,而本篇則涵蓋了這些頁面上的文字如何取得其確切形狀

為什麼 PDF 轉譯出來是方框而不是文字?

轉譯的 PDF 輸出中出現方框、空白或微小錯誤的字元,幾乎總是意味著轉譯器向作業系統要求了字型,而不是使用嵌入在檔案中的字型;這類客訴每次都以相同的方式出現:檔案在製作它的電腦上看起來很完美,然後客戶在乾淨的伺服器或受管制的桌面上開啟它,日文發票就會顯示豆腐字方塊,或者替換的相似字型導致每個換行都發生偏移;這些字型從未存在於該電腦上——僅存在於 PDF 內部——而僅止步於系統字型替換的轉譯器是無法看到它們的;子集字型讓情況變得更糟:子集可能在私下指派給該單一檔案的字元編碼下攜帶四十個圖像,而這項指派是任何已安裝字型都不共享的

ISO 32000-1 §9.9 定義了字型描述元中內嵌字型程式的三種載體:FontFile 包含原始的 Type 1 程式、FontFile2 包含 TrueType 程式,而 FontFile3 則包含裸 CFF(Type1CCIDFontType0C)或 OpenType 包裝器;第四種形式是 ISO 32000-1 §9.6.5 的 Type 3 字型,它完全不嵌入任何二進位內容——每個圖像都是一個在原地執行的微型 PDF 內容資料流;這三種載體在外框數學上有所不同(二次 B 樣條曲線與三次字元字串以及任意頁面運算子的對比),因此忠實的轉譯器需要為每種載體提供獨立的解譯器,以及一個在處理任何外框之前將字元編碼轉換為正確圖像索引的編碼層

HotPDF 如何將 TrueType glyf 外框轉換為 GDI 路徑?

HPDFGlyphRender.pas 中的 THPDFEmbeddedTTF 讀取 loca 表以定位每個圖像紀錄,逐點遍歷 glyf 輪廓,並發出 GDI 路徑;有兩個 TrueType 約定需要顯式處理;第一,連續的線外點(off-curve point)意味著在它們的中點有一個線上限(on-curve point),且點全部為線外點的輪廓會從其最後一點和第一點的中點開始——忽略其中任何一個規則,圓形圖像就會長出扁平的刻面或塌陷;第二,TrueType 曲線是二次貝茲曲線,而 GDI 的 PolyBezierTo 接受三次曲線,因此每個二次區段都是精確升階(degree-elevated),而不是被扁平化為線段

// Exact degree elevation: quadratic (P0, Q, P2) -> cubic (P0, C1, C2, P2)
// C1 = P0 + 2/3 (Q - P0),  C2 = P2 + 2/3 (Q - P2)
C1.X := P0.X + 2 * (Q.X - P0.X) / 3;
C1.Y := P0.Y + 2 * (Q.Y - P0.Y) / 3;
C2.X := P2.X + 2 * (Q.X - P2.X) / 3;
C2.Y := P2.Y + 2 * (Q.Y - P2.Y) / 3;
// then PolyBezierTo with C1, C2, P2 — geometrically identical curve

升階是無損的:三次曲線描繪出完全相同的曲線,因此轉譯後的外框在任何縮放比例下都與符合標準的檢視器從相同表中繪製的內容相符;其餘的工作是放置;每個圖像都是以字型單位(通常是 1000 或 2048 units-per-em 網格)編寫的,且轉譯器在填滿路徑之前,會將比例矩陣、文字矩陣和目前轉換矩陣組合成一個圖像到裝置的轉換矩陣;這裡的順序比看起來更重要:如果反向組合這三個相同的矩陣,每個圖像都會向原點塌陷——這會產生一個看起來錯誤的頁面,而其實際的錯誤只是矩陣代數中的一行程式碼

Type 2 字元字串解譯器如何處理 CFF 字型

THPDFEmbeddedCFFFontFile3 程式提供了一個真正的 Type 2 字元字串解譯器:它解析 CFF INDEX 結構、Top DICT 與 Private DICT,然後執行每個字元字串並將路徑區段直接輸出給 GDI;OpenType 包裝器(OTTO 容器)會先被剝離以獲取裸 CFF 表;裸 CIDFontType0CType1C 資料流則被直接使用;字元字串是一種緊湊的堆疊語言,其三項約定決定了解譯器是否能與位元組流保持同步;選用性的寬度前綴意味著第一個清除堆疊的運算子可能會攜帶一個額外的領先運算元;當運算元仍在堆疊上時,hintmask 運算子暗指一個 vstemhm,且要跳過的遮罩位元組數量取決於累積的字幹 (stem) 計數——只要算錯一次計數,後續的每個 opcode 都會被誤讀;此外,子常式呼叫在尋找之前會向其索引加入偏差值(依子常式數量而定,分別為 107、1131 或 32768),因此未調整偏差的呼叫會完全指向錯誤的子常式

以 CID 為鍵的 CFF 增加了一層會使簡單實現陷入困境的間接設定:字元編碼選擇了 CID,但字元字串索引是 GID,且字型的字元集將 GID 對應到 CID——因此轉譯器在繪製之前建置了反向 CID 到 GID 的對應,並針對攜帶多個 Private DICT 的字型透過 FDSelect 選擇每個圖像的 Private DICT;以名稱為鍵的 Type1C 程式(簡單 Type 1 字型的常用載體)則透過 CFF 程式的內建編碼或接下來描述 of PDF 層級編碼機制解析單位元組編碼;一項真實的限制:解譯器讀取微調 (hint) 運算子以保持資料流同步,但不執行微調,這項界限將在最後討論

什麼是 Type 3 字型以及它是如何繪製的?

Type 3 圖像根本不是外框——ISO 32000-1 §9.6.5 將其定義為內容資料流,因此 HotPDF 藉由推送圖形狀態、將字型矩陣、字型大小與文字矩陣組合至 CTM,並透過繪製頁面的同一個運算子解譯器執行圖像程序來轉譯它,且此時字型本身的 /Resources 處於作用域中;有兩個規格細節對正確性至關重要;Type 3 /Widths 是以圖像空間表示,而不是其他所有字型類型所使用的 1/1000 文字空間,因此前進量必須通過 /FontMatrix——否則具有 0.01 矩陣的條碼字型會產生一個數量級的步進錯誤;此外,以 d1 運算子開頭的圖像程序做出了兩項由轉譯器強制的承諾:繪製被剪裁至宣告的邊界框,且根據 ISO 32000-1 §9.6.5.2,圖像會忽略其自身的色彩運算子,並使用呼叫者的目前填滿色彩進行繪製,因此程式內部的 rggk 及其描邊對應項在該圖像持續期間會被抑制;若忽略色彩規則,被頁面蓋上藍色印記的 d1 條碼字型會變成黑色;若忽略剪裁,異常的圖像會繪製到其儲存格之外

字元編碼如何成為圖像 ID

外框解譯器只完成了工作的一半,因為 PDF 文字字串中的位元組是字元編碼,而不是圖像索引,且 ISO 32000-1 專門用了兩個子條款來描述此對應關係;對於簡單字型,§9.6.6 規定了嚴格的優先順序:/Differences 陣列會覆蓋基礎編碼(WinAnsiEncodingMacRomanEncodingStandardEncoding),而基礎編碼又會覆蓋字型程式本身的對應表;HotPDF 將該鏈解析為一個包含 256 個項目的編碼到 GID 對應表,並透過三種途徑將圖像名稱翻譯為圖像索引:CFF 程式內部的精確字元集比對、被視為字面索引的數值型 gNN/glyphNN 名稱,以及 Adobe Glyph List 名稱到 Unicode 翻譯,隨後對 TrueType 程式進行 cmap 尋找;對於複合字型,§9.7 由 CIDToGIDMap 主導:常見情況是 /Identity,但該項目也可能是由 CID 索引的大端序 (big-endian) 雙位元組配對資料流——而 HotPDF 本身的 Unicode 輸出正是對緊湊子集使用該資料流形式,因此該資料流路徑並非罕見的分支

// /CIDToGIDMap as a stream: big-endian Word pairs indexed by CID
if 2 * CID + 1 <= High(MapBytes) then
  GID := (MapBytes[2 * CID] shl 8) or MapBytes[2 * CID + 1]
else
  GID := 0;  // out of range maps to .notdef

當需要進行 TrueType cmap 尋找時,HotPDF 會遍歷後備鏈,而不是信任單一子表:Windows Unicode 子表(格式 4,然後是適用於增補平面的格式 12)排在最前,接著是 (3,0) 符號子表,其 F000 私用區 (private-use-area) 約定被鏡像至低位元組——這就是像 Wingdings 這樣的符號字型能響應純 ASCII 編碼的原因——然後是舊版格式 6 與 0;格式 2 子表刻意不進行解譯:它們對應 Shift-JIS 和 Big5 等傳統多位元組字碼頁,而不是 Unicode,且現代的 CJK fonts 無論如何都必定會攜帶格式 4 或格式 12 子表;任何在這些路徑中都無法解析的編碼都會針對該單一圖像退回至 GDI 繪製,因此一個無法對應的字元只會損及一個圖像,而不會損及整個文字段落

內嵌路徑不執行的操作

這些界限值得明確指出;不執行微調 (Hinting)——外框會按原樣填滿,這在 150 DPI 及以上與微調後的輸出沒有區別,但在非常小的尺寸下可能會與微調後的點陣化器相差一個像素;FontFile 中的原始 Type 1 程式(eexec 加密的字元字串)不進行解譯,且 OpenType 可變字型軸不予套用;這兩種情況(如同損毀的字型程式或沒有任何可用 cmapglyf 表)都會退回至系統字型繪製,而不是使頁面載入失敗;這種精確度優先的方法也延伸到了轉譯器的其他地方——軸向與徑向漸層著色模式 得到了與漸層相同的對待——且生成端在 EndDoc 如何排序字型子集 中也有其自身的字型細微特性

使用此管線完全不需要任何特定字型的程式碼——上述的每個機制都會在頁面轉譯呼叫中自動啟動

var
  Pdf: THotPDF;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('invoice-embedded-fonts.pdf') > 0 then
    begin
      // Embedded TrueType, CFF, and Type 3 fonts render from the
      // file itself — nothing needs to be installed on this machine
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
      if Bmp <> nil then
      try
        Bmp.SaveToFile('page1.bmp');
      finally
        Bmp.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

實際的效果是您的客服收件匣所關心的:一個攜帶字型的 PDF 將會使用 these 字型進行轉譯,無論是在建置伺服器、Windows 容器,還是在從未見過該字體的客戶桌面上;內嵌圖像轉譯管線隨 Delphi 與 C++Builder 的 HotPDF 元件 出貨,這是一個原生 VCL 函式庫,涵蓋 PDF 建立、編輯、文字擷取與頁面轉譯,無外部 DLL 相依性