你给一份厂商手册加一个新的标签区间,想让附录显示 App-a、App-b 而不是 A-1、A-2,保存,重新打开文件。页码框还是 A-1。用文本编辑器打开 catalog,/PageLabels 字典把原因写得明明白白:它带着生成器留下的一个 /Kids 数组,紧挨着它又是装着你新区间的一个新 /Nums 数组。ISO 32000-1 允许数字树根带这两个键中的一个,阅读器在根上找到 /Kids 就顺势下行,不再多看一眼
PDF 是怎么存储页面标签区间的
页面标签是阅读器在页码框里显示的字符串,用来代替物理页索引,ISO 32000-1 §12.4.2 把它存成 catalog 键 /PageLabels 下的一棵数字树。每个键是一个 0 起始的页索引,标记一个标签区间的起点;每个值是一个页面标签字典,最多三个条目:/S 是编号样式(D、R、r、A 或 a),/P 是前缀字符串,/St 是区间第一页的数值,默认 1。一个区间延伸到下一个键为止。规范还要求页索引 0 必须有条目,所以文档里每一页都落在某个区间内
在 PDF Library for Delphi 里,写入方是 TPDFlib.AddPageLabels(Start, Style, Offset, Prefix),它到那个字典的映射有三条值得记住的规则。Start 和库里其他页面参数一样从 1 起算,写进树里时是 Start - 1。Style 取 0 到 5:0 完全不写 /S,标签就只剩前缀(标一页 Cover 时很顺手),1 到 5 对应 D、R、r、A 和 a。超出 0..5 的样式让调用返回 0,文档分毫未动。Offset 只有大于零时才写成 /St;传 0 就省略这个键,所有阅读器都把它解读为 1
排版工具产出的一本典型的书需要三个区间:前言用小写罗马数字,正文用阿拉伯数字,附录带前缀
uses
PDFlibrary;
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 …(十进制,从 1 重新起算)
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;
页面标签是 PDF 1.3 才有的,所以每次调用还会请求文档把输出版本抬到至少 1.3。如果你已经用 LockSaveVersion 锁定了版本,这个请求会被忽略、锁继续有效,因为库不会推翻你有意选择的版本。真要锁版本,任何写标签的作业都请锁在 1.3 或以上
根带 /Kids 时新区间为什么会消失?
区间消失,是因为 ISO 32000-1 §7.9.7(Table 37)规定数字树的根要么带 /Kids 要么带 /Nums,绝不同时都有,而阅读器在 /Kids 存在时选它。产出长文档的生成器常把树拆成中间节点——每个带一对 /Limits——挂在只有 /Kids 的根下。一个只认识 /Nums 的插入例程看向这个根,找不到数组,就在既有 /Kids 旁边新建一个,把新区间放进去。写入成功了,文件照样能解析。然后每个阅读器都沿 /Kids 下行,看到生成器原本的三个区间,从不碰那个多出来的数组——这正是开头那段厂商手册症状的由来
出路是别再把根当成一个可能长出第二个入口的容器。AddPageLabels 在根上发现 /Kids 时,先从左到右走遍既有树的每个叶子,收齐每个键及其标签字典,再从这个列表构建一个扁平的 /Nums 数组。/Kids、/Limits 和任何残留的 /Nums 在挂上扁平数组之前先从根上移除,然后新区间才以普通有序插入的方式进去。移除 /Limits 也有讲究:Table 37 只允许它出现在中间节点和叶子上,一个留着它的根即使现在持有 /Nums 也是畸形的
有一个副作用容易漏看。如果到你手里的文件本来就是坏的——根上两个键都有,因为早先某个工具做了朴素的插入——叶子遍历会和阅读器一样沿 /Kids 走,所以那个多余数组里的区间不会被带过来。阅读器从来没显示过那些区间,所以编辑之后的标签与读者已经看到的东西一致
摊平后的树事后不会被重建成平衡的 /Kids 节点,我们认为对这棵特定的树来说这是正确的取舍。平衡对有成千上万个目标或嵌入文件的名称树才划算。页面标签树每个标签区间一个条目,再大的参考手册也很少超过几十个;大多数生成器不为拆而拆时,写的也正是单个叶子。一次重平衡会在每次保存时多一段可能出错的代码,换来的查找收益在这么小的树上没人测得出来
// 给 /PageLabels 根用 /Kids 的文件重编附录标签
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
WriteLn('Before: ', Lib.GetPageLabel(121)); // 例如 A-1
// 与既有区间同一起始页,所以它的值会被替换
if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
begin
WriteLn('After: ', Lib.GetPageLabel(121)); // App-a
WriteLn('Front: ', Lib.GetPageLabel(2)); // ii,未变
Lib.SaveToFile('vendor-manual-relabeled.pdf');
end;
end;
因为新区间与既有键落在同一页索引上,插入会替换那个键的字典,而不是为页索引 120 添加第二个条目。生成器叶子里的罗马和十进制区间带着原字典原样保留,包括任何 /St 值
叶子遍历还得扛得住畸形树。指向自己某个祖先的 /Kids 条目,或者两个分支共享一个叶子,这些在受损管线产出的文件里都会遇到。遍历记住自己访问过哪些节点,环走一次就放下,共享的叶子只贡献一次它的区间。名称树上的同类问题在 处理名称树环、坏 /Limits 与大叶子 一文中有述
成对走 /Nums 数组
/Nums 数组是交替条目的扁平序列 [key0 value0 key1 value1 ...],只有偶数位才是键。这种布局天然招来一个经典错误。一次前进一个元素、逢元素就问"这是不是数字"的循环,只要某个值恰好是数字,就会把它当键来比较。如果这个比较赢下一次小于测试,插入点就落在奇数索引上,新的键值对插进既有对的中间,后面每一对键值都交换了位置
页面标签的值是字典,所以这个 bug 藏在 /PageLabels 自己身上不发作。等同一个辅助函数被拿去处理值为普通数字的数字树那天,它就冒头了,而数组语法里没有任何东西提醒你拿到的哪一种。PDF Library for Delphi 在叶子遍历和插入里都按 X * 2 读键、按 X * 2 + 1 读值,键精确命中时替换值并停止扫描。你要对着原始对象自己写数字树代码,第一件事就是把这套步长抄过去
用 GetPageLabel 读回标签
TPDFlib.GetPageLabel(Page) 返回 1 起始页码对应的标签,它的两个回落行为是有意区别对待的。完全没有 /PageLabels 条目的文档返回十进制页码,调用方可以放心无条件拿它做显示。有树但没有区间覆盖所请求页面的文档返回空字符串。第二种情况正是文件跳过页索引 0 的强制条目时的产物:前几页按规范的意义没有标签,空字符串比编造一个数字更诚实地报告了这一点。标签集合永远从第 1 页开始写
字母样式遵循 PDF 规范而不是电子表格的列。Z 之后是 AA,然后 BB,然后 CC;字母重复而不是进位,所以 A 区间的第 53 页是 AAA。罗马数字 R 用大写、r 用小写生成,前缀在两种情况下都原样前置
var
P: Integer;
Data: WideString;
begin
// 快速审查阅读器页码框里会显示什么
for P := 1 to Lib.PageCount do
WriteLn(P, ' -> ', Lib.GetPageLabel(P));
// 选项值 4 只导出标签区间,每个区间一个 PageLabelBegin
// 块
Data := Lib.ExportDocumentData(4);
// …编辑这段文本,或者把它带到另一个文件…
// 第一条标签记录清掉既有区间,然后每条记录
// 经 AddPageLabels 重放;返回应用的记录数
WriteLn(Lib.ImportDocumentData(Data, 4), ' label ranges applied');
end;
批量编辑时,选项值 4 的 ExportDocumentData 把每个区间写成一个 PageLabelBegin 块,后跟 PageLabelNewIndex(1 起始的起始页)、PageLabelStart、可选的 PageLabelPrefix 和 PageLabelNumStyle 行,样式名用 LowercaseRomanNumerals、DecimalArabicNumerals 这类写法。ImportDocumentData 把遇到的第一条标签记录当作完整替换:先调一次 ClearPageLabels——它把整个 /PageLabels 条目从 catalog 上摘掉——再把每条记录喂给 AddPageLabels。重建出的树从一开始就是单个叶子,所以对一个 /Kids 形态的原件做文本往返,回来也是重编标签路径产出的那种扁平形态
重编标签之后的叶子顺序与页面搬动
摊平按它找到的顺序保留区间。§7.9.7 要求键升序,但叶子乱序的文件确实在流传,而 GetPageLabel 应用该顺序中键小于等于页索引的最后一个区间。对一个把区间声明为 [0 roman, 120 A-, 4 decimal]、十进制区间从 1 起算的文件,页 122 解析到十进制区间,读出 118,而你期待的是 A-2。如果上面逐页审查显示出这类标签,不要想着再多调一次 AddPageLabels 去打补丁。调 ClearPageLabels,按升序把整套区间重写一遍;四次调用比推理哪个区间会赢便宜得多
标签还绑定在页索引而不是页对象上。任何改变页数或页序的操作都让区间原地不动,指向那些索引上此刻坐着的任何页面。保留对象号的页面替换 这类原地交换保住页数,标签因此仍然对得上;交错双面扫描的整理合并 这类合并产出新的页面序列,其输出理应在合并之后配一套新写的区间,从第 1 页开始
这里的页面标签调用、/Kids 摊平和文档数据导出导入都属于 PDF Library for Delphi,它面向 Delphi、C++Builder 和 Lazarus;AddPageLabels 的参考条目把样式取值和返回码列在同一张表里