產生報表、嵌入 TrueType 字型,而輸出在您試過的每個檢視器中都能正確開啟。字型對了,文字可選取,檔案也有效。唯一不對的是大小。一份只用了幾十個拉丁字元的檔案卻帶上整個 350 KB 字型,一份只印了一段中文的檔案卻帶上 14 MB 的 CJK 字型,而不是它原本只需要的半 MB 切片。沒有例外、沒有警告,而且檔案通過驗證。這就是順序錯置的終止步驟從外部看起來的樣子:哪裡都沒有失敗,唯一的證據就是數字大得離譜
造成這個問題的錯誤曾存在於 HotPDF 某個版本線中,後來已修正。這值得寫下來,不是當作缺陷公告,而是當作一個教訓,因為這種錯誤的形狀很普遍。任何文件引擎都有一個終止階段,會在寫入前變更物件,而那個階段的正確性完全取決於各步驟相對於序列化的順序。只要把某一步放到寫入的錯邊,它就會默默什麼都不做
字型子集化應該要做的工作
子集字型是檔案實際使用的 TrueType 檔案部分;ISO 32000-1 §9.9 描述了內嵌字型程式如何載入由字型描述子參照的串流中,對於 TrueType 程式,該串流是 /FontFile2,並帶有給出未壓縮位元組計數的 /Length1;子集化會重寫 glyf 和 loca 表,使其僅包含檔案參照的字圖,重新編號字圖識別碼,並在 /BaseFont 名稱前加上六個字母的標籤(例如 ABCDEF+)以將字型標記為子集,這完全符合規範的要求;一個拉丁字型子集化為 10 或 15 KB,是精簡 PDF 與為了單一標題而傳送整個字型的 PDF 之間的區別
這發生的時間點非常重要;子集化並非套用到已在磁碟上之位元組的轉換;它會編輯記憶體中的物件圖:縮小 /FontFile2 串流內容、修正 /Length1,並重寫 /BaseFont 字串;當序列化器遍歷該圖並輸出位元組時,所有這些都必須就緒;如果編輯落在寫入位元組之後,它們將更新沒有人會讀取的物件
症狀,以及為什麼沒人抱怨
回報的行為是輸出中保留完整字型,而且沒有任何診斷資訊。註冊了 Unicode TrueType 字型並產生一般文件的使用者發現,內嵌字型物件與來源 .ttf 檔案長度相同,而且 /BaseFont 名稱沒有六個字母的子集前綴。從只用了 10 個字圖的執行到用了 10,000 個字圖的執行,輸出大小始終沒有縮小
沒有任何錯誤正是這類問題代價高昂的原因。在錯誤時間執行的子集化常式仍然會執行。它會遍歷累積的碼位使用情況,建立完全正確的子集,並將其套用到記憶體中的物件圖。內部工作都完成了,而呼叫也乾淨地返回。唯一的問題是,它所編輯的物件圖已經不是正在被寫出的內容,因為寫入器早就完成工作。從呼叫端看來,文件是順利產生並儲存的,這正是靜默失敗會給人的印象
根本原因是終止順序
在 HotPDF 中,關閉工作發生在 EndDoc 內部。子集化步驟是一個名為 BuildAndApplyUnicodeFontSubset 的內部常式。它會讀取每份文件的已使用碼位集合,這個集合保存在一個位元圖中,當顯示字圖時,文字輸出路徑會把它填進去;接著再透過快取的碼位到字圖對照表,將每個已使用的碼位對應到真實的字圖識別碼,然後圍繞那個閉包重寫字型程式。當註冊 Unicode TrueType 字型時,輸出路徑會為每個畫出的字元在已使用碼位集合中設一個位元,因此檔案關閉時,引擎已經清楚知道子集必須保留哪些字圖
缺陷在於 BuildAndApplyUnicodeFontSubset 是在 SaveToStream 或 SaveToFile 已經把文件序列化之後才被呼叫。子集化器對 /FontFile2 的編輯、修正後的 /Length1,以及六個字母的 /BaseFont 前綴,全都是在一個已經轉成位元組的物件圖上運算。修正方法只有一行順序調整:把子集呼叫移到序列化之前,讓寫入器輸出子集化後的字型,而不是原始字型。修正後的順序就是先執行子集化器,接著才序列化
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
Pdf.CurrentPage.TextOut(72, 760, 0, '报表标题 Report Heading');
Pdf.EndDoc; // subsetting runs here, before the write
Pdf.SaveToFile('Report.pdf');
finally
Pdf.Free;
end;
end;
在順序修正後,呼叫端程式碼不需要任何變更;一旦註冊了 Unicode TrueType 字型,子集化預設就是啟用的;您註冊字型、開始檔案、繪製並結束它,子集就會在位元組離開記憶體之前,從您使用的字圖中建立出來
一個錯置的步驟就是一整個類別
這值得當作教訓,而不只是旁註的原因,在於 EndDoc 會輸出一系列終止步驟,而每一步都對它相對於寫入的位置很敏感。字型子集化就是其中之一。PDF/A 輸出需要一個 /CIDSet 串流,精確列出子集中存在的字圖識別碼,這是 ISO 19005 強制要求的約束,讓驗證器可以確認內嵌程式與字型描述子宣稱的內容一致;這個串流在同一個終止視窗中輸出,並且取決於子集先已建立。依 ISO 14289-1 §7.18.3,PDF/UA-1 也要求每個帶註解的頁面宣告值為 /S 的 /Tabs,而名為 EnsurePDFUATabsOnAnnotatedPages 的內部常式會在同一階段寫入那個鍵。輸出意圖檢查也在那裡進行
讓子集化失效的同一個順序錯誤,也會讓帶註解的頁面失去 PDF/UA 的 tab 順序鍵,因為那一步同樣站在寫入的錯邊。veraPDF 和 PAC 會把缺少 /Tabs /S 回報成違反 Matterhorn protocol checkpoint 21-001。也就是說,一個放錯位置的呼叫不只讓檔案變大,還會在沒有任何錯誤提示的情況下破壞無障礙相容性要求。這就是終止階段的危險:各步驟共享同一個前提,而一個順序錯誤就可能一次讓好幾步失效,卻仍然都回傳成功
如何實際抓到靜默的輸出失敗
不會丟出例外的錯誤,不能靠執行程式本身抓到;只能透過檢查輸出,再把它和輸入理應產生的結果比對。對字型子集化而言,檢查非常具體。先把輸出檔案大小和大致預期相比:只碰到少數字圖的文件,不該大到像完整字型。打開內嵌字型物件,讀它的位元組長度;拉丁字型的子集化 /FontFile2 只是來源檔案的一小部分。再讀 /BaseFont 名稱,確認六個字母的前綴在不在,因為沒有那個前綴就是沒有套用子集的直接訊號
var
Pdf: THotPDF;
Output: TMemoryStream;
begin
Output := TMemoryStream.Create;
try
Pdf := THotPDF.Create(nil);
try
Pdf.RegisterUnicodeTTF('C:\Fonts\DejaVuSans.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('DejaVu Sans', [], 11);
Pdf.CurrentPage.TextOut(72, 760, 0, 'Subset me');
Pdf.EndDoc;
Pdf.SaveToStream(Output);
finally
Pdf.Free;
end;
// A few glyphs from a ~700 KB face must not yield a multi-hundred-KB stream.
if Output.Size > 100 * 1024 then
raise Exception.Create('Font subset did not shrink the output');
finally
Output.Free;
end;
end;
對於 PDF/A 輸出,檢查更加嚴格,因為驗證器會為您完成這項工作;設定相容性等級並將結果透過 veraPDF 執行:遺失 /CIDSet 或與描述子不相符的子集會被回報為失敗條款,而不是留給您用肉眼去發現;驅動此終止工作的相容性切換是檔案上的屬性;PDFACompliance 接受字串,例如 PDF/A-2 Level B 的 '2B',而 PDFUACompliance 是一個布林值,用於啟用標記 PDF(Tagged PDF)和 Tab 順序要求
Pdf := THotPDF.Create(nil);
try
Pdf.PDFACompliance := '2B'; // PDF/A-2 Level B, drives /CIDSet emission
Pdf.PDFUACompliance := True; // stamps /Tabs /S on annotated pages
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansSC-Regular.ttf');
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Noto Sans SC', [], 12);
Pdf.CurrentPage.TextOut(72, 760, 0, '合规报告');
Pdf.EndDoc;
Pdf.SaveToFile('Report_PDFA.pdf');
finally
Pdf.Free;
end;
工程上的教訓
這裡得到兩條規則。第一,任何會修改物件的終止步驟,都必須在那些物件被序列化之前執行,而文件引擎的關閉階段應該被視為一條有順序的管線,序列化是最後一步,不是其中之一。第二,也是最花時間學到的教訓:對輸出步驟來說,沒有錯誤不代表成功。一個建立正確子集卻把它套到錯的、已經寫出的圖上的常式,不會回報任何錯誤,因為從它自己的角度看並沒有出錯。驗證必須看產出物,而不是傳回碼。檢查輸出大小、讀內嵌字型的位元組長度與 /BaseFont 前綴,並讓 veraPDF 判定 PDF/A 輸出;在那裡,缺少 /CIDSet 會把靜默的不足變成有名字的失敗
字型處理的產生端,也就是如何註冊並嵌入字型以供報表輸出,在我們關於報表輸出中字型與影像的文章中有說明;驗證端,也就是這些終止步驟如何依標準檢查,在PDF/A 和 PDF/UA 驗證的逐步說明中有介紹。兩者都和這裡描述的子集化與相容性工作一起提供,作為 Delphi 和 C++Builder 的 HotPDF Component 一部分,並和本部落格其他地方介紹的載入、編輯、加密與簽章 API 搭配