PDF Library for Delphi 用 AddPageLabels 写入页面标签区间,从 v3.539.10 起,这个调用也能用在已加载文件上——即使它们的 /PageLabels 数字树被拆成了 /Kids 节点:根节点会先被摊平成单个 /Nums 叶子,新区间才插进去,于是标签真的会显示在阅读器里,而不是被无声忽略。典型的受害者是排版工具产出的书式 PDF:前言用罗马数字,正文用阿拉伯数字,附录标成 A-1、A-2,而你只想改附录的标签,结果什么都没变
PDF 页面标签是什么,怎么存储?
页面标签是阅读器在页码框里显示的字符串,用来代替物理页索引,ISO 32000-1 §12.4.2 把它存成 catalog 键 /PageLabels 下的一棵数字树。每个键是一个 0 起始的页索引,标记一个标签区间的起点;每个值是一个页面标签字典,最多三个条目:/S 是编号样式(D、R、r、A 或 a),/P 是前缀字符串,/St 是区间第一页的数值,默认 1。一个区间延伸到下一个键为止,规范要求数字树必须包含页索引 0 的值,所以每页都落在某个区间里
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
Exit;
// 第 1-4 页:i、ii、iii、iv(小写罗马)
Lib.AddPageLabels(1, 3, 1, '');
// 第 5-120 页:1、2、3 …(十进制)
Lib.AddPageLabels(5, 1, 1, '');
// 第 121 页起:A-1、A-2 …(带前缀的十进制)
Lib.AddPageLabels(121, 1, 1, 'A-');
WriteLn(Lib.GetPageLabel(5)); // 1
WriteLn(Lib.GetPageLabel(122)); // A-2
Lib.SaveToFile('handbook-labeled.pdf');
finally
Lib.Free;
end;
end;
弄清三条规则之后,TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) 的参数到那个字典的映射毫无意外。Start 和库里其他页面参数一样从 1 开始计数,写进树里时是 Start - 1。Style 取 0 到 5,0 表示只有前缀,1 到 5 对应 /S 值 D、R、r、A 和 a;超出范围返回 0,什么都不动。Offset 只有在大于零时才写成 /St,传 0 就省略这个键,阅读器回落到默认值 1。因为页面标签是 PDF 1.3 才有的,这个调用还会跑 EnsureMinVersion('1.3', '/PageLabels'),把较老文件的输出版本抬上去,除非你已经显式锁定了保存版本
为什么树里有 /Kids 时新页面标签会消失?
因为 ISO 32000-1 §7.9.7(Table 37)规定数字树的根要么带 /Kids 要么带 /Nums,绝不能两者都有,而早先的 NumTreeSet 辅助函数只认识 /Nums。产出长文档的生成器常把树拆成中间节点——每个带一对 /Limits——挂在只有 /Kids 的根下。旧代码在那个根上找不到 /Nums,就在已有 /Kids 旁边新建一个,把新区间插进去。结果是一个带两个互斥入口的根。阅读器沿 /Kids 下行,永远不看那个多出来的数组;库自己的 EnumNumTree 也是先查 /Kids;NumTreeLookup 则拒绝 HasKids xor HasNums 不成立的节点。AddPageLabels 照样返回 1,保存的文件照样干净打开,这是最糟的一种失败:没有任何东西抱怨,标签只是维持原样
NumTreeSet 的修法是在插入任何东西之前先把根转成叶子。根带 /Kids 时,EnumNumTree 按顺序走每个叶子、收齐每一对键值,从这个列表构建一个新的扁平 /Nums 数组,然后在挂上扁平数组之前,把 /Kids、/Limits 和任何残留的 /Nums 从根上清掉。去掉 /Limits 不是走过场,因为 Table 37 只允许这个条目出现在中间节点和叶子上,根上永远不行。从那一刻起,插入就是对单个数组的普通有序插入,既有区间带着原来的标签字典完好存活。这个取舍是刻意的:事后不把树重建成平衡的 /Kids 节点。对页面标签来说这毫无代价,因为再厚的参考手册也很少超过几十个区间,而大多数生成器本来就只写单个叶子
// 给 /PageLabels 根用 /Kids 的文件重编附录标签
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
WriteLn('Before: ', Lib.GetPageLabel(121)); // 例如 A-1
// 替换从第 121 页开始的区间:App-a、App-b …
if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
Lib.SaveToFile('vendor-manual-relabeled.pdf');
// 既有的罗马与十进制区间仍在摊平后的叶子里
WriteLn('After: ', Lib.GetPageLabel(121)); // App-a
WriteLn('Front: ', Lib.GetPageLabel(2)); // ii,未变
end;
/Nums 数组怎么会被误读成键?
当代码一次一个元素地走 /Nums 数组时就会误读,因为这个数组是交替键值对的扁平序列 [key0 value0 key1 value1 ...],只有偶数位才是键。旧的 NumTreeSet 循环对每个元素都做数值类型测试,于是一个恰好是数字的值会被当成键来比较;一次小于命中可能把插入点定在奇数索引上,把新对塞进既有对的中间,让后面每一对都错开相位。EnumNumTree 有一模一样的单步遍历。两者现在都以步长 2 成对迭代,在 X * 2 读键、在 X * 2 + 1 读值,键精确命中时替换值并 Break 退出。平心而论,页面标签的值是字典,所以第二个 bug 在 /PageLabels 上很少触发,但一个读错步长的数字树辅助函数在任何值是数字的那一刻就开始损坏数据,所以它在同一轮里被一起修掉了
读回标签并让它们完整往返
TPDFlib.GetPageLabel(Page) 返回 1 起始页码对应的标签,有两个值得知道的回落行为。完全没有 /PageLabels 条目时,它返回十进制页码,所以调用方可以无条件使用。有树但没有任何区间覆盖该页时,它返回空字符串——文件跳过了强制的索引 0 条目时正是这种情况;参考文档写明必须存在一个从第 1 页开始的区间标签才能正确显示,代码把这个要求显式化了。字母样式遵循规范而不是电子表格的列:过了 Z 是 AA,然后 BB,重复字母而不是进位
var
P: Integer;
Data: WideString;
begin
// 快速审查阅读器页码框里会显示什么
for P := 1 to Lib.PageCount do
WriteLn(P, ' -> ', Lib.GetPageLabel(P));
// 选项值 4 只把标签区间导出成 PageLabelBegin 记录
Data := Lib.ExportDocumentData(4);
// 导入经由 ClearPageLabels + AddPageLabels 重放它们
Lib.ImportDocumentData(Data, 0);
end;
批量编辑时,选项值 4 的 ExportDocumentData 把每个区间写成一个 PageLabelBegin 块,内含 PageLabelNewIndex、PageLabelStart、PageLabelPrefix 和 PageLabelNumStyle 行,而 ImportDocumentData 把它看到的第一条标签记录当作完整替换:先调一次 ClearPageLabels,再把每条记录喂给 AddPageLabels。这让文本往返即使原文件用的是 /Kids 树也是确定的,因为清除会移除整个 catalog 条目,重建出的树从一开始就是单个叶子
这次修复仍然不保证什么?
摊平是单向的,而且信任它找到的顺序。EnumNumTree 按文件顺序收集键值对,GetPageLabel 应用键小于等于页索引的最后一个区间,所以一个叶子乱序的外来文件——§7.9.7 禁止但确实在流传——仍可能给出错误标签,直到你用 ClearPageLabels 和新的 AddPageLabels 调用重建区间。标签还绑定在页索引而不是页对象上,所以任何改变页数或页序的操作都会让区间留在原地。保留对象号的页面替换这类原地交换保住页数,标签因此仍然对得上;而像交错双面扫描的整理合并这样的合并产出新的页面序列,理应配一套新写的区间
这里描述的页面标签调用、数字树处理和文档数据导出导入都随 PDF Library for Delphi 发布,支持 Delphi、C++Builder 和 Lazarus,AddPageLabels 的参考条目记录了样式取值与返回码