Herdou uma pasta de PDFs de algum sistema a montante, e a tarefa parece trivial: diga-me quais os bookmarks que saltam para um URL externo, quais correm JavaScript e onde é que os internos realmente aterram. Depois abre a referência da API e descobre que a biblioteca consegue criar todas essas acções, mas não oferece nada para as ler de volta. Esta assimetria existe em toda a instrumentação PDF. Escrever um bookmark que abre https://example.com é uma linha; perguntar a um bookmark existente "o que fazes e para onde?" costuma significar percorrer à mão a árvore de objectos bruta através de /A, /S, /Dest e uma fan-out de variantes de fit que quase ninguém acerta à primeira
PDF Library for Delphi é uma biblioteca PDF nativa em Object Pascal para Delphi e C++Builder, e durante muito tempo teve a mesma lacuna: setters ricos no lado de escrita, getters que devolviam apenas um TPDFObject cru e o deixavam a vasculhar. A versão v3.77.0 fechou parte disso com um pequeno conjunto de chamadas tipadas de introspecção que devolvem o tipo de action, a carga útil da action e a geometria do destino como registos simples. Este artigo explica como essas chamadas mapeiam o modelo de actions e destinos do ISO 32000-1, e os três tropeços concretos que fazem uma implementação manual correr mal sem dar logo sinal
Porque ler acções é mais difícil do que escrevê-las
Uma action em PDF é um dicionário com a chave /S a nomear o seu subtipo: GoTo, GoToR, URI, Launch, Named, JavaScript e uma cauda mais longa que raramente encontra (ISO 32000-1 §12.6.4). O problema é que a carga útil vive numa chave diferente para cada subtipo, e não existe uma ranhura uniforme do tipo «dá-me o alvo». Uma action URI mantém o endereço em /URI. Uma GoToR ou Launch mantém a especificação do ficheiro em /F. Uma JavaScript guarda o script em /JS. E uma GoTo interna não transporta carga útil própria; o seu alvo é um destino, pendurado em /D, que depois tem de resolver separadamente
Quando escreve uma action, sabe o tipo logo à partida, por isso nada disto importa. Quando lê uma, tem de ramificar primeiro por /S, depois entrar na chave certa e só então lidar com o facto de o mesmo conceito lógico, "a coisa para onde isto aponta", se espalhar por vários campos diferentes. É exactamente essa ramificação que os getters tipados absorvem. GetOutlineActionInfo e GetAnnotActionInfo devolvem ambos um record TPDFlibActionInfo:
type
TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
akLaunch, akNamed, akJavaScript);
TPDFlibActionInfo = record
Kind: TPDFlibActionKind;
URI: AnsiString; // preenchido para akURI
JavaScript: WideString; // preenchido para akJavaScript
FileName: AnsiString; // preenchido para akGoToR / akLaunch
OpenInNewWindow: Boolean; // akGoToR / akLaunch
end;
O registo diz-lhe quais os campos que são relevantes através de Kind. Se Kind voltar akURI, leia URI e ignore o resto. Se voltar akGoTo, nenhum dos campos de carga útil se aplica e passa-se para o destino, que é uma chamada separada coberta mais abaixo. akNone é a resposta honesta quando o bookmark ou a anotação não tem acção nenhuma, em vez de um zero cujo significado tem de adivinhar
Percorrer a árvore de outline para encontrar um bookmark
Antes de introspectar um bookmark precisa do seu handle. O PDF Library for Delphi identifica nós de outline por um ID inteiro, e FindOutlineByTitle localiza um pelo texto visível com controlo explícito sobre até onde a pesquisa vai:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
O argumento Depth é a parte que merece pausa. osdSiblingsOnly percorre a cadeia de irmãos no nível do nó inicial e pára; encontra um bookmark vizinho, mas nunca desce para os filhos desse vizinho. osdChildrenOnly olha apenas para os filhos directos do bookmark inicial. osdFullSubTree percorre tudo abaixo do ponto de partida. Isso dá-lhe um controlo determinístico sobre o custo e sobre o que conta como correspondência. Escolher o errado é uma falha silenciosa, não um erro: uma pesquisa só por irmãos por um título que vive dois níveis abaixo simplesmente devolve zero, e conclui que o bookmark não existe quando ele esteve lá todo o tempo. Passe GetFirstOutline como 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
// Procura em toda a árvore, a partir da raiz, por um bookmark aninhado.
FoundID := Lib.FindOutlineByTitle('Appendix B',
Lib.GetFirstOutline, osdFullSubTree);
if FoundID <> 0 then
// FoundID é agora um handle que podes passar aos getters de ação e
// de destino abaixo.
;
end;
finally
Lib.Free;
end;
end;
A correspondência faz-se na string exacta do título, comparada como WideString, por isso é sensível a maiúsculas e minúsculas e respeita o Unicode exactamente como está armazenado. Se os seus PDFs de origem vierem de produtores inconsistentes, normalize o título que procura antes de chamar FindOutlineByTitle, ou vai passar mais tempo a perseguir variações do que a consumir resultados
Resolver a action e o destino de um bookmark
Com o handle na mão, GetOutlineActionInfo dá-lhe a vista tipada. O padrão é: chame, faça switch em Kind, leia 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'); // ver destino abaixo
akNamed:
Writeln('Named action (NextPage, Print, etc.)');
akNone:
Writeln('Bookmark has no action');
end;
end;
É aqui que vive a primeira armadilha real, e foi a que o feedback de testes ajudou a revelar durante a implementação. Há um getter antigo, GetActionURL, e recorrer a ele para ler uma action URI é o erro que parece óbvio. O GetActionURL resolve uma especificação de ficheiro através da chave /F. Isso é o certo para GoToR e Launch, cujos alvos são genuinamente ficheiros, mas é completamente a chave errada para uma action URI. O endereço de uma action URI é uma string simples na própria chave /URI da action, não uma especificação de ficheiro. Dê uma action URI ao caminho de especificação de ficheiro e obtém um resultado vazio ou sem sentido. O getter tipado trata disto internamente, lendo /URI directamente para akURI e só invocando o resolvedor de especificação de ficheiro para akGoToR e akLaunch — exactamente a distinção que uma versão escrita à mão tende a esbater
Tipos de fit do destino e a geometria por trás deles
Uma action akGoTo quer dizer "navegar dentro deste documento", mas não lhe diz onde nem como. Esse é o trabalho do destino, e os destinos têm mais nuance do que a maioria das pessoas espera. Um destino PDF não é só uma página; é uma combinação de página, tipo de enquadramento e, por vezes, zoom ou rectângulo. GetOutlineDestinationInfo devolve-o como um 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;
Os oito tipos de fit respondem a perguntas de enquadramento diferentes. dkXYZ posiciona um ponto específico no canto superior esquerdo com um zoom explícito, por isso usa Left, Top e Zoom. dkFit ajusta a página inteira à janela e ignora coordenadas. dkFitH e dkFitV ajustam a largura ou a altura da página com uma única coordenada relevante (uma margem superior ou uma margem esquerda). dkFitR é o caso interessante: ajusta um rectângulo especificado, por isso as quatro margens contam. A família dkFitB* faz o mesmo em relação à bounding box do conteúdo visível, e não à página completa. Saber que campos estão activos para cada tipo é a diferença entre ler um destino correctamente e imprimir coordenadas sem sentido que por acaso são zero

Por baixo, a implementação assenta numa peça deliberada de alinhamento que vale a pena conhecer porque explica por que razão o mapeamento é fiável. GetDestType interno devolve um inteiro 1..8 para os oito tipos de fit, pela ordem exacta XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. O TPDFlibDestinationKind está declarado de modo a que os ordinais coincidam um a um: dkXYZ é o ordinal 1, dkFitBV é o ordinal 8, com dkNone no zero. Assim, a conversão é um cast directo de ordinal com um guarda de intervalo, e não uma tabela de conversão que pode dessincronizar à medida que a enumeração cresce. É um detalhe pequeno, mas é o tipo de coisa que, feita da forma ingénua, se torna num bug off-by-one à primeira reordenação de uma enumeração
var
Dest: TPDFlibDestinationInfo;
begin
Dest := Lib.GetOutlineDestinationInfo(FoundID);
if Dest.Page = 0 then
Exit; // o destino não resolveu
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;
Um Page igual a zero é o sinal de que o destino não foi resolvido, normalmente porque a action não transporta destino ou porque o destino nomeado não foi encontrado. Verifique isso antes de confiar em qualquer coordenada. Note também que GetOutlineDestinationInfo procura nos dois sítios onde um destino pode viver: directamente no /Dest do bookmark, e dentro do /D de uma action GoTo incorporada. Não precisa de saber que forma o produtor usou
Actions de anotações e a armadilha de SelectPage
As anotações de link transportam actions exactamente da mesma forma que os bookmarks, e GetAnnotActionInfo devolve o mesmo registo TPDFlibActionInfo com o mesmo padrão tipo-mais-carga útil. Mas aqui existe um detalhe com estado que não se aplica aos bookmarks: antes de interrogar uma anotação tem de seleccionar a página certa
As anotações pertencem a páginas, e o PDF Library for Delphi expõe as anotações da página corrente através de estado que só fica válido depois de seleccionar essa página. Chamar GetAnnotActionInfo sem primeiro chamar SelectPage(N) deixa o handle da anotação a zero; a chamada devolve akNone e conclui erradamente que a página não tem anotações com acções. A correcção é uma linha, mas é fácil de esquecer quando está a percorrer páginas:
var
P: Integer;
Info: TPDFlibActionInfo;
begin
for P := 1 to Lib.PageCount do
begin
Lib.SelectPage(P); // obrigatório antes de mexer nas annotations
// GetAnnotActionID(1) <> 0 é o teste fiável de "tem ação"
// test. CheckPageAnnots returns a boolean-style flag, not a
// contagem, por isso é o sinal mais fraco aqui.
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;
Há duas coisas deliberadas nesse ciclo. Primeiro, SelectPage(P) vem antes de qualquer acesso a anotações em cada iteração; o estado de anotações por página não é acumulativo. Segundo, o teste de existência usa GetAnnotActionID(1) <> 0 em vez de CheckPageAnnots. Este último reporta presença como uma flag de estilo booleano, e não como uma contagem, por isso um ID de acção diferente de zero é a forma mais precisa de perguntar "existe uma primeira anotação, e traz uma acção que eu consiga ler?". Mais uma subtileza que vale a pena assinalar: nas anotações, o script de uma action JavaScript é lido directamente de /JS, descodificando um stream quando o script está guardado dessa forma e lendo uma string caso contrário, por isso sobrevive às duas codificações comuns
Onde a introspecção do lado da leitura se encaixa
Estas funções getter são intencionalmente estreitas. São leituras puras construídas em cima das camadas de actions e destinos com handle inteiro já existentes na biblioteca, por isso não tocam no caminho de escrita e não acrescentam risco aos documentos que esteja também a editar. Reportam o que está no ficheiro; não validam contra uma política nem reescrevem nada. Se o objectivo é o inverso — criar bookmarks e anotações de link que transportem estas acções —, isso vive do lado da escrita, e o artigo complementar sobre acções de formulários interactivos e JavaScript no Delphi mostra como criá-los. Para extrair o conteúdo visível e estrutural de um PDF em vez do seu grafo de navegação, veja extrair texto, imagens e fontes com PDF Library for Delphi
A fronteira honesta a ter em mente é esta: a introspecção só vê o que o produtor realmente escreveu. Um bookmark cuja action foi deixada malformada por um gerador, ou um destino que aponta para um alvo nomeado nunca definido, vai aparecer como akNone ou uma página zero, em vez de uma excepção. Isso é o comportamento certo para uma API de leitura que audita ficheiros não fidedignos, mas significa que o seu código deve tratar esses resultados zero como "ausentes ou não resolvidos", e não como garantia de entrada bem formada. A introspecção tipada de acções e destinos aqui mostrada faz parte da PDF Library for Delphi, a biblioteca PDF nativa para Delphi e C++Builder