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 值就是结构元素本身,不是数组
容器格式是 §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 前进到新键加一
新键值对按升序插入 /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 对其中两个都是错的。组件以错误拒绝这次编辑,而不是猜
同一唯一性规则也管 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