技術文章

在 Delphi 中使用 PDFium VCL 實現 PDF/A 歸檔合規性

您釋出了一個會將每個檔案標記為 PDF/A-1b 的轉換器,客戶的紀錄系統將它們攝取 (ingest) 了一年,然後一次稽核將整批檔案跑過 veraPDF,結果有三分之一被判定為不合規。沒有發生當機、沒有引發例外,檔案在您桌上的每一個檢視器中都能順利開啟。它們只是不符合您在上面蓋的標準印章。這是歸檔用 PDF 的常態失敗模式,這也是為什麼「我們設定了旗標」永遠不等於「它通過了驗證」

關於 PDFium 和 PDF/A,首先要了解的是,引擎本身跟它一點關係都沒有。PDFium 負責渲染、解析和寫入 PDF,但它的公開介面沒有 ConvertToPDFA、沒有 OutputIntent 寫入器,也沒有 XMP API。歸檔合規性的每一個環節——XMP 封包、OutputIntent 及其 ICC 設定檔、目錄標記 (catalog markers)、驗證——都存在於 PDFiumPas 內部,在一個大約 2,000 行的純 Pascal 單元 (FPdfPdfa.pas) 中,它負責解析儲存的位元組,並透過漸進式更新 (incremental update) 重新寫入它們。了解工作是在哪裡進行的,就會知道錯誤藏在哪裡,而它們並沒有藏在 PDFium 裡

PDF/A 實際的要求是什麼,陷阱又在哪裡

PDF/A 不是一種單一格式。ISO 19005 定義了三個部分 (PDF/A-1, -2, -3),而在每個部分中,又定義了保證不同事項的一致性等級 (conformance levels)。Level B (basic) 僅保證視覺外觀是可重現的。Level A (accessible) 在 B 的基礎上增加了標籤結構樹 (tagged structure tree) 和 Unicode 對應。Level U 僅存在於第 2 和第 3 部分,介於兩者之間:具有可靠的 Unicode 文字,但沒有完整的結構樹。ISO 19005-1 沒有 Level U,程式庫直接編碼了這項限制

實際上,這項格式的規則中只有少數幾條會成為陷阱。完全禁止加密(ISO 19005-1 條款 6.1.3 及其後續版本):PDF/A 檔案不能帶有 /Encrypt 字典。文件必須透過 OutputIntent 宣告一個輸出渲染條件 (output rendering condition),且其目的地必須是有效的 ICC 設定檔(條款 6.2.3.2)。一致性聲明 (conformance claim) 本身必須作為 XMP 詮釋資料 (metadata) 出現在 PDF/A 識別結構 (identification schema) 之下。Level A 額外要求條款 6.8 的邏輯結構,也就是讓文件具備機器可讀性的標籤樹。只要遺漏其中任何一項,一致性驗證器就會拒絕該檔案,即使它能完美渲染

產生封存檔的單一呼叫

PDFiumPas 將整個管線封裝在 TPdf.SaveAsPdfA 之後。這個簡單的多載方法接收一個目標一致性,並預設為 PDF/A-1b,這對於常見的「讓它永遠可渲染」案例來說,是正確的預設值

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // Default conformance is pac1b (PDF/A-1b)
    if Pdf.SaveAsPdfA('invoice_archive.pdf') then
      // file now carries XMP, sRGB OutputIntent, and catalog markers
    else
      raise Exception.Create('PDF/A save failed');
  finally
    Pdf.Free;
  end;
end;

在底層,這是一個兩階段的動作。SaveAsPdfA 首先要求 PDFium 使用 FPDF_SaveAsCopy 序列化文件,然後將該位元組串流交給 InjectPdfAMarkers,後者會附加 XMP 詮釋資料、帶有嵌入式 ICC 設定檔的 sRGB OutputIntent,以及一個重寫後的目錄 (catalog),作為一個漸進式更新 (incremental update)。來源是從位置 0 讀取,目的地則是從位置 0 寫入;原始的物件樹保持不變,而標記則會掛載在現有的 %%EOF 之後。如果您需要的是位元組而不是檔案,SaveAsPdfAToStream 接收一個 TStream 和相同的選項

使用選項紀錄 (options record) 選擇一致性

要以特定部分和等級為目標,請傳遞一個 TPdfASaveOptions 紀錄。它的 Conformance 欄位接收一個 TPdfAConformance 值。這個列舉涵蓋了所有有效的組合,沒有別的:第 1 部分的 pac1bpac1a;第 2 部分的 pac2bpac2upac2a;第 3 部分的 pac3bpac3upac3a,加上用於驗證端的 pacUnknownpacNone。沒有 pac1u,因為標準中不存在這個等級

var
  Pdf: TPdf;
  Opts: TPdfASaveOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf');
    Opts := TPdfASaveOptions.Default;
    Opts.Conformance := pac2u;           // PDF/A-2u: reliable Unicode text
    Opts.Title := 'Quarterly Report 2026';
    Opts.Author := 'Finance';
    // Leave IccProfileData empty to use the built-in sRGB IEC61966-2.1 profile
    if not Pdf.SaveAsPdfA('report_a2u.pdf', Opts) then
      raise Exception.Create('PDF/A-2u save failed');
  finally
    Pdf.Free;
  end;
end;

這個紀錄的大部分內容都可以留空。將 TitleAuthorSubjectKeywordsCreatorProducer 留空,SaveAsPdfA 會透過 FPDF_GetMetaText 從文件的 Info 字典中自動填入。將 CreationDateModDate 留空,它會將目前的 UTC 時間用於這兩個 XMP 日期。將 DocumentIdInstanceId 留空,程式庫會透過 FPDF_GetFileIdentifier 預先填入,如果失敗,則退回使用一個衍生自來源位元組的確定性 ID。您可能想要刻意覆寫的唯一欄位是 IccProfileData:空白表示使用隨附的 sRGB IEC61966-2.1 設定檔,但 CMYK 或灰階工作流程應該要提供自己的設定檔

為什麼 Level A 會降級,以及為什麼這是誠實的選擇

這裡有一個微妙之處,會讓期望旗標就是保證的人跌跤。您可以對一個沒有標籤樹 (tag tree) 的文件要求 pac1a,但 PDF/A-1a 需要條款 6.8 的邏輯結構,而程式庫無法憑空從一個沒有標籤的 PDF 中製造出結構樹。與其發出一個宣稱符合 Level A 卻沒有通過驗證的檔案,SaveAsPdfA 會檢查是否有真正的標籤結構(/StructTreeRoot 加上帶有 /Marked true/MarkInfo),如果不存在,就會降級該聲明:在所有三個部分中,pac1a 變成 pac1bpac2a 變成 pac2b,依此類推。內部的輔助函式是 PdfAIsLevelAPdfADowngradeToLevelB

背後的理由值得清楚說明:一個誠實宣告其符合等級的檔案,比一個謊報其未達到等級的檔案更有用。Level U 的處理方式不同。偵測真正的 Unicode 涵蓋範圍會需要一個天真的「它有 /ToUnicode 嗎」測試,這會過度降級合法的文件(WinAnsi 及類似的編碼是豁免的),所以儲存端會依據呼叫端的宣告發出 U 的聲明,並將差異留給驗證端去標記。如果您需要一個保證為 Level A 的封存檔,請在轉換之前為文件加上標籤;轉換器不會發明不存在的結構

只有真正的驗證器才抓得到的 ICC 陷阱

這是讓我們學到最深刻教訓的一次失敗,因為程式庫自己的檢查器通過了,而 veraPDF(ISO 19005 的參考驗證器)卻沒有通過。PDF/A 要求 OutputIntent 的目的地設定檔必須是一個有效的 ICCBased 串流,而條款 6.2.3.2 規定驗證器必須將該串流驗證為色彩空間 (colour space)。ICCBased 串流必須宣告 /N,也就是色彩元件的數量。早期版本的注入器寫入 ICC 串流字典時只有 /Length 而沒有 /N,於是 veraPDF 拒絕了該結果並提示 "The N entry (value null)... is missing"

這件事之所以陰險,是因為這個拒絕只會在 PDF/A-1b 和 -1a 時觸發。第 2 和第 3 部分的一致性模型並沒有對目的地設定檔執行那個特定的檢查,所以完全相同的注入結構在 pac2bpac3bpac2u 下都通過了驗證,卻僅僅因為 pdfaid:part 的值而在 pac1b 下失敗。單元測試永遠無法看見它,因為程式庫自己的 ValidatePdfACompliance 只檢查了 /DestOutputProfile 鍵是否存在,而沒有檢查串流字典裡面的內容。內部測試維持綠燈;真正的歸檔驗證卻失敗了

修復方式是 IccComponentCount,它讀取位於 ICC 標頭偏移量 16 的資料色彩空間簽章,並將其對應到元件數量:GRAY 是 1,RGB Lab XYZ 是 3,CMYK 是 4,未知設定檔預設為 3。這個計數作為 /N 進入串流字典。它是計算出來的,而不是硬編碼為 3,因此透過 IccProfileData 提供 CMYK 或灰階設定檔的呼叫端,仍然能獲得正確的值。更廣泛的教訓在於方法論:程式庫內建的檢查器和權威的驗證器都有各自的盲點,而 PDF/A 輸出必須跟像 veraPDF 這樣的參考實作進行端到端的測試,而不是信任自我檢查。確保歸檔乾淨的漸進式更新紀律,在使用 PDFium VCL 驗證壓縮的物件和 xref 串流中有探討,這很重要,因為注入器所消費的現代 PDF 經常是建立在交叉參照串流 (cross-reference streams) 上的

加密、xref 串流以及其他邊界情況

由於 ISO 19005 禁止加密,儲存路徑在寫入前會將其剝離。SaveAsPdfA 在序列化時會套用 FPDF_REMOVE_SECURITY,因此加密的來源(使用其密碼載入)在進入封存檔的途中會被解密。在未加密的文件上這是一個空操作,不會改變任何東西。其必然結果就跟 HotPDF 從另一個方向所執行的限制一樣:單一檔案不可能既是加密的又是 PDF/A。當一個工作流程需要這兩者時,答案就是產出兩個成品,一個用於發佈的加密副本,以及一個用於歸檔的獨立乾淨副本

還有一個在咬到您之前看不見的邊界情況:使用純交叉參照串流且不帶有 trailer 關鍵字的 PDF 1.5+ 文件。注入器會讀取預告區 (trailer) 來尋找來源的 /Info,並附加其漸進式更新,它必須接受 xref 串流的形式,否則這樣的文件就會在標記被默默丟棄的情況下被直接複製過去。ISO 32000-1 條款 7.5.6 明確允許在 xref 串流文件之後跟隨一個傳統的預告區漸進式更新,並使用指向 xref 串流偏移量的 /Prev,這正是注入器所發出的結構。PDFium 自己的 FPDF_SaveAsCopy 總是寫入傳統的預告區,所以在正常管線中,注入器永遠不會遇到純 xref 串流的來源,但讀取路徑會處理來自其他地方的這類文件

在信任聲明前進行驗證

本程式庫隨附了一個位元組等級的檢查器 TPdf.ValidatePdfA,它會回傳一個 TPdfAValidationResult。其 Conformance 欄位報告偵測到的等級,而 Issues 是一個 TPdfAValidationIssue 值的集合 (set);便利方法 IsCompliant 只有在偵測到真實的等級且問題集合為空時才為真。在批次處理中,請將其作為快速的第一道關卡來執行

var
  Pdf: TPdf;
  Res: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice_archive.pdf');
    Res := Pdf.ValidatePdfA;
    if Res.IsCompliant then
      Writeln('Conformant: detected level ', Ord(Res.Conformance))
    else
      Writeln('Issues found: ', SizeOf(Res.Issues), ' flags set');
  finally
    Pdf.Free;
  end;
end;

對這能為您帶來什麼好處要誠實以對。位元組等級的檢查器能以高置信度抓出結構性問題(缺少 OutputIntent、禁止的操作、出現 /Encrypt、在第 1 部分禁止透明度的地方使用了透明度),而字型嵌入偵測使用了一種計數啟發式演算法,刻意只回報高置信度的訊號,而不是去追蹤每個字元的涵蓋範圍。它不會做的是內容串流運算子分析,這會需要一個完整的內容解析器,在設計上就排除了。作為發佈的關卡,請將程式庫內建的檢查器與 veraPDF 搭配使用:檢查器是即時的,且無需 DLL 即可在任何地方執行,而 veraPDF 則是權威的。將這種配對串接進一個批次執行中,是批次預檢報告 CLI 的主題,這也是這項驗證在真實歸檔工作流程中的歸宿

這裡展示的 SaveAsPdfAInjectPdfAMarkersValidatePdfA API,隨附於 Delphi、C++Builder 和 Lazarus/FPC 的 PDFium Component 之中。產品頁面連結了完整的 API 參考手冊,包含這幾個範例背後的完整一致性列舉和選項紀錄