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

Чтение bookmark и annotation action в Delphi

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

PDF reader bookmark navigation panel showing a nested outline tree
Каждый bookmark в этой navigation panel разрешается в action и, для внутренних переходов, в destination с собственным fit type и координатами.

Под капотом реализация опирается на одно намеренное выравнивание, о котором стоит знать, потому что оно и делает 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