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

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