Artigo Técnico

Ler acções de bookmarks e anotações PDF no Delphi

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

Cada bookmark neste painel de navegação resolve para uma action e, para saltos internos, para um destino com o seu próprio tipo de fit e coordenadas
Cada bookmark neste painel de navegação resolve para uma action e, para saltos internos, para um destino com o seu próprio tipo de fit e coordenadas.

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