技术文章

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

一套图纸送来,尺寸标注单独占一层,想藏起它们最直接的办法是把该组从默认配置的 /ON 数组挪进 /OFF。文件保存重开,尺寸线照样印在标题栏上。原因就在隔壁对象里——组字典内部:/Usage << /View << /ViewState /ON >> >>,渲染器读取时这条显式状态排在配置数组之前

图层状态在 PDF 里可以待在好几个地方,改 /OCProperties 只碰到其中一处。Google 的 PDFium 引擎渲染可选内容没有问题,但它的公开 C API 没有枚举可选内容组(OCG)、选择配置或开关组的功能。驱动图层的 Delphi 代码只能自己重写对象,并严格按渲染器的判定顺序重演,否则图层列表报一种状态、页面显示另一种

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

/ON 和 /OFF 数组只是五步中的第二步,后面还有两步可以推翻它们。ISO 32000-1 §8.11.4.3 定义可选内容配置字典,含 /BaseState、/ON、/OFF、/Locked、/RBGroups 和 /AS;§8.11.4.4 定义 OCG 可以携带的 Usage 字典。在规范里,Usage 描述组是干什么的,/AS 里的自动状态条目把这份描述变成 ON 或 OFF。PDFium 走的路更短:为某个目的渲染时,组自己针对该目的的显式状态获胜。合在一起,顺序如下:

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

第三步解释了开头那份文件:一些导出器在每个新建组里都写上 /ViewState /ON,从此改数组怎么也藏不住这些图层。打印和导出遵循同一条规则,外加一个细节:渲染器先读 /PrintState 或 /ExportState,缺了才回退 /ViewState。所以孤零零一条 /ViewState /ON 也能把图层钉在纸上。第四步更安静:标着 /Intent /Design 的组完全置身视图意图可见性之外,无论数组如何都绘制;没有 /Intent 键的组则按 /View 处理,这是规范给的默认

用 InspectOptionalContent 列出图层及其渲染状态

PDFium Component 在 TPdf.InspectOptionalContent 里重演这条顺序。方法填一份 TPdfOptionalContentInventory,成功返回 True;Usage 参数默认 ocuView,ConfigurationIndex 默认 -1,即选索引 0。配置索引 0 是默认的 /D 字典,/Configs 的条目从索引 1 起按文件顺序排。扫描前,方法让 PDFium 把打开的文档存进内存并展开所有对象流,会话中先前的修改因此能在结果里看到。没有 /OCProperties 的文件,或超出可用范围的配置索引,让调用返回 False、原因写进 ErrorMessage;这两种情况都不抛异常

procedure TFormMain.ListLayers;
var
  Inv: TPdfOptionalContentInventory;
  Cfg: TPdfOptionalContentConfiguration;
  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;
  Cfg := Inv.Configurations[Inv.SelectedConfigurationIndex];
  Memo1.Lines.Add(Format('Configuration %d "%s", %d groups, /AS present: %s',
    [Inv.SelectedConfigurationIndex, string(Cfg.Name), Length(Inv.Groups),
     BoolToStr(Cfg.HasAutomaticState, True)]));
  for G in Inv.Groups do
    Memo1.Lines.Add(Format('obj %d  %-24s visible=%s  locked=%s  radio=%d',
      [G.ObjectNumber, string(G.Name),
       BoolToStr(G.EffectiveVisible, True),
       BoolToStr(G.Locked, True), G.RadioGroupIndex]));
end;

每个 TPdfOptionalContentGroup 带着对象号、名称、Intents 和三个显式状态 ViewState、PrintState、ExportState,取值为 ocsUnspecified、ocsOn 或 ocsOff。当某个状态读出 ocsOn 而组又躺在 /OFF 数组里,你就抓到了开头那段说的陷阱。Locked 和 RadioGroupIndex 来自所选配置;不属于任何 /RBGroups 集合的组,RadioGroupIndex 为 -1

Usage 字典的其余部分按文件所写如实上报:Language 与 LanguagePreferred、UserType 与 UserNames、PageElementSubtype,以及 HasZoomRange 标志背后的 MinimumZoom 与 MaximumZoom。EffectiveVisible 不依赖其中任何一项,随缩放变化的显示留给你的查看器。每个 TPdfOptionalContentConfiguration 暴露名称、BaseState、数组内容和 RadioGroups,外加把 /Order 摊平成组引用、跳过嵌套标签数组文本标签的 OrderGroupObjectNumbers

打印和导出可能与屏幕不一致

Usage 参数会改变答案,这正是它存在的意义。在 ocuPrint 下,清单读 /PrintState(带回退到 /ViewState),只有 /Event 为 /Print 的 /AS 条目参与。为只在纸上出现而建的水印层,在 ocuView 下报隐藏、在 ocuPrint 下报可见。两趟对比能在打印件惊到任何人之前找出这类图层

procedure TFormMain.ReportPrintOnlyLayers;
var
  OnScreen, OnPaper: TPdfOptionalContentInventory;
  I: Integer;
begin
  if not Pdf.InspectOptionalContent(OnScreen, ocuView) or
     not Pdf.InspectOptionalContent(OnPaper, ocuPrint) then
    Exit;
  // 两趟读同一个 /OCGs 数组,组顺序因此一致
  for I := 0 to High(OnScreen.Groups) do
    if OnScreen.Groups[I].EffectiveVisible <> OnPaper.Groups[I].EffectiveVisible then
      Memo1.Lines.Add(Format('%s: screen=%s, print=%s',
        [string(OnScreen.Groups[I].Name),
         BoolToStr(OnScreen.Groups[I].EffectiveVisible, True),
         BoolToStr(OnPaper.Groups[I].EffectiveVisible, True)]));
end;

OCMD 可见性表达式怎么求值?

可选内容成员字典(OCMD,ISO 32000-1 §8.11.2.2)通过 /P 策略或 /VE 可见性表达式把一段内容拴到多个组上,后者用 /And、/Or、/Not 组合。规范给 /P 默认值 /AnyOn,规定 /VE 存在时优先于 /OCGs 和 /P,并说没有组的 OCMD 不影响可见性

清单的 Memberships 数组按这些规则、对照同一趟的逐组结果求值,你请求的 usage 因此贯穿始终。每个成员报告组对象号、Policy(ocmpAnyOn、ocmpAllOn、ocmpAnyOff、ocmpAllOff,四个之外的名字报 ocmpUnknown)、原始 VisibilityExpression 文本和自己的 EffectiveVisible。没有组的 OCMD 算可见;指向未知 OCG 对象号的引用也算可见,一个悬空引用不会把周围全藏起来。/VE 求值在嵌套深度 32 处截断,更深视为隐藏,自引用表达式无法撑爆栈。扫描列出每个以间接对象存储、带 /Type /OCMD 的 OCMD,对象流里的也不例外

用 SaveAsOptionalContentConfigured 写入新的图层状态

TPdf.SaveAsOptionalContentConfigured 接受一组 TPdfOptionalContentStateChange 记录,每条带 GroupObjectNumber 和 Visible 标志。它写出一份副本,让所选配置在五步顺序下产生完全一致的状态。你没提到的组保持清单为它们算出的可见性

procedure TFormMain.SaveWithoutDimensions(DimensionsObj, NotesObj: Integer;
  ForPrint: Boolean);
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;

  Options := TPdfOptionalContentConfigureOptions.Default;
  if ForPrint then
  begin
    Options.Usage := ocuPrint;         // 写出 /Print << /PrintState ... >>
    Options.ConfigurationIndex := 1;   // 第一个 /Configs 条目,/D 保持原样
  end;

  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 名称树与编号树编辑器用的是同一套经校验的增量修订。要确认打印预设,重新加载输出并调 InspectOptionalContent(Inv, ocuPrint, 1);各组应报出你传入的状态

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

配置化保存拒绝做什么?

配置化保存拒绝任何文档不允许或无法安全承载的更改,且每次拒绝都发生在写目的地之前。检查按固定顺序执行:签名文档最先抛 EPdfError,除非传 AllowSignedDocument = True;加密文档其次被拒,因为明文增量对象带不动现役安全处理器。然后逐条检查请求的更改:不在 /OCGs 里的对象号直接失败;对列入配置 /Locked 数组的组所做的更改失败,重申其当前值则放行。最后,EnforceRadioGroups 打开时,会让可见成员超过一个的 /RBGroups 集合被拒。除签名之外的所有拒绝都返回 False,原因在 Report.ErrorMessage

PDFium Component 的 SaveAsOptionalContentConfigured 写出配置化 Delphi PDF 之前的拒绝闸门:OCGs 之外的对象号失败、锁定组失败、可见成员超过一个的 RBGroups 集合被拒、加密文档带不了明文增量对象、签名文件要求 AllowSignedDocument
每次拒绝都发生在碰目的地之前;原因落在 Report.ErrorMessage,签名文件则是 EPdfError 消息

其中两道闸门比规范严格要求的走得更远。§8.11.4.3 把 /Locked 描述为防查看器用户界面更改,把单选组描述为开一个、关其余。PDFium Component 对程序性更改同样适用这两条,单选组则直接拒绝而不是替你挑成员去关。要求同时改互斥集合的两个成员,库无从知道你指的是哪一个,猜一个就违背了你的请求。在同一个 Changes 数组里把其余成员一并关掉,保存即可通过

签名文档闸门的存在原因是:追加的修订叠在 PDFium 完整重存的上面,那是新的字节序列,已有签名覆盖的字节范围不能假设原样幸存。只在能接受丢掉该签名时才传 AllowSignedDocument = True

批处理作业里有三个副作用要留意。其一,重写把所选配置归一化为 /BaseState /ON,作者的 /Unchanged 或 /OFF 基线变成显式数组而可见性结果不变。其二,去掉 /AS 也顺带移除了只在纸上出现的水印层这类打印专用把戏;设 DisableAutomaticState 为 False 可保留这些规则,但要接受它们可能在自己关心的事件上覆盖你请求的状态。其三,/ON 和 /OFF 数组按为 Options.Usage 算出的状态写出,所以存进配置 0 的打印预设也会改变屏幕上所有没有 Usage 字典的组的显示——打印预设请放进 /Configs 条目,/D 留给屏幕。合规方面,PDF/A-2(ISO 19005-2 第 6.9 条)和 PDF/UA(ISO 14289-1 第 7.10 条)都禁止配置字典里的 /AS,默认输出因此提前清掉了 PDFium Component 的 PDF/A 预检验证本会报出的一条发现

在 Delphi 查看器里应用图层更改

在查看器里,图层控制是一份用清单填出来的复选框列表,保存进内存再重载。复选框用 Groups 填充,Locked 置位的项禁用,共享 RadioGroupIndex 的成员按互斥处理,应用时经流重载写到 TMemoryStream。TPdf.LoadDocument 要求组件处于非活动状态,先关掉它;缓冲加载会复制字节,流可以立刻释放

procedure TFormMain.ApplyLayerChecklist(
  const Changes: TPdfOptionalContentStateChanges);
var
  Report: TPdfOptionalContentConfigureReport;
  Output: TMemoryStream;
  KeepPage: Integer;
begin
  Output := TMemoryStream.Create;
  try
    try
      if not Pdf.SaveAsOptionalContentConfigured(Output, Changes,
        TPdfOptionalContentConfigureOptions.Default, Report) then
      begin
        ShowMessage('Layer update rejected: ' + Report.ErrorMessage);
        Exit;
      end;
    except
      on E: EPdfError do
      begin
        ShowMessage(E.Message);       // 签名文件:Output 仍是空的
        Exit;
      end;
    end;
    KeepPage := Pdf.PageNumber;
    Pdf.Active := False;
    Pdf.LoadDocument(Output);         // Buffered 默认 True
    Pdf.PageNumber := KeepPage;
  finally
    Output.Free;
  end;
end;

重载之后,PDFium 按新配置绘制,屏幕和下一次 InspectOptionalContent 调用从此一致。TPdf 与 TPdfView 之间的接线见 用 PDFium VCL 在 Delphi 中构建功能完整的 PDF 查看器。文档还没有图层而你需要添加时,在 Delphi 中用 OCG 和 OCMD 创建 PDF 图层一文带你走一遍 SaveAsOptionalContentCreated

定价、试用版和组件的其他文档功能列在 PDFium Component for Delphi 产品页