技术文章

Delphi 中 PDF 大纲编辑与页面重映射

从一本 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 把编辑状态保存为带稳定整数 IdTPdfOutlineItem 记录的深度优先数组,所以一个子树是连续切片,兄弟链是派生的,从不手工维护。TPdfOutlineEditor.Move 提起那个切片,在请求的兄弟索引处重新插入新父节点之下,只重派块的根。它还拒绝两种会弄坏图的移动:把一个条目移进它自己的子树,以及指定一个不存在的父节点

PDFiumPas 在 Delphi 中的大纲编辑:把第 3 章从第 I 部分移到文档根下,会重写被移动节点的 /Parent 指针,以及切口和插入点周围的 /First 与兄弟 /Prev、/Next 链接
一次 Move 调用重写被提起子树的父指针,以及切口和插入点两侧的兄弟链接

为什么 /Count 是有符号的?

因为符号携带的是展开状态,不是大小。正的 /Count 意味着条目打开,数字是当前可见的后代数;负的 /Count 意味着条目折叠。PDFiumPas 为每个有孩子的条目写后代数,并在 IsOpenFalse 时取负,加载时它把状态读回为 IsOpen := HasCount and (CountValue > 0)。这是大纲写入器里最常见的手写 bug:发出无符号计数,悄悄迫使整棵树展开

PDFiumPas 在 Delphi 中如何编码大纲展开状态:正的 /Count 表示条目打开并计数可见后代,负的 /Count 表示折叠,无符号计数迫使每个阅读器展开整棵树
/Count 的符号是展开状态、幅值是可见后代数,所以无符号计数会悄悄迫使整棵树展开
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 处理规范允许的两种形态。把 DestinationInActionFalse,PDFiumPas 写直接的 /Dest 数组;传 True,它按 ISO 32000-1 §12.6.4.2 写一个跳转动作 /A << /S /GoTo /D [ page ref suffix ] >>。无论哪种,它都先从条目上剥掉任何已有的 /Dest/A,使两者不能共存且不一致。后缀默认为 /Fit,且必须以 PDF 名开头,这就是空的或畸形的后缀会立即抛异常、而不是产出一个没有阅读器能解析的目标数组的原因

ApplyPageMap 如何消费页面计划?

ApplyPageMap 接收的正是你的页面计划已验证过的数组:NewPageNumbers,按旧页码减一索引,装着新的一基页码,或在该页未能幸存时为零。它倒序遍历条目数组,使删除子树绝不会让它尚未访问的索引失效,并通过 RemappedDestinationCountRemovedDanglingItemCount 报告它做了什么

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 被移除,这正是有人要在审阅中重定向它时你想要的。真正畸形的输入仍响亮失败而不是被打补丁:负条目或指向所给映射末尾之外的目标返回 FalseIssueKind 设为 poviInvalidPageMap

PDFiumPas 的 ApplyPageMap 在 Delphi 中如何重定向 PDF 书签:按旧页码减一索引的页面映射把幸存目标送到新页码,而映射为零的条目要么随子树被删除,要么被剥掉目标
页面映射按旧页码减一索引,零条目要么删除悬垂子树,要么留下被剥掉目标的条目

不透明条目,以及诚实的取舍

并非每个大纲项都有 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;

把大纲当作它本来的样子——一张有自身不变量的链接对象图——删页就不再是书签灾难,而变成你交给一次方法调用的一张页面映射。TPdfOutlineEditorApplyPageMap 和经核验的增量写入器从 v3.98.0 起随 PDFiumPas 交付,支持 Delphi、C++Builder 和 Lazarus;你可以在 PDFium Delphi 组件产品页查看完整 API 并下载试用版