技术文章

在 Delphi 中校验 PDF/光栅扫描文档

PDF/R,标准化为 ISO 23504-1,是为扫描文档而设的 PDF profile:每一页恰好携带一张带状图像(strip image),别无其他。PDFium Component 从 Delphi、Lazarus 和 C++Builder 通过 ValidatePdfRCompliance 校验它,该函数读取一个流,返回合规级别外加一组具体问题

这个 profile 之所以存在,是因为扫描仪和文档采集系统需要一个比 PDF/A 更窄的目标。一份归档 PDF 可以装下该子集允许的任何东西;一份光栅 PDF 则是刻意地贫乏,所以任何合规阅读器都能同样地显示它,任何合规写入器都能在没有创作引擎的情况下从一次扫描产出它

PDF/A 允许而 PDF/R 禁止的是什么

实际上是文本。一张光栅页面携带扫描图像、别无其他,所以页面上出现一个字体资源就是违规——在 ISO 23504-1 §6.5.2 之下被报告为 pvriFontForbidden。这让那些为可搜索性加上一层不可见 OCR 文本层的人感到意外,那在 PDF/A 工作流里是一件正常而有用的事,但根本就不是 PDF/R

页面与图像的关系同样严格。§6.5.1 让每一页恰好是一张带状图像,所以当图像数量与页面数量不匹配时 pvriPageImageMismatch 触发——一张没有图像的页面和一张有两张图像的页面都不合规。而 pvriBadMediaBox 报告一张 MediaBox 不具有 [0 0 w h] 形式的页面(§6.5.3),因为一份扫描没有理由坐在偏移的原点上

uses FPdfPdfr;

var
  Src: TFileStream;
  Res: TPdfRValidationResult;
begin
  Src := TFileStream.Create('scan-batch-0142.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Res := ValidatePdfRCompliance(Src);
    if Res.IsCompliant then
      Memo1.Lines.Add('PDF/R-1 conformant')
    else
    begin
      if pvriFontForbidden in Res.Issues then
        Memo1.Lines.Add('A page names a font resource; a raster page carries no text');
      if pvriPageImageMismatch in Res.Issues then
        Memo1.Lines.Add('Image count does not match page count');
      if pvriForbiddenImageFilter in Res.Issues then
        Memo1.Lines.Add('A strip image uses an encoding outside the white list');
    end;
  finally
    Src.Free;
  end;
end;

允许哪些图像编码

四种,白名单很短是有原因的。§6.6 接受 /CCITTFaxDecode/DCTDecode/JPXDecode/FlateDecode——二值传真、JPEG、JPEG 2000 和无损 deflate,它们合起来覆盖了每一种要紧的扫描仪输出。其他一切都作为 pvriForbiddenImageFilter 报告,包括 /LZWDecode/RunLengthDecode/ASCII85Decode/ASCIIHexDecode/JBIG2Decode/Crypt

其中两条拒绝值得去理解而不是死记。/JBIG2Decode 把二值扫描压缩得极好,在 PDF/A 里完全合法,但它的符号字典重建可能替换成视觉上相似的字形——一种有据可查的扫描数字失败模式——而一个全部立意就在于忠实光栅复现的 profile 不能接受这种风险。ASCII 过滤器被排除则是出于相反的原因:它们让文件膨胀,却不带来任何光栅 profile 需要的东西

在任何一页被读取之前就会触发的结构规则

PDF/R 也约束容器。pvriObjStmPresent 报告一个 /Type /ObjStm 流,该 profile 完全禁止它——对象流让一台光栅阅读器本应能执行的简单、顺序解析变得复杂。pvriBadHeader 报告 %PDF-1.4 到 1.7 以及 %PDF-2.0 之外的头部,而 pvriEncryptVersionMismatch 按 §6.2.3 报告一份头部不是 %PDF-2.0 的加密文件

Catalog 和 Info 字典是白名单式而非仅被检查。pvriProhibitedCatalogEntrypvriProhibitedInfoEntry 对允许集合之外的条目触发,pvriInfoXmpMismatch 在一条 Info 条目与其 XMP 对应物不一致时触发。一个缺失的 Catalog /Metadata 流、一个缺失的 trailer /ID 以及一个缺失的 %PDF-raster-1.0 页脚标记,也各有各的问题

为什么保存选项记录里没有 Title 和 Author

TPdfRSaveOptions 携带 CreatorProducerCreationDateModDateDocumentIdInstanceId,并刻意没有 Title、Author、Subject 或 Keywords 字段。这四项正是 §6.4.3 禁止的条目,所以一条暴露它们的记录,会邀请调用方通过一个合规 API 写出一份不合规文件

两个布尔选项控制在转换既有 PDF 时的清理。StripInfoOptionalEntries 默认为 True,从源 Info 字典移除 Title、Author、Subject、Keywords 和 Trapped。StripCatalogOptionalEntries 同样默认为 True,移除 Names、Outlines、StructTreeRoot、OutputIntents、Lang 等其余项,只留下 §6.3 白名单。把任何一个设为 False,你就保留了那些条目——并失去合规,这偶尔确实是一份内部文件所想要的

var
  Opts: TPdfRSaveOptions;
  Src, Dest: TFileStream;
begin
  Opts := TPdfRSaveOptions.Default;
  Opts.Creator := 'Capture Station 4';
  Opts.Producer := 'PDFium Component';
  Src := TFileStream.Create('scan-in.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('scan-pdfr.pdf', fmCreate);
    try
      InjectPdfRMarkers(Src, Dest, Opts);   // markers + metadata, not page content
    finally
      Dest.Free;
    end;
  finally
    Src.Free;
  end;
end;

请注意标识注入不做的事:它加上元数据和标识,无法提供页面内容。一张不携带带状图像的源页面在注入之后仍会因 pvriPageImageMismatch 失败,因为缺的那张图像从来就不是元数据问题

PDF/R 在采集流水线里的位置

用在交付物就是扫描本身、保真度就是全部契约的场合——取证影像、支票与汇款采集、来自大幅面扫描仪的工程图归档。一旦文档需要可搜索的文本、标签化、嵌入附件或任何其他被光栅 profile 剥去的东西,就改用 PDF/A

一种常见而可行的安排是两者都产出:一份永不更改的 PDF/R 原件,加一份带 OCR 层用于检索的 PDF/A 衍生件。两个校验器相互独立,所以同一个批量任务可以把每件产物对着它实际声称的 profile 检查。关于那对组合中的归档侧,参见 PDF/A 归档合规PDF/A 预检校验的笔记;对于面向印刷的输出,参见 校验印刷就绪的 PDF/X 文档的详解

PDFium Component 把 PDFium 引擎带给 Delphi、C++Builder 和 Lazarus,配以 VCL API 以及针对 PDF/A、PDF/X、PDF/E、PDF/UA 和 PDF/R 的合规校验器——PDFium Component 产品页列出了支持的标准与 IDE 版本