Technical Article

Delphi 中的 PDF 图层:可选内容组 (OCG)

测量员打开场地平面图,希望在保留公用设施的同时隐藏等高线。审查员希望红线批注在屏幕上可见,而在打印件上消失。产品说明书从一个文件以三种语言发送,读者可以选择显示哪种语言。这三者都是同一个 PDF 功能,在 Acrobat 中驱动它们的面板称为“图层”。该面板下方的功能是可选内容,它允许单个页面承载多个独立的视觉层,查看器可以将其打开和关闭。

可选内容在 ISO 32000-1 第 8.11 节中指定。可见性的单位是可选内容组(Optional Content Group,OCG),这是一种带有名称的 /OCG 类型字典。页面上的标记内容与组相关联,查看器决定当前是否显示该组。一个相关的构造是可选内容成员资格字典(Optional Content Membership Dictionary,OCMD),它允许可见性取决于多个组的布尔组合,但日常情况是代表单个图层的单个命名组。文档通过一个目录条目 /OCProperties 将整个机制绑定在一起,接下来将对此进行描述。

目录必须承载的内容

OCG 本身是惰性的。为了让查看器列出图层并记住其状态,文档目录需要一个 /OCProperties 字典,第 8.11.4 节详细列出了其中的内容。有一个 /OCGs 数组命名文件中的每个组,还有一个 /D 条目保存默认配置。默认配置是阅读器在文件首次打开时应用的部分。它记录了哪些组开始时开启,哪些组开始时关闭,哪些条目被锁定以防止用户切换它们,并通过 /Order 数组记录了图层名称在面板中是如何排列和嵌套的。

实际的结果是,创建图层绝不是单纯的局部行为。该组必须绘制在页面上,并且还必须在先前不存在的目录级结构中进行注册。PDFlibPas 为您完成了这两项工作。创建组的第一个调用会将 /OCProperties 条目添加到目录并播种默认配置,因此无需在您这边进行单独记账即可绘制和列出图层。

为什么合规模式可以拒绝该功能

在运行任何图层代码之前,文档的一致性目标决定了可选内容是否合法。ISO 19005-1 中定义的存档规范 PDF/A-1 在第 6.1.13 节中完全禁止了 /OCProperties 条目。其推理符合该格式的目的。存档文件在遥远的未来对于每个阅读器必须渲染得完全相同,而查看器可以更改其可见性的内容其外观是不固定的,因此该规范禁止了这一构造,而不是允许含糊不清的归档。在 ISO 19005-2 和 ISO 19005-3 中定义的 PDF/A-2 和 PDF/A-3 在其第 6.9 节中持相反观点,允许可选内容,并规定了默认可见性的规则。

这种差异直接体现在 API 中。当文档处于 PDF/A-1 模式时,NewOptionalContentGroup 拒绝创建组并返回零,因为响应此请求将生成一个无法通过其声明一致性的文件。在 PDF/A-2 或 PDF/A-3 模式以及普通不受限的 PDF 中,相同的调用会成功并返回非零组 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 通过两个设置器公开了这一对状态。SetOptionalContentGroupVisible 接受组 ID 和一个标志(其中 1 表示可见,0 表示隐藏),并管理默认的屏幕显示状态。SetOptionalContentGroupPrintable 接受组 ID 和一个决定在文档打印时是否输出该图层的标志。配套的获取器 GetOptionalContentGroupVisibleGetOptionalContentGroupPrintable 各返回一或零,因此您可以分别读回图层的屏幕和打印配置,而不是从一个推断另一个。

在页面上构建两个图层

创建图层并填充它遵循固定的顺序。您在当前页面上绘制图层的内容,然后使用组 ID 调用 SetContentStreamOptional,这将包装页面的当前内容流,以便到目前为止绘制的所有内容都属于该组。因为该调用捕获了那一刻流上的任何内容,所以原则是先放下图层的标记,分配它们,然后才开始下一个图层。下面的示例在第一页上放置公用设施,在第二页上放置审查员红线,设置每个图层的屏幕和打印状态并保存。

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;

红线图层是值得注意的情况。它在屏幕上显示,以便审查员看到该注释,并且其可打印标志为零,因此同一文件的打印件不会携带任何审查文本。这种不对称性正是保持这两种状态独立开来的全部意义所在。

读回配置

读取图层是对相同结构的另一种遍历。加载文件后,GetOptionalContentConfigCount 会报告文档包含多少个配置字典;第一个默认配置是配置 ID 1。在配置内,GetOptionalContentConfigOrderCount 给出顺序树中的条目数,并且您从 1 开始对它们进行索引。对于每个条目,GetOptionalContentConfigOrderItemLabel 返回其显示文本,GetOptionalContentConfigOrderItemLevel 返回其嵌套深度,因此可以逐字重构在标题下缩进子图层的面板大纲。

每个条目也有一个类型。GetOptionalContentConfigOrderItemType 将实际的可选内容组与仅用于标题树中某个部分的纯文本标签区分开来。这种区别非常重要,因为按组的状态查询仅对真正的组有意义。对于组条目,GetOptionalContentConfigState 报告配置是将其启动为开启、关闭还是保持不变,而 GetOptionalContentConfigLocked 报告是否禁止用户切换它。下面的循环渲染顺序树以及每个组的状态和锁定状态,并按层级进行缩进。

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 的,从 1 到总数,与库内部对树进行编号的方式相匹配。并且只有当项目类型是组时才运行按组调用,因为文本标签是一个具有名称和层级的标题,但没有开启、关闭或锁定状态可供查询。跳过该保护,您就会向标签请求它没有的状态。

图层是一种呈现机制,因此引擎必须在渲染页面的每个路径上支持它们,渲染方面在我们在 Delphi 中多引擎渲染的演练中进行了介绍。它们还与文档结构相交叉,因为图层的名称是面向作者的文本,而读者可以从结构化的图层大纲中受益,这与我们关于标记 PDF 和可访问性结构的分析文章中的工作相联系。两者都与此处介绍的可选内容 API 相结合,这些 API 作为 Delphi PDF 库的一部分提供,与本博客其他地方讨论的页面、文本、字体和一致性工具相配套。