Технічна стаття

Додавання закладок до існуючих PDF-файлів у Delphi за допомогою HotPDF

HotPDF додає закладки до існуючого PDF у Delphi за допомогою методу AddLoadedOutline, який створює елементи структури на будь-му рівні вкладеності в завантаженому документі, а також зчитує та записує іменовані цілі (named destinations) через 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 чітко їх розділяє. Явна ціль (explicit destination) — це вбудований масив типу [page /XYZ x y zoom]: пряме посилання на об'єкт сторінки разом зі специфікацією вигляду. Натомість іменована ціль (named destination) зберігає рядок імені, а фактична ціль знаходиться в каталозі документа в дереві імен /Names /Dests — це рівень опосередкованості, який дозволяє багатьом посиланням ділити одну ціль і дає змогу переміщувати ціль без зміни самих посилань. HotPDF записує явні цілі /XYZ при створенні закладок і надає окремі виклики для читання та розширення дерева імен

Як додати закладки до існуючого PDF у Delphi?

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

Один нюанс зв'язування важливий для порядку сортування: новий елемент вставляється на початок дочірнього ланцюжка свого батька як новий /First. Отже, якщо ви спочатку додасте «Chapter 1», а потім «Chapter 2» як елементи верхнього рівня, то «Chapter 2» з'явиться над «Chapter 1» на бічній панелі, а «Chapter 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 — це та сама модель «редагування-збереження», яку API використовує для перезапису заголовка, автора та метаданих 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?

Іменовані цілі (named destinations) — це механізм стабільних якорів згідно з ISO 32000-1 §12.3.2.3: замість вбудовування посилання на сторінку в кожне посилання, документ зберігає карту імен та цілей у дереві імен каталогу /Names /Dests, а посилання звертаються до елементів за іменем. Метод ResolveLoadedNamedDestination(Name) шукає ім'я в цьому дереві та повертає 0-індексований номер сторінки, на яку воно вказує, або -1, якщо ім'я відсутнє. Резолвер підтримує обидва формати збереження значення: простий масив цілей типу [page /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 без будь-яких зовнішніх залежностей чи потреби в окремому середовищі перегляду