HotPDF Delphi Component 通过 THotPDF.DeletePage 从一份已加载的 PDF 里删除页面,而从 2.751.0 版本起,这个调用还会一并清理所有仍然指向该页的文档级引用:/Names /Dests 树里的命名目标、catalog 里遗留的 /Dests 字典、书签的 /GoTo 动作、/StructTreeRoot 下的结构元素、ParentTree、注释的 OBJR 条目,以及幸存页面上的链接注释。页面树放在最后重建,那时已经没有任何东西能到达被删掉的那个对象
这个改动防住的失败很容易复现,也很难诊断。删掉一份带标签报告的封面页,保存,打开结果:Acrobat 显示的页数是对的,但「目录」书签现在落到空处,无障碍检查器报告一个没有页面的结构元素,而严格的校验器列出一条指向空闲对象的引用。页面树里没有任何东西是错的。问题在于一个 PDF 页面不只是 /Pages 的一片叶子;它是一个被半个 catalog 指着的目标,删掉这片叶子会让其中每一个指针都悬空
为什么只把页面从 /Kids 里拿掉不够?
因为 ISO 32000-1 允许至少七种彼此独立的结构持有对同一个页面对象的引用,而其中只有一个是页面树。把页面从 /Kids 里拿掉并让 /Count 减一满足 §7.7.3,而其他每一条引用都变成了指向一个要么在 xref 里被释放、要么在重写后的文件里干脆不存在的对象的指针。沿其中某个指针走下去的查看器拿到 null,而它拿这个 null 怎么办是它自己的事
/Names/Dests下的名称树(§7.7.4、§12.3.2.3)把名字映射到以页面为首元素的目标数组- 1.2 之前就有的、直接挂在 catalog 里的
/Dests字典,按名字为键装着同一类数组 - 大纲项(§12.3.3)到达页面的途径,要么是内联的
/Dest,要么是带/S /GoTo和/D数组的/A动作 - 结构元素(§14.7.2)带一个
/Pg键,指明它们的标记内容住在哪一页上,而它们的/K子项可能是与那一页绑定的标记内容引用和对象引用(§14.7.4.3) ParentTree(§14.7.4.4)把页面和注释的/StructParents编号映射回结构元素,而一个元素可以只住在那里、完全不出现在从根开始的/K链上- 其他页面上的链接注释(§12.5.6.5)带一个指向该页的
/Dest或/GoTo动作,而 catalog 的/OpenAction也可能如此
在碰页面树之前,THotPDF.DeletePage 清理了什么?
THotPDF.DeletePage(PageIndex) 在已加载的文档上先跑完整个引用清扫,然后用 DeleteObj 把页面对象标记为已删除,从 AcroForm 字段树上摘下任何 widget 注释,移动内部页面数组,最后调用 RebuildLoadedPageTree 重写 /Kids、/Count 以及每个幸存页面的 /Parent。清扫以固定顺序走访 catalog:/Names /Dests 名称树、老式 /Dests 字典、/OpenAction、大纲树、/StructTreeRoot 及其 ParentTree,最后是每个留下来的页面的 /Annots 数组。每一步都按规范允许那种结构在没有该页的情况下怎么做,来决定一条引用是被移除、被改指还是不动。这一切开始之前有两道守卫:DeletePage 对越界的索引抛 Invalid page number,并拒绝删掉最后一页,因为一个零子项的 /Pages 节点不是合法 PDF;而 DeletePages 采用与其他已加载文档页面操作相同的 1 基 "1,3-5,7-" 记法,并从选中的最大索引往下迭代,好让你写的索引在工作过程中一直有效
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
begin
// 0 基:删掉封面页。指向它的命名目标、
// 书签、结构树、ParentTree 和链接注释
// 都会在 /Pages 树被重建之前
// 被清理掉
Pdf.DeletePage(0);
// 批量删除用 1 基区间记法,内部从最大索引开始,
// 这样较小的索引一直有效
Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
命名目标与书签的处理为什么不同?
命名目标是被移除,书签是被改指,因为一个不再存在的名字是可以接受的结果,而一个没有目标的书签是看得见的缺陷。在 /Names /Dests 树里,HotPDF 遍历每个节点,对每个目标——裸数组形式和带 /D 键的字典形式都算——拿它跟被删的页面对比,当数组第一个元素是那一页时就把这对名字与值移除。一个 /Names 和 /Kids 都变空的节点会被标记删除并从父节点上摘下来,所以树里永远不会留着空心叶子。同一套测试也会在 catalog 里老式的 /Dests 字典上跑一遍,而 catalog 的 /OpenAction 如果打开的正是被删的那一页,就直接丢掉。这里有一条边界:当一个名称树节点失去条目时,HotPDF 删掉该节点的 /Limits 对,而不是重新计算新的最低和最高键;查看器没有它照样能解析名字,但读 ISO 32000-1 §7.9.6 的严格一致性检查器可能会对一个缺少 /Limits 的非根节点报警
大纲项走的是另一条路。RetargetOutlineDestinations 从大纲根沿 /First 和 /Next 遍历,带一个已访问列表和 128 的深度上限,这样一棵损坏成环的树挂不住这个调用;对每一个指向该页的 /Dest 数组或 /GoTo 动作 /D 数组,它把首元素替换成 NearestRetainedPage:被删页面的后一页,被删的是最后一页时则取前一页。页面引用之后的视图参数保持原样。于是一个指向已删章节开篇的书签会落到剩下内容的第一页,而不是从侧边栏里消失,这正是审阅者对一份裁过的文档的期待。不过目标测试只匹配显式数组:一个大纲项的 /Dest 若是一个名字字符串、原本解析到被删的那一页,它不会被改指,因为名称树里的条目已经没了、这条引用现在什么都解析不到而不是解析到一个被释放的对象,查看器会把它当作死书签处理。大纲树本身的机制,/First、/Next 以及并不直观的 /Count 语义,写在在已加载 PDF 上添加书签与命名目标的指南里
// 去验证这次清扫,而不是相信它
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
ShowMessage('Named destination "cover" was pruned');
// 原本指向封面页的书签现在解析到它后面那一页
// (删除之后是 0 基索引 0)
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
ShowMessage('Bookmark retargeted to the nearest retained page');
结构树和 ParentTree 会怎样?
只因被删页面而存在的结构元素被移除,跨多页的元素丢掉 /Pg 键但保留子项。PruneStructureElement 从 /StructTreeRoot 沿 /K 链下潜到 128 层,同时处理 §14.7.2 允许的 /K 的数组形式和单字典形式。对每个元素它先清理子项,再评估元素本身:如果清理让它的 /K 空了,该元素被标记删除,父节点把它丢掉。如果元素自己的 /Pg 指名被删的页面,而元素还有子项并且有 /P 父节点,那就只移除 /Pg,因为元素上的 /Pg 只是它那些标记内容子项的默认页面,而那些子项可能显式引用别的页面。只有当一个元素的 /Pg 是被删页面并且在它下面什么都不剩时,它才被整个移除
ParentTree 得到同样的处理,理由正是开发期间踩到的那个:一个结构元素可能从 ParentTree 可达,别处都到不了。这棵 number tree 把 /StructParents 整数映射到单个元素或元素数组,而 PruneParentTreeNode 对它找到的每个值跑 PruneStructureElement,移除被清理掉的值,在值数组为空时删掉那对 /Nums,并摘掉 /Nums 和 /Kids 都没了的节点。只清理 /K 的后代会把这些孤儿元素留下来,它们通过 /Pg 指向一个被释放的页面,又通过自己的 /MCR 子项指向被释放的标记内容引用。如果你按结构顺序提取文本,这一点直接相关:按结构顺序提取文本走的正是这些树,而一个 /Pg 为 null 的元素就是一个悄悄从阅读顺序里掉出去的段落
幸存页面上的哪些链接注释会被移除?
幸存页面上任何 /Dest 数组或 /GoTo 动作指向被删页面的链接注释,都会连同它的结构树归属一起被移除。RemoveRetainedPageDestinationAnnotations 遍历除目标之外的每一页的 /Annots 数组,套用与大纲相同的那套目标测试,把命中的注释标记删除、从数组里拿掉,然后调用 PruneAnnotationReferencesInStructureTree,好让 /Obj 指名该注释的那个 OBJR 字典从它的结构元素里被移除,而如果那个 OBJR 是元素唯一的子项,元素本身也被移除。把 OBJR 留在原处会违反 §14.7.4.3,那一条要求 /Obj 引用一个存在的对象,而且会在 PDF/UA 检查里表现为一个背后没有注释的带标签链接。注意这里与书签的不对称:链接是被移除,不是被改指。正文里一句「见第 3 页」的交叉引用在第 3 页没了之后就是错的,把它指到第 4 页在某种程度上等于说谎,而书签落到最近的章节不算,所以如果你的工作流需要保住那些链接,请在调用 DeletePage 之前自己把它们改指
为什么被移除的 /MCR 或 /OBJR 绝不能注册为空闲?
因为标记内容引用和对象引用通常是父元素 /K 数组里的直接字典,而增量变更注册表会把一个直接对象解析到包含它的最近一个间接对象。当 RemoveArrayItem 从 /K 数组里丢掉一个子项时,只有当它是 THPDFLink 或非间接值时才释放内存里的对象,而 MarkRemovedObject 只在对象号大于零时才把对象注册进空闲列表。这个清扫的第一版没做这个区分,而在增量保存里的效果正好就是注册表被设计成会做的事:RegisterIncrementalChange 从那个直接的 /MCR 往上走到它的图事务根,也就是拥有它的那个幸存结构元素,然后把这个元素写成 null。一份只丢了一页的文档回来时,其他页面上的带标签内容悄悄变成未打标签。对一个直接子项唯一正确的做法是通过 TouchContainer 把它的容器标记为脏,好让容器被重写,同时不要碰空闲列表
// 增量更新:只有被触碰过的容器和
// 被释放的页面对象进入追加段
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// /K 里丢掉了一个直接 /MCR 的幸存结构元素
// 会就地重写,绝不会被写成 null
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
同样的谨慎也决定了 DeletePage 在一份已加载文档上刻意不去释放什么。被删页面的内容流、XObject 以及非 widget 注释都作为对象留着,因为一个已加载的文件可能把它们中的任何一个与一个留下来的页面共享,而在删除时没有便宜的办法证明不是这样。为了正确性,移除页面树里的那条引用就够了;那些对象仍然占着的字节是另一个问题,而对象依赖图与保留字节分析正是用来测量一份裁过的文档还背着多少东西的工具
DeletePage 还是 DeleteLoadedPage:该调哪一个?
任何面向用户的页面删除都调 DeletePage,把 DeleteLoadedPage 留给整份文档正在被重排、没有任何文档级引用值得保留的场景。THotPDF.DeleteLoadedPage(PageIndex) 是 2.508.0 版本加入的轻量变体:它移动内部页面数组,调用 RebuildLoadedKidsArray 重写 /Kids 和 /Count,让渲染页面缓存失效,并触发 OnLoadedDocumentModified。它不走访名称树、大纲、结构树或其他页面的注释,也不把页面对象标记为已删除。在 N-up 拼版里这是正确的工具,HotPDF 在那里追加新拼好的版面页然后用 DeleteLoadedPage(0) 丢掉每一个原始页面:源页面是被整批替换掉的,而版面页的内容引用的是它们的资源而不是页面对象。对普通的「从这份合同里删掉第 7 页」这种活,DeletePage 是唯一能让一份带标签、带书签、交叉链接的文档足够一致、能过校验器的调用,经 SaveLoadedDocument 的完整重写和经 SaveIncrementalUpdate 的增量更新都一样。两个方法都随 HotPDF Delphi Component 一起发布,面向 Delphi 和 C++Builder,不需要任何外部查看器运行时或依赖