技術文章

在 Delphi 中使用 PDFium VCL 進行 PDF/A 預檢驗證 (Preflight Validation)

一個封存檔匯入關卡 (archive ingestion gate) 拒絕了一批 "PDF/A-2b" 檔案,這些檔案在桌面上的每個檢視器中都能正常開啟。供應商發誓他們是合規的。其實不然:每一個檔案都在目錄 (catalog) 中隱藏了 JavaScript 動作,這是肉眼隨便看看永遠不會發現的,而像 veraPDF 這樣完整的 PDF/A 驗證器則會立即標示出來。問題在於,沒有人想在 Delphi 批次服務 (batch service) 上掛載一個 Java 工具鏈,只為了每個檔案回答一個「是或否」的問題。這正是 PDFium 元件 (PDFium Component) 中的 ValidatePdfACompliance 所填補的空白,而它如何在完全不解析內容串流的情況下達成判定,非常值得深入了解

為什麼 PDFium 本身無法回答這個問題

首先要誠實面對的是:搭售的 pdfium.dll 完全沒有 PDF/A 功能。它的公開介面上沒有 ConvertToPDFA,沒有輸出意圖 (OutputIntent) 寫入器,也沒有 XMP API。此函式庫中關於 PDF/A 的每個部分 (包含寫入端和檢查端) 都完全使用 Pascal 寫在 FPdfPdfa.pas 中,並透過位元組等級 (byte-level) 解析加上遞增更新 (incremental update) 來運作。因此,當您呼叫驗證器時,您並不是在詢問 Chromium 的渲染器。您正在對檔案的結構化位元組執行一個 Pascal 標記掃描器 (token scanner)

這個公開 API 刻意設計得很小巧。一個函式會從位置 0 讀取串流並回傳一個記錄 (record):

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

IsCompliant 編碼了在關卡中至關重要的規則:只有在偵測到真正的合規等級 (conformance level) 且問題集合 (issue set) 為空時,檔案才算通過。解析成功但未找到 pdfaid 標記 (marker) 時會解析為 pacNone,這明確表示未通過。這與 批次預檢報告 CLI (batch preflight report CLI) 從外部提出的觀點相同:在一個無法識別的檔案上得到空白的發現列表,並不代表它完全健康

在任何標記掃描之前剝離串流主體 (stream bodies)

這是最重要的一個實作細節,也是如果您編寫自己的掃描器時最容易出錯的地方。偵測器透過搜尋帶分隔符號的名稱標記 (name tokens) (例如 /JavaScript/LZWDecode/BM) 來尋找違規。如果您掃描原始檔案位元組,則內嵌的二進位串流主體、壓縮圖片、ICC 設定檔 (ICC profiles)、字型程式 (font programs) 將會隨機包含看起來像那些標記的位元組序列。您會回報「找到」了 /AA/3D,只因為 JPEG 裡面的三個位元組剛好拼出了它。那簡直是製造誤判的工廠

解決方案是 PdfStructureBytes:它會走訪檔案,並將每個 streamendstream 關鍵字之間的位元組清空為空白字元,同時保持字典 (dictionary) 結構完好無缺。只有在那之後才會執行掃描。驗證器中的每一個名稱標記檢查都是在這個被剝離的副本上運作的。如果您要從本文帶走一個觀念,那就是這個。相同的原則也反映在 PDF/UA 驗證器中,它保留了自己的該常式副本,因為這兩個標準是獨立演進的

29 個問題及其各自的含義

TPdfAValidationIssue 是一個被記錄下來的合約。其序數是固定的,因為 DUnitX 測試、展示程式 (demos) 和報告層都依賴於它們,因此新的發現結果只會附加在最後面。截至 v1.63.0,總共有 29 個成員。它們分為幾個家族:

  • 中繼資料與識別碼 (Metadata and identity)pvaiMissingXmpMetadatapvaiMissingPdfAIdentifierpvaiMissingTrailerId (ISO 19005-1 6.1.3)、pvaiMissingXmpDates
  • 色彩與輸出 (Color and output)pvaiMissingOutputIntentpvaiMissingIccProfile,以及當 DeviceRGB 和 DeviceCMYK 同時出現時的 pvaiMixedDeviceColorSpaces (6.2.3.3)
  • 所有部分的嚴格禁令 (Hard prohibitions for every part)pvaiEncryptionPresent (完全禁止使用 /Encrypt 字典)、pvaiJavaScriptPresentpvaiForbiddenActionpvaiAdditionalActionspvaiLzwUsedpvaiXfaPresentpvaiNeedAppearancesTruepvaiForbiddenAnnotation
  • 字型 (Fonts)pvaiFontNotEmbedded 和更嚴格的 pvaiUnembeddedFont,加上針對沒有 /ToUnicode 卻宣告為 Level U 的 pvaiUnicodeMappingMissing
  • 標記 (Tagging):當合規性 (conformance)=A 的宣告沒有標記結構 (tagged structure) 時的 pvaiLevelAStructureMissing

新增在序數 24 到 29 之間的最新的六個成員,涵蓋了審閱者實際會遇到的微妙情況:pvaiTrappedTrue (資訊字典中的 /Trapped /True,這是一個「假朋友」,因為該值必須為 False 或 Unknown)、pvaiForbiddenActionSubtype (將聲音 (Sound) 或影片 (Movie) 用作動作,而不僅僅是註解)、pvaiTransparentColorSpace (非 Normal 的混合模式 (blend mode),或是 /CA//ca 不等於 1.0)、pvaiAnnotationDictViolationpvaiUnembeddedFont 以及 pvaiMixedDeviceColorSpaces

感知各部分的門檻:A-1 很嚴格,A-2 和 A-3 放寬了

PDF/A 並非只有一本規則書。從 PDF/A-2 開始,明確允許 PDF/A-1 所禁止的三件事:透明度 (一個 /Transparency 群組或一個作用中的 /SMask,6.4)、可選內容 (optional content) (/OCProperties,6.1.13) 以及內嵌檔案 (/EmbeddedFiles/EF,6.1.11)。一個在每個檔案上都將這三者標示為違規的單純驗證器,將會大規模地拒絕完全有效的 PDF/A-2 文件

因此,驗證器會透過 PdfAPartOf 從 pdfaid 標記讀取部分編號 (part number),並將那些檢查關在 PartNo = 1 的門檻後面。針對新的透明度問題的混合模式和註解 Alpha 值檢查也類似,僅限於 part-1:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

有一個保守的預設值值得一提:當完全沒有 pdfaid 標記時,該部分會被視為最嚴格的 1。理由是,一個無法識別的檔案應該受到最嚴格的規則約束,而不是讓它輕易過關。JavaScript、禁止動作、LZW、XFA、NeedAppearances、禁止註解以及未內嵌的字型在每一個部分都是被禁止的,所以這些檢查永遠不會放在門檻後面

展開物件串流 (object streams),讓任何東西都無所遁形

PDF 1.5 引入了交叉參考串流 (cross-reference stream) 和物件串流 (/Type /ObjStm),它們為單純的位元組掃描器製造了盲點。目錄 (catalog)、輸出意圖 (OutputIntent)、動作字典 (action dictionary),任何本身不是串流的東西,都可以在 ObjStm 內部進行 Flate 壓縮。掃描原始結構,您將什麼都看不到,然後回報一個其實一點也不乾淨的檔案為乾淨

PdfExpandObjectStreams 彌補了這個漏洞。在任何檢查執行之前,驗證器會進行 Data := PdfExpandObjectStreams(Data)。這個常式會找到每個 ObjStm,讀取它的 /N/First 標頭 (header) 以取得包含的物件編號和偏移量,使用 PdfInflate (RTL zlib,Delphi 上的 System.ZLib 和 FPC 上的 zstream) 將主體解壓縮,然後將每個包含的物件作為普通的 N 0 obj ... endobj 附加到該位元組副本的末端。現有的標記檢查便能找到這些物件,而無須改變其邏輯

有兩個限制讓這項操作變得乾淨而非脆弱。串流物件、中繼資料、ICC 設定檔和字型程式,都不能存在於物件串流中,只有非串流字典可以,因此展開操作永遠只處理字典,並且附加的物件不帶有任何會干擾主體剝離過程的 stream 關鍵字。而且因為附加的內容落在 %%EOF 之後,從 startxref 反向搜尋仍然能找到原始的預告 (trailer)。交叉參考串流預告本身在稍早的 v1.49.3 中就已經處理過,方法是直接從純文字的 xref 串流字典讀取 Root、Size 和 ID,這是一個在關於驗證物件和交叉參考串流的姊妹篇中探討過的主題;物件串流的工作只需要加上解壓縮步驟,而不需要解碼 type-2 xref 項目或展開 PNG 預測器 (PNG predictor)

位元組等級 (byte-level) 檢查器的誠實限制

這是一個預檢工具,而不是一個經過認證的驗證器,而且其界限是真實存在的。字型內嵌是一種計數啟發式方法 (counting heuristic),要把它做對需要進行一次值得了解的修正。原始的檢查使用了 PdfCountName('/FontDescriptor'),但是每一種字型都會貢獻兩個 /FontDescriptor 標記,一個是來自字型字典的參考,另一個是描述符 (descriptor) 物件本身的 /Type,所以相對於 N 個內嵌程式,計數是 2N,導致該測試結果永遠為真。修正的方法是 PdfCountDescriptorRefs,它只計算 /FontDescriptor N G R 參考形式,每種字型一個,並且只有在內嵌程式真正較少時才會引發 pvaiUnembeddedFont

K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

即使修正了,它還是很粗略:一個混合文件中,如果每個描述符剛好都有某些 FontFile,仍然可能讓個別不合規的字型成為漏網之魚。展開物件串流也有一個已知的副作用,它會暴露出 AcroForm /DR 所攜帶的 standard-14 預設資源 (例如 /Helv),而此啟發式方法會盡責地報告它們為未內嵌,儘管 veraPDF 讓它們通過,因為它們實際上從未被用來渲染。內容串流操作員等級 (operator-level) 的檢查 (6.2.10) 則完全超出了範圍,因為它們需要完整的內容解析而不是位元組掃描。將驗證器視為一個快速、無相依性的第一道關卡,用來捕捉標記注入無法修復的違規行為,並保留一個完整的驗證器來進行最終認證

這只是故事中關於檢查的一半。互補的寫入端,也就是 SaveAsPdfA 注入 XMP、輸出意圖 (OutputIntent) 和 sRGB ICC 設定檔,並誠實地將沒有標記結構的 Level A 請求降級的地方,是建立在相同的位元組等級機制之上。這兩半都包含在 PDFium Component for Delphi 之中,這是一個單一的 VCL 封裝 (package),在沒有任何外部執行階段可供安裝的情況下,覆蓋於一個純 Pascal 的 PDF/A 實作之上