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

Добавление закладок в существующие PDF-файлы в Delphi с помощью HotPDF

HotPDF позволяет добавлять закладки в существующие файлы PDF в Delphi с помощью метода AddLoadedOutline, который создает записи структуры любого уровня вложенности в загруженном документе, а также читать и записывать именованные переходы с помощью функций ResolveLoadedNamedDestination и AddLoadedNamedDestination. Загрузите файл, постройте дерево закладок, вызовите SaveLoadedDocument, и боковая панель просмотра, которая раньше была пустой, теперь будет содержать работающее оглавление

Сценарий, побуждающий к использованию этих функций, удручающе распространен. Вы объединяете десяток контрактов в один пакет для рецензирования или сшиваете три руководства по продукту в один файл для распространения, и результат выглядит отлично: каждая страница на месте, каждый шрифт сохранен. Но когда кто-то открывает его в Acrobat, панель закладок оказывается пустой. Документ на 400 страниц без навигации технически полон, но практически непригоден для использования. До версии 2.347.0 HotPDF умел читать, изменять и удалять закладки в загруженных документах, но не создавать их. Теперь этот пробел устранен, и в этой статье мы рассмотрим новый API, а также одну деталь архитектуры PDF — ключ /Count, который доставит вам неприятности, если вы будете относиться к нему как к простому счетчику записей

Что из себя представляет дерево структуры на самом деле

Структура PDF представляет собой двусвязное дерево словарей, а не плоский список, и эта структура объясняет каждый параметр в API. Стандарт ISO 32000-1 §12.3.3 определяет это так: каталог документа указывает на корень /Outlines корень указывает на свои первый и последний дочерние элементы через /First и /Last, соседние элементы связываются через /Next и /Prev, а каждая запись указывает вверх через /Parent. Каждая запись содержит строку заголовка /Title и, как правило, точку назначения /Dest, которая указывает, куда клик по закладке должен перенаправить читателя

Точки назначения бывают двух видов, и стандарт ISO 32000-1 §12.3.2 намеренно разделяет их. Явное назначение — это встроенный массив вида [страница /XYZ x y zoom]: прямая ссылка на объект страницы плюс спецификация вида. Именованное назначение вместо этого хранит строку имени, а фактическая цель живет в каталоге документа в дереве имен /Names /Dests — это один уровень косвенной адресации, позволяющий многим ссылкам использовать одну цель и позволяющий цели перемещаться без изменения самих ссылок. HotPDF записывает явные назначения /XYZ при создании закладок и предоставляет отдельные вызовы для чтения и расширения дерева имен

Как добавить закладки в существующий PDF-файл в Delphi?

Вызовите метод AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) после LoadFromFile: передайте ParentIndex = -1 для записи верхнего уровня или индекс (начиная с 0) существующей записи верхнего уровня для вложения под нее. Функция создает корень /Outlines, если у документа его не было, строит словарь записей, записывает массив /Dest [страница /XYZ x y zoom], указывающий на DestPageIndex (индексация с нуля), и связывает новую запись в цепочку соседних элементов. Она возвращает 0 при успешном выполнении и -1 при ошибке (например, если индекс DestPageIndex выходит за пределы диапазона)

Один нюанс связывания важен для порядка отображения: новая запись вставляется в начало дочерней цепочки своего родителя в качестве нового элемента /First. Таким образом, если вы добавите "Глава 1", а затем "Глава 2" как записи верхнего уровня, "Глава 2" появится выше "Главы 1" на боковой панели, а "Глава 1" сместится на индекс 1 верхнего уровня. Практический шаблон заключается в добавлении записей в обратном порядке чтения либо в создании каждого родительского элемента и немедленном заполнении его дочерних элементов, пока он все еще находится на индексе 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Add chapters in reverse reading order: each new
      // top-level entry becomes /First (index 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 is now top-level index 0; nest sections
      // under it (children also prepend, so reverse order).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Параметры X, Y и Zoom сопоставляются непосредственно с точкой назначения /XYZ и по умолчанию равны 0. PDF помещает начало координат в левый нижний угол, поэтому Y = 792 указывает на верхнюю часть страницы US Letter, а согласно ISO 32000-1 нулевое значение в позиции /XYZ предписывает программе просмотра сохранить текущие настройки, что почти во всех случаях подходит для масштабирования. Все операции происходят с графом объектов в памяти; ничего не записывается на диск до вызова SaveLoadedDocument — та же модель изменения и последующего сохранения, которая используется для перезаписи заголовка, автора и метаданных XMP в загруженных PDF

Перенаправление закладок: переходы по страницам против URI-действий

Существующие закладки можно перенаправить без их повторного создания, и HotPDF предоставляет для этих двух случаев два разных вызова, поскольку лежащие в основе структуры PDF принципиально различаются. Метод SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) заменяет /Dest записи верхнего уровня с индексом Index на новый массив /XYZ — это инструмент для исправления классической ошибки после объединения файлов, когда каждая закладка продолжает указывать на номера страниц исходного файла. Метод SetLoadedOutlineURI(Index, URI) делает нечто иное структурно: он добавляет словарь действия /A с типом /S /URI, превращая закладку в веб-ссылку вместо перехода внутри документа

// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Различайте эти два механизма при проектировании дерева. /Dest — это чистая навигация внутри документа; URI-действие полностью выводит пользователя за пределы документа, и стандарт ISO 32000-1 ожидает, что запись структуры использует один из этих механизмов, но не оба сразу. Поэтому оставьте SetLoadedOutlineURI для записей, которые изначально не имеют страницы назначения, например, для ссылок "Домашняя страница продукта" или "Сообщить о проблеме" в самом конце дерева, а не для заголовков глав. Записанное здесь URI-действие представляет собой ту же структуру /S /URI, которая используется для ссылок-аннотаций на страницах, что более подробно описано в руководстве по созданию гиперссылок в PDF-документах с помощью HotPDF

Как работают именованные переходы в загруженном PDF?

Именованные назначения — это механизм стабильных якорей согласно стандарту ISO 32000-1 §12.3.2.3: вместо внедрения ссылки на страницу в каждую ссылку документ хранит карту сопоставления имен с точками назначения в дереве имен каталога /Names /Dests, а ссылки обращаются к записям по имени. Метод ResolveLoadedNamedDestination(Name) ищет имя в этом дереве и возвращает индекс страницы (начиная с 0), на которую оно указывает, или -1, если имя отсутствует. Распознаватель поддерживает обе формы хранения сопоставленных значений, разрешенные спецификацией: простой массив назначения вида [страница /XYZ x y zoom] и форму словаря, оборачивающую массив под ключом /D

Метод AddLoadedNamedDestination(Name, DestPageIndex, X, Y) работает в обратном направлении: он регистрирует новый якорь в дереве /Names /Dests, создавая само дерево, если в документе его нет, и возвращает True в случае успеха. Вместе эта пара охватывает рабочий процесс, при котором объединенный документ должен учитывать якоря, созданные вышестоящими инструментами, и публиковать новые якоря для нижестоящих ссылок

var
  PageIdx: Integer;
begin
  // Honor an anchor another tool wrote into /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publish a new anchor for other links to target.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

Объективные ограничения: точки назначения, которые записывает HotPDF как для закладок, так и для именованных якорей, имеют тип /XYZ. Другие явные типы, определенные спецификацией (/Fit, /FitH, /FitB и подобные), этими вызовами не создаются, хотя /XYZ с нулевым масштабом покрывает большинство сценариев использования вида "перейти к этой точке, сохранить масштаб". Метод GetLoadedBookmarkPageIndex(Title) — удобный инструмент поиска, принимающий заголовок закладки и возвращающий страницу ее назначения, сканирует только верхний уровень дерева структуры, поэтому используйте его для проверки записей уровня глав, а не глубоко вложенных элементов

Ловушка /Count: это число потомков, а не записей

Ключ /Count в корне структуры означает совсем не то, что предполагает большинство разработчиков, и неверное предположение приводит к сбоям в тестах, которые выглядят как повреждение данных. Стандарт ISO 32000-1 §12.3.3 определяет /Count как общее количество видимых потомков на всех уровнях, а не количество записей верхнего уровня. На уровне отдельной записи знак также имеет значение: положительное значение означает, что запись раскрыта и это число потомков отображается на боковой панели, а отрицательное значение указывает на то, что запись свернута и |N| ее потомков скрыты внутри нее

Эта проблема обнаружилась в наборе регрессионных тестов самого HotPDF при разработке API закладок для загружаемых документов. Тестовый документ содержал три закладки верхнего уровня, одна из которых имела дочерний элемент. После удаления одной записи верхнего уровня метод GetLoadedOutlineCount, считывающий корневой /Count, вернул 1 вместо ожидаемых 2. Данные не были повреждены: исходное число 3 никогда не означало "три записи верхнего уровня", а правильный перерасчет должен обходить цепочку верхнего уровня, суммируя каждый узел плюс его положительный /Count, пропуская потомков свернутых узлов. Урок для вашего кода прост: никогда не делайте утверждений (assert) относительно изменений /Count после структурных правок, поскольку это значение меняется нелинейно в зависимости от видимых потомков. Вместо этого проверяйте структуру по ее содержимому

// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Место в инструментарии для работы с загруженными документами

Создание закладок и именованных переходов завершает картину возможностей, которую уже давно развивает API загруженных документов: тот же цикл загрузки, редактирования и сохранения уже позволяет перезаписывать метаданные документов, добавлять ссылки-аннотации и экспортировать/импортировать аннотации через формат XFDF. Общий паттерн всегда один: LoadFromFile, последовательность вызовов *Loaded* для графа объектов в памяти, а затем вызов SaveLoadedDocument. Это позволяет легко встроить создание структуры в существующий конвейер объединения или маркировки документов в качестве еще одного шага перед сохранением

Описанные здесь API (AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination и GetLoadedBookmarkPageIndex) поставляются в стандартном комплекте HotPDF Component для Delphi и C++Builder без внешних зависимостей и без необходимости использования отдельного рантайма просмотра