Ви успадковуєте теку з PDF-файлами десь вище в ланцюжку, і завдання звучить тривіально: скажіть, які закладки переходять на зовнішній URL, які запускають JavaScript, а куди саме ведуть внутрішні. Потім ви відкриваєте довідник API і виявляєте, що бібліотека може створювати всі ці дії, але не дає нічого, щоб читати їх назад. Така асиметрія всюди в PDF-інструментах. Записати закладку, що відкриває https://example.com є справою одного рядка; запитати в наявної закладки "що ти робиш і куди саме ведеш?" зазвичай означає вручну пройтись по сирому дереву об'єктів через /A, /S, /Dest і розгалуження варіантів типу fit, у яких майже ніхто не потрапляє правильно з першого разу
PDFlibPas - це нативна бібліотека PDF на Object Pascal для Delphi і C++Builder, і довгий час у неї була та сама прогалина: багаті засоби запису, а гетери повертали вам голий TPDFObject і залишали вас ритися далі. Реліз v3.77.0 закрив частину цієї прогалини невеликим набором типізованих викликів інспекції, які повертають вид дії, її корисне навантаження та геометрію призначення у вигляді звичайних записів. Ця стаття про те, як ці виклики відповідають моделі дій і призначень ISO 32000-1, та про три конкретні пастки, через які самописні версії цього коду тихо працюють неправильно
Чому читати дії складніше, ніж записувати їх
Дія в PDF - це словник з /S ключем, що називає її підтип: GoTo, GoToR, URI, Launch, Named, JavaScript, а також довшим хвостом, з яким ви рідко стикаєтеся (ISO 32000-1 §12.6.4). Проблема в тому, що корисне навантаження лежить у різному ключі для кожного підтипу, і немає уніфікованого слота "дай мені ціль". Дія URI зберігає свою адресу в /URI. Дія GoToR або Launch зберігає специфікацію файлу в /F. Дія JavaScript зберігає свій скрипт у /JS, який може бути або рядком, або потоком. Дія GoTo взагалі не має власного корисного навантаження; її ціль - це призначення, що висить у /D, яке потім треба розв'язати окремо
Коли ви записуєте дію, ви знаєте її тип наперед, тож усе це не має значення. Коли ви читаєте її, треба спочатку розгалужитися за /S спочатку, потім залізти у правильний ключ, а потім зважити на те, що той самий логічний концепт ("те, на що вказує ця дія") закодовано трьома несумісними способами. Саме це розгалуження й ховають типізовані гетери. GetOutlineActionInfo і GetAnnotActionInfo обидва повертають TPDFlibActionInfo запис:
type
TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
akLaunch, akNamed, akJavaScript);
TPDFlibActionInfo = record
Kind: TPDFlibActionKind;
URI: AnsiString; // populated for akURI
JavaScript: WideString; // populated for akJavaScript
FileName: AnsiString; // populated for akGoToR / akLaunch
OpenInNewWindow: Boolean; // akGoToR / akLaunch
end;
Запис показує, які поля мають значення, через Kind. Якщо Kind повертає akURI, читайте URI і ігноруйте решту. Якщо він повертає akGoTo, жодне з полів корисного навантаження не застосовується, і ви переходите до destination, який розглянуто окремим викликом нижче. akNone є чесною відповіддю, коли закладка або анотація взагалі не мають дії, а не нулем, значення якого вам доводиться вгадувати
Обхід дерева структури для пошуку закладки
Перш ніж можна буде проаналізувати закладку, потрібен її дескриптор. PDFlibPas ідентифікує вузли структури за цілим ID, і FindOutlineByTitle знаходить її за видимим текстом із явним керуванням тим, наскільки далеко сягає пошук:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
Параметр Depth заслуговує на окрему увагу. osdSiblingsOnly переглядає ланцюжок сусідів на рівні початкового вузла і зупиняється; він знайде сусідню закладку, але ніколи не спуститься до дочірніх елементів сусіда. osdChildrenOnly дивиться на один рівень нижче, у безпосередні дочірні елементи початкового вузла. osdFullSubTree рекурсивно проходить усю гілку. Неправильний вибір дає тихий промах, а не помилку: пошук лише серед сусідів для заголовка, який лежить на два рівні глибше, просто повертає нуль, і ви робите висновок, що закладки не існує, хоча вона була там увесь час. Передайте GetFirstOutline як початковий ID, щоб шукати від кореня документа
var
Lib: TPDFlib;
FoundID: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') = 1 then
begin
// Search the whole tree from the root for a nested bookmark.
FoundID := Lib.FindOutlineByTitle('Appendix B',
Lib.GetFirstOutline, osdFullSubTree);
if FoundID <> 0 then
// FoundID is now a handle you can pass to the action and
// destination getters below.
;
end;
finally
Lib.Free;
end;
end;
Порівняння виконується за точним рядком заголовка, який зіставляється як WideString, тож воно чутливе до регістру та точно враховує текст Unicode так, як його збережено. Якщо ваші вихідні PDF надходять від несумісних виробників, нормалізуйте заголовок, який ви шукаєте, так само, як його зберіг документ, інакше ви гнатиметеся за примарними промахами
Визначення дії та цілі закладки
Маючи дескриптор, GetOutlineActionInfo дає типізоване подання. Шаблон такий: викличте його, перемкніться за Kind, і прочитайте поле, яке заповнює цей тип
var
Info: TPDFlibActionInfo;
begin
Info := Lib.GetOutlineActionInfo(FoundID);
case Info.Kind of
akURI:
Writeln('Opens URL: ', Info.URI);
akGoToR, akLaunch:
Writeln('Opens file: ', Info.FileName,
' (new window: ', Info.OpenInNewWindow, ')');
akJavaScript:
Writeln('Runs script: ', string(Info.JavaScript));
akGoTo:
Writeln('Jumps within this document'); // see destination below
akNamed:
Writeln('Named action (NextPage, Print, etc.)');
akNone:
Writeln('Bookmark has no action');
end;
end;
Саме тут ховається перша справжня пастка, яку підсвітили відгуки тестів під час реалізації. Є старіший getter, GetActionURL, і звернення до нього для читання URIдії є очевидною помилкою. GetActionURL розв'язує файлову специфікацію через ключ /F Це правильний варіант для GoToR і Launch, чиї цілі справді є файлами, але це неправильний ключ для URI дії повністю. Дія URI адреса дії є звичайним рядком у власному /URI ключі, а не файловою специфікацією. Передайте URI дію до шляху file-spec, і ви отримаєте порожній або беззмістовний результат. Типізований геттер обробляє це всередині, читаючи /URI безпосередньо для akURI і викликаючи розв'язувач файлової специфікації лише для akGoToR і akLaunch, і саме цю відмінність ручна реалізація зазвичай розмиває
Типи fit для destination і геометрія, що стоїть за ними
Дія akGoTo означає "navigate within this document," але не говорить вам нічого про де або як. Це завдання destination, а destinations мають більше нюансів, ніж очікують люди. PDF destination - це не просто номер сторінки; це сторінка плюс специфікація "fit", яка каже, як переглядач має кадрувати цю сторінку (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo повертає її як запис:
type
TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);
TPDFlibDestinationInfo = record
Kind: TPDFlibDestinationKind;
Page: Integer; // 1-based; 0 when unresolved
Left, Top, Right, Bottom, Zoom: Double;
end;
Вісім типів fit відповідають на різні запитання про кадрування. dkXYZ розміщує конкретну точку у верхньому лівому куті за вказаного масштабу, тому використовує Left, Top та Zoom. dkFit вміщує всю сторінку у вікні й ігнорує координати. dkFitH та dkFitV вміщують ширину або висоту сторінки за однією релевантною координатою, верхнім або лівим краєм. dkFitR є найцікавішим: він вміщує заданий прямокутник, тому мають значення всі чотири краї. dkFitB* сімейство робить те саме відносно обмежувальної рамки видимого вмісту, а не всієї сторінки. Знання того, які поля є активними для кожного типу, відрізняє правильне читання призначення від виведення сміттєвих координат, які випадково дорівнюють нулю

Під капотом реалізація спирається на навмисне узгодження, про яке варто знати, бо воно пояснює, чому зіставлення надійне. Внутрішній GetDestType повертає ціле число від 1 до 8 для восьми типів fit точно в порядку XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind оголошено так, щоб його порядкові значення збігалися один до одного: dkXYZ має порядковий номер 1, dkFitBV має порядковий номер 8, а dkNone стоїть на нулі. Тож перетворення тут є прямим приведенням порядкового значення з перевіркою межі, а не таблицею пошуку, яка може розсунутися із зростанням enum. Це дрібниця, але саме такі речі за наївної реалізації перетворюються на помилку на одиницю, щойно хтось переставляє елементи переліку
var
Dest: TPDFlibDestinationInfo;
begin
Dest := Lib.GetOutlineDestinationInfo(FoundID);
if Dest.Page = 0 then
Exit; // destination did not resolve
case Dest.Kind of
dkXYZ:
Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
dkFitR:
Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
[Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
dkFit, dkFitB:
Writeln(Format('Page %d, fit whole page', [Dest.Page]));
else
Writeln(Format('Page %d, fit kind %d',
[Dest.Page, Ord(Dest.Kind)]));
end;
end;
Поле Page із нульовим значенням означає, що призначення не розв'язалося, зазвичай тому, що дія не містить призначення або іменоване призначення не вдалося знайти. Перевіряйте це, перш ніж довіряти будь-якій координаті. Також зверніть увагу, що GetOutlineDestinationInfo шукає в обох місцях, де може міститися призначення: безпосередньо в /Dest, а також усередині вбудованої GoTo дії /D. Вам не потрібно знати, який саме варіант використав той, хто створив документ
Дії анотацій і пастка SelectPage
Анотації посилань містять дії так само, як і закладки, а GetAnnotActionInfo повертає той самий TPDFlibActionInfo запис із тим самим шаблоном «тип, потім дані». Але тут є станова пастка, яка не стосується контуру, і це третя пастка
Анотації належать сторінкам, і PDFlibPas надає анотації поточної сторінки через стан, який стає чинним лише після вибору цієї сторінки. Викликайте GetAnnotActionInfo без попереднього виклику SelectPage(N) і дескриптор анотації дорівнює нулю; виклик повертає akNone і ви помилково робите висновок, що сторінка не має анотацій з дією. Виправлення займає один рядок, але про нього легко забути під час циклу по сторінках:
var
P: Integer;
Info: TPDFlibActionInfo;
begin
for P := 1 to Lib.PageCount do
begin
Lib.SelectPage(P); // mandatory before touching annotations
// GetAnnotActionID(1) <> 0 is the reliable "has an action"
// test. CheckPageAnnots returns a boolean-style flag, not a
// count, so it is the weaker signal here.
if Lib.GetAnnotActionID(1) <> 0 then
begin
Info := Lib.GetAnnotActionInfo(1);
if Info.Kind = akURI then
Writeln(Format('Page %d link -> %s', [P, Info.URI]));
end;
end;
end;
Дві речі в цьому циклі зроблено навмисно. По-перше, SelectPage(P) виклик стоїть перед будь-яким доступом до анотацій на кожній ітерації; стан анотацій для окремої сторінки не переноситься. По-друге, перевірка наявності використовує GetAnnotActionID(1) <> 0 замість CheckPageAnnots. Останній варіант повідомляє про наявність як булевий прапорець, а не як лічильник, тож ненульовий ID дії є точнішим способом запитати: "чи є перша анотація, і чи має вона дію, яку я можу прочитати?" Ще одна важлива дрібниця: для анотацій сценарій дії JavaScript читається з /JS безпосередньо, декодуючи потік, коли сценарій збережено саме так, і читаючи рядок в іншому разі, тож це працює в обох поширених кодуваннях
Де доречна інспекція під час читання
Ці гетери навмисно вузькі. Це чисті операції читання, побудовані на наявних у бібліотеці шарах дій і призначень з цілими дескрипторами, тож вони не торкаються шляху запису й не додають ризику для документів, які ви також редагуєте. Вони повідомляють, що є у файлі; вони не перевіряють це за політикою і нічого не переписують. Якщо ваша мета зворотна, тобто будувати закладки й анотації посилань, що з самого початку несуть ці дії, це належить до частини запису, а супровідний матеріал про інтерактивні дії форм і JavaScript у Delphi описує, як їх створювати. Для вилучення видимого й структурного вмісту з PDF замість його графа навігації див. вилучення тексту, зображень і шрифтів за допомогою PDFlibPas
Чесна межа, про яку варто пам’ятати: інспектування бачить лише те, що фактично записав автор документа. Закладка, дію якої генератор залишив пошкодженою, або призначення, що вказує на іменований цільовий об’єкт, який так і не було визначено, буде відображатися як akNone або нульовою сторінкою, а не винятком. Це правильна поведінка для read API, що перевіряє ненадійні файли, але це означає, що ваш код має трактувати такі нульові результати як «відсутнє або невизначене», а не як гарантію правильно сформованого введення. Показане тут типізоване інспектування дій і призначень є частиною PDFlibPas, нативної бібліотеки PDF для Delphi та C++Builder