技术文章

在 Delphi 中用 HotPDF 自动化 PDF 印前检查

文件在你机器上打得干干净净。Acrobat 能显示,打印预览看着没问题,每一页都在。然后它被送到印刷厂,或者送进那套按月吞下你整批文件的归档系统,接着被退了回来:CMYK 作业里混着 RGB 图像,没有 /Trapped 键,输出意图和印刷机对不上。文档本身没有任何人眼看得见的毛病。它是相对某个配置文件而言错了,而那个配置文件是在你不在场的地方被检查的。印前检查就是印刷行业对这道检查的叫法,真正的问题是:当 PDF 出自你自己的 Delphi 代码而不是某位设计师的桌面时,这道检查该放在哪里

HotPDF 并没有给你一个可以调用的印前检查函数。组件在 GUI 演示里带了一个印前检查报告窗口,但它背后没有服务或构建脚本能调用的 API,假装有只会害你去找一个并不存在的方法。这听上去像个缺口,直到你注意到:对于你自己生成的文件,在自己的输出上调校验器本来就是错误的形状。校验器要检查的每一项属性,本来就都由你掌控。有用的分工是让生成器根本不可能吐出坏文件,再用一个不是你写的工具来证明这一点

Delphi 印前检查流水线示意图:HotPDF 的合规设置在生成阶段就阻止坏 PDF 出现,veraPDF 与 Acrobat 印前检查则在外部给出证明
预防把 PDF/A 与 PDF/X 规则直接烘焙进 HotPDF 的生成过程,外部校验器则给出生成器无法自评的裁定

为什么检查自家输出的方式不一样

传统印前检查假设面对的是一份陌生人的文件。某位设计师、某个别的应用、某条不明的编辑链条把它做了出来,你去检视它,是因为你压根不知道里面有什么。而你的代码产出的文档不是陌生人。字体嵌入、色彩空间、输出意图、元数据块:这一切都是你的程序在文件落盘前几毫秒决定的。事后再去检视,只为发现你刚刚做过的选择,纯属白费功夫。更便宜的做法是把这些选择约束住,让不合规的文件根本不会存在,也就无从抓起

把验证保持在外部还有一个可信度上的理由。一个给自己输出盖章的库,是在给自己的考卷打分。当客户的归档系统或印刷厂的 RIP 拒了你的文件,“我们的组件说它没问题”一点分量都没有。veraPDF 或 Acrobat 给出的裁定才有分量,因为对面跑的是同一批工具

把合规做成一项设置,而不是一张检查清单

预防层不过是配置。在 BeginDoc 之前设置 PDFACompliancePDFXCompliance,HotPDF 就会在整个生成过程中守住对应的规则:嵌入字体,对照你声明的输出意图监视 DeviceRGB 与 DeviceCMYK 的使用,并拒绝该配置文件禁止的特性。矛盾会在 EndDoc 处浮现,合规关卡会在那里抛出,而不是悄悄放行一份注定在下游失败的文件。文件保存之后,同一批属性能读回究竟实际执行了什么,而这正是你的流水线日志最需要的那条事实:

// EndDoc 之后:把实际生效的配置文件随运行元数据一并记录
if Pdf.PDFACompliance <> '' then
  Log('Generated as PDF/A level ' + Pdf.PDFACompliance);
if Pdf.PDFXCompliance <> '' then
  Log('Generated as PDF/X profile ' + Pdf.PDFXCompliance);

把这些标志和输入数据的哈希、HotPDF 版本号写在同一行日志里。哪天校验器和你的生成器就某份文件产生分歧,这一行会告诉你是哪个模板产出的、加载的是库的哪个构建,一场本来要吃掉一下午的争论就变成一次 grep。这些标志背后的输出意图、ICC 配置文件和标记,在用 HotPDF 输出 PDF/A、PDF/X 与 PDF/UA 的指南里有详细说明

给非自家生成的文件设一道便宜的头道关卡

并非每条流水线都是纯生成式的。客户会上传 PDF,扫描仪会往文件夹里丢,合作方会把它们塞进邮件附件。把这些统统推进一个完整的结构校验器,会把队列时间浪费在连打开都打不开的文件上。HotPDF 的 Direct File API 读取文件结构的程度,刚好够回答“这到底算不算一份可用的 PDF”,而不必加载整棵对象树,这让它成为快速失败的好位置:

function TriagePdf(Pdf: THotPDF; const FileName: string): Boolean;
var
  Handle, Pages: Integer;
begin
  Result := False;
  Handle := Pdf.DAOpenFileReadOnly(FileName, '');
  if Handle <= 0 then
    Exit;  // 结构上不可读:隔离处理,不进入验证
  try
    Pages := Pdf.DAGetPageCount(Handle);
    Result := Pages > 0;
  finally
    Pdf.DACloseFile(Handle);
  end;
end;

关于这个 API 有两点事实决定了你该怎么包装它。扁平内存这条捷径只对未加密的输入成立;给 DAOpenFileReadOnly 传一个密码,它会悄悄退回完整解析,所以已知加密的文件应当先经 DecryptFile 变成一份普通工作副本再做分诊。另外,在没有干净打开的句柄上,DAGetPageCount 毫无意义,因此句柄检查要保持严格,非正的结果是拒绝,不是重试。更多这类模式见面向大型 PDF 工作流的 Direct File API 文章

把 veraPDF 作为构建的一部分来跑

凡是你宣称为 PDF/A 或 PDF/UA 的东西,veraPDF 就是该接进来的校验器。它无界面运行,接受批量输入,输出 XML 或 JSON,并按 ISO 条款为每处失败命名,因此一条针对 ISO 19005-1 第 6.2.2 条的规则失败会直接指回某项生成器设置,而不是把你留在原地猜。从 Delphi 驱动它就是普通的进程控制:

function RunVeraPdf(const PdfFile, ReportFile: string): Cardinal;
var
  Cmd: string;
  SI: TStartupInfo;
  PI: TProcessInformation;
begin
  Cmd := Format('cmd /c verapdf.bat --format xml "%s" > "%s"',
    [PdfFile, ReportFile]);
  FillChar(SI, SizeOf(SI), 0);
  SI.cb := SizeOf(SI);
  if not CreateProcess(nil, PChar(Cmd), nil, nil, False,
      CREATE_NO_WINDOW, nil, nil, SI, PI) then
    RaiseLastOSError;
  try
    WaitForSingleObject(PI.hProcess, 120000);  // 为每个文件的等待设上限
    GetExitCodeProcess(PI.hProcess, Result);
  finally
    CloseHandle(PI.hThread);
    CloseHandle(PI.hProcess);
  end;
end;

那个超时值回票价。一份畸形文件能把任何解析器逼进一个再也出不来的死角,而队列工作进程里一次无限期的等待,会把队列其余部分一起拖垮。给等待设上限,给超时安排它自己的失败码,然后把这个文件挪到一边交给人来看。读取结果时,要从 XML 里解析规则标识符,而不是那段给人看的文字。规则 ID 能挺过校验器升级,消息措辞挺不过,而一个稳定的代码是支持工程师能拿去搜旧工单的东西

你怎么跑这一批,和每份文件是否通过同样重要。一个文件一个进程,而不是一批一个进程,这样一份有毒的输入只让你付出该文件的超时代价,此外别无损失。把校验器进程数封顶在核心数上,因为构建 XML 报告是 CPU 密集型的,超额订阅只会互相拖累。还要在入口处设一个体积上限,因为一本两 GB 的扫描书无论解析器多有耐心都会霸占整条队列。这些严格说来都不算印前检查。它们决定的是:一道关卡究竟能活过月底的业务量,还是在凌晨两点第一次卡住流水线的那晚就被关掉

Delphi 批量关卡示意图:每份 PDF 起一个 veraPDF 进程并设定超时上限,从 XML 中提取规则 ID 而非消息文字,并把每份报告与文件存放在一起
入口守卫为队列负载封顶,每份文件配一个全新的 veraPDF 工作进程,有毒输入便无法卡住构建

PDF/X 是这套做法力有不逮的地方。veraPDF 不校验它,所以真正管用的检查依然是 Acrobat 的印前检查,配上你的印刷厂指定的那份 ISO 15930 配置文件。Acrobat 需要人,这意味着抽样而非全覆盖:新模板产出的第一份文件,再加上每批里少量随机抽取的样本,其余能不靠人处理的部分交给自动关卡。一次真正跑得起来的抽样检查,胜过一套永远做到一半的完整自动化

一份一年后你仍会庆幸留着的报告

印前检查关卡的回报会兑现两次。一次是它在门口拦下坏文件时,另一次是很久以后有人追问某份文件当初为什么被放行时。真正该决定报告格式的是后一个时刻,因为那正是报告太单薄就会让你束手无策的时刻。对每份检查过的文件,都要留下输入哈希、上面那行日志里的生成器合规标志与库版本、校验器名称与版本、所依据的配置文件、通过还是失败,以及失败的规则 ID 与校验器给出的页码。把这份报告存放在它所描述的文件旁边。放进另一套系统,那套系统会先于它所记录的归档被下线

例外也必须写下来。当客户坚持要发出一份关卡不喜欢的文件时,答案不是为所有人放宽规则。记下是谁批准了这份文件、依据是什么、有效到哪一天,再把这份豁免附到它的报告上。一份带名字和有效期的豁免,是有人认领的决定。一处被“临时”注释掉的检查,则是一起只差日期的事故

还有一个习惯自己就能回本:文件失败时,在任何人碰它之前先把它复制进一个命名过的回归文件夹。几乎每一个值得调试的印前检查问题都能追溯到某一份具体输入,而留住这些输入的团队能在一小时内修好复现,而不是干等它在生产环境里再次冒头。这里展示的合规属性和 Direct File API 属于面向 Delphi 与 C++Builder 的 HotPDF Delphi Component,其文档对每个调用都有完整说明