技術文章

Delphi 精確 PDF 版本儲存:PDFiumPas 合規性檢查

PDFiumPas 是 Google PDFium 引擎的 Delphi 與 C++Builder 封裝元件,透過 TPdf.SaveAs 方法的 PdfVersion 參數,可將文件以 1.3 到 1.7 之間的精確版本儲存。PDFium 本身的 FPDF_SaveWithVersion 呼叫只會改寫 %PDF-M.m 標頭,並不會檢查文件的實際內容在該版本下是否合法。PDFiumPas 透過儲存後的合規性檢查來補上這個缺口:在檔案離開該方法之前,會走訪有效的交叉參照修訂鏈,並檢查 Adobe Extension Level 宣告

這種區別在印刷生產環境中最為重要:PDF/X 描述檔會指定一個精確的 PDF 版本,而預檢工具或 RIP 會拒絕任何與自身標頭悄悄不符的檔案,這個情境從輸出端的角度已在以 PDFiumPas 驗證印刷用 PDF/X 文件中討論過。SaveAsTPdfVersion 列舉型別公開目標版本,除了較舊的 pv10pv12 之外,還有 pv13pv17,另外還有一個獨立的 TSaveOption 用於控制增量或完整改寫。傳入 PdfVersion 後,PDFiumPas 會在同一次呼叫中完成兩件事:先請 PDFium 標記所要求的標頭,接著重新讀取剛寫入的位元組,若該版本下無法合法存在目前有效的內容,就拒絕回傳檔案

var
  Pdf: TPdf;
begin
  Pdf:= TPdf.Create(nil);
  try
    Pdf.FileName:= 'source.pdf';
    Pdf.Active:= True;
    try
      Pdf.SaveAs('press-ready.pdf', saNoIncremental, pv17);
    except
      on E: Exception do
        // E.Message names the offending feature and the version or
        // extension level it actually needs, for example:
        // "RichMedia annotations and RichMediaExecute actions require
        // /Extensions /ADBE with /BaseVersion /1.7 and /ExtensionLevel 3
        // or newer."
        raise;
    end;
  finally
    Pdf.Free;
  end;
end;

為什麼不能信任檔案中最後一個物件定義?

PDF 檔案中具有特定編號的最後一個實體物件,不見得就是合規閱讀器如今會為該編號解析出的物件。一份經歷過多次增量更新的 PDF,並不是只有一張物件圖,而是在同一個檔案內層層疊加的多個歷史版本;每一次附加更新都可能釋放某個物件、以新的世代編號重新定義它,或是把舊的實體內容留在兩個 endobj 標記之間,卻不再有任何交叉參照項目指向它

PDFiumPas 在明確追蹤 xref 修訂之前,就曾踩過這個失效模式:一個因後續頁面物件改寫而變成孤兒的 Redact 註解,或是一個實體上仍存在、卻沒有任何 xref 項目指向的 /MarkInfo 字典,都可能在逐位元組掃描中被找到,並觸發一個對閱讀器實際會開啟的文件而言早已不適用的版本功能檢查。這種失效的方向是誤判拒絕,而非誤判通過:一個在目前修訂版本中確實已經不再使用某項功能的檔案,仍可能因為誰也無法再存取的內容,而被擋下無法儲存為較低版本

PDFiumPas 如何判斷哪些物件定義才是真正有效的?

PDFiumPas 判斷有效物件集合的方式,與合規閱讀器完全相同:走訪交叉參照鏈,而不是逐位元組掃描物件標頭。解析器從檔案中最後一個 startxref 位移開始,沿著每一個 /Prev 連結往回走訪較舊的修訂版本,過程中會解析傳統交叉參照表、混合式 /XRefStm 連結串流,以及純交叉參照串流。整個走訪是由新到舊進行,每個物件編號在第一次出現時就會被確定下來,因此較新修訂版本中的釋放項目,會正確地遮蔽掉較舊版本所寫入的物件內容;而以新位移或新世代編號重新定義的物件,也一定會勝過它所取代的舊物件

物件串流(object stream)成員會多一道單靠位移查找無法提供的檢查,這個機制在以 PDFiumPas 驗證物件與交叉參照串流一文中有更深入的說明。從 /ObjStm 還原出的壓縮物件,必須在同一次走訪中確認其父串流為有效狀態,且其索引必須與該成員在串流標頭中的自身位置相符,PDFiumPas 才會把它視為有效內容。ISO 32000-1 第 7.5.8.4 節甚至描述了一種混合參照的情況:傳統相容性表將某個物件標記為已釋放,而尾端字典(trailer)的 /XRefStm 項目卻同時把同一個物件定義為位於他處的壓縮成員;PDFiumPas 會在套用傳統項目之前,先把補充的 xref 串流併入同一個修訂版本,讓壓縮定義依照規範所預期的方式勝出

Adobe Extension Level:凌駕於版本號之上的門檻

%PDF-1.7 標頭只保證了 ISO 32000-1 在 2008 年標準化的功能集合,而今日許多 PDF 產生工具所仰賴的功能,都是後來以 Adobe 專屬補充規範的形式,疊加在同一個版本號之上推出的。Adobe 把每一項補充規範都登記為一組 BaseVersionExtensionLevel,記錄在文件目錄(document catalog)的 /Extensions 字典中、以開發者前綴標示——Adobe 自家的擴充功能使用 ADBE——如此一來,閱讀器就能分辨出一份單純的 PDF 1.7 檔案,與一份同時實作了特定編號擴充等級的檔案。以 pv17 儲存卻缺少該宣告本身並不算錯誤;唯有當目前有效的內容確實依賴該宣告理應涵蓋的功能時,這才會成為問題

哪些高版本功能會觸發明確版本檢查?

PDFiumPas 檢查的是一份明確、依規範制定的清單,而不是單憑版本號猜測。帶有明確 /SMaskInData 項目或 /BitsPerComponent 值為 16 的影像字典,兩者都需要 PDF 1.5,其中 16 位元的情況直接依循 PDF Reference 1.5 第 4.8 節的影像元件規則。RichMedia 註解與 RichMediaExecute 動作需要 /BaseVersion /1.7 搭配 /ExtensionLevel 3 或更高版本。PRC 3D 串流——以同時帶有 /Type /3D/Subtype /PRC 的字典識別——需要相同的基礎版本,但只要 /ExtensionLevel 1 即可。地理空間 Measure 字典與 Projection 註解則需要 /BaseVersion /1.7 搭配 /ExtensionLevel 3,與 RichMedia 所依賴的是同一份 Adobe 補充規範

地理空間檢查有一個值得留意的規範判讀細節,若你打算在 PDFiumPas 之上建立自己的版本門檻邏輯,這點特別重要。ISO 32000-1 表 254 將 Measure 字典的 /Type 項目標記為選填,僅註明「若存在,應為 Measure」;而表 311 則規定 PRC 內容所在的 3D 串流字典必須要有 /Type。實際上,來自地圖繪製工具的 GeoPDF 輸出,通常會在 Measure 字典中省略 /Type,只寫入 /Subtype /GEO,因此 PDFiumPas 的地理空間偵測器只依 /Subtype 比對,而不像其 PRC 3D 偵測器那樣可以安全地要求兩個鍵值都存在。若同時要求兩個字典都帶有 /Type,將會讓合規的 GeoPDF 內容悄悄溜過這道檢查,最終出現在一份沒有擴充等級宣告支撐的單純 PDF 1.7 檔案中

PDFiumPas 會自動降級不支援的功能嗎?

就一般能力而言並不會,若假設它會,正是這裡要避免的誤解。SaveAs 會把目標版本交給內部的 ValidatePdfVersionCompliance 常式處理,當該常式發現某項功能是目標版本或其擴充等級宣告無法支援的,SaveAs 就會拋出帶有該常式錯誤訊息的例外,而不是寫入檔案;呼叫端拿回的一定是精確、指名功能的原因,而不會是一份被悄悄改寫過的文件。PDFiumPas 唯一會自動改寫內容的情況是以 PDF 1.3 為目標時:它會移除語意中立的 /BM /Normal/CA 1/ca 1 透明度預設值——這些是 PDFium 無論目標版本為何都一律會寫入 ExtGState 字典的內容——因為這些特定值本身不帶任何視覺意義,而 PDF 1.3 的年代還早於這些鍵值的出現

// PDF 1.3 targets rewrite the saved bytes to strip transparency
// defaults PDFium always emits, so incremental mode cannot apply
Pdf.SaveAs('legacy-archive.pdf', saIncremental, pv13);
// raises: PDF 1.3 normalization is incompatible with incremental
// save mode

真正非預設的透明度與影像柔化遮罩(soft mask),在以 PDF 1.3 為目標時仍然會直接失敗,因為移除它們會改變頁面實際的外觀,而 PDFiumPas 不會代替你做這個決定。在把精確版本納入批次處理流程之前,有兩個相關限制值得事先規劃。明確版本輸出永遠不會帶有 /Encrypt 字典;若來源檔案受保護,儲存會立即失敗,這恰好與禁止加密的 PDF/X 與 PDF/A 描述檔一致,但這也代表解密是你工作流程中另外獨立的一個步驟,而不是 SaveAs 會替你完成的事。PDFiumPas 也沒有公開方法可以把 /Extensions /ADBE 宣告寫入目錄,因此若來源檔案含有 RichMedia、PRC 3D 或地理空間內容,卻缺少該宣告,無論你要求哪一種 PdfVersion 都無法通過這道檢查;該宣告必須已經存在於來源檔案中(通常是由製作工具寫入的),否則就得在儲存前先移除相關功能。唯讀屬性 TPdf.PdfVersion 值得在嘗試精確版本儲存之前先檢查一下,因為它解析出的正是儲存時驗證器本身所依賴的、同樣具備目錄感知能力的有效版本——無論目前生效的是標頭還是 /Version 覆寫值

Pdf.FileName:= 'incoming.pdf';
Pdf.Active:= True;
// PdfVersion resolves the same catalog-aware effective version the
// save-time validator uses, so a mismatch here is worth investigating
// before spending a full SaveAs attempt on it
LogSourceVersion('incoming.pdf', Pdf.PdfVersion);

當精確版本目標觸發 SaveAs 例外時,應把它當成一份預檢報告來看待,而不是程式錯誤:訊息會指名來源文件違反的確切條款,這正是印刷廠或封存流程在檔案繼續往下一步之前所需要的資訊。本文所描述的明確版本儲存路徑、有效 xref 修訂解析器,以及 Adobe Extension Level 檢查,都是 Delphi 與 C++Builder 標準版PDFiumPas Component的一部分;產品頁面收錄了完整的 TPdf.SaveAs 參考資料,以及其餘的合規性與表單 API