技術文章

Delphi 的 PDF/UA 字型稽核:Widths、CharSet、CIDSet

一份 veraPDF 報告說某個字圖寬度與內嵌字型程式不一致,但幾乎不告訴您是哪個字圖、或為什麼。PDFlibPas 回答這個問題的方式是:把每個字元碼經由內嵌 cmap 解析為字圖索引,把字型程式度量正規化到每 em 1000 單位,然後再比較

為什麼字圖寬度會不一致?

因為被比較的兩個數字活在不同的座標系裡,而 PDF 字典裡沒有任何東西告訴您換算方式。字型字典以字圖空間書寫 /Widths,PDF 把它固定為 em 的千分之一(ISO 32000-1 §9.2.4)。內嵌 TrueType 程式裡的 hmtx 表以字型設計單位書寫步進寬,而 head 表決定多少個設計單位構成一個 em:多數 TrueType 字體是 2048,CFF 衍生的是 1000,偶爾是完全不同的值。直接比較原始值,您語料庫裡每個 2048-upem 字型看起來都是壞的。這就是 ISO 14289-1 §7.21.5 給任何想靠讀字典欄位稽核寬度的人設下的陷阱

PDFlibPas 在 Delphi 中的字圖寬度稽核:字圖空間中的 /Widths 項目與字型設計單位中的 hmtx 步進寬,在任何比較之前先把程式度量縮放到每 em 1000 單位,帶入同一個座標系
PDFlibPas 先把每個 hmtx 步進寬縮放到 em 的千分之一再與字典寬度比較,且只回報差距超過一個單位的項目

PDFlibPas 在載入時正規化。TPDFTrueTypeParserAdvance * 1000 div unitsPerEm 存進它的寬度陣列,所以 Parser.GetWidth(GID) 給出的答案已經是 PDF 所用的千分之幾 em,而 GetRawWidth 仍可在您需要取回設計單位時使用。這還留下更難的另一半:從字元碼到字圖索引。對簡單 TrueType 字型而言,路線取決於 FontDescriptor 中的 Symbolic 旗標,即 /Flags 的第 3 位元

Parser := TPDFTrueTypeParser.Create;
try
  Parser.LoadFromString(FontProgram);
  if Symbolic then
  begin
    // Symbolic 字體直接透過程式 cmap 定址,
    // 後備方案是 (3,0) 的高位元組慣例
    GID := Parser.GetGlyphIndex(Code);
    if GID = 0 then
      GID := Parser.GetGlyphIndex($F000 + Code);
  end
  else
  begin
    // 非 Symbolic:碼 -> 經由編碼取得字圖名,名稱 -> 經由
    // Adobe Glyph List 取得 Unicode,Unicode -> 經由程式 cmap 取得 GID
    UnicodeValue := GetGlyphUnicode(EncodingNames[Code and $FF]);
    if UnicodeValue = 0 then
      Continue;
    GID := Parser.GetGlyphIndex(UnicodeValue);
  end;
  if (GID > 0) and (GID < Parser.GlyphCount) then
    if Abs(PDFWidth - Parser.GetWidth(GID)) > 1 then
      Inc(MismatchCount);
finally
  Parser.Free;
end;

那段程式裡有兩個細節有份量。容差是一個單位而非零,因為正規化是整數除法,合法產生的檔案也可能差一個單位;這正是診斷 10036 所回報的「在 em 的千分之一以內」措辭。而 GID < Parser.GlyphCount 守衛不是裝飾。GetWidth 為了算繪呼叫方而寫得寬容:把越界索引箝制到 hmtx 的最後一項,表格缺席時退回 750。寬容對算繪是對的,對稽核是錯的,所以稽核會在詢問寬度之前先拒絕該索引,而不是採信箝制結果

CIDFontType2 多了一層間接

PDFlibPas 以同樣方式走訪複合字型,只是在 CID 與字圖之間插入 /CIDToGIDMap。寬度從 /W 陣列抵達,ISO 32000-1 §9.7.4.3 給了它兩種可以在同一陣列中自由交替的形狀:一個起始 CID 接著一個連續寬度陣列,或是首 CID、末 CID,加上套用到整段範圍的單一寬度。稽核兩種都解析,然後把每個產出的配對交給同一個比較,總數回報在診斷 10037 之下。對應這一步是複合字型不同之處,也是在讀任何寬度之前缺圖診斷 10021 為什麼重要——缺失或畸形的 /CIDToGIDMap 不只是違反 §7.21.3.2,它讓寬度問題根本無法回答

PDFlibPas 在 Delphi 中從字元碼到字圖索引的三條路線:Symbolic TrueType 字型走程式 cmap,非 Symbolic 字型繞道編碼與 Adobe Glyph List,CIDFontType2 則經過 CMap 加 /CIDToGIDMap
在字元碼解析為字圖索引之前,寬度比較無法開始,而每一種字型經由不同的路線抵達那個索引
// /CIDToGIDMap 是名稱 /Identity,或是大端序 16 位元字圖索引的
// 串流,每個 CID 一項(ISO 32000-1 section 9.7.4.2)
Obj := DerefIndRef(FDoc, CIDFont.FindValueByKeyName('CIDToGIDMap'));
if (Obj is TPDFName) and (TPDFName(Obj).Name = 'Identity') then
begin
  GID := CID;
  Result := True;
end
else if Obj is TPDFStream then
begin
  Data := TPDFStream(Obj).GetDecodedStream;
  P := CID * 2 + 1;                       // Pascal 字串為 1 起始
  if (P >= 1) and (P + 1 <= Length(Data)) then
  begin
    GID := (Integer(Byte(Data[P])) shl 8) or Integer(Byte(Data[P + 1]));
    Result := True;
  end;
end;

當字型程式無法解碼時,稽核器該怎麼辦?

什麼都不說。ISO 14289-1 §7.21.4.2 要求的 /CharSet/CIDSet 完整性檢查——診斷 10038 與 10039——正是過度熱心的驗證器變成負債的地方,因為對讀報告的人來說,「您的 CharSet 不完整」與「我們的 Type 1 解碼器放棄了」無法區分。因此 PDFlibPas 只在三件事全部成功時才回報缺項:字型程式可解碼、碼到字圖的對應可解析、集合本身可解碼。TPDFType1Decoder.LoadPFBFromString 必須回傳 True 並給出 charstring 數量,任何字圖名才會被拿來對 /CharSet 字串檢查;/CIDSet 路徑需要串流能展開、字圖數量為正,才會測試任何一個位元。途中任何例外都收斂為「無發現」,而不是一項缺陷

PDFlibPas PDF/UA 稽核中的保守回報規則:只有當字型程式可解碼、碼到字圖對應可解析、且集合本身可解碼時,才回報 /CharSet 或 /CIDSet 缺項;任何失敗都產生沉默
發出缺項發現需要三項獨立成功,所以放棄的解碼器帶來的是漏報,而不是誣告

這是刻意偏向漏報的設計,值得明說而不是埋藏。損毀的 CFF 表、不支援的 Type 1 變體、或比字圖範圍短的 /CIDSet,全都產生沉默而非診斷。理由是 PDF/UA 稽核會被轉寄給不是工具作者的人,而一次誣告的代價高於一次漏報:作者得燒掉一天來證明一份合規檔案合規,並從此不再信任整份報告。Matterhorn Protocol 以另一種形式做了同樣的區分:把機器可判定的檢查與必須由人判定的檢查分開,而這些檢查就住在它的 Fonts 檢查點(31)裡。如果您需要更嚴格的解讀,把 PDFlibPas 當快速閘門跑,再讓專用驗證器當第二意見——這個組合與 PDF/A 與 PDF/UA 預檢導覽中描述的是同一個

頁面的 /Contents 是清單,不是串流

內容串流稽核中代價最高的單一錯誤,是把 /Contents 當成一個串流。ISO 32000-1 §7.7.3.3 允許頁面持有一個串流陣列,其各段以空白串接後才是頁面程式;產生器會在任意位置切分,一個 BT 可能坐在某個成員裡,而與它配對的 ET 在下一個。內容處理器維護狀態——標記內容巢深、上一個 Tf 選取的字型、文字物件旗標——而 Process 在進入時重置該狀態。對每個陣列成員各呼叫一次,第一個之後的每個串流都會在沒有目前字型的情況下開始,於是標記完好的文字被讀成無標記、無字型的雜訊。PDFlibPas 先串接,再處理一次

function ContentObjectData(FDoc: TSmartPDFDocument; Obj: TPDFObject): AnsiString;
var
  I: Integer;
begin
  Result := '';
  Obj := DerefIndRef(FDoc, Obj);
  if Obj is TPDFStream then
    Result := TPDFStream(Obj).GetDecodedStream
  else if Obj is TPDFArray then
    for I := 0 to TPDFArray(Obj).Count - 1 do
      Result := Result + ContentObjectData(FDoc, TPDFArray(Obj).Item[I]) + #10;
end;

// 對整段串接結果呼叫一次 Process,絕不逐成員呼叫
Scanner.Process(ContentObjectData(FDoc, PageDict.FindValueByKeyName('Contents')));

哪些 Form XObject 才算非結構化?

只有那些頁面真正調用、調用位置在標記內容之外、且自身內容帶有文字的。診斷 10040 執行 ISO 14289-1 §7.20 的方式是為每個物件編號記錄三項獨立事實——有文字、被調用、在標記內容內被調用——並只回報前兩項的交集減去第三項。兩種捷徑各自錯在您會出貨的方向:標記 /Resources 裡每個帶文字的 Form,會懲罰到沒人取用的範本庫;標記每個被調用的 Form,會懲罰到不帶文字、不需要標記的向量 logo。調用位置按物件編號而非資源名解析,因為同一個 Form 在不同頁面上經常透過不同名稱抵達。配套的診斷 10041 走訪同一段串接程式以執行 §7.21.8,把每個顯示文字的運算元經由作用中字型解析,並計算落到 .notdef 的碼數——無論文字算繪模式為何都是禁止的,包括掃描影像背後使用的不可見模式。存活的 Form 該如何包裹屬於結構樹問題,見建置標記 PDF 結構一文

完全沒有 FontDescriptor 的字型

未內嵌字型是這項稽核的合法輸入,不是錯誤狀態,而內嵌檢查之下的每個輔助函式都必須承受它。當 PDFlibPas 找不到 /FontDescriptor,或描述器沒有 FontFileFontFile2FontFile3,它記錄診斷 10020——名稱屬於 Standard 14 時記錄 10022,§7.21.4 NOTE 5 刻意不豁免它們——然後繼續走完檔案其餘部分。這正是報告存在的意義:作者要的是一次跑完拿到所有發現,而不是每次執行拿一項。所以交給寬度、cmap、CharSet 與 CIDSet 輔助函式的描述器參照可以是 Nil,它們各自在進入時測試,而不是假設先前的檢查已中止稽核。如果修法是把缺的字型嵌進去,做法見把缺失字型內嵌進既有 PDF 的說明

執行稽核

一次呼叫,對象可以是一份不是您產生的檔案。TPDFlib.CheckFileCompliance 接受一個合規測試選擇器——2 代表 ISO 14289-1:2014 下的 PDF/UA-1——並回傳零或一個字串清單控制代碼,其項目為數字代碼、冒號加上可讀訊息。本文討論的字型與內容串流發現佔據 10020 到 10041 區段,與 00xxx 的 PDF/A 代碼在數值上保持距離,混合日誌才不會難讀。在 Options 傳 1 會在第一項發現時短路,這是建置閘門想要、而編寫工具不想要的行為。對記憶體中仍開啟的文件,GetPDFUADiagnostics 執行等價檢查,無需繞道磁碟

var
  Issues, Count, I: Integer;
begin
  // ComplianceTest = 2 選取 PDF/UA-1;Options = 0 回報每一項發現
  Issues := PDF.CheckFileCompliance('delivery.pdf', '', 2, 0);
  if Issues = 0 then
    WriteLn('delivery.pdf: PDF/UA-1 conformant')
  else
  begin
    Count := PDF.GetStringListCount(Issues);
    for I := 1 to Count do
      WriteLn('  ', PDF.GetStringListItem(Issues, I));   // 例如 10037 CIDFontType2 ...
  end;
end;

這一切都不需要機器上存在外部驗證器執行檔,而這正是「每次建置都跑的檢查」與「有人想起來才跑的檢查」之間的差別。本文描述的合規與診斷 API 隨標準版 PDFlibPas Delphi PDF Library 提供,其產品頁收錄了完整的 PDF/UA-1 診斷代碼表,以及 PDF/A、PDF/X 與 PDF/E 測試套件