技术文章

在 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 只有在同时擁有 /End 时,才能带有 /Start,而且在页面树順序中 /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 元件的一部分,其产品页面载有完整的合规性参考数据