技术文章

Delphi 中的 PDF 图层:Optional Content Groups (OCG)

测量人员打开一张场地图,希望隐藏等高线而保留管线设施。审阅人员希望红线批注在屏幕上可见,但打印时不出现。产品资料从同一个文件交付三个语言版本,由读者决定显示哪一种语言。这三种场景本质上都来自同一个 PDF 特性,在 Acrobat 里驱动它们的面板叫作 Layers。这个面板背后的能力就是 optional content,它允许同一页包含多个彼此独立的视觉层,由查看器决定开启或关闭哪些层

optional content 在 ISO 32000-1 第 8.11 节中定义。可见性的基本单位是 optional content group,也就是 OCG,它是一个类型为 /OCG 并带有名称的字典。页面上的 marked content 会与某个 group 关联,查看器再决定该 group 当前是否显示。相关的另一个结构是 optional content membership dictionary,也就是 OCMD,它允许可见性依赖多个 group 的布尔组合,不过日常最常见的情况还是一个具名 group 对应一个图层。整个机制通过目录中的一个入口 /OCProperties 连接在一起,下面就来看它的作用

目录必须承载什么

单独存在的 OCG 本身不会起作用。要让查看器列出图层并记住它的状态,文档目录必须包含一个 /OCProperties 字典,8.11.4 节明确规定了其中的内容。这里会有一个 /OCGs 数组列出文件里的所有 group,还会有一个 /D 条目保存默认配置。默认配置就是阅读器首次打开文件时采用的那部分设置。它记录哪些 group 初始为开启,哪些初始为关闭,哪些条目被锁定不允许用户切换,以及通过 /Order 数组定义图层名称在面板中的排列和嵌套方式

实际含义是,创建图层从来不是纯粹的局部操作。该 group 不仅要在页面内容里被绘制出来,还必须注册到此前并不存在的目录级结构中。PDFlibPas 会替你完成这两件事。第一次创建 group 时,库会自动把 /OCProperties 加入目录并初始化默认配置,因此图层既会被画出来,也会出现在图层面板中,而你无需自己维护这套账本

为什么合规模式可能拒绝这个特性

在任何图层相关代码运行前,文档选择的合规目标就决定了 optional content 是否允许使用。ISO 19005-1 定义的归档规范 PDF/A-1 在 6.1.13 节中直接禁止 /OCProperties 条目。这个限制和格式的目标一致。归档文件必须在未来长期由任何阅读器呈现出一致的结果,而可由查看器切换可见性的内容天然不是固定外观,因此该规范直接禁止这一结构,而不是允许含糊不清的归档行为。相反,ISO 19005-2 和 ISO 19005-3 定义的 PDF/A-2 与 PDF/A-3 在 6.9 节中允许 optional content,但会约束默认可见性

这种差异会直接体现在 API 中。当文档处于 PDF/A-1 模式时,NewOptionalContentGroup 会拒绝创建 group 并返回零,因为如果仍然满足该请求,就会生成一个与声明合规级别不一致的文件。在 PDF/A-2、PDF/A-3 或普通不受限制的 PDF 模式下,同样的调用会成功并返回非零 group ID。所以返回零并不是一个留待之后统一排查的泛化失败,而是库在明确告诉你,当前的合规级别根本不允许这个特性

var
  Pdf: TPDFlib;
  LayerID: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.SetPDFAMode(1);                       // PDF/A-1a: OCProperties forbidden

    LayerID := Pdf.NewOptionalContentGroup('Utilities');
    if LayerID = 0 then
      // refused under PDF/A-1; not a transient error, the mode bans layers
      ShowMessage('Optional content is not available in PDF/A-1 mode.');
  finally
    Pdf.Free;
  end;
end;

每个图层有两个状态,而不是一个

图层不只是可见或不可见。默认配置会同时记录屏幕显示状态和独立的打印状态,因为 8.11.4 节明确区分查看器显示的内容和打印流程输出的内容。这两者被有意设计为彼此独立。草稿水印可以在屏幕上显示但在纸面上省略,切割线图层也可以在屏幕上隐藏却发送到绘图仪。如果把它们强行合并,就会让一个状态被迫跟随另一个状态,从而失去这个特性本来要提供的控制力

PDFlibPas 通过两个 setter 暴露这对状态。SetOptionalContentGroupVisible 接收 group ID 和一个标志,其中 1 表示可见,0 表示隐藏,用于控制默认的屏幕显示状态。SetOptionalContentGroupPrintable 同样接收 group ID 和一个标志,用来决定打印文档时是否输出该图层。对应的 getter,也就是 GetOptionalContentGroupVisibleGetOptionalContentGroupPrintable,分别返回 1 或 0,因此你可以分别读取图层的屏幕状态和打印状态,而不必从一个状态去推断另一个状态

在页面上构建两个图层

创建图层并填充其内容有一个固定顺序。你先在当前页绘制这个图层的内容,然后调用 SetContentStreamOptional 并传入 group ID,这个调用会把当前页面内容流包装起来,让此前已经绘制到流里的内容都归属于这个 group。因为这个调用会捕获调用瞬间已经存在于流中的内容,所以正确的习惯是先画完一个图层的标记,再把它们归属给 group,然后再开始下一个图层。下面的示例在第一页放置 utilities,在第二页放置审阅红线,分别设置两个图层的屏幕状态和打印状态,最后保存文件

var
  Pdf: TPDFlib;
  FontID, UtilLayer, RedlineLayer: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;                          // unconstrained PDF: layers allowed
    Pdf.SetPageDimensions(595, 842);          // A4 in points
    FontID := Pdf.AddStandardFont(0);         // Helvetica
    Pdf.SelectFont(FontID);

    // Layer 1: utilities, drawn then assigned to its own group
    Pdf.SetTextColor(0.10, 0.30, 0.65);
    Pdf.DrawText(72, 770, 'Utilities: water main, valve chamber');
    UtilLayer := Pdf.NewOptionalContentGroup('Utilities');
    Pdf.SetContentStreamOptional(UtilLayer);
    Pdf.SetOptionalContentGroupVisible(UtilLayer, 1);   // shown on screen
    Pdf.SetOptionalContentGroupPrintable(UtilLayer, 1); // and on paper

    // Layer 2: reviewer redline on a fresh page
    Pdf.InsertPages(2, 1);                     // append one page after page 1
    Pdf.SetTextColor(0.80, 0.10, 0.10);
    Pdf.DrawText(72, 770, 'REVIEW: revise valve spec before issue');
    RedlineLayer := Pdf.NewOptionalContentGroup('Reviewer markup');
    Pdf.SetContentStreamOptional(RedlineLayer);
    Pdf.SetOptionalContentGroupVisible(RedlineLayer, 1);    // visible while reviewing
    Pdf.SetOptionalContentGroupPrintable(RedlineLayer, 0);  // never printed

    Pdf.SaveToFile('SitePlan_Layers.pdf');
  finally
    Pdf.Free;
  end;
end;

最值得注意的是红线图层。它在屏幕上可见,因此审阅者能看到批注;而它的 printable 标志为零,所以同一个文件打印出来时不会带上任何审阅文字。这种不对称正是把两个状态分开保存的意义所在

把配置读回来

读取图层,就是沿着同一套结构走另一遍。加载文件后,GetOptionalContentConfigCount 会告诉你文档里有多少个配置字典;第一个默认配置的 config ID 是 1。在一个配置内部,GetOptionalContentConfigOrderCount 会给出顺序树里的条目数量,并且索引从 1 开始。对每个条目,GetOptionalContentConfigOrderItemLabel 会返回显示文本,GetOptionalContentConfigOrderItemLevel 会返回嵌套层级,因此你可以原样重建图层面板的层级大纲,把子图层按缩进放在标题之下

每个条目还有自己的类型。GetOptionalContentConfigOrderItemType 会区分真正的 optional content group 与仅用于树形结构标题的纯文本标签。这个区分很重要,因为按组查询状态只对真实 group 才有意义。对于 group 条目,GetOptionalContentConfigState 会告诉你该配置初始将其设为 on、off 或 unchanged,而 GetOptionalContentConfigLocked 会告诉你用户是否被禁止切换它。下面这段循环按层级缩进输出顺序树,同时附带每个 group 的状态和锁定信息

var
  Pdf: TPDFlib;
  Cfg, Count, I, ItemType, GroupID, Indent: Integer;
  Line: string;
begin
  Pdf := TPDFlib.Create(nil);
  try
    if Pdf.LoadFromFile('SitePlan_Layers.pdf', '') = 0 then Exit;
    if Pdf.GetOptionalContentConfigCount = 0 then Exit;

    Cfg := 1;                                  // the default configuration
    Count := Pdf.GetOptionalContentConfigOrderCount(Cfg);
    for I := 1 to Count do
    begin
      Indent := Pdf.GetOptionalContentConfigOrderItemLevel(Cfg, I);
      Line := StringOfChar(' ', Indent * 2)
              + Pdf.GetOptionalContentConfigOrderItemLabel(Cfg, I);

      ItemType := Pdf.GetOptionalContentConfigOrderItemType(Cfg, I);
      if ItemType = 1 then                     // 1 = optional content group
      begin
        GroupID := Pdf.GetOptionalContentConfigOrderItemID(Cfg, I);
        case Pdf.GetOptionalContentConfigState(Cfg, GroupID) of
          1: Line := Line + '  [on]';
          2: Line := Line + '  [off]';
          3: Line := Line + '  [unchanged]';
        end;
        if Pdf.GetOptionalContentConfigLocked(Cfg, GroupID) = 1 then
          Line := Line + ' (locked)';
      end;
      // ItemType = 2 is a text label heading; it has no per-group state

      Writeln(Line);
    end;
  finally
    Pdf.Free;
  end;
end;

要让这段循环保持正确,有两个细节必须记住。第一,顺序索引是从 1 到 count 的一基索引,这和库内部给顺序树编号的方式一致。第二,只有当条目类型是 group 时,才应该调用按组查询的函数,因为文本标签只是一个带有名称和层级的标题,并没有 on、off 或 locked 这样的状态可供查询。如果跳过这层保护,你就会向一个根本不具备状态的标签请求状态

它适合放在什么位置

图层是一种表现层机制,因此引擎必须在所有渲染页面的路径上都正确支持它,而渲染侧的细节已经在我们关于 Delphi 多引擎渲染的讲解中展开说明。它也会与文档结构发生交集,因为图层名称是面向作者的文本,读者也会受益于结构化的图层大纲,这又会关联到我们关于 tagged PDF 与可访问性结构的文章。这些内容都能和这里介绍的 optional content API 配合使用,而这些 API 又作为 Delphi PDF Library 的一部分提供,并与本博客其他文章中讨论的页面、文本、字体和合规能力一同交付