Technical Article

靜默停用字型子集化的 EndDoc 錯誤

產生一份報表,內嵌 TrueType 字型,輸出檔案在您嘗試的每個檢視器中都能正確開啟;字圖正確,文字可選取,檔案也有效;唯一的問題是大小;一個使用數十個拉丁字元的檔案攜帶了整個 350 KB 的字型;而列印了一段中文的檔案則攜帶了 14 MB 的 CJK 字型,而不是它原本所需的一半百萬位元組(MB)切片;沒有引發異常,沒有記錄警告,且檔案通過了驗證;這就是順序錯誤的終止步驟在外部的表現:沒有任何地方失敗,唯一的證據就是一個過大的數值

產生此問題的錯誤曾在 HotPDF 的某個版本分支中存在,此後已被修正;這值得寫下來,不是作為缺陷通知,而是作為一個教訓,因為這種錯誤的模式具有普遍性;任何檔案引擎都具有一個終止階段,該階段會在寫入物件之前對其進行修改,而該階段的正確性完全取決於其步驟相對於序列化的順序;如果將某個步驟放在寫入的錯誤一側,它就會靜默地什麼都不做

字型子集化應該要做的工作

子集字型是檔案實際使用的 TrueType 檔案部分;ISO 32000-1 §9.9 描述了內嵌字型程式如何載入由字型描述子參照的串流中,對於 TrueType 程式,該串流是 /FontFile2,並帶有給出未壓縮位元組計數的 /Length1;子集化會重寫 glyfloca 表,使其僅包含檔案參照的字圖,重新編號字圖識別碼,並在 /BaseFont 名稱前加上六個字母的標籤(例如 ABCDEF+)以將字型標記為子集,這完全符合規範的要求;一個拉丁字型子集化為 10 或 15 KB,是精簡 PDF 與為了單一標題而傳送整個字型的 PDF 之間的區別

這發生的時間點非常重要;子集化並非套用到已在磁碟上之位元組的轉換;它會編輯記憶體中的物件圖:縮小 /FontFile2 串流內容、修正 /Length1,並重寫 /BaseFont 字串;當序列化器遍歷該圖並輸出位元組時,所有這些都必須就緒;如果編輯落在寫入位元組之後,它們將更新沒有人會讀取的物件

症狀以及為何沒有 any 警告

回報的行為是輸出中包含完整字型且沒有任何診斷資訊;註冊了 Unicode TrueType 字型並產生一般檔案的使用者發現,內嵌的字型物件與來源 .ttf 檔案長度相同,且 /BaseFont 名稱沒有攜帶六個字母的子集前綴;在只使用 10 個字圖與使用 10,000 個字圖的執行過程之間,輸出大小從未縮小

沒有任何錯誤是使這類錯誤代價高昂的原因;在錯誤時間執行的子集化常式仍會執行;它會遍歷累積的碼位使用情況,建立完全正確的子集,並將其套用到記憶體中的物件圖;在內部,工作已經完成且呼叫乾淨地返回;唯一的問題是它所編輯的物件圖已不再是正在被寫入的內容,因為寫入器已經完成工作;從呼叫者的角度來看,檔案已順利產生並儲存,這正是靜默失敗所帶來的印象

根本原因在於終止順序

在 HotPDF 中,關閉工作發生在 EndDoc 內部;子集化步驟是一個名為 BuildAndApplyUnicodeFontSubset 的內部常式;它讀取每個檔案的已使用碼位集合(保存在一個點陣圖中,當顯示字圖時,文字輸出路徑會對其進行填入),並透過快取的碼位至字圖對照表將每個已使用的碼位對應到真實的字圖識別碼,然後圍繞該閉包重寫字型程式;當註冊 Unicode TrueType 字型時,輸出路徑會為其繪製的每個字元在已使用碼位集合中設定一個位元,因此當檔案關閉時,引擎已確切知道子集必須保留哪些字圖

該缺陷是 BuildAndApplyUnicodeFontSubsetSaveToStreamSaveToFile 已經將檔案序列化之後才被叫用;子集化器對 /FontFile2 的編輯、修正後的 /Length1 以及六個字母 of /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 回報為違反馬特洪峰協定檢查點 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;

工程上的教訓

由此得出兩個規則;第一,any 終止步驟如果修改了物件,都必須在這些物件被序列化之前執行,且檔案引擎的關閉階段應該被視為一個有序的管線,其中序列化是最後的動作,而不是多個動作之一;第二個規則是在這裡花費最多時間的教訓:對於輸出步驟,沒有錯誤並不代表成功;一個建立正確子集並將其套用到錯誤、已寫入之圖上的常式不會回報任何錯誤,因為從它自身的角度來看沒有任何錯誤;驗證必須看產出物,而不是傳回碼;檢查輸出大小,讀取內嵌字型的位元組長度及其 /BaseFont 前綴,並讓 veraPDF 評判 PDF/A 輸出,在那裡遺失 /CIDSet 會將靜默的不足轉化為具名的失敗

字型處理的產生端(如何註冊和內嵌字型以進行報表輸出)在我們關於報表輸出中字型與影像的文章中有所探討;驗證端(在其中根據標準檢查這些終止步驟)在PDF/A 和 PDF/UA 驗證的逐步說明中有所介紹;兩者都與此處描述的子集化和相容性工作相結合,這些工作作為 Delphi 和 C++Builder 的 HotPDF 元件的一部分隨附,並與本部落格其他地方介紹的載入、編輯、加密和簽章 API 搭配