技術文章

在 Delphi 中使用 PDFium 元件 CLI 進行批次 PDF 預檢報告

批次預檢工具是一個沒有視窗的主控台程式,指向一個 PDF 資料夾,根據您指定的合規標準對每個檔案進行驗證,並留下機器可讀的發現證明。沒有人會坐著看它執行。它在凌晨兩點透過 cron 或 Windows 工作排程器執行,或者作為 CI 管道中的一道關卡,下一個關心其輸出的人,不是讀取退出代碼的排程器,就是幾週後開啟報告的稽核員。這改變了「正確」的含義。PDFium 元件(適用於 Delphi、C++Builder 和 Lazarus 的原始碼 PDF 函式庫)的預檢引擎使得驗證呼叫本身變得幾乎微不足道。決定該工具是否值得運作的工作圍繞在這些呼叫中:您檢查了哪個設定檔(profile),退出代碼告訴了排程器什麼,以及當有人去尋找時,原本能捕捉到錯誤的報告是否仍然存在

合約:排程器實際能看到的內容

CI 執行器或 Windows 工作排程器從您的工具中只會確切看到兩樣東西:退出代碼,以及它留下的任何檔案。日誌行、主控台顏色、進度輸出:所有這些都是給現場觀看的人類看的,而在凌晨兩點沒有人在看。所以在您觸碰 API 之前,先固定退出代碼的詞彙表,並保持它的無趣:

  • 0: 每個檔案都符合每個請求的設定檔
  • 1: 至少有一個檔案產生了驗證問題(findings)
  • 2: 工具本身在至少一個檔案上失敗(損毀的輸入、鎖定、當機)

代碼 1 和 2 之間的區別,是許多團隊跳過並在事後後悔的。無法開啟的損毀 PDF 不是驗證失敗。如果將它歸入代碼 1,一大堆損壞的掃描檔就會在您的儀表板上顯示為合規性的突然崩潰,讓某個人去追查一個從未發生過的標準回歸問題,而真實的情況卻是上游一台壞掉的掃描器

合約中還包括另外兩項。第一項是針對每個檔案的逾時。一個病態的 PDF,數千頁帶著深度嵌套的物件結構,可以讓單次驗證過程卡住好幾分鐘,而夜間執行視窗對此是沒有耐心的。在期限到達時終止該檔案的工作,將其計為工具失敗,並讓批次工作繼續進行。第二項是隔離目錄:將每個逾時或無法開啟的輸入移至一旁,而不是留在原處。幾個月下來,該目錄會悄悄地累積您的真實客戶所發送的最糟糕的檔案,而這個語料庫對於發布測試而言,比您能手工編寫的任何合成範例更有價值

挑選標準,以及為何合規層級很重要

TPdfPreflightStandard 列舉涵蓋了實務上出現的家族:ppsPdfA 用於 ISO 19005 歸檔合規性,ppsPdfUa 用於 ISO 14289 可及性,ppsPdfX 用於列印交換,加上適用於工程、光柵和變動資料工作的 ppsPdfEppsPdfRppsPdfVT。在一個家族中,引擎會讀取檔案聲明的合規層級,並根據標準在結果的 ConformanceName 中報告。僅命名家族通常是不夠的,因為層級才是真正差異所在。PDF/A-2b 承諾視覺重現性,僅此而已。PDF/A-3a 增加了對邏輯結構標記的要求,並允許嵌入來源檔案,對於完全沒有標記樹(tag tree)的掃描材料來說,這是一個難度高得多的門檻。在這兩個方向上出錯,批次工作都會對您撒謊。如果您的保留政策實際上需要的是 PDF/A-2b,但您卻因為缺少結構標記而讓檔案不通過,報告將充滿永遠不會有人修復的發現。如果您接受任何 PDF/A 標籤而不檢查其層級,您就簽署了通過一些比您所承諾之標準更弱的檔案。來自政府買家的可及性強制規定越來越多地將 PDF/UA 疊加在所有這一切之上,這不會增加執行的成本,因為 BuildPdfPreflightReport(來自 FPdfPreflightReport 單元)會接收一組標準:

Report := BuildPdfPreflightReport(Pdf, [ppsPdfA, ppsPdfUa]);

單次呼叫即可評估兩種標準,並交回單一的綜合報告記錄

為何空的問題清單不等於通過

該報告會按標準列舉問題發現,而一個空的問題清單僅表示「在實際執行的標準中沒有發現問題」。這比「檔案符合您所關心的標準」的聲明要窄,而兩者之間的差距正是批次預檢悄悄腐敗的地方。一個設定錯誤的打字失誤(從集合中丟失了 ppsPdfA)所產生的空問題清單,與一個真正乾淨的檔案完全一樣。因此,將沉默視為可疑。遍歷 Report.Results 並為您意圖檢查的每個標準斷言兩件事:它是否有結果項目存在,且其由 Status = pfsPass 支援的 IsCompliant 旗標是否為 true。一個將「沒有發現」等同於「已準備好歸檔」且從未確認評估了哪些標準的夜間工作,就是讓一個裝滿不合規檔案的資料夾順利通過數個月的典型方式,直到外部稽核員用 veraPDF 開啟其中一個檔案,整個檔案庫都受到質疑為止

第二個陷阱隱藏在所謂的發現究竟是什麼。每個 TPdfPreflightIssue 帶有一個 Code、一個 Category、一個 Description 和一個 Recommendation,並且它點名了被違反的規則,而不是頁面或物件。這是一個對回饋迴圈會產生後果的設計選擇。報告告訴生產團隊存在什麼類別的缺陷,一個未嵌入的字體或缺失的 XMP 識別碼,而找出具體的違規物件是下游修復工具的工作,而不是驗證工具的。請根據穩定的 Code 值來建置您的報告使用者(report consumers),絕對不要根據人類可讀的描述文字,因為後者可能會在版本發布之間無預警地被改寫

給機器與給值班人員的報告檔案

報告記錄以五種格式寫入相同的發現:SaveJsonToFileSaveCsvToFileSaveHtmlToFileSaveTextToFileSaveMarkdownToFile,每種格式都有一個相符的 ToJson 風格的函式,供您想要記憶體中的字串而不是寫入磁碟時使用。請克制只挑選一種的衝動。為管道寫入 JSON,這樣 CI 就可以將其附加到工作記錄中,並解析問題代碼和每項標準的狀態,而無需抓取文字。為被呼叫的人類寫入 HTML,因為它可以在沒有任何工具的任何瀏覽器中開啟。這兩者結合,每個檔案只需多寫一行程式碼,並省去了值班工程師在批次處理中最糟糕的一項任務:在凌晨兩點對原始 JSON blob 進行逆向工程以瞭解哪個檔案損壞了。有一項紀律比格式選擇更重要:每個報告名稱必須衍生自輸入檔案名稱,絕不要衍生自時間戳記,否則兩次平行執行將會交錯報告,導致您無法再將其匹配回它們的輸入檔案

嚴重性閾值屬於設定,而不是程式碼。一個沒有替代說明的註解,對 PDF/UA 提交入口網站而言是個硬性的失敗,而對內部歸檔來說則是一個可忽略的備註,然而兩者在發現上卻是完全相同的。公開每個設定檔的觸發失敗層級(fail-on level),這樣政策可以在不重新編譯的情況下轉換,並將當時有效的層級戳記到工作摘要本身。下個季度沒有人會記得去年 10 月的批次是在哪個閾值下執行的,而摘要是這份記憶留存的唯一地方

隔離檔案,以免一個壞的 PDF 毀了這批工作

procedure RunPreflightBatch(const InputDir, ReportDir: string;
  out FilesWithFindings, ToolFailures: Integer);
var
  SR: TSearchRec;
  Pdf: TPdf;
  Report: TPdfPreflightReport;
begin
  FilesWithFindings := 0;
  ToolFailures := 0;
  if FindFirst(InputDir + '*.pdf', faAnyFile, SR) = 0 then
  try
    repeat
      Pdf := TPdf.Create(nil);   // fresh instance per file: no state bleed
      try
        try
          Pdf.FileName := InputDir + SR.Name;
          Pdf.Active := True;
          if not Pdf.Active then  // load failures are silent, not raised
            raise EPdfError.Create('Cannot open ' + SR.Name);
          Report := BuildPdfPreflightReport(Pdf, [ppsPdfA, ppsPdfUa]);
          Report.SaveJsonToFile(ReportDir + ChangeFileExt(SR.Name, '.json'));
          Report.SaveHtmlToFile(ReportDir + ChangeFileExt(SR.Name, '.html'));
          if Report.TotalIssueCount > 0 then
            Inc(FilesWithFindings);
        except
          on E: Exception do
          begin
            Inc(ToolFailures);   // exit-code-2 territory, not a validation verdict
            WriteLn(ErrOutput, SR.Name + ': ' + E.Message);
          end;
        end;
      finally
        Pdf.Free;
      end;
    until FindNext(SR) <> 0;
  finally
    FindClose(SR);
  end;
end;

在該迴圈中存在三個刻意的選擇。每個檔案一個全新的 TPdf 保證了一個損壞引擎狀態的檔案,無法毒害跟隨在它之後的檔案。明確的 Active 檢查贏得了它的位置,因為 Active := True 會吞噬載入錯誤而不是引發它們;如果放棄這個防護,一個被截斷的檔案就會漂流進驗證呼叫,然後在下游的某處失敗並給出誤導性的訊息。內部的 try..except 故意放置在每個檔案的作用域內,因此單一例外會增加失敗計數器並讓迴圈繼續執行。即使第 5,000 個檔案被撕碎,您也會想要前 4,999 個好檔案的乾淨報告。而且,兩種報告格式都在統計裁決結果之前寫入磁碟,這意味著即使後續摘要邏輯中的錯誤導致計算錯誤,證據也能存留下來

然後,退出代碼的對應會濃縮為專案檔案中的幾行程式碼:

begin
  RunPreflightBatch(ParamStr(1), ParamStr(2), Findings, Failures);
  if Failures > 0 then
    Halt(2)
  else if Findings > 0 then
    Halt(1);
  // falling through exits with 0: every file conformed
end.

預檢不會為您做的事

引擎負責偵測;它不會修復。關於未嵌入字體或依賴裝置的色彩空間的發現,對於產生檔案的人來說是一份工作單,驗證器無法就地修補它。因此,請刻意規劃回饋迴圈。報告必須降落在生產團隊實際閱讀它們的地方,否則同樣的發現每晚都會重新出現,直到有人終於問起為何合規率從未改善。在外部稽核員為您進行交叉檢查之前,將一些裁決範例與獨立的驗證器(如適用於 PDF/A 的 veraPDF 或適用於 PDF/X 的 Acrobat 預檢)進行交叉檢查也是值得的。當兩個引擎在一個真實客戶檔案上出現分歧時,該檔案並不是一個麻煩;它正是您的發布測試所遺漏的回歸案例。保留它,命名它,並在每個建置上執行它

還有一種配對值得了解。相同的驗證引擎驅動著審閱 UI 中的互動式檢查,因此這個無頭 CLI 和面向分析師的 PDF 收件審閱工作台 可以共用單一的驗證詞彙,而不是隨著時間的推移而產生分歧。由於 [ppsPdfA, ppsPdfUa] 在同一次過程中評估了可及性,批次工作中的 PDF/UA 部分就可以跟檢視器端的工作乾淨地對齊,例如在 Delphi 中建置無障礙 PDF 閱讀器。設定檔、報告格式以及完整的預檢 API,均記錄在 PDFium 元件 的產品頁面上