技術文章

在 Delphi 中使用 PDFium VCL 進行 PDF/VT 變動資料列印 (Variable Data Printing)

一家交易列印廠 (transactional print shop) 退回了您 80,000 頁的對帳單批次列印,並附上一行拒絕理由:「不是 PDF/VT,RIP 無法快取」。該檔案在您桌上的每個檢視器中都能正常開啟,顏色正確,資料也正確合併。但這些都不是數位印刷機 (digital press) 所要求的。高速變動資料列印 (high-speed variable-data printing) 的成敗取決於印刷機能否識別出第 1 頁的客戶標誌區塊與第 40,000 頁的區塊在位元組等級 (byte-for-byte) 上是同一個物件,將其渲染一次,然後重複使用。PDF/VT 正是讓這項承諾可被機器檢查的標準,而「看起來正確」正是一個陷阱,因為 RIP 讀取的結構在螢幕上是看不見的。

PDFiumPas 透過 TPdf 上的一個小介面揭露了該結構:SaveAsPdfVT 寫入它,ValidatePdfVT 檢查它。本文探討這兩個方法實際上在磁碟上寫入了什麼以及檢查了什麼、ISO 16612-2 在哪些地方比乍看之下還要嚴格,以及哪些部分只是誠實的結構錨點 (structural anchors),而不是您可以向客戶收費的完整預檢 (preflight)。

PDF/VT 標準化了什麼,以及為什麼 PDF/X 排在第一位

PDF/VT (ISO 16612-2:2010) 並不是一種全新的檔案格式。它是附加在 PDF/X 檔案上的一層最佳化中繼資料 (metadata),而這個順序是承重的 (load-bearing)。該標準定義了三個合規層級,但其中只有兩個是用來命名 PDF 檔案的:PDF/VT-1,一個單一的、獨立的文件;以及 PDF/VT-2,一個頁面參照共享外部資源的檔案集 (file-set) 模型。您可能會看到的第三個標記 PDF/VT-2s,根本不是一個檔案層級的值;它存在於附錄 A 中描述的 MIME 串流標頭中。如果您發現有程式碼將 GTS_PDFVTVersion = "PDF/VT-2s" 蓋章到文件的 XMP 中,那麼該程式碼是錯誤的。

對於單一檔案而言,不可妥協的規則是 PDF/X 基礎。ISO 16612-2 §6.2.1 要求每一個 PDF/VT-1 檔案也必須是一個有效的 PDF/X-4 檔案。根據 §6.2.2,PDF/VT-2 的檔案集則必須建立在 PDF/X-4p、PDF/X-5g 或 PDF/X-5pg 之上。這就是為什麼 PDF/VT 寫入器不能只是附加幾個識別碼鍵值:它必須攜帶整個 PDF/X-4 標記集,這意味著需要有一個 OutputIntent、一個內嵌的 ICC 目的地設定檔 (destination profile)、相符的 XMP 和文件 Info 項目、一個預告片 (trailer) /ID,而且不能有加密。省略其中任何一項,您就會得到一個自稱是 PDF/VT 的檔案,並在合規的消費者 (consumer) 檢查基礎時立刻失敗。PDFiumPas 將 PDF/X-4 層視為 PDF/VT 儲存的一部分,因此您不需要先單獨呼叫 SaveAsPdfX;注入器 (injector) 會在一次傳遞中寫入這兩層。

使用 SaveAsPdfVT 寫入檔案

最簡單的呼叫只需要一份使用中的文件,因為 TPdfVTSaveOptions.Default 提供了內建的 sRGB ICC 設定檔和 pvc1 的合規性。儲存作業在內部執行三個步驟:它會移除任何安全性設定 (將純文字標記注入加密的物件串流會導致其損毀),將文件現有的 Info 字典和預告片 (trailer) /ID 橋接到標記集中以確保 XMP 和 Info 值一致,然後透過漸進式更新 (incremental update) 附加 PDF/X-4 和 PDF/VT 物件。

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    if Pdf.LoadFromFile('statements-merged.pdf') then
    begin
      // Default options: built-in sRGB OutputIntent, PDF/VT-1, synthesised DPart
      if Pdf.SaveAsPdfVT('statements-pdfvt.pdf') then
        Writeln('PDF/VT-1 written')
      else
        Writeln('Save failed (document not active?)');
    end;
  finally
    Pdf.Free;
  end;
end;

對於真正的生產輸出,您幾乎總是希望使用您的印刷機特徵 (press's characterization) 來覆寫 OutputIntent,而不是使用通用的 sRGB 備用方案 (fallback)。透過 TPdfVTSaveOptions 提供 ICC 位元組 (bytes) 和條件識別碼:

var
  Pdf: TPdf;
  Opt: TPdfVTSaveOptions;
  Icc: TBytes;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('directmail-merged.pdf');
    Icc := LoadIccProfile('GRACoL2013_CRPC6.icc');  // your own loader

    Opt := TPdfVTSaveOptions.Default;
    Opt.Conformance := pvc1;            // pvc2 is normalised to pvc1 on write
    Opt.IccProfileData := Icc;
    Opt.OutputConditionIdentifier := 'CGATS21_CRPC6';
    Opt.OutputCondition := 'Commercial print, coated, CRPC6';
    Opt.RegistryName := 'http://www.color.org';
    Opt.Title := 'Spring 2026 Direct Mail Run';
    Opt.Trapped := ptvFalse;           // PDF/X Info /Trapped state

    Pdf.SaveAsPdfVT('directmail-pdfvt.pdf', Opt);
  finally
    Pdf.Free;
  end;
end;

該程式碼片段中的一個細節是一個刻意設立的防護欄 (guardrail),而不是一個您可以爭論的限制。設定 Opt.Conformance := pvc2 並不會產生一個 PDF/VT-2 檔案。寫入器會將任何非 pvc1 的要求正規化回到 pvc1,因為 PDF/VT-2 是一種檔案集格式,而一個只附加一份輸出文件的單一檔案寫入器在物理上無法組裝 §6.2.2 所要求的外部資源集。pvc2 的值是為了讀取路徑而存在的,所以 ValidatePdfVT 可以識別並回報一個現有的檔案集文件;它不是一個寫入目標。

DPart 樹狀結構:RIP 實際讀取的結構

PDF/VT 的核心是文件部分 (Document Part, DPart) 階層結構。它讓印刷機可以將一個長列印批次分割成記錄 (records),將記錄分組為收件人或郵件包 (mail bundles),並附加文件部分中繼資料 (Document Part Metadata),以便下游設備可以針對每一件進行路由和計費。ISO 16612-2 §6.5 勾勒了這個線路:目錄 (catalog) 帶有 /DPartRoot,根 DPart 節點帶有 /DPartRootNode 以及為每個階層層級命名的 /NodeNameList,葉節點 DParts 涵蓋頁面樹的範圍,而屬於某個部分的每一頁都透過頁面層級的 /DPart 項目指回其葉節點。

當您的來源文件已經包含一個可用的階層結構時,SaveAsPdfVT 會保留它。當它沒有時,寫入器會合成一個最簡單的結構:一個跨越目前頁面樹 (依順序) 的單一文件層級 DPart,每個活動頁面物件都附加了一個 /DPart 反向參照 (back-reference),以及一個單一層級的 /NodeNameList [/Document]。對自己誠實一點,了解這個極簡的樹狀結構是什麼。它是一個滿足 §6.5 形狀要求的結構錨點;它不是商業中繼資料。它無法憑空發明收件人、郵件邊界或產品批次,因為該資訊從未存在於來源中。如果您有每個收件人的資料,您應該自行建置一個更深的 DPart 樹狀結構,並擴充 /NodeNameList 以符合您所建立的層級。

超越鍵值存在的驗證

ValidatePdfVT 回傳一個包含三項資訊的 TPdfVTValidationResult 記錄 (record):偵測到的合規性 (Conformance)、問題集合 (Issues),以及一個只有在合規性為真實層級且問題集合為空時才為 true 的 IsCompliant 輔助屬性。問題列舉 (issue enumeration) 是刻意具體化的,所以一個失敗的結果會告訴您遺漏了哪一個條款,而不僅僅是「無效」:

var
  Pdf: TPdf;
  Res: TPdfVTValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('statements-pdfvt.pdf');
    Res := Pdf.ValidatePdfVT;

    if Res.IsCompliant then
      Writeln('PDF/VT compliant: ', VTLevelName(Res.Conformance))
    else
    begin
      if pvviMissingDPartRoot in Res.Issues then
        Writeln('DPart hierarchy missing or unusable');
      if pvviMissingPdfXIdentifier in Res.Issues then
        Writeln('PDF/X-4 base identifier absent');
      if pvviMissingOutputIntent in Res.Issues then
        Writeln('OutputIntent / ICC profile missing');
      if pvviEncryptionPresent in Res.Issues then
        Writeln('Encrypted - PDF/X forbids this');
    end;
  finally
    Pdf.Free;
  end;
end;

值得深入了解的兩個檢查是合規性配對 (conformance pairing) 和 DPart 走訪,因為這兩者過去都太過寬鬆,後來為了符合規範而變得更加嚴格。在配對方面,驗證器執行的是精確匹配,而不是「任何 PDF/X 都可以」:一個 PDF/VT-1 檔案只會被接受建立在 PDF/X-4 基礎上,而一個 PDF/VT-2 檔案只接受 PDF/X-4pPDF/X-5gPDF/X-5pg。位於 PDF/X-1a 基礎上的 PDF/VT-1 標記會被報告出來,而不是輕易放行。

DPart 走訪是大部分嚴謹性所在之處。目錄中擁有 /DPartRoot 鍵值是不夠的,因為一個偽造的空物件或沒有頁面連結的物件仍然無法被使用。HasValidDPartHierarchy 和遞迴的 ValidateDPartNode 會追蹤整個結構:它們跟隨父連結,拒絕重複的子節點和循環,強制 /Start/DParts 是互斥的 (mutually exclusive),並要求葉節點頁面範圍必須以深度優先 (depth-first) 的順序涵蓋頁面樹,且每一頁的 /DPart 都要指向包含它的葉節點。所有這些內部錯誤都歸結為單一的 pvviMissingDPartRoot 問題位元,而不是擴充公開的列舉,所以請將這一個旗標視為「DPart 階層無法使用」,而不是字面上的「遺失根鍵值」。

驗證器現在強制執行的三個語法陷阱

針對 §6.5 表 4 所進行的連續測試,發現了一些早期版本接受但標準不允許的形狀。這些正是手工建置的 DPart 樹狀結構容易出錯的地方,因此值得特別提出來說明:

  • /DParts 是一個陣列的陣列 (array of arrays),而不是一個扁平陣列 (flat array)。 外部陣列的每個元素本身必須是一個間接參照陣列。一個扁平的 /DParts [9 0 R] 會被拒絕;合規的形狀是 /DParts [[9 0 R] [10 0 R]]。這能阻止非階層式的結構偽裝成有效的層級。
  • /End 只標示一個真正的多頁面範圍。 一個葉節點 DPart 只有在同時擁有 /Start 時,才能帶有 /End,而且在頁面樹順序中 /End 必須落在 /Start 之後。一個退化的 /Start 3 0 R /End 3 0 R 現在會使階層無法使用,而不是被解讀為一個單頁面部分。
  • /NodeNameList 的名稱在做為 XML NMTOKENs 進行 PDF 名稱反跳脫 (unescaping) 後必須保持有效。/Bad#20Name 這樣的名稱會展開為包含一個空白字元,這不是一個有效的標記 (token)。該實作進行了輕量級的 ASCII 檢查 (字母、數字、.-_:,加上非 ASCII 位元組),以捕捉空白字元和分隔符號錯誤,同時不拒絕合法的在地化或供應商特定的名稱。

XMP 標記:編寫相同屬性的兩種方法

PDF/VT 識別 (identification) 存在於 XMP 中的 pdfvtid 命名空間 (namespace) 之下,具體來說是 GTS_PDFVTVersionGTS_PDFVTModDate,與標準的 xmp:CreateDatexmp:ModifyDate 並列。一個會導致天真 (naive) 的閱讀器發出錯誤「遺失」報告的微妙之處在於,這些項目中的任何一個都可以透過兩種方式進行序列化 (serialized):作為元素文字 (element text) (<pdfvtid:GTS_PDFVTVersion>PDF/VT-1</pdfvtid:GTS_PDFVTVersion>) 或是作為描述 (description) 元素上的 RDF 屬性。PDFiumPas 會讀取這兩種形式,所以另一個工具以屬性樣式寫入的檔案不會受到懲罰。它還強制執行 §6.3 關於一致性的規則,即 GTS_PDFVTModDate 必須等於 xmp:ModifyDate;不匹配會引發 pvviModDateMismatch

同一條款的另一條規則:一個未知的 GTS_PDFVTVersion 值會被保留為 pvcUnknown,而不是被折疊回 (folded back) pvcNone。這個區別在操作上很重要。pvcNone 的意思是「根本沒有 PDF/VT 標記,這是一個普通的 PDF」,而 pvcUnknown 的意思是「有某個東西蓋上了一個這個驗證器不認識的版本印章」(其中包含了 PDF/VT-2s 的情況)。將這兩者混為一談,會將一個畸形的檔案隱藏在與普通文件相同的分類中。

保證的極限在哪裡

準確說明這些方法所承諾的界線是值得的,因為變動資料列印的合規性與真金白銀息息相關。DPart 和配對檢查屬於位元組等級 (byte-level) 的結構驗證。它們確認最佳化骨架、PDF/X-4 基礎標記、OutputIntent 以及 XMP 的存在,並且在內部是一致的。它們不是內容層級的 PDF/X-4 預檢:它們不驗證每種顏色是否都在宣告的輸出條件內,不驗證是否所有的字型都已嵌入,也不驗證是否有任何被禁止的透明度混合邊緣情況 (transparency-blending edge case) 溜進來。對於您要交付給合約印刷機 (contract press) 的工作,請將 PDFiumPas 的結構驗證與專用的 PDF/X 預檢引擎及測試列印搭配使用,就像您對任何其他合規性聲明進行健全性檢查 (sanity-check) 一樣。結構層捕捉的是那些會默默破壞 RIP 快取的失敗;它是一項完整檢查的一半,而不是全部。

如果您要將這些檢查建置到更廣泛的發布關卡 (release gate) 中,同樣的位元組等級掃描方法也奠定了該函式庫的其他標準工作基礎,這包括在檔案抵達預檢前驗證物件和交互參照串流 (cross-reference streams),以及透過 Form XObjects 可重複使用頁面戳記 (reusable page stamps) 背後的共享物件原則,這些原則才是讓文件一開始就對 RIP 友善的原因。這裡所描述的 PDF/VT 和 PDF/X 儲存與驗證 API,是適用於 Delphi 和 C++Builder 的 PDFium VCL 元件的一部分,其產品頁面載有完整的合規性參考資料。