Вы получаете папку PDF от какого-то внешнего источника, и задача звучит тривиально: скажи, какие bookmark прыгают на внешний URL, какие запускают JavaScript и куда на самом деле ведут внутренние. А потом вы открываете API reference и обнаруживаете, что библиотека умеет создавать все эти action, но не предлагает ничего, чтобы читать их обратно. Такая асимметрия встречается в PDF tooling повсюду. Записать bookmark, который открывает https://example.com , можно одной строкой; а вот спросить у уже существующего bookmark "что ты делаешь и на какую цель указываешь?" обычно означает вручную идти по сырому дереву объектов через /A , /S , /Dest и веер fit-type вариантов, которые почти никто не реализует правильно с первого раза
PDFlibPas - это нативная Object Pascal PDF-библиотека для Delphi и C++Builder, и долгое время у нее была та же дыра: богатые setter на стороне записи и getter, которые просто возвращали голый TPDFObject и оставляли вас самостоятельно заниматься spelunking. Релиз v3.77.0 частично закрыл этот пробел небольшим набором typed introspection calls, которые выдают вид action, payload action и геометрию destination в виде обычных records. Эта статья о том, как эти вызовы ложатся на модель action и destination из ISO 32000-1 и о трех конкретных ловушках, из-за которых самодельные версии такого кода тихо ошибаются
Почему читать action сложнее, чем писать
Action в PDF - это dictionary с ключом /S , называющим subtype: GoTo , GoToR , URI , Launch , Named , JavaScript и более длинный хвост, с которым вы редко сталкиваетесь, ISO 32000-1 §12.6.4. Проблема в том, что payload лежит в разном ключе для каждого subtype, и единого слота "дай мне target" не существует. У action URI адрес хранится в /URI . Action GoToR или Launch хранит file specification в /F . Action JavaScript хранит script в /JS , причем это может быть и string, и stream. А action GoTo вообще не несет собственного payload: его target является destination, висящим на /D , который затем еще нужно отдельно разрешить
Когда вы пишете action, его вид вам известен заранее, поэтому все это не важно. Когда вы его читаете, сначала нужно ветвиться по /S , затем лезть в правильный ключ и затем учитывать, что одна и та же логическая сущность, "на что указывает это action", кодируется тремя несовместимыми способами. Именно эту ветвящуюся логику и поглощают typed getter. GetOutlineActionInfo и GetAnnotActionInfo оба возвращают record 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;
Record сообщает, какие поля имеют смысл, через Kind . Если Kind возвращается как akURI , читайте URI и игнорируйте остальное. Если же он возвращается как akGoTo , ни одно из payload-полей не применимо, и дальше вы переходите к destination, о котором речь пойдет ниже. akNone - это честный ответ в случае, когда у bookmark или annotation вообще нет action, а не ноль, значение которого вам приходится угадывать
Обход дерева outline для поиска bookmark
Прежде чем анализировать bookmark, нужно получить его handle. PDFlibPas идентифицирует узлы outline целочисленным ID, а FindOutlineByTitle находит такой узел по видимому тексту с явным контролем глубины поиска:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
Аргумент Depth здесь и заслуживает паузы. osdSiblingsOnly сканирует цепочку sibling на уровне стартового узла и на этом останавливается: он найдет соседний bookmark, но никогда не опустится в детей соседа. osdChildrenOnly заглядывает на один уровень вниз, к непосредственным детям стартового узла. osdFullSubTree рекурсивно обходит всю ветку. Выбор неверного варианта дает тихий промах, а не ошибку: sibling-only поиск по заголовку, лежащему двумя уровнями ниже, просто вернет zero, и вы решите, что bookmark нет, хотя он все время был на месте. Передавайте GetFirstOutline как start 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 text ровно так, как он записан. Если source PDF пришли от непоследовательных producer, нормализуйте заголовок поиска так же, как он хранится в документе, иначе будете ловить призрачные промахи
Разрешение action и target у bookmark
Имея handle, GetOutlineActionInfo дает typed view. Шаблон такой: вызвали метод, переключились по 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;
Здесь живет первая настоящая ловушка, и именно ее вытащил test feedback во время реализации. Есть старый getter, GetActionURL , и потянуться к нему для чтения action URI - как раз та очевидная ошибка, которую легко сделать. GetActionURL разрешает file specification через ключ /F . Это правильно для GoToR и Launch , чьи target действительно являются файлами, но для action URI это совершенно не тот ключ. Адрес action URI - это обычная string в собственном ключе /URI действия, а не file spec. Пропустите action URI через путь file-spec, и получите пустой либо бессмысленный результат. Typed getter обрабатывает это правильно: он читает /URI напрямую для akURI и зовет resolver file specification только для akGoToR и akLaunch . Именно эту разницу самописные реализации обычно и размывают
Destination fit type и стоящая за ними геометрия
Action akGoTo означает "перейти внутри этого документа", но сам по себе не говорит ни куда , ни как . Этим занимается destination, и в destination нюансов больше, чем обычно ожидают. Destination PDF - это не просто номер страницы, а страница плюс fit specification, который говорит viewer, как именно нужно поместить эту страницу в окно, ISO 32000-1 §12.3.2.2. GetOutlineDestinationInfo возвращает его как record:
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 позиционирует конкретную точку в левый верхний угол при явном zoom, поэтому использует Left , Top и Zoom . dkFit помещает в окно всю страницу и игнорирует координаты. dkFitH и dkFitV подгоняют ширину или высоту страницы с одной значимой координатой, верхней границей или левым краем. dkFitR интереснее: он подгоняет указанный прямоугольник, поэтому важны все четыре края. Семейство dkFitB* делает то же самое, но относительно bounding box видимого содержимого, а не полной страницы. Понимать, какие поля активны для каждого вида, критично. Иначе вы либо правильно прочитаете destination, либо напечатаете мусорные координаты, случайно равные нулю

Под капотом реализация опирается на одно намеренное выравнивание, о котором стоит знать, потому что оно и делает mapping надежным. Внутренний GetDestType возвращает целое 1..8 для восьми видов fit ровно в порядке XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind объявлен так, чтобы его ordinals совпадали один к одному: dkXYZ имеет ordinal 1, dkFitBV ordinal 8, а dkNone находится на нуле. Поэтому преобразование - это прямой ordinal cast с проверкой диапазона, а не lookup table, которая может разъехаться, когда enum начнет расти. Деталь маленькая, но именно из таких наивных мест и рождаются off-by-one bug, как только кто-нибудь переупорядочит перечисление
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 равное zero - это сигнал, что destination не разрешился. Обычно это означает, что action не несет destination или named destination не был найден. Проверяйте это, прежде чем доверять каким-либо координатам. Также важно, что GetOutlineDestinationInfo ищет destination в обоих местах, где он может жить: прямо в /Dest bookmark и внутри вложенного action GoTo через его /D . Вам не нужно заранее знать, какой именно формой воспользовался producer
Annotation action и ловушка SelectPage
Link annotation несут action точно так же, как и bookmark, а GetAnnotActionInfo возвращает тот же record TPDFlibActionInfo с тем же шаблоном kind-then-payload. Но здесь есть stateful ловушка, которой не бывает у outline, и это третья важная проблема
Annotation принадлежат страницам, и PDFlibPas выставляет annotation текущей страницы через состояние, которое становится корректным только после выбора этой страницы. Вызовите GetAnnotActionInfo , не сделав до этого SelectPage(N) , и handle annotation окажется нулевым. Вызов вернет akNone , и вы ошибочно заключите, что на странице нет annotation с action. Исправление занимает одну строку, но его очень легко забыть, когда вы идете по страницам циклом:
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) вызывается перед любым доступом к annotation на каждой итерации; состояние annotation не переносится от страницы к странице. Во-вторых, для проверки существования используется GetAnnotActionID(1) <> 0 , а не CheckPageAnnots . Последний сообщает наличие в стиле boolean flag, а не count, поэтому ненулевой action ID является более точным ответом на вопрос "есть ли здесь первая annotation и несет ли она action, которое я могу прочитать?". Есть и еще одна тонкость: для annotation script у action JavaScript читается прямо из /JS , декодируя stream, если script хранится именно так, и читая string в противном случае. Поэтому обе распространенные формы кодирования переживают чтение
Где уместна read-side introspection
Эти getter намеренно узкие. Это чистое чтение, построенное поверх уже существующих integer-handle слоев action и destination в библиотеке, поэтому они не трогают write path и не добавляют риска документам, которые вы одновременно редактируете. Они сообщают, что находится в файле. Они не валидируют это против политики и ничего не переписывают. Если ваша цель обратная, то есть построить bookmark и link annotation, которые вообще несут такие action, это уже write side, и сопутствующий материал о interactive form actions and JavaScript in Delphi показывает, как их создавать. Если же вам нужно извлекать из PDF не navigation graph, а видимое и структурное содержимое, смотрите extracting text, images, and fonts with PDFlibPas
Честная граница, о которой важно помнить: introspection видит только то, что producer действительно записал. Bookmark, у которого generator оставил malformed action, или destination, указывающий на named target, который никогда не был определен, проявится как akNone или zero page, а не как исключение. Для read API, который аудирует недоверенные файлы, это именно правильное поведение, но это означает, что ваш код должен трактовать такие нулевые результаты как "отсутствует или не разрешилось", а не как гарантию корректного входа. Показанная здесь typed introspection action и destination является частью PDFlibPas , нативной PDF-библиотеки для Delphi и C++Builder