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

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

Вы добавляете новый диапазон меток в вендорский мануал, чтобы приложение читалось App-a, App-b вместо A-1, A-2, сохраняете и переоткрываете файл. Поле страницы по-прежнему говорит A-1. Откройте каталог в текстовом редакторе — словарь /PageLabels покажет причину: в нём массив /Kids, унаследованный от производителя, и прямо рядом свежий массив /Nums с вашим диапазоном. ISO 32000-1 разрешает корню number tree один из этих ключей, но не оба, и читатель, нашедший на корне /Kids, спускается в него и дальше не смотрит

Как 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 применяет последний диапазон с ключом не выше страницы, так что любая страница разрешается во что-то

В 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) даёт корню number tree либо /Kids, либо /Nums, но никогда оба, а читатели при наличии выбирают /Kids. Производители длинных документов часто режут дерево на промежуточные узлы, каждый со своей парой /Limits, и подвешивают их к корню, у которого есть только /Kids. Процедура вставки, знающая только про /Nums, смотрит на такой корень, не находит массива, создаёт его рядом с существующим /Kids и опускает туда новый диапазон. Запись проходит. Файл остаётся разборным. Затем каждый вьювер спускается по /Kids, видит исходные три диапазона производителя и никогда не трогает потерянный массив — ровно тот симптом из вендорского мануала, с которого началась статья

Выход — перестать обращаться с корнем как с контейнером, который может отрастить второй вход. Когда AddPageLabels находит на корне /Kids, он сначала обходит все листья существующего дерева слева направо и собирает каждый ключ со словарём его метки, затем строит из списка один плоский массив /Nums. /Kids, /Limits и любой оставшийся /Nums удаляются с корня до присоединения плоского массива, и только потом новый диапазон входит обычной сортированной вставкой. Удаление /Limits тоже важно: Table 37 разрешает её только промежуточным и листовым узлам, и корень, который её сохраняет, искажён, даже если теперь держит /Nums

Починка number tree в PDFlibPas: корень с /Kids и потерянным массивом /Nums невидим для вьюверов, потому что ISO 32000-1 разрешает лишь одно из двух, поэтому NumTreeSet сплющивает все листья в единственный массив /Nums и вычищает /Kids и /Limits, которые Table 37 на корне не допускает никогда
Никто не жаловался, потому что все проверки проходили: AddPageLabels возвращал 1, сохранённый файл открывался чисто, а читатель, спускающийся сначала по /Kids — как делают и вьюверы, и сама библиотека, — новый диапазон так и не находит

Один побочный эффект легко пропустить. Если файл доходит до вас уже сломанным, с обоими ключами на корне, потому что какой-то ранний инструмент сделал наивную вставку, обход листьев идёт по /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, указывающая назад на собственного предка, или две ветки, делящие один лист, всплывают в файлах из повреждённых конвейеров. Обход запоминает, какие узлы уже посетил, поэтому цикл проходится один раз и оставляется позади, а общий лист отдаёт свои диапазоны единожды. Те же заботы для деревьев имён разобраны в статье обработка циклов name tree, плохих /Limits и больших листьев

Обход массива /Nums парами

Массив /Nums — плоская череда чередующихся записей, [key0 value0 key1 value1 ...], и только чётные позиции — ключи. Такая раскладка подсказывает классическую ошибку. Цикл, шагающий по одному элементу и спрашивающий "число ли это?", сравнивает значения как ключи всякий раз, когда значение оказывается числовым. Если это сравнение выигрывает проверку «меньше», точка вставки падает на нечётный индекс, новая пара попадает в середину существующей, и все последующие ключи и значения меняются местами

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

Значения меток страниц — словари, поэтому баг прячется на самом /PageLabels. Он всплывёт в тот день, когда тот же хелпер переиспользуют для number tree, чьи значения — простые числа, а в синтаксисе массива нет ничего, что подсказало бы, какого сорта данные у вас в руках. PDF Library for Delphi читает ключи по индексу X * 2 и значения по X * 2 + 1 и в обходе листьев, и во вставке, а точное совпадение ключа заменяет значение и останавливает сканирование. Если пишете собственный код number tree по сырым объектам, скопируйте сначала именно этот шаг

Чтение меток обратно через GetPageLabel

TPDFlib.GetPageLabel(Page) возвращает метку страницы, считаемой с 1, и его два запасных поведения различаются намеренно. Документ вообще без записи /PageLabels возвращает десятичный номер страницы, так что вызывающий может использовать функцию для показа безусловно. Документ, у которого дерево есть, но ни один диапазон не покрывает запрошенную страницу, возвращает пустую строку. Второй случай — то, что выдаёт файл, пропустивший обязательную запись для индекса 0: первые страницы не имеют метки в смысле спецификации, и пустая строка сообщает об этом честнее, чем выдуманное число. Всегда начинайте набор меток со страницы 1

Буквенные стили следуют спецификации PDF, а не колонкам электронных таблиц. После Z идёт AA, затем BB, затем CC; буква повторяется вместо переноса, поэтому страница 53 диапазона A — это 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;

Для массовых правок ExportDocumentData со значением опции 4 пишет каждый диапазон блоком PageLabelBegin со строками PageLabelNewIndex (стартовая страница с отсчётом от 1), PageLabelStart, опциональными PageLabelPrefix и PageLabelNumStyle, используя имена вроде LowercaseRomanNumerals и DecimalArabicNumerals. ImportDocumentData трактует первую встреченную запись меток как полную замену: один раз вызывает ClearPageLabels, убирающую запись /PageLabels из каталога целиком, а затем скармливает каждую запись AddPageLabels. Перестроенное дерево с самого начала — единственный лист, так что текстовый round trip через исходник в форме /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 сводит значения стилей и коды возврата в одну таблицу