技术文章

在 Delphi 中安全地重写 PDF 内容流

HotPDF 会遍历一个页面内容流中的每一个操作符,并通过 VisitLoadedPageContentOperators 把每一个都交给你的代码处理。对每个操作符你都可以选择一个动作:保留它、删除它、用你自己的字节替换它、停止遍历,或者拒绝整次编辑。当所有操作符都被保留时,重写后的流与原始流逐字节完全相同,正是这一特性使得这套机制可以放心地运行在你并非作者的文档上

内容流编辑正是 PDF 工具通常出错的地方。流是一个基于栈式机器的操作符序列,而不是一棵文档树,对解码后的字节直接跑正则表达式这种朴素做法,一旦字符串字面量里出现看起来像操作符的内容,或者内联图像包含任意二进制数据,就会出问题。一个已经把流分词过的访问者不存在这个问题

BT、Tf、TJ 等 PDF 内容流操作符流入访问者事件数据,携带阶段、操作符名称、操作数个数、字节区间和嵌套状态,随后返回五种动作之一:保留、替换、删除、停止或拒绝
每个被访问的操作符恰好产生一个决策,全程保留不改的一趟遍历会把未改动的区间原样写回,从而得到逐字节一致的输出

操作符级别的访问实际能做什么?

三大类任务。审计:统计某页在已知调色板之外设置颜色的次数,或者找出用未嵌入字体绘制的文本。移除:删除以 marked content 形式绘制的水印,或者剥离某个特定的图像调用。以及规范化:用下游消费者处理得更好的等价形式替换某种操作符形式

这些任务共有一个要求:你必须能让流中其余 99% 的内容原封不动。HotPDF 的语法树只存一份源码副本,加上一批紧凑的区间索引,编辑则是一组已排序、互不重叠的覆盖层。写出结果时会把未改动的区间直接复制过去,只在你确实改动的地方才写出替换字节,因此一次不做任何改动的往返操作不会引入任何漂移

访问者约定

实现该接口或者赋值事件,然后调用访问方法,审计时把 ApplyChanges 设为 false,重写时设为 true。事件数据携带阶段、操作符名称、操作数个数、其字节区间、当前嵌套状态,以及一个用于读取操作数的借用视图:

uses
  HPDFDoc, HPDFContentStream;

procedure TAuditor.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
var
  Bytes: TBytes;
begin
  if EventData.Phase <> covpOperator then
    Exit;

  // 统计在 marked content 内绘制的文本显示操作符
  if (EventData.OperatorName = 'Tj') or
     (EventData.OperatorName = 'TJ') then
  begin
    Inc(FTextOperators);
    if EventData.State.MarkedContentDepth > 0 then
      Inc(FTextInsideMarkedContent);
  end;

  // 只在需要时才读取操作数
  if EventData.OperatorName = 'Do' then
    if EventData.View.GetOperandBytes(0, Bytes) then
      NoteXObjectUse(TEncoding.ANSI.GetString(Bytes));

  EventData.Action := coaKeep;
end;

procedure TAuditor.Run(const FileName: string);
var
  Pdf: THotPDF;
  Stats: THPDFContentOperatorVisitStatistics;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;
    Pdf.OnContentOperator := HandleOperator;
    for I := 0 to Pdf.LoadedPageCount - 1 do
      Pdf.VisitLoadedPageContentOperators(I, False, Stats);   // 仅审计
    Writeln(Format('%d operators visited, max q/Q depth %d',
      [Stats.VisitedOperatorCount, Stats.MaximumGraphicsStateDepth]));
  finally
    Pdf.Free;
  end;
end;

为什么视图是借用的而不是可变的?

回调永远不会拿到一棵可修改的语法树。它拿到的是一个视图,可以按需取得当前操作的字节,或者某个特定操作数,仅此而已。所有改动都必须走 action 字段和 replacement 字段

这层间接并不是形式主义。如果允许直接修改树,访问者就能绕过替换字节预算和重叠编辑检查,而在字节区间叠加模型中,重叠的编辑产生的是悄悄出错的输出,而不是显眼报错的输出。把所有改动都通过 action 字段路由,意味着无论访问者的意图是什么,每一处编辑都会以同一种方式被校验

按需读取也让常见情形保持低成本。一个只统计操作符名称的审计过程,根本不会去具体化任何操作数字节,这一点在一个有几十万条操作的内容流上很重要

状态字段描述的始终是输入

每个事件都携带图形状态深度、文本对象深度、marked content 深度和兼容性区块深度,这些数值反映的是原始输入在该操作符即将执行之前的状态。即便你的访问者已经删除或替换了更早的操作符,这一点依然成立

把状态锚定在原始输入而不是编辑后的输出上,是一个经过深思熟虑的选择,它带来一个实际的好处:对同一份文档运行同一次审计两次会得到完全相同的结果,而与编辑并行运行的审计也不会因此偏移自己的参照系。如果你需要知道输出中的深度,你自己清楚删掉了什么,因此可以算出来;反过来,从一个不断变化的输出反推出稳定的输入位置,是做不到的

删除与替换,以及保护你的那些规则

替换内容是原始的内容流字节,其语法正确性由你负责。围绕它的保护措施包括:替换字节总量的预算、不重叠的要求,以及可选的输出校验——在接受结果之前先重新解析一遍:

Delphi 中的重写保护机制:访问者声明的 Action 与 Replacement 意图经过替换预算、不重叠字节区间和输出校验三道关卡,最终流入已校验并保存的页面
每一处改动都通过 action 字段汇入预算、重叠和重新解析这几道防护,因此校验失败会中止操作,而不会写出损坏的操作符
procedure TWatermarkStripper.HandleOperator(Sender: TObject;
  var EventData: THPDFContentOperatorEventData);
begin
  EventData.Action := coaKeep;
  if EventData.Phase <> covpOperator then
    Exit;

  // 丢弃绘制水印 form 的操作符
  if (EventData.OperatorName = 'Do') and OperandIsWatermark(EventData) then
    EventData.Action := coaDelete
  // 把游离颜色统一成站内标准灰色
  else if EventData.OperatorName = 'rg' then
  begin
    EventData.Replacement := TEncoding.ANSI.GetBytes('0.2 0.2 0.2 rg');
    EventData.Action := coaReplace;
  end
  // 遇到无法理解的内容时拒绝整次编辑
  else if EventData.OperatorName = 'BI' then
    EventData.Action := coaReject;
end;

var
  Options: THPDFContentOperatorVisitOptions;
begin
  Options := THPDFContentOperatorVisitOptions.Default;
  Options.ValidateOutput := True;        // 接受前先重新解析
  Options.MaxReplacementBytes := 64 * 1024;

  Pdf.OnContentOperator := HandleOperator;
  if Pdf.VisitLoadedPageContentOperators(PageIndex, True, Options, nil, Stats) then
    if Stats.Modified then
      Pdf.SaveLoadedDocument('cleaned.pdf');
end;

动作集合里包含两种退出方式是有原因的。coaStop 结束遍历,并保留目前为止已经决定的内容,这在你已经找到所需内容时是正确的做法。coaReject 放弃整次访问,让页面保持不动,这在你遇到重写规则未覆盖的结构时是正确的做法。内联图像就是典型例子:它们的二进制负载不是一个专做颜色规范化的访问者应该去编辑的对象

解读统计信息,以及遍历在哪里停止

状态字段区分了完成、停止、拒绝、输入无效、超出限制、输出无效和访问者错误。这些是不同的问题:输出无效意味着你的替换字节没能重新解析成功,而输入无效意味着源流本身格式就已错误,根本没有尝试任何编辑

计数器让审计具备自我校验能力。VisitedOperatorCount 必须等于 KeptOperatorCount、DeletedOperatorCount 和 ReplacedOperatorCount 之和,而最大深度数值能告诉你这一页的嵌套是否深到值得进一步细看。SourceBytes 对照 OutputBytes,一个数字就能反映出你的编辑对体积的影响

审计统计的自我核对:保留、删除和替换的操作符计数之和等于已访问总数,旁边列出从完成到访问者错误共七种状态结局,并附注说明状态深度描述的是原始输入,因此审计可重复得到相同结果
必须自洽的计数器加上一个为每种可能结局命名的状态字段,让审计在你并非作者的文档上也能自我校验

对于常见任务,还有无需编写访问者的更高层路径。永久性移除内容,并附带真正的编辑(redaction)所需的资源清理,见 已加载文档上的编辑与页面拼接。替换可见文本见 在已有 PDF 中查找并替换文本。当你的规则是结构性的而非文本性的,或者当你需要一份现有报告都无法回答的审计时,才应该使用操作符访问者

内容流访问、提取和重写,在 Delphi 与 C++Builder 中都作用于同一个已加载文档模型;完整功能列表见 HotPDF Delphi PDF 组件页面