PDFium Component 透過 AddCidType2Text,以字形層級把文字寫入 PDF 頁面,並把你提供的 TrueType 資料內嵌為一個 CID Type 2 字型。它會為每一個字形實例指派一個循序的 CID,產生明確的 CID 對 GID 對應表與 ToUnicode CMap,並把位於同一基線上的字形合併成單一原生文字物件。選用的子集化功能能讓檔案保持精簡,並針對子集化失敗時該怎麼做訂有明確策略
當文字已經完成整形(shaping)時,你需要的就是字形層級的寫入方式。阿拉伯文、天城文,以及任何具有上下文字形變化的文字系統,產生的都是一串不再與字元一一對應的字形識別碼序列,因此一個只接受字串與字型名稱的 API,根本無法表達這樣的結果。直接傳入字形與位置,是把正確整形的複雜文字系統內容放進 PDF 的唯一方式
為何每個字形實例都各自擁有自己的 CID
一個很誘人的最佳化做法是去重複:每個不同的字形識別碼只用一個 CID,該字形每次出現都重複使用同一個。這樣做能產生較小的字型,卻會破壞文字擷取,因為同一個字形在不同地方,本來就可能合理地對應到不同的 Unicode 內容
連字字形是最清楚的例子。同一個「ffi」字形,可能在某個詞中代表這三個字元,而在別處經過不同的整形決策後,又代表別的內容。ToUnicode 對應表是以 CID 為鍵值,因此一個共用的 CID 只能攜帶一種對應關係,而在這場競爭中落敗的那段文字,就會變得無法擷取
因此每一個字形實例都會取得屬於自己的 CID,而每個對應關係也可以自由地對應到多個 UTF-16 碼元。沒有實際文字意義的字形——例如裝飾元素、純粹用於定位的字形——會明確對應到 U+200B(零寬空格),因此每個 CID 都會有一個可觀察的擷取結果,而不是一個空缺
uses
PDFium;
var
Pdf: TPdf;
Glyphs: TPdfCidGlyphs;
Options: TPdfCidFontOptions;
Report: TPdfCidFontReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'label.pdf';
Pdf.LoadDocument;
Pdf.PageNumber := 1;
// 每個已整形字形各佔一筆項目:字形 id、它所代表的文字,
// 以及它在文字空間中的前進量與偏移量
SetLength(Glyphs, 3);
Glyphs[0].GlyphID := 402; Glyphs[0].UnicodeText := 'ffi';
Glyphs[0].Advance := 18.4;
Glyphs[1].GlyphID := 71; Glyphs[1].UnicodeText := 'c';
Glyphs[1].Advance := 9.8;
Glyphs[2].GlyphID := 74; Glyphs[2].UnicodeText := 'e';
Glyphs[2].Advance := 9.2;
Options := TPdfCidFontOptions.Default; // 偏好子集化
Options.VerifyExtraction := True;
if Pdf.AddCidType2Text(LoadFileBytes('NotoSans.ttf'), Glyphs,
11, 72, 700, Options, Report) then
Writeln(Format('%d glyphs, %d unique, font %d -> %d bytes, match=%s',
[Report.GlyphCount, Report.UniqueGlyphCount,
Report.OriginalFontBytes, Report.EmbeddedFontBytes,
BoolToStr(Report.ExtractionMatches, True)]));
finally
Pdf.Free;
end;
end;
子集化,以及回傳碼給不了你的檢查
把字型子集化到只剩實際用到的字形,是內嵌 300 KB 與內嵌 12 KB 之間的差別,而在含有多種字型的文件上,這一點決定了檔案能不能用電子郵件寄送。平台的子集化路徑直接接受一份字形清單,這與這個 API 完美契合,因為呼叫端本來就已經知道所有用到的字形識別碼
它給不了你的是信心。一次子集化呼叫可能回報成功,卻傳回無法使用的輸出結果,因此元件在接受結果之前,會驗證三項特性:輸出必須比原始檔案小、必須能重新剖析為一個有效的 sfnt 且帶有可讀取的 maxp 表,並且必須保留到所要求的最高原始識別碼為止的所有字形。只要有任何一項失敗,這次子集化就會被拒絕
接下來會發生什麼事,取決於呼叫端的策略。在預設值 pcfemSubsetPreferred 之下,被拒絕的子集化會回退為內嵌完整字型,因此頁面結果仍然正確,只是檔案較大。在 pcfemSubsetRequired 之下,操作會在頁面被修改之前就失敗,這正是有大小限制的處理流程所需要的行為。pcfemFull 則完全略過子集化。報告會透過 SubsetAttempted、SubsetApplied、UsedFullFontFallback 與 SubsetErrorCode,告訴你實際走了哪一條路徑
不會產生偽陽性的驗證
啟用 VerifyExtraction 後,元件會確認它所寫入的內容可以被正確讀回。天真的做法是擷取整個頁面並搜尋預期的字串,而這種做法是錯的:即使新寫入的文字有誤,一個原本就含有該文字的頁面,仍然會通過這項檢查
取而代之的做法是重建文字頁面,並依插入順序逐一讀取這次呼叫所插入的物件控制代碼,再串接起來。結果會與預期文字進行比對,報告會同時揭露這兩段字串以及布林結果,讓不相符的情況得以被診斷,而不只是被偵測到
在開發期間,以及任何要求可擷取性的處理流程中——可搜尋的封存、無障礙合規、下游文字探勘——都應該開啟這項功能。它的代價是每次呼叫都要重建一次文字頁面,這也是為何在緊密迴圈中它預設不會開啟的原因
額度與原子性失敗
字型位元組數、字形實例數與 Unicode 碼元數,都會在配置記憶體之前各自設有上限,字型格式、TTC 索引、字形識別碼範圍與幾何數值,也都會在寫入任何內容之前先經過驗證。幾何數值必須是有限值,這聽起來理所當然,直到某個整形引擎因為一個格式錯誤的字型,回傳給你一個 NaN 的前進量為止
失敗是以頁面為單位、具原子性的。若字型載入、物件寫入、內容產生或擷取驗證失敗,這次呼叫所插入的每一個物件都會以相反順序移除,並重新產生頁面內容。一次失敗的呼叫,會讓頁面維持原樣,而不會留下半截文字段落
這在文字處理流程中的定位
這裡的分工值得說清楚。整形——把字元轉換成帶有位置的字形——不是這個 API 的工作,它屬於整形引擎的職責,而元件自身的雙向文字與複雜文字系統支援,涵蓋於表情符號、CJK 與代理對處理一文。這個 API 是你在整形引擎產生字形序列之後才會呼叫的下一步
對於沒有上下文字形變化的一般拉丁文文字,較簡單的字串型文字 API 才是正確工具,寫出的程式碼也更精簡。當你已經有整形後的輸出、需要精確控制字形識別碼,或必須從你手上持有的位元組內嵌字型、而不是依名稱解析字型時,才該使用 CID Type 2 寫入方式——依名稱解析字型的替代做法,是控制 PDF 字型替換一文所描述的供應者機制
有一項部署上的提醒:內嵌字型既是技術問題,也同樣是授權問題。不同字型在是否允許內嵌、是否只允許用於檢視、或是否允許用於編輯上各有規定。函式庫會內嵌你交給它的任何位元組,而檢查授權條款是你自己的責任,不是檔案格式的責任
字形層級文字寫入、字型供應與文字擷取,在 Delphi、C++Builder 與 Lazarus 中共用同一套頁面模型;完整 API 說明請見PDFium Component for Delphi 頁面