Техническая статья

Метки страниц PDF в Delphi: чиним number tree с /Kids

PDF Library for Delphi пишет диапазоны меток страниц через AddPageLabels, и с v3.539.10 этот вызов работает и по загруженным файлам, чей number tree /PageLabels разбит на узлы /Kids: корень сплющивается в единственный лист /Nums до вставки нового диапазона, так что метка реально появляется во вьювере, а не молча игнорируется. Типичная жертва — «книжный» PDF из вёрстального пакета: римские цифры в начале, арабская нумерация в теле и приложение с метками A-1, A-2, где вы хотели перезадать метки одного лишь приложения, а не изменилось ничего

Что такое метки страниц PDF и как они хранятся?

Метки страниц — это строки, которые вьювер показывает в поле страницы вместо физического индекса страницы, а ISO 32000-1 §12.4.2 хранит их в виде number tree под ключом каталога /PageLabels. Каждый ключ — это индекс страницы с нуля, открывающий диапазон меток, а каждое значение — словарь метки страницы максимум с тремя записями: /S для стиля нумерации (D, R, r, A или a), /P для строки-префикса и /St для числового значения первой страницы диапазона, по умолчанию 1. Диапазон тянется до следующего ключа, а спецификация требует, чтобы в дереве было значение для индекса 0, так что каждая страница покрыта каким-то диапазоном

Хранение меток страниц в терминах PDFlibPas: number tree /PageLabels ключует каждый диапазон по его стартовой странице с нуля, каждое значение — словарь метки со стилем /S, префиксом /P и первым числом /St, а книжный пример раскладывает римское начало, арабские страницы тела и приложение A- на три диапазона
Диапазон тянется до следующего ключа, спецификация требует значение для индекса страницы 0, а GetPageLabel применяет последний диапазон с ключом не выше страницы, так что любая страница разрешается во что-то
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) требует, чтобы корень number tree нёс либо /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. Для меток страниц это ничего не стоит, потому что даже в большом справочном руководстве диапазонов редко больше пары десятков, а единственный лист — то, что большинство производителей и так пишут

Починка number tree в PDFlibPas: корень с /Kids и потерянным массивом /Nums невидим для вьюверов, потому что ISO 32000-1 разрешает лишь одно из двух, поэтому NumTreeSet сплющивает все листья в единственный массив /Nums и вычищает /Kids и /Limits, которые Table 37 на корне не допускает никогда
Никто не жаловался, потому что все проверки проходили: AddPageLabels возвращал 1, сохранённый файл открывался чисто, а читатель, спускающийся сначала по /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 был тот же одношаговый обход. Теперь оба итерируют пары с шагом в два, читая ключ в X * 2 и значение в X * 2 + 1, а точное совпадение ключа заменяет значение и выходит через Break. Справедливости ради, значения меток страниц — словари, так что второй баг на самом /PageLabels срабатывал редко, но хелпер number tree, читающий не тем шагом, портит данные в момент, когда хоть одно значение числовое, и его починили тем же заходом

Починка шага пар в number tree PDFlibPas: массив /Nums — плоская череда чередующихся ключей и значений, поэтому обход, проверяющий каждый элемент, мог вставить новую пару на нечётный индекс и сдвинуть последующие пары из фазы, тогда как исправленный обход читает ключ в X*2 и значение в X*2+1
На /PageLabels баг срабатывал редко, потому что значения меток — словари, но хелпер number tree с неверным шагом портит данные в момент, когда хоть одно значение числовое, поэтому оба обхода теперь шагают парами

Читаем метки обратно и гоняем их туда-обратно

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;

Для массовых правок ExportDocumentData со значением опции 4 пишет каждый диапазон блоком PageLabelBegin со строками PageLabelNewIndex, PageLabelStart, PageLabelPrefix и PageLabelNumStyle, а ImportDocumentData трактует первую встреченную запись метки как полную замену: один раз вызывает ClearPageLabels и затем скармливает каждую запись AddPageLabels. Это делает текстовый round trip детерминированным, даже когда исходный файл использовал дерево с /Kids: очистка убирает запись каталога целиком, а перестроенное дерево с самого начала — единственный лист

Чего исправление по-прежнему не гарантирует?

Сплющивание однонаправленно и доверяет тому порядку, который находит. EnumNumTree собирает пары в файловом порядке, а GetPageLabel применяет последний диапазон с ключом, не превосходящим индекс страницы, так что чужой файл с листьями не по порядку — §7.9.7 это запрещает, но в обращении такие есть — всё ещё может выдать неверные метки, пока вы не перестроите диапазоны через ClearPageLabels и свежие вызовы AddPageLabels. Метки привязаны к индексам страниц, а не к объектам страниц, поэтому любая операция, меняющая число или порядок страниц, оставляет диапазоны там, где они были. Замена на месте вроде замены страниц с сохранением номеров объектов держит счёт, а значит, и метки согласованными, тогда как слияние вроде сортировки чередующихся дуплексных сканов порождает новую последовательность страниц, заслуживающую заново написанного набора диапазонов

Описанные здесь вызовы меток страниц, обработка number tree и экспорт-импорт данных документа выходят в PDF Library for Delphi для Delphi, C++Builder и Lazarus, а справочная статья по AddPageLabels документирует значения стилей и коды возврата