技术文章

用 PDFium 在 Delphi 中做 PDF 接收审查工作台

PDF 接收审查工作台是一个只有一项职责的小程序:在下游任何环节被允许碰这份文件之前,先看一眼每一份文件。要做到这件事,它必须把若干能力装进同一趟处理。它打开文件(但不信任它),读出文件对自己的声称,寻找会误导天真提取器或携带攻击的内容,判断究竟有没有可提取的文本,然后根据发现把文档路由到某个队列。跳过检查,故障就都是安静的:一份用所有者密码加密、里面包着 XFA 表单的 PDF,会以空字符串的形态从文本提取器里一路滑过去,被当作空白文档索引,直到下游有人去找那些从来没被读到过的内容时才有人察觉。PDFium Component 是一个面向 Delphi、C++Builder 和 Lazarus 的源码级 VCL/LCL 查看与检视库,它暴露了这个工作台所需要的自省调用。下面几节会走一遍哪个调用回答哪个问题,以及那两处显而易见的调用会给你一个自信满满的错误答案的地方

文件被路由之前要回答的五个问题

把表格和缩略图条剥掉,接收分流就归结为五个问题:

  • 这份文件到底能不能打开,用的是哪个密码?
  • 它声称自己是什么:标题、作者、创建日期?
  • 它是否携带活动内容或有风险的内容,比如 JavaScript、XFA 表单或嵌入文件?
  • 有没有可提取的文本,还是说这是一份要送去 OCR 的扫描件?
  • 综合以上,它该进哪个队列:直通处理、人工审查,还是隔离?
示意图:Delphi PDF 接收工作台在一次廉价的打开中回答五个分流问题,并把文件路由到就绪、待审、拦截或损坏状态
接收分流在一次廉价的打开中回答五个问题,并把文件路由到就绪、待审、拦截或损坏

每个问题都对应一到两个 PDFium Component 调用。其中两处映射带着锋利的棱角,我在生产环境里不得不排查的错误路由文件大多出在那里。文档元数据住在两个可能互相矛盾的地方,而加密并不一定会阻止文档打开

廉价地打开:关掉表单填充,一页都不渲染

分流应当是尽可能便宜的一次打开。在 Active := True 之前设置 FormFill := False,会告诉组件完全跳过表单填充环境。这缩短了加载时间,而且(对来源不明的文件来说同样重要)它阻止了任何文档级 JavaScript 的初始化。下面用到的检视属性没有一个需要渲染页面,所以一趟分流永远不必产出哪怕一张位图

procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := IncomingPath;
    Pdf.FormFill := False;     // 没有表单环境,也不初始化 JavaScript
    Pdf.Active := True;        // 失败是无声的:Active 只是保持 False

    if not Pdf.Active then
    begin
      Rec.OpenFailed := True;  // 文件损坏,或被用户密码锁住
      Exit;                    // finally 块照样会执行
    end;

    Rec.PageCount := Pdf.PageCount;
    CollectIdentity(Pdf, IncomingPath, Rec);
    CollectRiskSignals(Pdf, Rec);
  finally
    Pdf.Active := False;
    Pdf.Free;                  // 遇到畸形文件也绝不泄漏这个实例
  end;
end;

赋值之后的那道检查不是可选的,而且它是一道检查而不是一个异常处理器,这是有原因的。当引擎无法加载文件时,组件会把内部的 EPdfError 吞掉,并让 Active 停在 False,而不是把它往外传。等着异常的代码会心安理得地从一份根本没打开的文档上读 PageCount。如果拒收流程需要引擎真正的错误文本,就把文件读进一个字节数组,调用接受 TBytes 的那个 LoadDocument 重载;那条路径确实会抛出带消息的 EPdfError,密码情形也包括在内。try..finally 仍然值得留着。接收服务会无人值守地跑上好几周,之后任何一个异常都不许泄漏那个 TPdf 实例,或者留下一把会绊倒重试的锁

吞吐很少成为瓶颈。关掉表单填充又不做渲染,一次分流打开的耗时由 I/O 主导,单个工作进程从本地磁盘每秒轻松检视好几份文件。万一接收量真的超出了一个工作进程,那就按文件而不是按检查项来切分工作。五个问题共享同一次打开,把它们拆到不同进程里只会把最贵的那一步乘以份数,而不是把它摊薄

元数据住在两个地方,而且它们说法不一

ISO 32000-1 为文档元数据定义了两个家:文档信息字典(第 14.3.3 条)和挂在目录上的 XMP 包(第 14.3.2 条)。TitleAuthorSubjectCreationDate 属性读的是 Info 字典,另有 MetaText[] 读其他任意键,以及 DecodeDate 解析 D:YYYYMMDD... 这种日期字符串。麻烦在于现代的生产工具越来越只写 XMP,而 ISO 32000-2 在 PDF 2.0 中把大部分 Info 字典键标记为弃用,等于把这个方向正式化了。这在接收工具里的症状很具体。你的工作台显示标题为空,而 Adobe Acrobat 却显示得出来,因为 Acrobat 回退到了 XMP 包里的 dc:title,那是 Info 字典属性从来不碰的地方

示意图:PDF 元数据住在 Info 字典和 XMP 包两个地方,在 Delphi 接收工具里两者可能对标题说法不一
文档元数据既住在 Info 字典里,也住在 XMP 包里,而这两个家可能对标题说法不一
procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
  var Rec: TIntakeRecord);
begin
  Rec.Title := Pdf.Title;             // Info 字典里的值
  Rec.Author := Pdf.Author;
  Rec.CreatedAt := Pdf.CreationDate;  // 原始的 PDF 日期字符串(D:2026 开头)

  // Info 里标题为空,并不意味着这份文档没有标题。组件不暴露
  // XMP 包,所以在相信这个空值之前,先在原始文件字节里探测
  // 一下 dc:title 元素
  if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
    Include(Rec.Flags, ifTitleInXmpOnly);
end;

即便是上面这种粗糙的子串探测也值回票价:"元数据在,只是不在老工具会去看的地方"对任何按标题或作者建索引的归档流水线来说,都是一个与路由相关的事实。如果你的下游索引只读 Info 字典,被这样标记的文件就会悄无声息地变得搜不到

照样能打开的加密文件

一份加密的文档不一定就打不开。标准安全处理器(ISO 32000-1 第 7.6.3 条)区分用户密码和所有者密码:前者是打开文档所必需的,后者只是给打印、复制之类的权限设个门。相当大一部分"受保护"的商务文档,用的是所有者密码加上一个空的用户密码。它们不弹提示就能打开、完全解密,靠的是查看器自愿去尊重那些权限标志。那是策略,不是保护,而你的接收状态应当反映出这个区别

在成功打开之后检测加密,需要一次引擎调用外加一个兜底。FPDF_GetSecurityHandlerRevision(Pdf.Document) 对未受保护的文件返回 -1,否则返回处理器修订号,而 Pdf.Permissions 返回任何不是全位置一的 $FFFFFFFF 掩码的值,就是佐证信号。对真正被用户密码锁住的文件,要在设置 Active := True 之前先赋值 Password;如果打开仍然失败,就把文件路由到一个拦截状态,通过安全渠道向发送方索要凭据,而不是盲目重试。另外要抵住把"加密"直接当成自动隔离理由的诱惑。在大多数文档密集的行业里,加密但打得开的文件是常态,不是可疑对象

活动内容:JavaScript、XFA 与嵌入文件

有三项发现应当始终抵达路由决策。第一是 JavaScript:OnUnsupportedFeature 事件会在引擎遇到 XFA 或 3D 内容这类结构性特性时报告它们,但它检测不到 JavaScript。改用 JavaScriptActionCount,并把非零结果当作活动内容。第二是 XFA:当 FormType 返回 ftXfaFull 时,可见的页面往往不过是 XFA 模板的一次渲染,而常规的文本提取看到的会是套话而不是填进去的值。第三是附件:PDF 是一种容器格式,而 AttachmentCount 会告诉你这一份有没有载着乘客

示意图:Delphi 中的 PDF 接收风险信号——加密处理器修订号、JavaScript 动作计数、XFA 表单类型和危险附件
加密状态以及 JavaScript、XFA 和附件的计数,是必须一路活到路由决策里的那些信号
procedure CollectRiskSignals(Pdf: TPdf; var Rec: TIntakeRecord);
var
  i, PageNo: Integer;
  Ext: string;
begin
  Rec.IsEncrypted := Assigned(FPDF_GetSecurityHandlerRevision) and
    (FPDF_GetSecurityHandlerRevision(Pdf.Document) <> -1);
  Rec.HasForms := Pdf.FormType <> ftNone;
  Rec.IsXfa := Pdf.FormType = ftXfaFull;
  Rec.HasJavaScript := Pdf.JavaScriptActionCount > 0;

  // AnnotationCount 是按页的属性;遍历各页把它累加起来。
  // 加载页面对象不会渲染任何东西,所以这一步依然很便宜
  Rec.Annotations := 0;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    Inc(Rec.Annotations, Pdf.AnnotationCount);
  end;

  Rec.Attachments := Pdf.AttachmentCount;

  for i := 0 to Rec.Attachments - 1 do
  begin
    Ext := LowerCase(ExtractFileExt(string(Pdf.AttachmentName[i])));
    if (Ext = '.exe') or (Ext = '.js') or (Ext = '.vbs') or (Ext = '.dll') then
      Include(Rec.Flags, ifDangerousAttachment);
  end;
end;

那个循环里有两个细节值得注意。附件名来自文档内部,所以绝不要不做净化就把它当作输出路径复用;一个形如 ..\..\start.exe 的嵌入名字,就是一条守着某次粗心保存调用的路径穿越。另外,扩展名黑名单是一根绊线,不是一份保证。它的职责是逼出一个人的决定,而不是给文件盖上"干净"的章

把信号变成路由状态

一个可用的状态模型需要的状态比大多数团队预想的更少:就绪(没有阻断项,有文本)、待审(打开成功但有东西需要人眼看看,比如 XFA 表单、JavaScript、空的文本层,或者标题只在 XMP 里)、拦截(需要用户密码)和损坏(打开失败)。把证据和状态一起记下来。文件哈希、页数、确切的标志位,以及损坏文件的引擎错误消息都要紧,因为质疑某个路由决定的人会在几周之后才来质疑,而那时的文件可能已经被替换或修改过了

当操作员确实需要看一眼被隔离的文件时,别把它交给系统默认的查看器。要在一个关掉脚本和链接处理的加固面板里渲染它,也就是在 Delphi 中构建安全 PDF 预览界面里描述的那种做法。而如果你的接收环节喂给的是一个有合规要求的归档系统,那么分流这一趟正是安排更深检查的自然位置;按 PDF/A 与 PDF/UA 规范做批量印前校验接手的地方,恰好就是本文这道检视停下的地方

组件的产品页涵盖授权、完整的检视 API 和随附的演示,其中包括一个接收风格的文档检视器:PDFium Component