从一本 200 页手册里删掉七页,每个书签都落在错误的地方。修法不是从一个扁平标题列表重建大纲。PDFiumPas 暴露 TPdfOutlineEditor,它加载真正的大纲树,让你移动和重定向条目,然后运行 ApplyPageMap 把每个显式目标按你的页面计划移位
为什么删页会弄坏每个书签?
因为大纲项不存页码。它存的是对页面对象的引用,当页面对象变化时,引用要么指向一个移动了的页面,要么什么都不指。ISO 32000-1 §12.3.2.2 把显式目标定义为一个数组,其第一个元素是对页面字典的间接引用,后面跟一个适配名如 /Fit 或 /XYZ。删掉页面,你只剩一个悬垂引用;重排页面,引用仍有效但如今描述的是另一章。PDFiumPas 加载时把那个数组解析回页码,所以 TPdfOutlineItem.PageNumber 给你一个与公共 TPdf API 一致的一基页面索引,而不是对象号。这就是这个抽象的全部要点:你的重映射逻辑与你拆分、重排或拼版文档时已建好的页面计划在同一坐标系里工作。如果你在构建那个计划,同样的一基约定贯穿把 PDF 文档拆分成多个文件和n-up 拼版与页面重排
大纲是双向链接的树,不是列表
你不能简单序列化一个扁平标题数组的原因是,ISO 32000-1 §12.3.3 把每个大纲项接进五条独立链接:/Parent、/Prev、/Next、/First 和 /Last。移动单个子树因此要重写旧父节点、新父节点、切口两侧与插入点两侧的相邻兄弟,以及被移动节点自身的父指针。其中一条弄错,合规阅读器就显示截断的树,或者死循环。PDFiumPas 把编辑状态保存为带稳定整数 Id 的 TPdfOutlineItem 记录的深度优先数组,所以一个子树是连续切片,兄弟链是派生的,从不手工维护。TPdfOutlineEditor.Move 提起那个切片,在请求的兄弟索引处重新插入新父节点之下,只重派块的根。它还拒绝两种会弄坏图的移动:把一个条目移进它自己的子树,以及指定一个不存在的父节点
为什么 /Count 是有符号的?
因为符号携带的是展开状态,不是大小。正的 /Count 意味着条目打开,数字是当前可见的后代数;负的 /Count 意味着条目折叠。PDFiumPas 为每个有孩子的条目写后代数,并在 IsOpen 为 False 时取负,加载时它把状态读回为 IsOpen := HasCount and (CountValue > 0)。这是大纲写入器里最常见的手写 bug:发出无符号计数,悄悄迫使整棵树展开
var
Source, Dest: TMemoryStream;
Editor: TPdfOutlineEditor;
Options: TPdfOutlineEditOptions;
Report: TPdfOutlineValidationReport;
RootId, ChapterId: Integer;
begin
Source := TMemoryStream.Create;
Dest := TMemoryStream.Create;
Editor := nil;
try
Source.LoadFromFile('handbook.pdf');
Options := TPdfOutlineEditOptions.Default; // MaxItems 100000、MaxDepth 64
if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
raise Exception.Create(Report.ErrorMessage);
RootId := Editor[0].Id;
ChapterId := Editor[2].Id;
Editor.Move(ChapterId, RootId, 1); // 成为根的第二个孩子
Editor.SetTitle(ChapterId, 'Appendix B');
Editor.SetStyle(ChapterId, [posBold, posItalic]);
Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
Editor.SetExpanded(RootId, False); // 写一个负的 /Count
Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');
if not Editor.SaveIncremental(Source, Dest, Report) then
raise Exception.Create(Report.ErrorMessage);
Dest.SaveToFile('handbook-edited.pdf');
finally
Editor.Free;
Dest.Free;
Source.Free;
end;
end;
Retarget 处理规范允许的两种形态。把 DestinationInAction 传 False,PDFiumPas 写直接的 /Dest 数组;传 True,它按 ISO 32000-1 §12.6.4.2 写一个跳转动作 /A << /S /GoTo /D [ page ref suffix ] >>。无论哪种,它都先从条目上剥掉任何已有的 /Dest 和 /A,使两者不能共存且不一致。后缀默认为 /Fit,且必须以 PDF 名开头,这就是空的或畸形的后缀会立即抛异常、而不是产出一个没有阅读器能解析的目标数组的原因
ApplyPageMap 如何消费页面计划?
ApplyPageMap 接收的正是你的页面计划已验证过的数组:NewPageNumbers,按旧页码减一索引,装着新的一基页码,或在该页未能幸存时为零。它倒序遍历条目数组,使删除子树绝不会让它尚未访问的索引失效,并通过 RemappedDestinationCount 和 RemovedDanglingItemCount 报告它做了什么
var
NewPageNumbers: array of Integer;
Report: TPdfOutlineValidationReport;
I: Integer;
begin
// 原始文档每页一个条目
SetLength(NewPageNumbers, OriginalPageCount);
for I := 0 to OriginalPageCount - 1 do
NewPageNumbers[I] := 0; // 0 == 此页被丢弃
NewPageNumbers[0] := 1; // 旧第 1 页 -> 新第 1 页
NewPageNumbers[1] := 2;
NewPageNumbers[9] := 3; // 旧第 10 页 -> 新第 3 页
// True:删除整个悬垂子树。False:保留条目,剥掉其目标
if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
raise Exception.Create(Report.ErrorMessage);
WriteLn(Format('%d remapped, %d dangling items removed',
[Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;
DeleteDangling 标志决定映射为零的目标的策略,两个分支都是刻意的。为 True 时,PDFiumPas 删除条目及其整个子树,因为目标消失的大纲节点通常领着一个随它一起消失的章节。为 False 时,条目带着完好的标题和层级幸存,但 /Dest 和 /A 被移除,这正是有人要在审阅中重定向它时你想要的。真正畸形的输入仍响亮失败而不是被打补丁:负条目或指向所给映射末尾之外的目标返回 False,IssueKind 设为 poviInvalidPageMap
不透明条目,以及诚实的取舍
并非每个大纲项都有 PDFiumPas 能推理的页码。有三类原样带过:命名目标、不是 /S /GoTo 的动作,以及生成文件的东西添加的未知字典键。这些加载时 PageNumber 为零,在条目中保留原始字节,除非你显式对它们调 Retarget,否则逐字写回
- 命名目标是进入文档名称树的键,所以正确重映射它意味着解析树并重写目标条目,而不是在大纲层猜测
/URI、/Launch或 JavaScript 动作完全没有页面语义,绝不能被悄悄转换成跳转- 厂商专有键和结构目标被保留,因为丢掉你不理解的东西正是往返丢数据的方式
代价真实且值得明说:ApplyPageMap 完全跳过那些条目,所以书签全用命名目标的文档在删页后,大纲结构有效但语义陈旧。这是刻意的选择——审阅者能抓住的陈旧链接,好过没人注意到的自信错误链接。如果你在编辑之前对进来的文件做分诊,PDF 收件审阅工作台里的清点趟会告诉你哪些文档落在那个桶里
保存:增量修订,然后独立重载
TPdfOutlineEditor.SaveIncremental 追加稀疏增量修订,而不是重写文件。加载来的条目保留其原始间接对象引用(含精确世代号),所以已有交叉引用保持有效;只有你添加的条目领取新编号,从修订最大对象号加一处分配。目录在同一修订中更新,源完全没有大纲时,会给它补上缺失的 /Outlines 条目
写入之后发生的事是值得照搬的部分。PDFiumPas 用一个完全独立的编辑器重开目标流,把重载的树与内存中的树比较——条目数、标题、页码、目标后缀、动作式与直接式目标形态、样式、展开状态和父子关系。任何不匹配、或任何加载失败,都清空目标流并返回 poviVerificationFailure,而不是交给你一个看着像样的文件。加密源一开始就以 poviEncryptedInput 被拒绝,因为新标题和目标会创造字符串内容,无法靠向前复制 /Encrypt 尾节产出
if not Editor.SaveIncremental(Source, Dest, Report) then
case Report.IssueKind of
poviEncryptedInput:
Log('Source is encrypted; outline editing needs an unprotected copy');
poviInvalidDestination:
Log(Format('Item %d %d targets a missing page',
[Report.ObjectNumber, Report.Generation]));
poviVerificationFailure:
Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
else
Log(Report.ErrorMessage);
end;
把大纲当作它本来的样子——一张有自身不变量的链接对象图——删页就不再是书签灾难,而变成你交给一次方法调用的一张页面映射。TPdfOutlineEditor、ApplyPageMap 和经核验的增量写入器从 v3.98.0 起随 PDFiumPas 交付,支持 Delphi、C++Builder 和 Lazarus;你可以在 PDFium Delphi 组件产品页查看完整 API 并下载试用版