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

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