技术文章

用 PDFlibPas 在 Delphi 中产出 PDF/E-1 工程文档

PDF/E-1 是面向工程文档的归档规范,PDFlibPas 把它实现为一个用 SetPDFEMode 打开的作者模式,外加一个逐操作符读取内容流的有界 preflight。这套规范不是换了个标签的 PDF/A:它有自己的标识命名空间、自己的生命周期元数据要求,还有一条让内容校验比你见过的任何归档规范都严格的规则

工程交付物是这套规范存在的理由。一套图纸,要求二十年后依然可读、且可证明未被改动,修订历史要留存,颜色在另一栋楼里的绘图仪上还要是同一个含义。这些需求造就了一份大部分要求都在页面内容之外的规范——落在元数据和色彩管理上,而这恰恰是通用 PDF 写出器最容易做错的地方

自己的标识体系,不是 PDF/A 的变体

首先要弄对的是:PDF/E-1 标识没法靠改一改 PDF/A 或 PDF/X 的套路凑出来。它用一个独立的 XMP 命名空间 http://www.aim.org/pdfe/ns/id/,而且版本值必须出现在两个位置:既作为文档信息项,也作为带命名空间限定的 XMP 属性。只发 XMP 属性、或者只发信息项,产出的文件带着正确意图却过不了校验

输出意图(output intent)同样有精确的形状要求。PDF/E-1 要求内嵌的 ICC profile 带子类型标识 ISO_PDFE1,且 profile 的分量数必须与文档实际使用的设备色族匹配。最后这半句正是实现悄悄出错的地方——它意味着输出意图不能事先选定之后就不再过问

为什么设备色需要全文档扫描?

因为色彩空间藏在一层页面级扫描永远够不到的资源字典里。PDF/E-1 把 DeviceRGB 和 DeviceCMYK 视为一个文档内互斥的两个色族,所以要校验 profile 就得知道文件里所有东西用到的每一个设备色彩空间。Form XObject 有自己的资源字典;pattern 有,图像也有。页面里 form XObject 里再嵌一个平铺 pattern,就是三层深——只检查顶层页面资源的校验器,会让一个同时用了两个色族的文档蒙混过关

因此这次扫描在遍历页面、form、图像和 pattern 的同时登记色彩空间,把整个文件当作一次遍历来走,然后才判断文档是否自洽、输出意图是否匹配。同样的思路也支撑着 preflight 的整体架构:部分遍历产生假通过,而一致性检查上的假通过比没有检查更糟,因为它会被记录成证据

var
  Lib: TPDFlib;
  Diag: WideString;
begin
  Lib := TPDFlib.Create(nil);
  try
    Lib.LoadFromFile('assembly-drawings.pdf');

    if Lib.SetPDFEMode(1) = 0 then
      raise Exception.Create('PDF/E author mode was refused');

    // 作者模式在每次保存时都让生命周期元数据保持同步。
    // 保存之前先问一句:这份文档过得了自己的闸门吗
    if not Lib.PDFEReadyForSave then
    begin
      Diag := Lib.GetPDFEDiagnostics;
      Writeln('PDF/E blockers: ', Diag);
      Exit;
    end;

    Lib.SaveToFile('assembly-drawings-pdfe.pdf');
  finally
    Lib.Free;
  end;
end;

生命周期元数据是每次保存的义务

PDF/E-1 要的不只是一个文档标识符。最小集合包括媒体管理文档标识符、版本标识符、rendition class、创建时间、修改时间、元数据时间和标题。这是一套修订追踪词汇,它存在的原因是:工程交付物被期待的是反复改版再发布,而不是写一次就完

对实现来说,后果是这些字段不能在创建文档时一次性设完。如果修改时间在你启用模式的那一刻写入,而文档事后又被编辑,XMP 快照和文档实际状态就漂移了,校验器一比对就报出谁也没想要的不一致。所以作者模式在每次保存之前立即同步这些字段,让元数据描述的是即将写入的那些字节,而不是模式刚打开时存在的那份状态

这是一条适用于一切一致性元数据的通用原则,值得从 PDF/E 里单独拎出来说:派生元数据属于保存路径,不属于编辑路径。任何从文档状态计算出来的字段,都必须在状态被冻结的那一刻重新计算,否则它就是一个没有失效机制的缓存

PDFlibPas 的 PDF/E-1 示意图:全文档设备色扫描按一次遍历走过页面、form XObject、平铺 pattern 和图像的资源字典,收集 DeviceRGB 与 DeviceCMYK 色族之后再判断自洽性;旁边是作者模式在每次保存前立即重新同步的生命周期元数据字段,让 XMP 快照与即将写入的字节保持一致
颜色自洽性只有在一次遍历触达每个资源字典之后才能判断;派生的生命周期元数据在文档状态被冻结的那一刻重新计算,而不是在模式打开的那一刻

让内容校验变严的那条规则

PDF/E-1 不允许兼容区段操作符吞掉未知内容。在普通 PDF 里,BXEX 圈出一段区域,消费方在其中必须忽略自己不认识的操作符——这是让生产方发出较新构造而不弄坏旧阅读器的逃生舱。在 PDF/E-1 之下这道逃生舱被封死了:preflight 不认识的任何操作符都会被无条件报告,不管它是否位于兼容区段之内

这对校验器的影响很大。它不能跳过自己看不懂的区域,这意味着操作数解析器必须老老实实解析每条内容流里的每个操作符。边界限制就是为这里准备的:遍历被限制在 128 层嵌套、一百万个对象和 64 MiB 内容以内,而且这些上限不是性能调优参数。一个恶意或者单纯坏掉的文件可以呈现带环的对象图、或者把递归校验器变成栈溢出的嵌套深度,正是这些上限让一次校验不会变成拒绝服务向量。安全解析不受信任的 PDF描述的正是同一种防御姿态

// 对一个不是你产出的文件做独立校验,
// 不必加载进文档实例
var
  Issues: TStringList;
  Stream: TFileStream;
  I: Integer;
begin
  Issues := TStringList.Create;
  Stream := TFileStream.Create('incoming.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if CheckCompliancePDFE(Stream, '', 0, Issues) = 0 then
      for I := 0 to Issues.Count - 1 do
        Writeln('PDF/E: ', Issues[I]);
  finally
    Stream.Free;
    Issues.Free;
  end;
end;

保存闸门修什么,又拒绝什么

闸门把工作分成两个阶段,这个拆分本身就是个可复用的设计思路。第一阶段规范化那些可以安全修复的东西:注释的打印标志、文本注释的禁止缩放和禁止旋转标志,以及 form 字典上的外观生成标志。这些都是在一套规范之下只有一个正确取值、不携带任何信息的设置,所以静默修正是对的,为此拒绝反而是迂腐

然后检查那些不改变文档含义就无法修复的约束:版本、标识、加密、输出意图、设备色自洽性,以及动态 form 内容的存在。任何一条不过,文档就被拒绝——因为替作者凭空造一个输出意图、或者替他挑一个色族,产出的文件能过校验,却是对内容的歪曲

PDFlibPas 面向 Delphi 的 PDF/E-1 保存闸门流程图:有界 preflight 在 128 层嵌套、一百万对象和 64 MiB 上限内扫描每条内容流的每个操作符,静默修复注释的打印、缩放和旋转标志,拒绝错误的版本、标识、加密、输出意图、设备色或动态 form 内容,并通过 GetPDFEDiagnostics 报告阻塞项
闸门只静默修复不携带信息的设置,拒绝一切修复会歪曲的约束,并在任何字节落盘之前通过 GetPDFEDiagnostics 把拒绝变成一份阻塞项清单

保存之前先用 GetPDFEDiagnostics 把诊断读回来,拒绝就变成一份可操作的清单,而不是一次失败的调用。在批处理流水线里,对每个文档都调用它,按文件记录阻塞项,把失败路由到一个有人看的队列。这比一次直接抛异常的保存有用得多,因为阻塞项通常是扎堆的:四十份文档因为同一个缺失的输出意图挂掉,是一个修复,不是四十个

在几套归档规范之间做选择

当交付物是带修订生命周期的工程文档时,PDF/E-1 是正确的目标——尤其是当设备色自洽性很重要、输出要上绘图仪和大幅面打印机的时候。当目标是文档的一般性长期可读时,PDF/A 是正确目标,而且它是校验器支持最广的那套规范。两者不可互换,一份文档可以满足其中一个而通不过另一个

PDFlibPas 面向 Delphi 的 PDF/E-1 与 PDF/A 归档规范决策图:PDF/E-1 面向带修订生命周期、绘图仪色彩与合同性校验要求的工程交付物,使用自己的 XMP 命名空间与 ISO_PDFE1 输出意图;PDF/A 面向一般性长期可读,校验器支持最广
从链路末端谁来校验这份文件出发做选择:两套规范要求的标识、元数据和色彩保证各不相同,一份文档可以满足其一而通不过其二

如果你在做选择,就从链路末端谁来校验这份文件开始想。PDF/A 校验工具遍地都是,PDFlibPas 里对应的 preflight 在 PDF/A 与 PDF/UA preflight 一文有描述。PDF/E 校验更专门化,通常是合同要求而非默认动作。当已有的归档必须被提升到一份它从未按其编写的规范时,带元数据修复转换 PDF/A 中的元数据修复路径就是要遵循的模式,同样的形状在这里也适用:识别、修复安全的部分、其余的带着清单拒绝

作者模式、有界内容 preflight 和独立合规检查都随 PDFlibPas Delphi PDF library 提供,一份文档可以在规范之下产出,之后经由独立代码路径再验证一次——对于一致性声明来说,这是唯一值得信任的安排