技术文章

用 PDFium 在 Delphi 里创建 PDF 图层(OCG 与 OCMD)

PDFium Component 在 Delphi 里经 TPdf.SaveAsOptionalContentCreated 创建新的 PDF 图层。一次经过验证的保存里,方法添加可选内容组(OCG)、可选内容成员字典(OCMD),以及由调用方提供、包进 Form XObject 的绘图——其 /OC 条目指向新组,于是 PDFium 和其他阅读器在图层切换时显示或隐藏那段绘图

需求很少是抽象的。工厂设计团队要在同一张图纸上放公制与英制尺寸,且恰好只显示一套。印刷店想要一个屏幕上可见、打样前可以关掉的裁切线覆盖层。两个文件都还没有任何图层,所以没东西可切。切换既有图层是另一份工作,用 SaveAsOptionalContentConfigured 切换既有 PDF 可选内容图层讲的是那件事;本文讲的是先把图层造出来

为什么不能直接用 PDFium 给页面对象设 /OC?

PDFium 没有把可选内容挂到既有页面对象上的公开函数。公开头文件里没有 FPDFPageObj_SetOC 或任何等价物,所以常规的先编辑再 FPDFPage_GenerateContent 的路子产不出受门控的内容。门必须写进文件结构,唯一的问题是 PDFium 渲染时会认哪个结构

ISO 32000-1 §8.11.3 给了三种把图形变成可选的办法,PDFium 对它们处理各不相同:

  • 包在 /OC /Name BDC ... EMC 标记内容里的页面内容流区段,渲染时按页面对象检查
  • 自己字典里带 /OC 条目的 Form XObject 和图像 XObject,绘制 XObject 时检查
  • 注解字典里带 /OC 条目的注解——PDFium 的渲染路径根本不看它,所以受门控的注解无论图层状态如何都可见

标记内容包装意味着逐操作符重写既有内容流,注解又被渲染器排除在外。剩下 Form XObject 这条路,PDFium Component 用的正是它:你的绘图进一个新的 Form XObject、/OC 指向组,form 注册进页面 /Resources /XObject 字典,一条只含单个 /Name Do 的新内容流追加到页面 /Contents。既有页面内容从不被解析或重写

PDFium 之下的 ISO 32000-1 可选内容门控路线:OC BDC 与 EMC 之间的标记内容按页面对象检查,带 /OC 条目的 Form XObject 在绘制时检查——这正是 PDFium Component 从 Delphi 写出的路线;注解的 /OC 则从不被查看
PDFium 检查标记内容区段和 XObject 字典的可选内容,但不检查注解——这就是 SaveAsOptionalContentCreated 经 Form XObject 和 Do 操作符给绘图上门控的原因

在 Delphi 里描述组与受门控的 form

TPdf.SaveAsOptionalContentCreated 收三个数组、返回一份报告。两个重载——一个写 TStream、一个写文件名——共享同一参数表:Groups: TPdfOptionalContentCreateGroups、Memberships: TPdfOptionalContentCreateMemberships、Forms: TPdfOptionalContentCreateForms、out Report: TPdfOptionalContentCreateReport,以及可选的 AllowSignedDocument: Boolean = False。方法随 PDFium Component v3.154.0 到来

每个 TPdfOptionalContentCreateGroup 有 Name、可选 Creator、pociView 或 pociDesign 的 Intent、InitialStateVisible 标志、Locked 标志和 RadioTag。共享同一 RadioTag 的组落进同一个 /RBGroups 数组——阅读器据此知道打开一个就关掉其他。每个 TPdfOptionalContentCreateForm 用 GroupIndex(Groups 数组的从零起计索引)点名它的门控组,外加从 1 起计的 PageNumber、原始内容流字节、包围盒、可选矩阵和可选 ResourceName

var
  Pdf: TPdf;
  Groups: TPdfOptionalContentCreateGroups;
  Forms: TPdfOptionalContentCreateForms;
  Report: TPdfOptionalContentCreateReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'sheet-a1.pdf';
    Pdf.Active := True;

    SetLength(Groups, 2);            // SetLength 把记录清零
    Groups[0].Name := 'Dimensions (mm)';
    Groups[0].Intent := pociView;
    Groups[0].InitialStateVisible := True;
    Groups[0].RadioTag := 'Units';
    Groups[1].Name := 'Dimensions (in)';
    Groups[1].Intent := pociView;
    Groups[1].InitialStateVisible := False;
    Groups[1].RadioTag := 'Units';

    SetLength(Forms, 2);
    Forms[0].GroupIndex := 0;
    Forms[0].PageNumber := 1;
    Forms[0].Content := TEncoding.ASCII.GetBytes(
      '0 0 1 RG 1.5 w 72 120 m 540 120 l S');
    Forms[0].BBoxRight := 612;
    Forms[0].BBoxTop := 792;
    Forms[0].ResourceName := 'DimMetric';
    Forms[1] := Forms[0];
    Forms[1].GroupIndex := 1;
    Forms[1].Content := TEncoding.ASCII.GetBytes(
      '1 0 0 RG 1.5 w 72 140 m 540 140 l S');
    Forms[1].ResourceName := 'DimImperial';

    if not Pdf.SaveAsOptionalContentCreated('sheet-a1-layers.pdf',
      Groups, nil, Forms, Report) then
      raise Exception.Create(Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

注意 form 内容是纯矢量绘图。组件在每个 form 上只写一个空的 /Resources 字典,所以内容流必须自给自足:设备色、路径、裁剪和变换都行,凡需要具名资源的(Tf 要的字体、Do 要的图像、gs 要的 ExtGState)都无处解析。ResourceName 留空时 form 命名为 FmOc1、FmOc2 等;自定义名字只能含字母、数字、下划线、减号和句点

OCMD 比普通组多出什么?

可选内容成员字典让可见性同时取决于多个组。ISO 32000-1 §8.11.2.2 定义两种形态:带 /P 策略(如 /AnyOn 或 /AllOn)的 /OCs 列表,以及规范里由 /And、/Or、/Not 构成的 /VE 可见性表达式。PDFium Component 收 TPdfOptionalContentCreateMembership——带 MemberGroupIndices、Policy 和 Expression;Expression.GroupIndices 非空时表达式获胜,/VE 与 /OCs 并写

procedure AddUnitMemberships(var Memberships: TPdfOptionalContentCreateMemberships);
begin
  SetLength(Memberships, 2);

  // 任一单位层开着即可见:/OCs [...] /P /AnyOn
  SetLength(Memberships[0].MemberGroupIndices, 2);
  Memberships[0].MemberGroupIndices[0] := 0;
  Memberships[0].MemberGroupIndices[1] := 1;
  Memberships[0].Policy := ocmpAnyOn;

  // 仅在英制层关闭时可见:/VE [ /Not ref ]
  SetLength(Memberships[1].MemberGroupIndices, 1);
  Memberships[1].MemberGroupIndices[0] := 1;
  Memberships[1].Expression.Kind := poceNot;
  SetLength(Memberships[1].Expression.GroupIndices, 1);
  Memberships[1].Expression.GroupIndices[0] := 1;
end;

受支持的子集刻意收窄。策略限 ocmpAllOn 与 ocmpAnyOn;ocmpAnyOff 和 ocmpAllOff 被拒。表达式一层深,对已创建组用 poceAll、poceAny 或 poceNot,/Not 要求恰好一个组索引。poceAll 序列化为 /And、poceAny 为 /Or、poceNot 为 /Not——§8.11.2.2 定义的仅有的三个操作符。3.181.2 之前的版本写的是 /All 和 /Any,PDFium 把它们求值为隐藏,所以用旧构建创建过 OCMD 的话,重新生成,别信那个表达式。每个成员即使在表达式在场时也需要 MemberGroupIndices 里至少一个条目,因为那份列表会变成 /OCs 数组。新字典的对象号在 Report.CreatedMembershipObjectNumbers 里回来

PDFium Component 在 Delphi 里创建的可选内容成员字典:带 /AnyOn 或 /AllOn 策略的 /OCs 列表一起求值各组状态,/VE 可见性表达式(如对某组用 /Not)以一层深的 And、Or、Not 逻辑门控内容
策略求值 /OCs 列表,/VE 表达式用 /And、/Or、/Not 组合各组——无论哪种,成员字典都至少要有一个成员组可引用

在 OCMD 上盖楼之前有两件事值得知道。其一,同一次调用里的受门控 form 只引用组:TPdfOptionalContentCreateForm.GroupIndex 索引的是 Groups,不是 Memberships,所以经这个 API 无法用新建 OCMD 给 form 门控。其二,PDFium Component 把每个新 OCMD 加进目录的 /OCProperties /OCGs 数组,而 /ON、/OFF 和 /Order 保持只放组。ISO 32000-1 §8.11.4.2 把 /OCGs 描述为组的列表,但没有这个注册,TPdf.InspectOptionalContent 完全枚举不到该成员字典。副作用是输出的清单会把每个 OCMD 列成 Inventory.Groups 里一个多余的匿名条目——所以按 Name 匹配组,别按位置

组件怎么给既有 /OCProperties 打补丁?

TPdf.SaveAsOptionalContentCreated 要么从零构建 /OCProperties,要么扩展目录里已有的那个,而且扩展靠追加、从不重建。没有图层的文档得到新字典:/OCGs 加一个默认配置 /D(带 /ON、/OFF 和 /Order),有任何组需要时再加 /Locked 与 /RBGroups,布局如 ISO 32000-1 §8.11.4.3 所述

对已有图层的文档,每个目标数组靠在闭括号之前插入新引用来打补丁。听着繁琐,但这么做的理由很硬。/Order 数组可以装嵌套数组和塑造阅读器图层树的文本标签,早先一版先摊平再重建数组的做法把这些标签弄丢了。在末尾插入保住既有树原样、只是把新组列在后面。住在自己间接对象里的数组则被解析后原位打补丁

用 PDFium Component 在 Delphi 里扩展既有 /OCProperties 字典:新可选内容组引用插在闭括号之前,嵌套 /Order 数组与文本标签得以幸存;间接的 /D 默认配置被跟踪并原位打补丁
在闭括号前追加引用保住既有 /Order 树的完整——先摊平嵌套数组与标签再重建的做法给不了这个保证

自 v3.175.0 起,默认配置本身也可以是间接的。有的生产者写 /OCProperties << /OCGs [...] /D 12 0 R >>,PDFium Component 现在会跟进那个引用、给目标字典打补丁,而不是以 malformed-entry 错误失败。直接与间接的页面 /Resources 字典——包括间接的 /XObject 子字典——同样处理

证明新图层真的能切

活在 /OCProperties 里却什么都不门控的图层,容易造出来、也难察觉。开发期,form 写入器的第一版把一个已带 N 0 obj 头和 endobj 尾的流对象又包进了第二层头。PDFium 解析不了这个双头对象,且不报错、无声跳过 Do 操作符。文件照开、图层面板看着正常、受门控的绘图从未出现。暴露它的是一个切换图层再比较渲染像素的探针:毫无差异,必有蹊跷

这就是方法在交付前先验证的原因。输出经严格加载重开——检查交叉引用表——每个请求的组经 InspectOptionalContent 读回,确认名字、初始状态与锁定注册。之后输出才复制到你的流;文件重载先写临时文件、再原子替换目标。你可以自己跑同样的切换检查:重开输出、按名找组、以相反状态存一份副本

function SaveImperialPreset(const SourceName, TargetName: string): Boolean;
var
  Pdf: TPdf;
  Inventory: TPdfOptionalContentInventory;
  Changes: TPdfOptionalContentStateChanges;
  Options: TPdfOptionalContentConfigureOptions;
  CfgReport: TPdfOptionalContentConfigureReport;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := SourceName;
    Pdf.Active := True;
    if not Pdf.InspectOptionalContent(Inventory) then
      raise Exception.Create(Inventory.ErrorMessage);

    for I := 0 to High(Inventory.Groups) do
      if (Inventory.Groups[I].Name = 'Dimensions (mm)') or
         (Inventory.Groups[I].Name = 'Dimensions (in)') then
      begin
        SetLength(Changes, Length(Changes) + 1);
        Changes[High(Changes)].GroupObjectNumber := Inventory.Groups[I].ObjectNumber;
        Changes[High(Changes)].Visible := Inventory.Groups[I].Name = 'Dimensions (in)';
      end;

    Options := TPdfOptionalContentConfigureOptions.Default;
    Result := Pdf.SaveAsOptionalContentConfigured(TargetName, Changes,
      Options, CfgReport);
  finally
    Pdf.Free;
  end;
end;

把两个文件的第一页都渲染出来,蓝线红线应当互换。没互换的话,先在清单里看该组的 EffectiveVisible——它反映配置数组与用法规则施加之后 PDFium 实际会渲染的状态

要提前规划的限制与陷阱

TPdf.SaveAsOptionalContentCreated 是个有界的 profile,多数意外来自把它当通用图层编辑器。下面这些约束提前强制执行,违规调用带着置位的 Report.ErrorMessage 失败、不写任何输出:

  • 至少要有一个组;组、成员字典与 form 各封顶 4096 条,组名必须唯一且长 1 到 255 字符,每个 form 的内容字节至多 16 MiB
  • 加密文档被拒;已签名文档需要 AllowSignedDocument = True,输出是完整重写,既有签名将不再通过验证
  • 每个 Forms 条目都成为自己的 Form XObject,所以把同一覆盖层放上 200 页就是把内容存 200 遍而不是一遍
  • 新的 Do 流追加在既有页面内容之后,于是运行在早先流留下的任何图形状态里;规范的页面会恢复状态,但 q/Q 不平衡的页面会让你的覆盖层跑位
  • 既有页面内容从不被挪进图层;被门控的只有你在 Forms 里传入的绘图

大文档要掂量的是重复这一点。覆盖层很重、每页都出现时,基于 Form XObject 的可复用页面印章的共享对象方案在文件里只留一份,代价是自己处理图层门控

FAQ

PDFium Component 能把既有页面内容放进新图层吗?

不能。TPdf.SaveAsOptionalContentCreated 只门控你在 Forms 里提供的内容;它不把既有操作符包进标记内容、也不把它们挪进 XObject。要给既有图稿分层,把它重新生成为自包含的 form 内容,再在单独一步里移除原绘图

图层关着,我的受门控注解为什么仍然可见?

PDFium 的渲染路径不检查注解字典的 /OC 条目,所以注解层的门控在基于 PDFium 的阅读器里无效。这正是 PDFium Component 经 Form XObject 挂图层内容的原因——PDFium 确实对它门控

以后切换新图层时该用哪些对象号?

Report.CreatedGroupObjectNumbers 存的是刚写出的那份文件里的号。重开那份文件做配置时,从 TPdf.InspectOptionalContent 取 ObjectNumber 并按 Name 匹配——即便之后的保存重排了对象号,这样做仍然正确

PDFium Component 把 SaveAsOptionalContentCreated、SaveAsOptionalContentConfigured 和 InspectOptionalContent 打包成一套面向 Delphi 与 C++Builder 的可选内容工具,构建在 PDFium 渲染器之上。详情与下载见 PDFium Component 产品页