Artículo técnico

Leer acciones de marcadores y anotaciones PDF en Delphi

Heredas una carpeta de PDFs de algún proceso anterior y la tarea parece trivial: decir qué marcadores saltan a una URL externa, cuáles ejecutan JavaScript y dónde aterrizan de verdad los internos. Luego abres la referencia de la API y descubres que la biblioteca puede crear todas esas acciones, pero no ofrece nada para leerlas de vuelta. Esa asimetría aparece en toda la herramienta PDF. Escribir un marcador que abre https://example.com es una sola línea; preguntar a un marcador existente "qué haces y hacia qué destino" suele significar recorrer a mano el árbol de objetos crudos a través de /A, /S, /Dest y una ramificación de variantes de ajuste que casi nadie hace bien al primer intento

PDFlibPas es una biblioteca PDF nativa en Object Pascal para Delphi y C++Builder, y durante mucho tiempo tuvo la misma brecha: setters ricos del lado de escritura, getters que te devolvían un TPDFObject desnudo y te dejaban hurgar. La versión v3.77.0 cerró parte de eso con un pequeño conjunto de llamadas tipadas de introspección que reportan el tipo de acción, la carga útil de la acción y la geometría del destino como registros simples. Este artículo explica cómo esas llamadas se mapean al modelo de acciones y destinos de ISO 32000-1, y los tres tropiezos concretos que hacen que las versiones hechas a mano de este código fallen en silencio

Por qué leer acciones es más difícil que escribirlas

Una acción en PDF es un diccionario con una clave /S que nombra su subtipo: GoTo, GoToR, URI, Launch, Named, JavaScript, y una cola más larga que rara vez aparece (ISO 32000-1 §12.6.4). El problema es que la carga útil vive en una clave distinta para cada subtipo, y no existe un campo uniforme para "dame el destino". Una acción URI guarda su dirección en /URI. Una acción GoToR o Launch guarda una especificación de archivo en /F. Una acción JavaScript guarda su script en /JS, que puede ser una cadena o un flujo. Una acción GoTo no lleva carga útil propia en absoluto; su destino es un destino, colgado de /D, que luego tienes que resolver por separado

Cuando escribes una acción ya sabes de antemano cuál es su tipo, así que nada de esto importa. Cuando lees una, primero tienes que ramificarte sobre /S, luego entrar en la clave correcta y después lidiar con el hecho de que el mismo concepto lógico, "la cosa a la que apunta esta acción", está codificado de tres maneras incompatibles. Esa ramificación es exactamente lo que absorben los getters tipados. GetOutlineActionInfo y GetAnnotActionInfo devuelven ambos un 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;

El registro te dice qué campos son significativos mediante Kind. Si Kind vuelve como akURI, lee URI e ignora el resto. Si vuelve como akGoTo, ninguno de los campos de carga útil aplica y pasas al destino, que es una llamada aparte cubierta más abajo. akNone es la respuesta honesta cuando el marcador o la anotación no tiene ninguna acción, en lugar de un cero cuyo significado tendrías que adivinar

Recorrer el árbol de marcadores para encontrar uno

Antes de poder inspeccionar un marcador necesitas su identificador. PDFlibPas identifica los nodos del árbol de marcadores con un ID entero, y FindOutlineByTitle localiza uno por su texto visible con control explícito sobre hasta dónde llega la búsqueda:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

El argumento Depth merece una pausa. osdSiblingsOnly recorre la cadena de hermanos en el nivel del nodo inicial y se detiene; encuentra un marcador del mismo nivel, pero nunca desciende a los hijos de ese par. osdChildrenOnly mira un nivel abajo, a los hijos inmediatos del nodo inicial. osdFullSubTree recorre toda la rama. Elegir el incorrecto produce una omisión silenciosa, no un error: una búsqueda solo entre hermanos para un título que vive dos niveles abajo simplemente devuelve cero, y terminas concluyendo que el marcador no existe cuando siempre estuvo ahí. Pasa GetFirstOutline como identificador inicial para buscar desde la raíz del 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;

La coincidencia se hace sobre la cadena exacta del título, comparada como WideString, así que distingue mayúsculas y respeta el texto Unicode exactamente como está almacenado. Si tus PDFs de origen vienen de productores inconsistentes, normaliza el título que buscas de la misma forma en que el documento lo almacenó, o perseguirás omisiones fantasma

Resolver la acción y el destino de un marcador

Con un identificador en mano, GetOutlineActionInfo te da la vista tipada. El patrón es: llamarlo, cambiar sobre Kind y leer el campo que ese tipo rellena

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;

Aquí vive la primera trampa real, y es la que la retroalimentación de pruebas descubrió durante la implementación. Existe un getter más antiguo, GetActionURL, y usarlo para leer una acción URI es el error obvio. GetActionURL resuelve una especificación de archivo a través de la clave /F. Eso es lo correcto para GoToR y Launch, cuyos destinos realmente son archivos, pero es la clave equivocada para una acción URI. La dirección de una acción URI es una cadena simple en la propia clave /URI de la acción, no una especificación de archivo. Si alimentas una acción URI al camino de la especificación de archivo, obtienes un resultado vacío o sin sentido. El getter tipado maneja esto internamente leyendo /URI directamente para akURI y solo invocando el resolver de especificación de archivo para akGoToR y akLaunch, que es exactamente la distinción que una versión escrita a mano suele mezclar

Tipos de ajuste de destino y la geometría detrás de ellos

Una acción akGoTo significa "navegar dentro de este documento", pero no te dice nada sobre dónde o cómo. Ese es el trabajo del destino, y los destinos llevan más matices de los que la gente espera. Un destino PDF no es solo un número de página; es una página más una especificación de "ajuste" que dice cómo el visor debe encuadrar esa página (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo la devuelve como un 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;

Los ocho tipos de ajuste responden preguntas distintas de encuadre. dkXYZ coloca un punto específico en la esquina superior izquierda con un zoom explícito, así que usa Left, Top y Zoom. dkFit ajusta toda la página en la ventana e ignora las coordenadas. dkFitH y dkFitV ajustan el ancho o la altura de la página con una sola coordenada relevante, un borde superior o un borde izquierdo. dkFitR es el interesante: ajusta un rectángulo específico, así que importan los cuatro bordes. La familia dkFitB* hace lo mismo, pero respecto del cuadro delimitador del contenido visible en lugar de la página completa. Saber qué campos están activos para cada tipo es la diferencia entre leer un destino correctamente e imprimir coordenadas basura que por casualidad valen cero

PDF reader bookmark navigation panel showing a nested outline tree
Cada marcador en este panel de navegación se resuelve en una acción y, para saltos internos, en un destino con su propio tipo de ajuste y coordenadas.

Por debajo, la implementación se apoya en una alineación deliberada que vale la pena conocer porque explica por qué el mapeo es confiable. La función interna GetDestType devuelve un entero de 1 a 8 para los ocho tipos de ajuste en el orden exacto XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind se declara para que sus ordinales coincidan uno a uno: dkXYZ es el ordinal 1, dkFitBV es el ordinal 8 y dkNone queda en cero. Así, la conversión es un cast ordinal directo con una guardia de rango, no una tabla de búsqueda que pueda desincronizarse a medida que crece la enumeración. Es un detalle pequeño, pero es el tipo de cosa que, hecha de la forma ingenua, se convierte en un error de uno en uno la primera vez que alguien reordena una enumeración

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;

Un Page igual a cero es la señal de que el destino no se resolvió, por lo general porque la acción no lleva destino o porque no se encontró el destino con nombre. Revísalo antes de confiar en cualquier coordenada. Nota también que GetOutlineDestinationInfo busca en ambos lugares donde puede vivir un destino: directamente en /Dest del marcador y dentro de /D de una acción GoTo incrustada. No tienes que saber cuál de las dos formas usó el productor

Acciones de anotación y la trampa de SelectPage

Las anotaciones de enlace llevan acciones exactamente igual que los marcadores, y GetAnnotActionInfo devuelve el mismo registro TPDFlibActionInfo con el mismo patrón de tipo y luego carga útil. Pero aquí hay una salvedad con estado que no aplica a los marcadores, y es la tercera trampa

Las anotaciones pertenecen a páginas, y PDFlibPas expone las anotaciones de la página actual mediante un estado que solo se vuelve válido después de seleccionar esa página. Llamar a GetAnnotActionInfo sin llamar primero a SelectPage(N) deja el identificador de anotación en cero; la llamada devuelve akNone y terminas concluyendo erróneamente que la página no tiene anotaciones accionables. La corrección es de una línea, pero es fácil olvidarla cuando recorres páginas:

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;

Hay dos cosas deliberadas en ese bucle. Primero, SelectPage(P) va antes de cualquier acceso a anotaciones en cada iteración; el estado de anotación por página no se arrastra. Segundo, la prueba de existencia usa GetAnnotActionID(1) <> 0 en lugar de CheckPageAnnots. Esta última informa presencia como una bandera de estilo booleano y no como un conteo, así que un ID de acción distinto de cero es la forma más precisa de preguntar "¿hay una primera anotación y lleva una acción que puedo leer?" Una sutileza más que vale la pena señalar: para las anotaciones, el script de una acción JavaScript se lee directamente desde /JS, decodificando un flujo cuando el script se almacena así y leyendo una cadena en caso contrario, de modo que sobrevive a ambas codificaciones comunes

Dónde encaja la introspección de lectura

Estos getters son deliberadamente estrechos. Son lecturas puras construidas sobre las capas existentes de acción y destino con identificadores enteros de la biblioteca, así que no tocan la ruta de escritura ni añaden riesgo a los documentos que también estés editando. Informan lo que hay en el archivo; no lo validan contra una política ni reescriben nada. Si tu objetivo es el inverso, construir marcadores y anotaciones de enlace que lleven estas acciones desde el principio, eso vive del lado de escritura, y el artículo complementario sobre acciones de formularios interactivos y JavaScript en Delphi recorre su creación. Para sacar el contenido visible y estructural de un PDF en lugar de su grafo de navegación, mira extraer texto, imágenes y fuentes con PDFlibPas

El límite honesto que conviene tener presente es este: la introspección solo ve lo que el productor escribió de verdad. Un marcador cuya acción quedó mal formada por un generador, o un destino que apunta a un nombre que nunca se definió, aparecerá como akNone o una página cero en lugar de una excepción. Ese es el comportamiento correcto para una API de lectura que audita archivos no confiables, pero significa que tu código debe tratar esos resultados en cero como "ausente o sin resolver", no como garantía de entrada bien formada. La introspección tipada de acciones y destinos que se muestra aquí forma parte de PDFlibPas, la biblioteca PDF nativa para Delphi y C++Builder