您編寫了一個小型的驗證器。它開啟 PDF,尋找到末尾,找到 startxref,讀取偏移量,並期望定位到關鍵字 xref(其下方有一個固定寬度的交叉參照表)。它從該表中收集物件偏移量,然後向後掃描關鍵字 trailer 以瞭解 /Root 和 /Size。在您產生用於測試的每個檔案上,它都能完美運作。然後,一個由最新版本 Word 或鎖定 PDF 1.5 的函式庫產生的檔案送達,驗證器卻宣告該檔案已損壞。在偏移量指向的地方沒有 xref 關鍵字,任何地方都沒有 trailer 字典,且驗證器建置的物件表幾乎是空的。然而,該檔案是合法的。這只是驗證器正以十五年前的眼光來讀取它
這是針對傳統配置編寫的位元組層級 PDF 檢查在現代文件上失敗的最常見單一原因。它所依賴的結構 —— 純文字交叉參照表和 trailer 關鍵字 —— 在 PDF 1.5 中已變為選用項目,且經常缺席。有兩項功能取代了它:交叉參照串流和壓縮物件串流。兩者均在 ISO 32000-1 中有描述,而不知道這些功能的驗證器會將一個健全的檔案視為一堆遺失的物件
PDF 1.5 對檔案尾部做了哪些改變
ISO 32000-1 §7.5.8 定義了交叉參照串流,而 §7.5.7 定義了類型為 /ObjStm 的物件串流。它們共同讓寫入器可以捨棄傳統剖析器所尋找的這兩種結構。PDF 1.5 檔案在結尾處可能完全沒有 xref 表。取而代之的是,startxref 指向的物件是一個普通的串流物件,其字典攜帶 /Type /XRef,且該串流以緊湊的二進位形式儲存交叉參照資料。也沒有 trailer 關鍵字,因為尾部現在就是串流自身的字典。傳統剖析器尋找的鍵 —— /Root、/Size 和 /ID —— 都存在於該字典內部
第二個改變移開了物件本身。寫入器不用以其自身的位元組偏移量寫入每個間接物件,而是可以將許多小物件(頁面字典、註解字典、結構樹)打包到單一物件串流中,並使用 Flate 壓縮整個容器。個別物件在檔案中不再具有位元組偏移量。它們在一個壓縮的 Blob 內部擁有一個位置。在原始位元組中掃描 1 0 obj 的驗證器永遠找不到它們,因為該文字只有在解壓縮後才存在。對於傳統剖析器而言,半個文件就這樣平白無故地消失了
即使在壓縮的檔案中,尾部鍵(Trailer Keys)也是純文字
令人欣慰的是,讀取交叉參照串流的尾部不需要解壓縮任何內容。串流物件被寫入為一個字典,後面跟著 stream 關鍵字,然後是壓縮的位元組。字典是純文字。因此,當 startxref 指向交叉參照串流時,緊跟在物件編號後面的位元組看起來就像一個普通的字典,且 /Root、/Size 和 /ID 就在那裡架構清楚可見,位於 stream 關鍵字和 Flate 資料開始之前
這意味著驗證器僅透過解析串流字典,就能瞭解它最需要的三個事實:編目(Catalog)在哪裡、檔案聲稱有多少個物件以及檔案識別碼。它不必解壓縮交叉參照資料,也不必解譯內部的二進位項目。擊敗天真剖析器的任務不是讀取尾部;而是尋找物件。這是兩個可以分離的問題,且解決第一個問題成本很低
物件串流:一個標頭,然後是一個 Flate Blob
物件串流是一個容器。其字典攜帶 /Type /ObjStm、給出內部打包物件數量的 /N 項目,以及給出在解壓縮資料內第一個物件主體開始處之位元組偏移量的 /First 項目。壓縮的承載資料一旦解壓縮,會以一個由 /N 個整數對組成的小標頭開始。每對都是一個物件編號和該物件主體相對於 /First 的偏移量。標頭之後是緊密相連的物件主體本身
一旦位元組被解壓縮,展開一個物件串流便是機械性的工作。您讀取字典以獲取 /N 和 /First,使用 Flate 解碼器解壓縮串流,遍歷前導的 /N 對以瞭解哪一個物件編號存在於哪一個偏移量,然後將每個主體提取出來,就好像它是普通的間接物件一樣。唯一真正的依賴是 Flate 解碼器,而您已經擁有一個:Delphi 出貨了 System.ZLib,且 Free Pascal 出貨了 zstream 單元,兩者都封裝了 zlib,且無需任何第三方程式碼即可解壓縮原始的 Flate 串流。一個將每個提取的物件附加至驗證器物件表的常式,能使驗證器的其餘部分(遍歷 /Root 並檢查頁面樹的部分)的行為與其在傳統檔案上的行為完全一致
您不必實作的內容
我們很容易高估這項工作。從壓縮檔案中讀取尾部鍵,不需要解碼交叉參照串流的二進位項目。第 7.5.8 節的交叉參照串流使用三種項目類型,而類型 2 項目(即聲明「此物件存在於物件串流 N 的索引 i 處」的項目)是您解碼以建立完整偏移量對應圖所需的內容。您需要該對應圖來依編號解析任意物件。但您不需要它來讀取純文字字典中的 /Root、/Size 和 /ID,您也不需要它來展開物件串流,因為每個 /ObjStm 都透過 /N 和 /First 宣告了自己的內容
您也無需僅僅為了獲取尾部鍵而處理交叉參照串流可能透過其 /DecodeParms 套用的 PNG 和 TIFF 預測因子(Predictor)函數。預測因子會過濾二進位交叉參照列以使其更好地被壓縮;它們與串流之前的字典沒有關係。因此,使傳統驗證器具備現代 PDF 感知能力的最小升級是很小的:當 startxref 落在串流而非 xref 關鍵字上時,解析串流字典以獲取尾部鍵,並展開您遇到的任何 /ObjStm 物件,以便它們的內容進入物件表中。解碼類型 2 項目和預測因子是一項單獨的、更龐大的工作,您可以推遲到真正需要隨機物件解析時再進行
為什麼符合性檢查必須先展開串流
一旦您執行設定檔檢查,這便不再是學術問題。PDF/A 或 PDF/X 驗證器會檢查特定的物件:文件編目的 /OutputIntents 陣列、帶有正確識別碼的 /Metadata 串流以獲取 XMP 封包、每個內嵌字型檔案的字型描述元、尾部以獲取 /ID。在壓縮檔案中,大多數此類物件都存在於物件串流內部。尚未展開物件串流的驗證器無法看見編目的鍵,找不到中繼資料,也無法列舉字型。它會將一個完全符合規範的文件回報為遺失輸出意圖、遺失 XMP,以及遺失一半的結構,因為它所需要的證據仍然存放在它從未解壓縮的 Flate Blob 內部
順序很關鍵。展開必須在檢查執行之前發生,而不是與其並行,因為每項檢查都假設它可以依編號存取物件。如果您將設定檔檢查直接接線到原始位元組掃描上,它將繼承傳統剖析器的盲目性,並在恰好是最可能格式良好的現代檔案上產生偽違規,因為它們來自足夠新的、能首先生產交叉參照串流的工具鏈
讓 PDFium 為您進行解析
PDFium 元件在載入文件時解析交叉參照串流和物件串流,這是避免手動實作解壓縮和展開步驟的實用方法。當您使用 TPdf 元件載入檔案時,打包在 /ObjStm 容器內部的物件已解析完成,且驗證進入點能看見完整展開的文件。ValidatePdfA 傳回一個 TPdfAValidationResult 記錄,其 Conformance 欄位是一個 TPdfAConformance 值(例如 pac1b 或 pacNone),其 Issues 欄位是發現的特定問題集,且其 IsCompliant 方法只有在偵測到符合性層級且問題集為空時才為 True。因為物件在載入期間已被展開,因此存在於物件串流內部的 /OutputIntents 陣列或內嵌字型會被找到,而不會被回報為遺失
uses
PDFium, FPdfPdfa;
function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True; // 載入時解析 xref/物件串流
Result := Pdf.ValidatePdfA; // 看見已展開的物件表
finally
Pdf.Free;
end;
end;
The same applies to ValidatePdfX, which returns a TPdfXValidationResult with the same shape. The point of routing through PDFium is that the structural decompression described above happens once, correctly, inside the loader, so your validation code never sees the difference between a classic file and a fully compressed one. Both arrive at the validator as a resolved set of objects
function PdfXConformanceName(C: TPdfXConformance): string;
begin
case C of
pxc1a: Result := 'PDF/X-1a';
pxc3 : Result := 'PDF/X-3';
pxc4 : Result := 'PDF/X-4';
else
Result := 'none';
end;
end;
var
Pdf: TPdf;
R : TPdfXValidationResult;
Issue: TPdfXValidationIssue;
IssueCount: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'Press_Ready.pdf';
Pdf.Active := True;
R := Pdf.ValidatePdfX;
if R.IsCompliant then
Writeln('PDF/X conformance: ', PdfXConformanceName(R.Conformance))
else
begin
IssueCount := 0;
for Issue in R.Issues do // Issues 是一個集合:計算其成員數量
Inc(IssueCount);
Writeln('Not conformant; issue count = ', IssueCount);
end;
finally
Pdf.Free;
end;
end;
如果位元組已經在記憶體中而不是在磁碟上,則相同的「載入後驗證」序列可以透過 LoadDocument(const Data: TBytes) 多載來運作,該多載接受原始檔案內容,並以與檔案路徑相同的方式解析其交叉參照和物件串流。手寫驗證器的重點是結構性規則,而不是 API:從純文字的串流字典中讀取尾部鍵,在您遍歷文件之前使用 Flate 解碼器展開每個 /ObjStm,並將解碼二進位交叉參照項目視為一項更龐大、選用的工作
一旦結構展開,驗證器便可以在其上驅動工作流程的其餘部分。對於回報資料夾中所有輸入符合性的命令列 Preflight 測試控能器,請參閱我們關於建置批次 Preflight 報告 CLI 的指南。當驗證是拆分大型文件之前的關卡時,在我們關於將 PDF 文件拆分為多個檔案的指南中介紹的技術,能與此處所示的「載入並檢查」模式自然配對。兩者都建置在適用於 Delphi 和 C++Builder 的 PDFium 元件的載入和驗證介面上