Artigo Técnico

Ler Ações de Favoritos e Anotações de PDF no Delphi

Você herda uma pasta de PDFs de algum lugar upstream e a tarefa parece trivial: me diga quais favoritos saltam para uma URL externa, quais executam JavaScript e onde os favoritos internos realmente aterrissam. Então você abre a referência da API e descobre que a biblioteca pode criar cada uma dessas ações, mas não oferece nada para lê-las de volta. Essa assimetria está em toda parte nas ferramentas de PDF. Gravar um favorito que abre https://example.com é uma única linha de código; perguntar a um favorito existente "o que você faz e para qual destino?" geralmente significa percorrer manualmente a árvore de objetos brutos através de /A, /S, /Dest e uma infinidade de variantes de tipos de ajuste (fit-type) que quase ninguém acerta de primeira

O PDFlibPas é uma biblioteca PDF em Object Pascal nativa para Delphi e C++Builder, e por muito tempo teve a mesma lacuna: métodos de gravação (setters) ricos, métodos de leitura (getters) que devolviam um TPDFObject puro e deixavam você para explorar. O lançamento da versão 3.77.0 fechou parte disso com um pequeno conjunto de chamadas de introspecção tipadas que relatam o tipo de ação, a carga útil da ação e a geometria de destino como registros simples. Este artigo aborda como essas chamadas se mapeiam no modelo de ação e destino da ISO 32000-1 e as três armadilhas concretas que fazem com que versões manuais desse código falhem silenciosamente

Por que ler ações é mais difícil do que gravá-las

Uma ação em PDF é um dicionário com uma chave /S nomeando seu subtipo: GoTo, GoToR, URI, Launch, Named, JavaScript, e uma lista mais longa com a qual você raramente se depara (ISO 32000-1 §12.6.4). O problema é que a carga útil reside em uma chave diferente para cada subtipo, e não há um campo uniforme de "forneça o destino". Uma ação URI mantém seu endereço em /URI. Uma ação GoToR ou Launch mantém uma especificação de arquivo em /F. Uma ação JavaScript mantém seu script em /JS, que pode ser uma string ou um fluxo (stream). Uma ação GoTo não carrega nenhuma carga útil própria; seu destino reside em /D, que você precisa resolver separadamente

Quando você grava uma ação, você sabe o tipo dela de antemão, então nada disso importa. Quando você a lê, você deve primeiro ramificar em /S, depois alcançar a chave correta e então lidar com o fato de que o mesmo conceito lógico ("aquilo para o qual esta ação aponta") é codificado de três maneiras incompatíveis. Essa ramificação é exatamente o que as funções de leitura tipadas absorvem. Tanto GetOutlineActionInfo quanto GetAnnotActionInfo retornam um registro 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;

O registro informa quais campos são significativos por meio do campo Kind. Se o Kind retornar akURI, leia URI and ignore o restante. Se retornar akGoTo, nenhum dos campos de carga útil se aplica e você passa para o destino, que é uma chamada separada abordada mais abaixo. akNone é a resposta honesta quando o favorito ou anotação não tem nenhuma ação, em vez de um zero cujo significado você teria que adivinhar

Percorrendo a árvore de contorno para encontrar um favorito

Antes de poder fazer a introspecção de um favorito, você precisa de seu identificador (handle). O PDFlibPas identifica nós de contorno por um ID inteiro, e a função FindOutlineByTitle localiza um pelo seu texto visível com controle explícito sobre até onde a pesquisa se estende:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

O argumento Depth é a parte na qual vale a pena pausar. O osdSiblingsOnly varre a cadeia de irmãos no nível do nó inicial e para; ele encontrará um favorito no mesmo nível, mas nunca descerá para os filhos do irmão. O osdChildrenOnly olha um nível abaixo, para os filhos imediatos do nó inicial. O osdFullSubTree faz uma busca recursiva por toda a ramificação. Escolher a opção errada causa uma ausência silenciosa, não um erro: uma busca apenas em irmãos por um título que reside a dois níveis de profundidade simplesmente retorna zero, e você conclui que o favorito não existe quando ele estava lá o tempo todo. Passe GetFirstOutline como o ID inicial para pesquisar a partir da raiz do documento

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;

A correspondência é feita na string exata do título, comparada como uma WideString, portanto diferencia maiúsculas de minúsculas e respeita o texto Unicode exatamente como armazenado. Se os seus PDFs de origem vêm de produtores inconsistentes, normalize o título que você pesquisa da mesma forma que o documento o armazenou, ou você buscará correspondências fantasmas

Resolvendo a ação e o destino de um favorito

Com um identificador em mãos, GetOutlineActionInfo fornece a visualização tipada. O padrão é: chamá-la, avaliar o Kind e ler o campo que esse tipo preenche

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;

É aqui que reside a primeira armadilha real, e foi a que os testes revelaram durante a implementação. Existe um método mais antigo, GetActionURL, e usá-lo para ler uma ação URI é o erro que parece óbvio. O GetActionURL resolve uma especificação de arquivo através da chave /F. Isso é o correto para GoToR e Launch, cujos destinos realmente são arquivos, mas é a chave errada para uma ação URI por completo. O endereço de uma ação URI é uma string simples na própria chave /URI da ação, e não uma especificação de arquivo. Enviar uma ação URI para o caminho de especificação de arquivo resulta em um resultado vazio ou sem sentido. O método de leitura tipado lida com isso internamente lendo /URI diretamente para akURI e apenas invocando o resolvedor de especificação de arquivo para akGoToR e akLaunch, que é exatamente a distinção que uma versão escrita manualmente tende a confundir

Tipos de ajuste de destino e a geometria por trás deles

Uma ação akGoTo significa "navegar dentro deste documento", mas não informa onde ou como. Esse é o papel do destino, e os destinos trazem mais nuances do que as pessoas esperam. Um destino em PDF não é apenas um número de página; é uma página mais uma especificação de "ajuste" (fit) que diz como o visualizador deve enquadrar aquela página (ISO 32000-1 §12.3.2.2). A função GetOutlineDestinationInfo o retorna como um registro:

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;

Os oito tipos de ajuste respondem a diferentes questões de enquadramento. O dkXYZ posiciona um ponto específico no canto superior esquerdo em um zoom explícito, portanto usa Left, Top e Zoom. O dkFit ajusta a página inteira na janela e ignora coordenadas. O dkFitH e o dkFitV ajustam a largura ou altura da página com uma única coordenada relevante (uma borda superior ou uma borda esquerda). O dkFitR é o mais interessante: ele ajusta um retângulo especificado, de modo que todas as quatro bordas importam. A família dkFitB* faz as mesmas coisas em relação à caixa delimitadora (bounding box) do conteúdo visível e não da página inteira. Saber quais campos estão ativos para cada tipo é a diferença entre ler um destino corretamente e imprimir coordenadas incorretas que por acaso são zero

PDF reader bookmark navigation panel showing a nested outline tree
Cada favorito neste painel de navegação resolve para uma ação e, para saltos internos, um destino com seu próprio tipo de ajuste e coordenadas.

Nos bastidores, a implementação se apoia em uma correspondência deliberada que vale a pena conhecer porque explica por que o mapeamento é confiável. A função interna GetDestType retorna um inteiro de 1..8 para os oito tipos de ajuste exatamente na ordem XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. O TPDFlibDestinationKind é declarado de modo que seus ordinais se alinhem um para um: dkXYZ é o ordinal 1, dkFitBV é o ordinal 8, com dkNone posicionado em zero. Portanto, a conversão é uma conversão direta de ordinal (cast) com um limitador de intervalo, e não uma tabela de consulta que pode perder a sincronia conforme o enum cresce. Esse é um detalhe pequeno, mas é o tipo de coisa que, feita de forma ingênua, torna-se um bug de desalinhamento (off-by-one) na primeira vez que alguém reordenar uma enumeração

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;

Uma Page igual a zero é o sinal de que o destino não foi resolvido, geralmente porque a ação não carrega nenhum destino ou o destino nomeado não pôde ser encontrado. Verifique isso antes de confiar em qualquer coordenada. Observe também que a função GetOutlineDestinationInfo procura em ambos os locais onde um destino pode residir: diretamente na chave /Dest do favorito e dentro de uma chave /D de uma ação GoTo incorporada. Você não precisa saber qual formato o produtor utilizou

Ações de anotação e a armadilha do SelectPage

As anotações de link carregam ações exatamente da mesma forma que os favoritos, e a função GetAnnotActionInfo retorna o mesmo registro TPDFlibActionInfo com o mesmo padrão de tipo-seguido-de-carga-útil. Mas há um detalhe de estado aqui que não se aplica aos contornos, e essa é a terceira armadilha

As anotações pertencem a páginas, e o PDFlibPas expõe as anotações da página atual por meio de um estado que só se torna válido após você selecionar aquela página. Chame GetAnnotActionInfo sem antes chamar SelectPage(N) e o identificador da anotação será zero; a chamada retorna akNone e você conclui incorretamente que a página não tem anotações acionáveis. A correção é de uma linha, mas é fácil de esquecer quando se está percorrendo as páginas em loop:

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;

Duas coisas nesse loop são deliberadas. Primeiro, SelectPage(P) vem antes de qualquer acesso à anotação em cada iteração; o estado da anotação por página não é mantido. Segundo, o teste de existência usa GetAnnotActionID(1) <> 0 em vez de CheckPageAnnots. Este último relata a presença como uma flag do tipo booleana e não uma contagem, de modo que um ID de ação diferente de zero é a maneira mais precisa de perguntar "existe uma primeira anotação e ela carrega uma ação que eu possa ler?". Mais uma sutileza importante: para anotações, o script de uma ação JavaScript é lido diretamente de /JS, decodificando um fluxo (stream) quando o script é armazenado dessa forma e lendo uma string caso contrário, sobrevivendo assim a ambas as codificações comuns

Onde se encaixa a introspecção de leitura

Esses métodos de leitura são intencionalmente específicos. São leituras puras construídas sobre as camadas existentes de ação e destino com identificadores inteiros da biblioteca, portanto não afetam nenhum caminho de gravação e não adicionam risco aos documentos que você também está editando. Eles relatam o que está no arquivo; não o validam contra uma política ou reescrevem qualquer coisa. Se seu objetivo for o inverso, como construir favoritos e anotações de link que carregam essas ações em primeiro lugar, isso reside no lado da gravação, e o artigo complementar sobre ações interativas de formulário e JavaScript no Delphi detalha como criá-los. Para extrair o conteúdo visível e estrutural de um PDF em vez do seu gráfico de navegação, consulte extraindo texto, imagens e fontes com o PDFlibPas

O limite honesto a ser mantido em mente: a introspecção só vê o que o produtor realmente gravou. Um favorito cuja ação um gerador deixou malformada, ou um destino apontando para um alvo nomeado que nunca foi definido, surgirá como akNone ou uma página zero em vez de uma exceção. Esse é o comportamento correto para uma API de leitura que audita arquivos não confiáveis, mas significa que seu código deve tratar esses resultados zero como "ausente ou não resolvido", e não como uma garantia de entrada bem formatada. A introspecção tipada de ação e destino mostrada aqui faz parte do PDFlibPas, a biblioteca PDF nativa para Delphi e C++Builder