PDF 接收審查工作台是一個只有一項工作的小程式:在任何下游程式被允許接觸檔案之前,先檢查每個檔案。為了完成這項工作,它必須在一次作業中組裝少數幾種功能。它開啟檔案(不信任它)、讀取檔案聲稱的關於自身的資訊、尋找會誤導天真的擷取器或夾帶攻擊的內容、決定是否根本有可擷取的文字,然後根據其發現將檔案路由到一個佇列中。略過檢查的話,失敗將會是無聲無息的:一個以擁有者密碼加密、內部包裝著 XFA 表單的 PDF,會像空字串一樣順利通過文字擷取器,被編製成空白檔案的索引,然後沒人會注意到,直到下游某人去尋找從未被讀取過的內容。PDFium Component 是 Delphi、C++Builder 與 Lazarus 適用的原始碼 VCL/LCL 檢視器與檢查函式庫,它暴露了這個工作台所需的內部檢查呼叫。以下各節將逐步解說哪個呼叫可回答哪個問題,以及在兩個地方,明顯的呼叫會給出自信滿滿卻錯誤的答案
檔案路由前要回答的五個問題
撇開網格與縮圖列不談,接收分流可簡化為五個問題:
- 檔案到底能不能開啟,而且是用哪個密碼?
- 它聲稱自己是什麼:標題、作者、建立日期?
- 它是否夾帶主動或危險內容,例如 JavaScript、XFA 表單或內嵌檔案?
- 是否有可擷取的文字,還是這是一份準備進行 OCR 的掃描檔?
- 考量上述所有情況,哪個佇列會接收它:直接處理、人工審查,還是隔離?
每個問題都對應到一兩個 PDFium Component 呼叫。其中兩個對應具有危險的死角,這是我在生產環境中必須除錯的大多數路由錯誤檔案的原因。檔案中繼資料存在於兩個可能互不一致的不同地方,而且加密不一定會阻止檔案被開啟
低成本開啟:關閉表單填寫,不渲染任何頁面
分流應該是成本最低的開啟方式。在 Active := True 之前設定 FormFill := False,是告訴元件完全略過表單填寫環境。這會縮短載入時間,而且(對於來源不明的檔案來說同樣重要)它能防止任何檔案層級的 JavaScript 初始化。以下使用的檢查屬性都不需要渲染頁面,因此分流階段絕不需要產生任何一個點陣圖
procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := IncomingPath;
Pdf.FormFill := False; // no form environment, no JavaScript init
Pdf.Active := True; // failure is silent: Active simply stays False
if not Pdf.Active then
begin
Rec.OpenFailed := True; // damaged file or user-password lock
Exit; // the finally block still runs
end;
Rec.PageCount := Pdf.PageCount;
CollectIdentity(Pdf, IncomingPath, Rec);
CollectRiskSignals(Pdf, Rec);
finally
Pdf.Active := False;
Pdf.Free; // never leak the instance on a malformed file
end;
end;
指派之後的檢查不是可有可無的,它是一個檢查而不是一個例外處理常式是有原因的。當引擎無法載入檔案時,元件會吞噬內部的 EPdfError 並讓 Active 保持在 False,而不是傳播它。等待例外的程式碼會開心地從從未開啟的檔案中讀取 PageCount。如果拒絕工作流程需要引擎實際的錯誤文字,請將檔案讀入位元組陣列,並呼叫接受 TBytes 的 LoadDocument 多載方法;那條路徑確實會引發帶有訊息的 EPdfError,包括密碼情況。try..finally 仍然有其作用。接收服務會在無人看管的情況下執行數週,任何後續的例外都不能洩漏 TPdf 執行個體,或是持有一個會讓重試路徑絆倒的鎖定
吞吐量很少成為瓶頸。在停用表單填寫且無渲染的情況下,分流開啟主要受限於 I/O,單一工作程式可以輕鬆地從本機磁碟每秒檢查數個檔案。如果接收量真的超出了單一工作程式的負荷,請以檔案而不是以檢查項目來劃分工作。這五個問題共用一次開啟,將它們分散到不同處理程序中,會使最昂貴的步驟倍增,而不是攤提它
中繼資料存在於兩個地方,且它們可能互不一致
ISO 32000-1 定義了檔案中繼資料的兩個歸宿:檔案資訊字典(第 14.3.3 條款)與附加到型錄的 XMP 封包(第 14.3.2 條款)。Title、Author、Subject 與 CreationDate 屬性讀取 Info 字典,使用 MetaText[] 讀取任何其他鍵值,並使用 DecodeDate 來解析 D:YYYYMMDD... 日期字串。問題在於現代的產生器越來越常只寫入 XMP,這是 ISO 32000-2 透過在 PDF 2.0 中棄用大多數 Info 字典鍵值而使其正式化的一個方向。在接收工具中的症狀很具體。您的工作台顯示空的標題,而 Adobe Acrobat 卻顯示了一個,因為 Acrobat 退而求其次使用了 XMP 封包內的 dc:title,而這是 Info 字典屬性永遠不會碰到的
procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
var Rec: TIntakeRecord);
begin
Rec.Title := Pdf.Title; // Info dictionary value
Rec.Author := Pdf.Author;
Rec.CreatedAt := Pdf.CreationDate; // raw PDF date string ("D:2026...")
// An empty Info title does not mean the document is untitled. The
// component does not expose the XMP packet, so probe the raw file
// bytes for the dc:title element before trusting the blank.
if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
Include(Rec.Flags, ifTitleInXmpOnly);
end;
即便上述粗糙的子字串探測也能發揮作用:「存在中繼資料,但不在舊版工具尋找的地方」,對於任何依據標題或作者建立索引的封存管線來說,這是一個與路由相關的事實。如果您的下游索引只讀取 Info 字典,以這種方式標記的檔案將會默默地變得無法搜尋
無論如何都能開啟的加密檔案
一個已加密的檔案不一定會無法開啟。標準的安全處理常式(ISO 32000-1 第 7.6.3 條款)區分了需要用來開啟檔案的使用者密碼,以及僅僅用來控管列印和複製等權限的擁有者密碼。很大一部分「受保護」的商業檔案是使用擁有者密碼和一個 空白 的使用者密碼進行加密的。它們可以在沒有提示的情況下開啟,完全解密,並依賴檢視器自願遵守權限標誌。那是政策,而不是保護,您的接收狀態應該反映出這個差異
在成功開啟後偵測加密需要一次引擎呼叫加上一個後備方案。FPDF_GetSecurityHandlerRevision(Pdf.Document) 會為未受保護的檔案傳回 -1,否則會傳回處理常式的修訂版本;而 Pdf.Permissions 傳回任何不是所有位元都設定的 $FFFFFFFF 遮罩的值,就是一個佐證訊號。對於真正被使用者密碼鎖定的檔案,在設定 Active := True 之前先指派 Password;如果開啟仍然失敗,請將檔案路由到一個受阻擋的狀態,透過安全通道向發送者請求憑證,而不是盲目重試。另外,請抗拒將「加密」視為自動隔離的誘惑。在大多數檔案繁重的產業中,已加密但可開啟的檔案是正常情況,而不是可疑情況
主動內容:JavaScript、XFA 與內嵌檔案
有三項發現應該始終影響路由決策。首先,JavaScript:OnUnsupportedFeature 事件會在引擎遇到 XFA 或 3D 內容等結構特徵時回報它們,但它不會偵測 JavaScript。請改為檢查 JavaScriptActionCount,並將非零結果視為主動內容。其次,XFA:當 FormType 傳回 ftXfaFull 時,可見頁面通常只不過是 XFA 範本的渲染結果,傳統的文字擷取將會看到樣板文字,而不是填寫的值。第三,附件:PDF 是一種容器格式,AttachmentCount 可以告訴您這份檔案是否夾帶了乘客
procedure CollectRiskSignals(Pdf: TPdf; var Rec: TIntakeRecord);
var
i, PageNo: Integer;
Ext: string;
begin
Rec.IsEncrypted := Assigned(FPDF_GetSecurityHandlerRevision) and
(FPDF_GetSecurityHandlerRevision(Pdf.Document) <> -1);
Rec.HasForms := Pdf.FormType <> ftNone;
Rec.IsXfa := Pdf.FormType = ftXfaFull;
Rec.HasJavaScript := Pdf.JavaScriptActionCount > 0;
// AnnotationCount is a per-page property; walk the pages to total
// it. Loading a page object renders nothing, so this stays cheap.
Rec.Annotations := 0;
for PageNo := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := PageNo;
Inc(Rec.Annotations, Pdf.AnnotationCount);
end;
Rec.Attachments := Pdf.AttachmentCount;
for i := 0 to Rec.Attachments - 1 do
begin
Ext := LowerCase(ExtractFileExt(string(Pdf.AttachmentName[i])));
if (Ext = '.exe') or (Ext = '.js') or (Ext = '.vbs') or (Ext = '.dll') then
Include(Rec.Flags, ifDangerousAttachment);
end;
end;
該迴圈中有兩個細節值得注意。附件名稱來自檔案內部,因此在沒有先進行淨化的情況下,絕對不要重複使用它作為輸出路徑;一個像 ..\..\start.exe 的內嵌名稱是一個路徑遍歷,正等著粗心的儲存呼叫。此外,副檔名封鎖清單是一條絆線,而不是保證。它的工作是強制進行人工決策,而不是證明檔案是乾淨的
將訊號轉換為路由狀態
一個可行的狀態模型所需要的狀態數比大多數團隊預期的還要少:準備就緒 (ready)(沒有阻礙,有文字)、審查 (review)(開啟成功但有些東西需要留意,例如 XFA 表單、JavaScript、空的文字層,或標題只在 XMP 中)、受阻 (blocked)(需要使用者密碼)與 損壞 (damaged)(開啟失敗)。在狀態旁邊記錄證據。檔案雜湊值、頁數、精確的標誌以及損壞檔案的引擎錯誤訊息都很重要,因為對路由決策提出質疑的人,會在數週後針對可能已經被替換或修改的檔案提出質疑
當操作員確實需要查看被隔離的檔案時,不要將它交給預設的殼層(shell)檢視器。請在一個停用指令碼與連結處理的強化窗格內渲染它,這是在在 Delphi 中建置安全的 PDF 預覽介面中所描述的方法。而且,如果您的接收作業是要將資料送入有符合性要求的封存中,分流階段自然是排程進行更深入檢查的好地方;針對 PDF/A 與 PDF/UA 設定檔的批次預檢驗證會精準地從這個檢查停止的地方接續進行
元件的產品頁面涵蓋了授權、完整的檢查 API 以及隨附的示範,包括一個接收風格的檔案檢查器:PDFium Component