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 GoTo é 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, /Dest e uma fan-out de variantes de fit que quase ninguém acerta à primeira
PDFlibPas é 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 action Named usa /N. Uma JavaScript guarda o script em /JS. E uma GoTo interna ainda tem de apontar para um destino separado, o que acrescenta outra camada de resolução
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. É aqui que as implementações manuais costumam falhar de forma silenciosa: fazem a pergunta errada com a suposição certa e devolvem algo que parece plausível mas não é o que o documento realmente diz
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 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 estrutura separada. Isso é muito mais seguro do que tentar adivinhar a semântica a partir de uma única chave opcional
Percorrer a árvore de outline para encontrar um bookmark
Antes de introspectar um bookmark precisa do seu handle. O PDFlibPas 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
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 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'); // see destination below
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 atalho que parece óbvio mas falha. O bug é subtil: o nome sugere cobertura ampla, mas o valor que devolve não preserva o tipo da action nem o resto do payload. Para introspecção fiel, use o record tipado. O getter antigo pode servir para código legado, mas não para uma ferramenta de diagnóstico que precisa de dizer a verdade sobre o documento
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. O estado exacto de abertura é o que dá ao visualizador uma instrução útil
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. dkFitR enquadra um rectângulo definido pelo autor. dkFitH e dkFitBH fixam a posição horizontal e deixam o resto ao visualizador. dkFitV e dkFitBV fazem o mesmo na vertical. E dkFitB ajusta a caixa de delimitação da página, o que é diferente de usar a área da página bruta

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, e essa enumeração corresponde directamente a TPDFlibDestinationKind. Isso elimina a necessidade de tabelas de conversão frágeis e mantém a tipagem clara: quando o record diz dkFitH, sabe exactamente o que significa
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;
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 pode dar-lhe um Kind resolvido mas sem página, e nesse caso a semântica é diferente de um salto interno válido. O record faz a distinção explícita para que a sua interface não tenha de a adivinhar
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 PDFlibPas 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) significa consultar o conjunto de anotações errado ou um estado vazio, e o resultado pode parecer plausível sem estar certo
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;
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 assumir que a primeira anotação tem sempre uma action. Isso evita falsos positivos quando uma anotação existe mas não transporta acção navegável
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 noutro sítio
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 tal. Isso é uma vantagem, não uma falha. A API não mascara problemas do documento; mostra-os com os tipos certos para que os possa diagnosticar sem dissecação manual