技术文章

用 PDFium 在 Delphi 里开关 PDF 可选内容图层

PDFium Component 在 Delphi 里通过两个 TPdf 方法控制 PDF 可选内容图层(OCG):InspectOptionalContent 列出每个图层以及 PDFium 实际会渲染的可见性,SaveAsOptionalContentConfigured 则写出一份验证过的副本,你选中的图层在其中被打开或关掉。第二个方法还会把 Usage 和 /AS 规则一并中和掉,否则它们会悄悄撤销你的修改。两个方法都作用于 TPdf 里已经打开的文档,所以不需要第二个解析器去和视图显示的东西保持同步

这个需求通常来自 CAD 或 GIS 行业:图纸集把尺寸标注、注释和标题栏放在不同图层上,客户要在发给供应商之前拿一份隐藏尺寸的副本。PDFium 渲染可选内容是正确的,但它的公开 ABI 没有枚举 OCG、挑配置或翻转图层状态的函数。于是你下沉到对象层,改 /OCProperties,保存,重新加载,图层还在那儿。原因在 PDFium 的可见性逻辑,动任何字节之前值得先搞懂它

为什么改 /ON 和 /OFF 改不动 PDFium 渲染的东西?

光改配置字典的 /ON 和 /OFF 数组不够,因为 PDFium 让 OCG 自己 /Usage 字典里的显式状态压过这两个数组,而一条 /AS 自动状态规则又能把两者都覆盖掉。ISO 32000-1 §8.11.4 把配置和 usage 字典描述成两套独立机制;PDFium 的渲染器把它们折成一个决定,InspectOptionalContent 按这个顺序复现它:

  • 从配置的 /BaseState 出发,/ON 和 /Unchanged 都算可见,只有 /OFF 隐藏
  • 套用配置的 /ON 数组,再套 /OFF 数组,所以同时出现在两个数组里的组最终隐藏
  • 对请求的 usage 套用组的显式 Usage 状态,比如 /Usage << /View << /ViewState /OFF >> >>,它压过上面的一切
  • 把 /Intent 里既无 /View 也无 /All 的组当作可见,因为它不参与 view intent 的可见性
  • 最后跑所选配置的 /AS 数组,其中匹配事件的条目会设置它们所列组的状态
PDFium Component 在 Delphi 里为每个 PDF 可选内容组重放的五步可见性判定示意图:BaseState 定起点,配置的 ON 与 OFF 数组按序套用,显式的 Usage ViewState 或 PrintState 条目压过两者,Intent 不参与算作可见,AS 数组最后跑
只改 ON 和 OFF 数组不够,因为 PDFium 把 BaseState、两个数组、组 Usage 状态以及最后的 AS 自动状态规则折成一个裁决,InspectOptionalContent 逐步复现它

第三步是最烧人的。排版工具存出的文件常常每个 OCG 都带着 /ViewState /ON,PDFium 于是无视你精心编辑的 /OFF 数组:保存成功,文件重新打开也没问题,图层照画。对 Print 和 Export,OcExplicitUsageState 先读 PrintState 或 ExportState,特定条目缺席时退回 ViewState,所以单独一个 ViewState /ON 会把图层在打印时也钉死。引用 OCMD(§8.11.2.2)的标记内容随后按这些逐组结果解析,走 /P 策略,有 /VE 可见性表达式时则走表达式

怎么列出 PDFium 实际会显示的图层?

TPdf.InspectOptionalContent 返回一个 TPdfOptionalContentInventory,其 Groups 数组带每个 OCG 的对象号、名称、intent、三个 Usage 状态、语言、缩放范围、Locked 标志、radio-group 索引以及算出来的 EffectiveVisible。方法先让 PDFium 把当前内存中的文档保存一遍,展开对象流,再扫描结果,所以会话早前的编辑都会反映出来。配置索引 0 永远是默认的 /D 字典,/Configs 的条目从索引 1 排起;默认参数 -1 选索引 0。没有 /OCProperties 的文档让方法返回 False、原因放在 ErrorMessage 里,而不是抛异常

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  G: TPdfOptionalContentGroup;
begin
  // Usage 默认 ocuView;-1 选配置 0,即 /D 字典
  if not Pdf.InspectOptionalContent(Inv) then
  begin
    Memo1.Lines.Add('No usable layers: ' + Inv.ErrorMessage);
    Exit;
  end;
  Memo1.Lines.Add(Format('Configuration %d: %s',
    [Inv.SelectedConfigurationIndex,
     string(Inv.Configurations[Inv.SelectedConfigurationIndex].Name)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %s  visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

Memberships 数组报告每个 OCMD 及其 Policy(ocmpAnyOn、ocmpAllOn、ocmpAnyOff、ocmpAllOff)、原始 VisibilityExpression 文本和它自己的 EffectiveVisible。几条边界规则是刻意的。/P 默认 /AnyOn,没有组的 OCMD 算作可见。引用一个不是已知 OCG 的对象号时按可见处理,而不让整个表达式失败。/VE 求值在嵌套深度 32 处停止,更深的一律当隐藏,免得一个恶意或自引用的表达式把检查变成栈溢出

用 SaveAsOptionalContentConfigured 写入新的图层状态

TPdf.SaveAsOptionalContentConfigured 接受一个 TPdfOptionalContentStateChange 记录数组(组对象号加 Visible),写出一份文档,其中所选配置恰好产出你要的状态。所选配置得到 /BaseState /ON 加覆盖每个组的完整 /ON 与 /OFF 数组,而每个已有 Usage 字典的 OCG 会收到一个与新状态匹配的显式 ViewState(或按 Options.Usage 给 PrintState / ExportState)。用 TPdfOptionalContentConfigureOptions.Default 时,所选配置的 /AS 键会被移除,这样 open、print 或 export 事件就没法把图层翻回去

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer);
var
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  SetLength(Changes, 2);
  Changes[0].GroupObjectNumber := DimensionsObj;
  Changes[0].Visible := False;
  Changes[1].GroupObjectNumber := NotesObj;
  Changes[1].Visible := True;

  // 配置 0、ocuView、DisableAutomaticState 与 EnforceRadioGroups 为 True
  Options := TPdfOptionalContentConfigureOptions.Default;

  if not Pdf.SaveAsOptionalContentConfigured('C:\Out\Drawing-NoDims.pdf',
    Changes, Options, Report) then
    raise Exception.Create('Layer update rejected: ' + Report.ErrorMessage);

  Log(Format('%d of %d groups changed, %d Usage states rewritten, /AS removed: %s',
    [Report.ChangedGroupCount, Report.GroupCount,
     Report.UpdatedUsageStateCount,
     BoolToStr(Report.RemovedAutomaticState, True)]));
end;

写入路径把 PDFium 自己保存的输出逐字节当作前缀,只追加重写过的配置宿主对象和带 Usage 字典的 OCG 对象,后面跟新的 xref 节和 trailer。在任何一个字节到达目的地之前,结果会先在一个独立的 TPdf 里按严格加载策略重新打开,交叉引用表校验不过方法就失败。文件重载还多走一步:先写到目标旁边的临时文件,验证成功后才替换目标,所以被拒的更新绝不会留下半张写坏的图。这正是 PDFium Component 的 PDF 名称树与编号树编辑器用的同一套验证式增量修订

PDFium Component 的 SaveAsOptionalContentConfigured 如何写出图层已开关的 Delphi PDF 示意图:状态变更与选项进入,所选配置用完整的 ON 与 OFF 数组及 Usage 状态重写,追加验证过的增量修订,任何东西写出之前必须先通过严格重开校验
配置化保存把 PDFium 自己的再保存逐字节当前缀,追加重写过的配置宿主对象加新的 xref 节,并在目的地被碰之前在一个独立 TPdf 里重开结果

配置化保存拒绝做什么?

配置化保存拒绝文档本身禁止或无法安全表达的任何变更,而且每次拒绝都发生在目的地被碰之前。不在 /OCGs 里的对象号直接失败。改配置 /Locked 数组里列出的组会失败,尽管重申其当前值是允许的。开着 EnforceRadioGroups 时,任何最终会多于一个可见成员的 /RBGroups 集合都会被拒,而不是悄悄把其他成员关掉。加密文档被拒,因为明文增量对象承载不了当前的安全处理器。签名文档会抛 EPdfError,除非你传 AllowSignedDocument = True——改变页面显示的内容可能破坏签名覆盖或认证策略

PDFium Component 的 SaveAsOptionalContentConfigured 写出配置化 Delphi PDF 之前套用的拒绝闸门示意图:OCGs 之外的对象号失败,锁定组失败,多于一个可见成员的 RBGroups 集合被拒,加密文档承载不了明文增量对象,签名文件要求 AllowSignedDocument
每次拒绝都发生在目的地被碰之前,失败原因落在 Report.ErrorMessage 里,而不是留下半张写坏的图
function TFormMain.SavePrintPreset(Target: TStream;
  const Changes: TPdfOptionalContentStateChanges): Boolean;
var
  Options: TPdfOptionalContentConfigureOptions;
  Report: TPdfOptionalContentConfigureReport;
begin
  Options := TPdfOptionalContentConfigureOptions.Default;
  Options.Usage := ocuPrint;          // 写出 /Print << /PrintState ... >>
  Options.ConfigurationIndex := 1;    // /Configs 的第一个条目,不是 /D
  try
    Result := Pdf.SaveAsOptionalContentConfigured(Target, Changes, Options,
      Report);                        // AllowSignedDocument 保持 False
    if not Result then
      ShowMessage(Report.ErrorMessage);
  except
    on E: EPdfError do
    begin
      ShowMessage(E.Message);         // 签名文件:Target 里什么都没写
      Result := False;
    end;
  end;
end;

接进批处理作业之前先认清取舍。追加的修订坐在 PDFium 完整再保存之上,而不是你原始的文件字节上——这正是签名输入需要显式同意的原因。重写还会把所选配置归一化成 /BaseState /ON,作者的 /Unchanged 或 /OFF 基线被替换成结果可见性相同的显式数组。去掉 /AS 会移除只在纸上出现的水印图层这类 print 专属戏法;把 DisableAutomaticState 设成 False 可以保留这些规则,代价是接受它们可能在对应事件上覆盖你要的状态。好的一面是,PDF/A-2(ISO 19005-2 第 6.9 条)和 PDF/UA(ISO 14289-1 第 7.10 条)都禁止配置字典里的 /AS,所以默认输出顺手消掉了 PDFium Component 的 PDF/A preflight 校验本来会报的一个问题

图层控制在 Delphi PDF 查看器里的位置

在查看器里,图层控制是一份由 inventory 驱动、配合保存结果重载的清单。用 Groups 填充清单,禁用 Locked 的条目,共享 RadioGroupIndex 的成员按互斥处理,应用时写进一个 TMemoryStream 再把流加载回 TPdf,让视图画出新状态。TPdf 与 TPdfView 之间的接线见用 Delphi 的 PDFium VCL 打造功能完整的 PDF 查看器。授权、试用下载和其余功能集见 PDFium Component for Delphi 产品页面