技术文章

Delphi 里让 /ParentTree 与 /StructParents 同步

PDFium Component 在 Delphi 里经 FPdfDocumentStructure.pas 的 EditPdfDocumentStructure 给既有 PDF 内容打标签。psekAddMarkedContent 和 psekAddObjectReference 两个操作做的不只是往结构元素的 /K 追加一条 MCR 或 OBJR:它们还分配 /StructParents 或 /StructParent 键、写出匹配的 /ParentTree 条目——ISO 32000-1 §14.7.4.4 要求的那张反向索引

给既有文档补打标签通常坏在这后半段。加一个 BDC 包装和一条 MCR 很容易,从树顶往下遍历结构树的阅读器也乐意找到新内容。但辅助技术常常反着走:它从页面上的某段内容出发,问哪个结构元素拥有它。页面没有 /StructParents 键、或 /ParentTree 里没有新 MCID 的条目时,这次查找就失败,检查器报成 parent tree 缺失或不一致。文件看着打了标签,却仍然不工作

/ParentTree 到底映射什么?

/ParentTree 是把整数键映射到结构元素的 number tree,而键来自内容、不来自树。ISO 32000-1 §14.7.4.4 给每个包含标记内容的页面或 Form XObject 一个 /StructParents 整数,该键下的 parent tree 值是按 MCID 索引的数组:数组第 3 项就是拥有 <</MCID 3>> 的结构元素。整体作为内容项使用的注解或 XObject 拿的是单个 /StructParent 键,其 parent tree 值就是结构元素本身,不是数组

PDFium Component 在 Delphi 里写出的 PDF/UA parent tree 反向索引:带 MCID 3 的内容标记经页面 /StructParents 键 17 解析进 /ParentTree number tree,其数组第 3 项是拥有该标记内容的结构元素
阅读次序对标签 PDF 很要紧:辅助技术从页面上的 MCID 经 /StructParents 走进 /ParentTree 数组,键或条目一缺,查找就断——尽管结构树本身看着完好

容器格式是 §7.9.7 的通用 number tree。叶节点持有 /Nums——按键升序排列的键值对平铺数组。根之下的中间节点与叶节点带 /Limits,标明各自覆盖的最小与最大键,而根自身不得有 /Limits。除此之外,结构树根还可以带 /ParentTreeNextKey(§14.7.2)——写入器下一个该发的键。所以每一次给新内容打标签的编辑,都要动至多四个必须相互一致的对象:内容流、页面或 form 字典、parent tree 叶节点,以及结构树根

正向方向也有自己的规则,见 §14.7.4.2 与 §14.7.4.3。页面自己内容流里的内容的标记内容引用(MCR)只需要 /Pg 和 /MCID。标记内容住在 Form XObject 里时,MCR 还要 /Stm 点名那个流,因为 MCID 是按内容流各自编号的。对象引用(OBJR)用 /Obj 点名注解或 XObject、用 /Pg 点名其页面

用 EditPdfDocumentStructure 给内容流打标签

EditPdfDocumentStructure 是带快照守卫的批量编辑器。先调 ReadPdfDocumentStructure 拿一份 TPdfDocumentStructureSnapshot,再把它的 SourceSha256 连同 TPdfStructureEditOperation 记录数组一起传回去。中间源字节若变了,编辑被拒、无输出。既有元素用其在 NodeID 里的对象号寻址,0 表示结构树根

uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentStructure;

function TagContentStream(const FileName, OutName: string;
  ElementID, StreamObj, StreamGen: Integer): Boolean;
var
  Source: TMemoryStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  Output: TBytes;
begin
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(FileName);
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);

    SetLength(Ops, 1);
    Ops[0] := Default(TPdfStructureEditOperation);
    Ops[0].Kind := psekAddMarkedContent;
    Ops[0].NodeID := ElementID;            // 既有元素,按对象号
    Ops[0].TargetObjectNumber := StreamObj; // 页面内容流或页面级 Form XObject
    Ops[0].TargetGeneration := StreamGen;

    Result := EditPdfDocumentStructure(Source, Snapshot.SourceSha256,
      Ops, Options, Output, Report);
    if not Result then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
  finally
    Source.Free;
  end;
end;

对这一个操作,EditPdfDocumentStructure 把目标流包进 /P <</MCID n>> BDC ... EMC,把 MCR 追加到元素的 /K,页面(或 form)没有 /StructParents 键就给它一个,并把元素 n 写进该键的 parent tree 数组。结果作为增量更新写在原始字节之后,Report.CreatedMarkedContents 告诉你创建了几条 MCR。流里已有配平的 BDC/EMC 对时,只包装最后一个顶层 EMC 之后未打标签的尾部;否则整条流

新 MCID 与 parent tree 键怎么选?

新 MCID 是当前最大值加一,新 parent tree 键是 Max(MaxKey + 1, ParentTreeNextKey)。MCID 方面,EditPdfDocumentStructure 取该页面(或该 Form XObject)已记录的 MCR 里的最大值,加上它在流自身属性列表里找到的每个 /MCID。两头都数是要紧的:结构树可能丢了一条 MCR 而内容还带着标记,复用那个号就会让两个元素认领同一段内容

键的逻辑遵循同样的防御思路。所有者已有 /StructParents 值时,在索引 MCID 处扩展既有数组、空缺以 null 填充,不分配新键。没有时,组件扫描每个 /Nums 叶节点找最高键,与 /ParentTreeNextKey 比较、取大者。低于已用键的过期 /ParentTreeNextKey、以及预留到最后一项之后的键都处理了——两者取大必空闲。分配之后,/ParentTreeNextKey 前进到新键加一

PDFium Component 在 Delphi 里的结构编辑器 MCID 与 parent tree 键分配:新 MCID 取 MCR 与流属性列表已记录的最大值加一;键取最高 /Nums 键加一与 /ParentTreeNextKey 中的较大者,数组空缺以 null 填充
EditPdfDocumentStructure 从 MCR 与流内标记两头推导新 MCID、防御性地分配 parent tree 键,过期的 /ParentTreeNextKey 或复用的 MCID 都没法让两个元素认领同一段内容

新键值对按升序插入 /Nums 而不是追加,因为 number tree 的查找依赖这个顺序。叶节点带 /Limits 时,界从重写后的数组重新计算。结构树根完全没有 /ParentTree 时——只写了正向树的工具产出的文件里很常见——EditPdfDocumentStructure 合成一个新的间接 << /Nums [] >> 对象、从 /StructTreeRoot 链上,然后才插入第一个条目

Form XObject、/StmOwn 与对象引用

Form XObject 里的标记内容只在那个 form 恰好被一个页面使用时受支持。EditPdfDocumentStructure 为 form 写的 MCR 带 /Pg、点名 form 流的 /Stm,以及点名页面的 /StmOwn,而 /StructParents 键落在 form 字典上、不在页面上。被三个页面绘制的 form 有三个都说得通的属主,任何单个 /StmOwn 对其中两个都是错的。组件以错误拒绝这次编辑,而不是猜

用 PDFium Component 在 Delphi 里给 Form XObject 内容打标签的单属主规则:单页使用的 form,其 MCR 带 /Pg、/Stm 和 /StmOwn,/StructParents 落在 form 字典上;被三个页面绘制的 form 让 /StmOwn 模棱两可,编辑被拒
/StmOwn 键只在一个页面拥有该 form 时才有意义——共享 form 与共享注解因此被拒,而不是带着一个猜出来的属主打上标签

同一唯一性规则也管 psekAddObjectReference。目标必须是只出现在一个页面 /Annots 里的注解,或只出现在一个页面资源里的 Form 或 Image XObject;其他一律拒绝。OBJR 拿到 /Obj 和 /Pg,目标拿到 /StructParent 键(已有则复用),parent tree 值是指向元素的普通引用。这是链接注解或插图图像的常见修法——它们看着打了标签,却从没接进树里

// 把未标记的图像包进新 Figure 元素,再用两种方式绑定:
// 作为图像 XObject 的 OBJR,以及页面上的标记内容
SetLength(Ops, 3);
Ops[0] := Default(TPdfStructureEditOperation);
Ops[0].Kind := psekInsertContainer;
Ops[0].NodeID := -1;              // 临时 ID,必须为负且唯一
Ops[0].ParentNodeID := SectID;    // 既有 Sect 元素
Ops[0].Value := 'Figure';         // 标准角色或 RoleMap 条目

Ops[1] := Default(TPdfStructureEditOperation);
Ops[1].Kind := psekAddObjectReference;
Ops[1].NodeID := -1;              // 指向上文创建的容器
Ops[1].TargetObjectNumber := ImageObj;
Ops[1].TargetGeneration := 0;

Ops[2] := Default(TPdfStructureEditOperation);
Ops[2].Kind := psekAddMarkedContent;
Ops[2].NodeID := -1;
Ops[2].TargetObjectNumber := ContentObj;
Ops[2].TargetGeneration := 0;

if EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops, Options,
  Output, Report) then
  Writeln(Format('Figure is object %d, %d OBJR, %d MCR', [
    Report.InsertedNodes[0].ObjectNumber,
    Report.CreatedObjectReferences, Report.CreatedMarkedContents]));

负的 NodeID 指同一批次里早先创建的容器,Report.InsertedNodes 把每个请求的 ID 映射到它拿到的对象号。psekInsertContainer 接受标准 PDF 1.7、PDF 2.0 和 PDF/UA 角色——P、H1 到 H6、Figure、Table、LI 等——外加已在 /RoleMap 里映射的自定义角色。在两个内容操作上设 BeforeNodeID,新的 MCR 或 OBJR 就插在某个既有孩子之前而不是末尾

为什么同一批里编辑同一条流要小心?

一个批次可能反复碰到同一个对象,而后面的每个操作都必须看到前面操作的结果。对同一页面的两个 psekAddMarkedContent 都会重写其内容流、都会更新同一个 parent tree 叶节点。把每次编辑暂存成独立追加对象的实现,会在更新里写出对象 12 的两个版本。读者留下哪一个,取决于它——你要么丢掉第一个 BDC 包装,要么丢掉第一条 /Nums 键值对,而且第二个 MCID 可能与第一个相撞,因为它是从过期字节算出来的

EditPdfDocumentStructure 按 (ObjectNumber, Generation) 键控暂存更新、原位覆盖既有条目,同时刷新该对象的内存副本。于是第二个操作解析到的已是包好的流,看到 MCID 0、分配 MCID 1、扩展同一个 parent tree 数组。一个对象、一个最终版本。返回任何东西之前,输出会被读回并与内存模型比对:元素数、内容项数、每条保留的 MCR 或 OBJR 都必须匹配,否则调用失败且无输出

压缩流走同一条路。带 /Filter /FlateDecode(或单元素 [/FlateDecode] 数组)且无 /DecodeParms predictor 的内容流,用有界解码器解压——上限 32 MiB 或 MaxOutputBytes 取小——包装后重压缩回 /FlateDecode 并写新的 /Length。解压会超上限的流被拒绝而不是截断,这对来自不可信来源的文件要紧

跑 PDF/UA 修复批次前值得知道的限制

EditPdfDocumentStructure 设计上保守,多数限制表现为清楚的 Report.ErrorMessage,而不是悄悄坏掉的文件。规划批量修复时记住这些:

  • 只有未过滤的和普通 FlateDecode 流能被包装;其他过滤器、或带 predictor 的 Flate,一律拒绝
  • 内容流必须恰好属于一个页面的 /Contents;Form XObject、注解和 XObject 必须恰好被一个页面使用
  • 包装总是用 /P 标签;语义角色来自结构元素的 /S,不是 BDC 标签名
  • parent tree 同步读取平铺的 /Nums 根或一层 /Kids 叶节点;更深的中间节点不下钻,编辑这类文件后要重新验证
  • 含签名或签字段的文件直接拒绝,所以结构修复之后再签名,别反过来

对修复管线来说,验证这步不是可选项。批次前后各跑一遍 PDFium Component 的 PDF/UA 结构树验证;要确认哪些页面对象落进了新标记,用 读取 BDC 标签与 MCID 的页面对象标记内容 API。干净的 parent tree 只能告诉你查找能解析;阅读次序对不对、替代文本对不对,它说不了

FAQ

EditPdfDocumentStructure 会重建整个 /ParentTree 吗?

不会。EditPdfDocumentStructure 只更新当前批次里被它打标签的内容的条目,复用既有 /StructParents 键、需要时才分配新键,既有 parent tree 条目原样不动

PDF 有结构树但没有 /ParentTree 会怎样?

组件创建一个间接的 << /Nums [] >> number tree,从 /StructTreeRoot 链上,并为被打标签的内容插入第一个键

能标记被多个页面共享的 Form XObject 里的内容吗?

这个操作不行。共享 form 让 /StmOwn 模棱两可,所以 psekAddMarkedContent 要求 form 恰好被一个页面引用,否则报错

结构树读取与编辑、PDF/UA 验证和标记内容访问都是 PDFium Component for Delphi and C++Builder 的一部分,它为 Delphi、C++Builder 与 Lazarus 项目封装 PDFium