技術文章

用 HotPDF 在 Delphi 中將字型子集快取於磁碟

同一份範本、同一套 TrueType 字面算繪出來的兩張發票,只差在客戶的姓,就會在字型子集快取裡留下兩個各自獨立的檔案。這個姓帶來一個大寫 Q 和一個小寫 z,是第一張發票從沒用過的,於是保留的字形集合變了,子集跟著變,快取鍵也跟著變。當你文件的字形組合會重複出現時,持久性子集快取才值得開;它暴露的計數器能讓你在一個批次之內就知道自己屬不屬於這種情況

HotPDF 四個字型子集快取鍵圖解:字型內容、字形集合、子集模式與結構描述版本;只差一個名稱的兩張發票落入不同項目
只差一個客戶名稱的兩張發票就是兩個快取項目 — 只有文件共用同一份字形庫時,快取才划算

在 HotPDF 中開啟磁碟快取

在你給 HotPDF 一個資料夾之前,子集快取是關著的。THotPDF 上有兩個屬性控制它:FontSubsetCacheFolder 指定目錄,FontSubsetCacheMaxBytes 設上限,預設 268435456 位元組(256 MiB)。只有資料夾非空、額度大於零時快取才會生效。指派資料夾時若目錄不存在會順手建立,並立刻把既存內容修剪到額度以內

子集化值得快取,因為它是實打實的工作。建構一個 TrueType 子集,要走訪複合字形閉包、改寫 loca 與 glyf、重建為字形編索引的資料表,並產出 PDF 能定址的 CID 對映。對單一文件,這份成本淹沒在雜訊裡。對一個在迴圈裡用同樣三套字面算繪幾千份對帳單的報表伺服器,同樣的子集被一遍遍重建,而這正是快取還給你的 CPU 時間

var
  Pdf: THotPDF;
  Info: THPDFFontSubsetCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.EnableFontSubsetting := True;
    // 純 ASCII 路徑:這個屬性是 AnsiString
    Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
    Pdf.FontSubsetCacheMaxBytes := 64 * 1024 * 1024;   // 64 MiB,不用 256 MiB 預設值
    // ... 批次裡每份文件各做一次 BeginDoc / 繪製 / EndDoc ...
    Info := Pdf.GetFontSubsetCacheInfo;
    // LogFmt 是你自己的記錄常式
    LogFmt('subset cache: %d hits, %d misses, %d bytes in %d files',
      [Info.HitCount, Info.MissCount, Info.CurrentBytes, Info.FileCount]);
  finally
    Pdf.Free;
  end;
end;

快取躲在兩條子集化路徑背後:透過 EnableFontSubsetting(涵蓋 TrueType 與 OpenType-CFF 字型)做子集的字面,以及用 RegisterUnicodeTTF 註冊的字面。繪圖程式碼其他部分什麼都不用改。資料夾路徑宣告為 AnsiString,所以目錄名稱帶系統字碼頁以外的字符是在自找麻煩;ProgramData 底下或服務資料目錄裡一個純 ASCII 路徑,直接繞開整個問題

快取鍵裡放了什麼

四個輸入決定一份儲存的子集能否重用:來源字型的確切位元組、文件保留的字形集合、子集模式,以及快取格式版本。每個鍵由字型位元組的 SHA-256 雜湊、加上已用字形 ID 位元圖的 SHA-256 雜湊組成,字首 v1-,稀疏子集字尾 -s、精簡子集字尾 -c。項目以 <key>.hpdfsubset 落在磁碟上,所以目錄清單一眼就能看出你的批次產出的是哪種模式

雜湊字型內容而不是檔名,有一個實際後果:升級 Arial、或換進較新版的企業字面,會自動產生新鍵,舊項目只是不再被命中,直到驅逐把它們移走。字型更新之後你不必清快取也能拿到正確輸出;清快取只是早點把空間要回來

子集模式是大家會忘的那個輸入。CompactFontSubsetting 把保留的字形重新編號成連續範圍,並寫出明確的 /CIDToGIDMap(ISO 32000-1 §9.7.4.2),而設了 PDFACompliance 時,註冊字面一律拿到精簡子集。CompressDocument 也會在該文件的存續期間打開精簡子集。所以同一份對帳單一次寫成普通 PDF、一次寫成 PDF/A 的服務,會為相同的字形填兩組項目,一組字尾 -s、一組 -c。OpenType-CFF 字面一律走稀疏路徑

命中率在字形集合這一關見分曉。固定範本產出的對帳單、變數資料多半是數字的表單、從同一個產品資料庫畫出來的型錄:這些工作負載一份份文件重複用同一批字形。每份文件都從上萬字形的 CJK 字面撈不同切片的工作負載幾乎全落空,因為幾乎每份文件的位元圖都是新的。先跑一輪有代表性的批次、讀一讀計數器,再判斷自己屬於哪一種

你怎麼知道快取真的有作用?

GetFontSubsetCacheInfo 回傳一筆九個欄位的 THPDFFontSubsetCacheInfo 記錄,HitCount 對 MissCount 直接回答這個問題。WriteCount 與 EvictionCount 顯示項目活得夠不夠久、能不能被重用,還是被過緊的額度擠出去。CurrentBytes 與 FileCount 描述資料夾此刻的狀態

HotPDF 的九個 GetFontSubsetCacheInfo 計數器面板:分組為命中率、存活與失敗計數,其中三種讀值會指名該調整的設定
HitCount 對 MissCount 的比值回答快取到底有沒有在做事,其餘計數器則說明為什麼沒有

把這些接進監控時有兩個細節要留意。七個事件計數器屬於 THotPDF 實例,元件建立時歸零,所以每份文件建一個元件的服務,每次讀值只看到一份文件的活動量。CurrentBytes 與 FileCount 相反,是呼叫當下枚舉資料夾得來的,因此反映的是每個在用這個資料夾的行程,也讓每次呼叫都付出一次目錄掃描的代價。每批結束時讀一次記錄就好,別把它放進每份文件的迴圈

剩下三個計數器才值得設警示。CorruptCount 計數載入時未通過驗證而被刪除的項目,每個也同時計一次 miss。斷電後出現零星幾個是正常的,持續涓涓流出就該懷疑儲存本身。RejectedCount 計數快取拒收的子集,典型原因是單一子集比整個額度還大。WriteFailureCount 計數沒能完成的寫入,實務上幾乎都是服務帳戶寫不進資料夾。三者都不會中斷文件產生,所以有寫入權限問題的快取,產出的 PDF 跟完全沒有快取時一模一樣——只是永遠不會變快

// SubsetCacheNeedsAttention 是你自己加的輔助函式;
// 門檻只是範例,請照你的批次大小調校
function SubsetCacheNeedsAttention(const Info: THPDFFontSubsetCacheInfo;
  out Reason: string): Boolean;
begin
  Reason := '';
  if Info.WriteFailureCount > 0 then
    Reason := 'cache folder is not writable for this account'
  else if Info.RejectedCount > Info.WriteCount then
    Reason := 'subsets exceed FontSubsetCacheMaxBytes, raise the budget'
  else if (Info.EvictionCount > Info.WriteCount div 2) and (Info.HitCount < Info.MissCount) then
    Reason := 'entries are evicted before they are reused'
  else if Info.CorruptCount > 10 then
    Reason := 'repeated damaged entries, check the storage';
  Result := Reason <> '';
end;

額度、驅逐,以及那個會清空資料夾的開關

驅逐按檔案修改時間做最近最少使用。每次成功載入都會碰一下項目的時間戳,每次寫入之後都跟著一次修剪,最舊的檔案先走,直到資料夾重新塞進額度。執行期調降 FontSubsetCacheMaxBytes 會當場修剪而不是等下一次寫入,所以對磁碟空間警報做出反應的服務,能在它決定的那一刻就縮減快取

這種即時性有一個利刃面。資料夾已設定的情況下把額度設為零,會修剪到零,資料夾裡每個 .hpdfsubset 檔都被刪掉。把 FontSubsetCacheFolder 設成空字串則是關掉快取,檔案原封不動留在原地。兩個開關都會讓下一份文件不再快取;只有其中一個保得住一整週的溫熱項目。排障時,清空資料夾屬性、額度別動

// 診斷跑一輪時暫停快取,保留每一個已儲存項目
Pdf.FontSubsetCacheFolder := '';

// 危險:資料夾已設定時,額度為零會把資料夾修剪到一無所有
// Pdf.FontSubsetCacheMaxBytes := 0;

// 刻意的重設:刪除 *.hpdfsubset 與殘留的暫存檔,
// 事件計數器保留其數值
Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
Pdf.ClearFontSubsetCache;

把快取暫停跑一輪診斷,是排除它最乾淨的辦法。/BaseFont 裡那個六字元子集標籤由保留的字形集合衍生,所以相同字形無論有沒有快取都產出相同標籤,內嵌的字型程式應該一致。整檔雜湊仍會不同,因為建立日期與文件 ID 會變,所以要字流對字流比,不要整檔比

受損或寫到一半的項目會怎樣?

受損項目會被刪除、子集照常重建,這些都發生在該項目的任何內容進到 PDF 串流之前。載入時 HotPDF 檢查檔案簽章、格式版本、子集模式旗標、字形數對來源字型的一致性、宣告長度對實際檔案大小,以及所存字型程式的 sfnt 標頭。任何一項不符就丟掉項目、CorruptCount 加一,退回記憶體內子集化。把壞掉的字型程式塞進客戶的 PDF,問題會在幾週後浮現在別人機器上的某個閱讀器裡,遠離成因——這正是這套驗證要防的失敗

HotPDF 受損快取項目處理流程圖:使用前先驗證、在任何 PDF 串流之前拒絕並移除、退回記憶體內子集化
損壞的項目採失敗開放策略 — 文件照樣產出;PDF/A 輸出仍保留快取,因為項目留有 CID 重映射

寫到一半的項目根本不會出現。寫入先落在暫存檔,檔名帶行程 ID 與元件實例,然後以單次 replace-and-flush 改名蓋到最終名稱上。寫到一半當機會留下一個遊離的 .tmp- 檔,ClearFontSubsetCache 會把它掃掉,而真正的項目不是完整的舊版就是完整的新版。精簡項目還會把舊到新的字形對照存在字型程式旁,所以快取的精簡子集能重建出同一份 /CIDToGIDMap,PDF/A 輸出不必繞過快取就能使用它

資料夾該放在哪裡?

三個條件決定這件事:服務帳戶必須寫得進去、資料夾應該在本機儲存上、部署流程不得把它清掉。網路共用把每次查找變成一次往返,還把改名語義交到檔案伺服器手裡。安裝程式會重建的應用程式目錄裡的資料夾,讓快取每次更新後都冷啟動,悄悄在你最緊盯效能的那天抹掉全部好處

多個元件、多個行程可以共用一個本機資料夾。暫存檔名永不碰撞,最終改名是原子的,輸掉競態的代價不過是一次 miss 或一次得在下一份文件重試的寫入。在同一部機器上共用是較好的預設,因為每個實例都在替其他實例溫熱快取。唯一看得見的副作用是:各實例的計數器只描述自己的流量,CurrentBytes 與 FileCount 卻描述所有人,而偶爾跳一下的 WriteFailureCount 可能只是兩個行程同時在替換同一個項目。唯讀資料夾仍能供應既有項目;只是無法更新它們的時間戳、也加不進新項目

什麼時候快取不是那把對的槓桿

快取移除的是重複的工作。它對批次裡的第一份文件毫無幫助,對字形集合從不重複的工作負載也幫助有限。如果輸出被一套用在無法預測文字上的大型 CJK 字面主宰,該看的是每個子集撈進了哪些字形、為什麼,字型子集封閉集合與塑形字符 的筆記講的就是這件事。如果目標是檔案大小而不是 CPU,CompressDocument 與精簡字型子集 一文解釋精簡模式對內嵌字型動了什麼手腳。當批次慢的原因根本不在字型,帶字型與圖片的報表輸出 逐步解說會告訴你其餘時間通常花在哪裡,EndDoc 字型子集排序 一文則講子集在文件收尾階段的建置時機

子集快取就住在元件裡,Delphi 或 C++Builder 報表伺服器只要設一個資料夾路徑與一個額度就能用上,不必多部署或監控任何附加服務。字型內嵌、子集化與其餘 PDF 產生功能列於 HotPDF Delphi 元件頁