Artículo técnico

Leer acciones de marcadores y anotaciones PDF en Delphi

Heredas una carpeta de PDF de algún proceso anterior, y la tarea suena trivial: dime qué marcadores saltan a una URL externa, cuáles ejecutan JavaScript y dónde aterrizan realmente los internos. Luego abres la documentación de la API y descubres que la biblioteca puede crear todas esas acciones, pero no ofrece nada para leerlas de vuelta. Esta asimetría aparece por todas partes en las herramientas PDF. Escribir un marcador que abra https://example.com es una línea; preguntar a un marcador existente "qué haces y a qué destino apuntas?" suele obligarte a recorrer a mano el árbol bruto de objetos a través de /A, /S, /Dest y un abanico de variantes de ajuste que casi nadie acierta a la primera

PDFlibPas es una biblioteca PDF nativa de Object Pascal para Delphi y C++Builder, y durante mucho tiempo tuvo la misma carencia: setters ricos en la escritura, getters que te devolvían un TPDFObject desnudo y te dejaban hurgar por tu cuenta. La versión v3.77.0 cerró parte de ese hueco con un pequeño conjunto de llamadas de introspección tipada que informan del tipo de acción, la carga útil de la acción y la geometría del destino como registros planos. Este artículo explica cómo encajan esas llamadas con el modelo de acciones y destinos de ISO 32000-1, y los tres tropiezos concretos que hacen que las implementaciones 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 te encuentras (ISO 32000-1 §12.6.4). El problema es que la carga útil vive en una clave distinta para cada subtipo, y no existe una ranura uniforme de "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 conoces su tipo de antemano, así que nada de esto importa. Cuando lees una, primero tienes que bifurcar por /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") se codifica de tres formas incompatibles. Esa bifurcación es precisamente 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 que veremos más abajo. akNone es la respuesta honesta cuando el marcador o la anotación no tienen ninguna acción, en lugar de un cero al que tengas que adivinarle el significado

Recorrer el árbol de marcadores para encontrar un marcador

Antes de poder introspeccionar un marcador necesitas su identificador. PDFlibPas identifica los nodos del esquema 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 es la parte que merece una pausa. osdSiblingsOnly recorre la cadena de hermanos en el nivel del nodo inicial y se detiene; encontrará un marcador vecino pero nunca descenderá a los hijos de ese vecino. osdChildrenOnly mira un nivel por debajo, en los hijos inmediatos del nodo inicial. osdFullSubTree recorre recursivamente toda la rama. Elegir el incorrecto falla en silencio, no da error: una búsqueda solo de hermanos para un título que vive dos niveles abajo simplemente devuelve cero, y concluyes que el marcador no existe cuando estaba allí desde el principio. Pasa GetFirstOutline como ID de inicio 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 con la cadena exacta del título, comparada como WideString, así que distingue mayúsculas y minúsculas y respeta el texto Unicode exactamente tal como está almacenado. Si tus PDF de origen vienen de productores inconsistentes, normaliza el título que buscas de la misma manera en que el documento lo almacenó, o perseguirás fallos fantasma

Resolver la acción y el destino de un marcador

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

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 las pruebas puso al descubierto durante la implementación. Existe un getter antiguo, GetActionURL, y acudir a él para leer una acción URI es el error que más obvio parece. GetActionURL resuelve una especificación de archivo a través de la clave /F. Eso es lo correcto para GoToR y Launch, cuyos destinos son realmente 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 pasas una acción URI por la ruta de especificación de archivo obtienes un resultado vacío o sin sentido. El getter tipado maneja esto internamente leyendo /URI directamente para akURI y llamando al resolvedor de especificaciones de archivo solo para akGoToR y akLaunch, que es exactamente la distinción que una versión escrita a mano suele difuminar

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

Una acción akGoTo significa "navegar dentro de este documento", pero no te dice nada sobre dónde ni cómo. Ese es el trabajo del destino, y los destinos tienen 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 debe encuadrar el visor esa página (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo lo 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 a preguntas de encuadre distintas. dkXYZ coloca un punto concreto en la esquina superior izquierda con un zoom explícito, así que usa Left, Top y Zoom. dkFit ajusta la página completa 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 concreto, así que importan los cuatro bordes. La familia dkFitB* hace lo mismo respecto al 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 casualmente valen cero

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

Por debajo, la implementación se apoya en una alineación deliberada que conviene conocer porque explica por qué el mapeo es fiable. El interno GetDestType devuelve un entero del 1 al 8 para los ocho tipos de ajuste, exactamente en el orden XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind está declarada para que sus ordinales encajen uno a uno: dkXYZ tiene el ordinal 1, dkFitBV el ordinal 8, y dkNone se queda en cero. Así que la conversión es un simple cast ordinal con una comprobación 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 manera ingenua, se convierte en un error de una posición 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 señala que el destino no se resolvió, normalmente porque la acción no lleva destino o porque no se encontró el destino con nombre. Compruébalo antes de confiar en cualquier coordenada. Ten en cuenta también que GetOutlineDestinationInfo mira en ambos sitios donde puede vivir un destino: directamente en /Dest del marcador, y dentro de /D de una acción GoTo incrustada. No necesitas saber qué forma 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 después carga útil. Pero aquí hay una trampa dependiente de estado que no aplica a los esquemas, y es la tercera

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. Llama a GetAnnotActionInfo sin haber llamado antes a SelectPage(N) y el identificador de anotación será cero; la llamada devuelve akNone y concluyes erróneamente que la página no tiene anotaciones accionables. La solución es una línea, pero se olvida con facilidad 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;

Dos cosas en ese bucle son deliberadas. Primero, SelectPage(P) va antes de cualquier acceso a anotaciones en cada iteración; el estado de anotaciones por página no se arrastra. Segundo, la prueba de existencia usa GetAnnotActionID(1) <> 0 en lugar de CheckPageAnnots. Esta última informa de presencia con una bandera de estilo booleano y no con un recuento, 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 pueda leer?". Un detalle más que merece quedar señalado: para anotaciones, el script de una acción JavaScript se lee directamente de /JS, decodificando un flujo cuando el script está almacenado así y leyendo una cadena en caso contrario, de modo que funciona con ambas codificaciones habituales

Dónde encaja la introspección de lectura

Estos getters son deliberadamente estrechos. Son lecturas puras construidas sobre las capas de acciones y destinos con identificadores enteros que ya tiene la biblioteca, así que no tocan la ruta de escritura ni añaden riesgo a los documentos que estés editando a la vez. Informan de 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 en la parte de escritura, y la pieza complementaria sobre acciones interactivas de formularios y JavaScript en Delphi repasa cómo crearlas. Si quieres extraer el contenido visible y estructural de un PDF en lugar de su grafo de navegación, consulta extraer texto, imágenes y fuentes con PDFlibPas

El límite honesto a tener en cuenta es este: la introspección solo ve lo que el productor escribió de verdad. Un marcador cuya acción quedó malformada por un generador, o un destino que apunta a un destino con 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 cero como "ausente o sin resolver", no como garantía de una 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