Artículo técnico

Agregar marcadores a archivos PDF existentes en Delphi con HotPDF

HotPDF agrega marcadores a un PDF existente en Delphi a través de AddLoadedOutline, que crea entradas de esquema en cualquier nivel de anidamiento sobre un documento cargado, y lee y escribe destinos con nombre a través de ResolveLoadedNamedDestination y AddLoadedNamedDestination. Cargue el archivo, construya el árbol, llame a SaveLoadedDocument, y la barra lateral del lector que antes estaba vacía ahora tendrá una tabla de contenidos funcional

El escenario que motiva todo esto es lamentablemente común. Usted fusiona una docena de contratos en un solo paquete de revisión, o impone tres manuales de producto en un único archivo distribuible, y el resultado es correcto desde el punto de vista estructural: cada página presente, cada fuente intacta. Luego, alguien lo abre en Acrobat y el panel de marcadores está vacío. Un documento de 400 páginas sin navegación está técnicamente completo pero es prácticamente inutilizable, y hasta la versión 2.347.0 HotPDF podía leer, modificar y eliminar marcadores en documentos cargados, pero nunca crearlos. Esa brecha ya se ha cerrado, y este artículo analiza la nueva superficie de la API y el concepto particular de PDF, la clave /Count, que le causará problemas si la trata como un simple contador de entradas

Qué es realmente el árbol de esquema (outline tree)

Un esquema de PDF es un árbol de diccionarios doblemente enlazado, no una lista plana, y esa estructura explica cada parámetro de la API. La norma ISO 32000-1 §12.3.3 lo define: el catálogo del documento apunta a una raíz /Outlines, la raíz apunta a sus primeros y últimos hijos a través de /First y /Last, y los hermanos se encadenan mediante /Next y /Prev, mientras que cada entrada apunta hacia arriba a través de /Parent. Cada entrada contiene una cadena /Title y, por lo general, un destino /Dest que indica a dónde debe llevar al lector al hacer clic en ella

Los destinos se presentan en dos variantes, y la norma ISO 32000-1 §12.3.2 los mantiene separados de manera deliberada. Un destino explícito es una matriz integrada como [página /XYZ x y zoom]: una referencia directa a un objeto de página más una especificación de vista. En cambio, un destino con nombre almacena una cadena de nombre, y el destino real reside en el catálogo de documentos bajo el árbol de nombres /Names /Dests, lo que representa un nivel de indirección que permite que muchos enlaces compartan un solo destino y que el destino se mueva sin necesidad de modificar los enlaces. HotPDF escribe destinos /XYZ explícitos al crear marcadores y le ofrece llamadas independientes para leer y extender el árbol de nombres

Diagrama de HotPDF del árbol de esquema PDF doblemente enlazado que construye AddLoadedOutline: el catalog del documento apunta a la raíz Outlines, el First y Last de la raíz alcanzan las entradas de los extremos, los hermanos se encadenan mediante Next y Prev mientras cada entrada almacena Parent, y el árbol Names Dests resuelve los destinos con nombre
El outline es un árbol doblemente enlazado — la raíz retiene /First y /Last, los hermanos se encadenan mediante /Next y /Prev, cada entrada almacena /Parent, y cada destino de clic viaja en un array /Dest [page /XYZ]

¿Cómo agregar marcadores a un PDF existente en Delphi?

Llame a AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) después de LoadFromFile: pase ParentIndex = -1 para una entrada de nivel superior, o el índice basado en cero de una entrada de nivel superior existente para anidarla debajo de ella. La función crea la raíz /Outlines si el documento nunca tuvo una, construye el diccionario de entradas, escribe una matriz /Dest [página /XYZ x y zoom] que apunta al índice DestPageIndex basado en cero y enlaza la nueva entrada en la cadena de hermanos. Devuelve 0 si tiene éxito y -1 en caso de fallo, por ejemplo, cuando DestPageIndex está fuera de rango

Un detalle de enlace es importante para el orden: se inserta una nueva entrada en la cabecera de la cadena de hijos de su elemento padre, como el nuevo /First. De este modo, si agrega el "Capítulo 1" y luego el "Capítulo 2" como entradas de nivel superior, el Capítulo 2 aparecerá arriba del Capítulo 1 en la barra lateral, y el Capítulo 1 se habrá desplazado al índice de nivel superior 1. El patrón práctico consiste en agregar las entradas en orden inverso al de lectura, o agregar cada elemento padre y rellenar sus hijos de inmediato mientras este aún se encuentra en el índice 0

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Agrega los capítulos en orden de lectura inverso: cada nueva
      // entrada de primer nivel pasa a ser /First (índice 0)
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 is now top-level index 0; nest sections
      // abajo de él (los hijos también se anteponen, así que en orden inverso)
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Los parámetros X, Y y Zoom se asignan directamente al destino /XYZ y todos tienen el valor predeterminado de 0. PDF sitúa el origen de coordenadas en la esquina inferior izquierda, por lo que Y = 792 apunta a la parte superior de una página tamaño Carta estadounidense (US Letter), y según la norma ISO 32000-1, un valor de cero en una ranura /XYZ le indica al visor que mantenga su configuración actual, que es exactamente lo que desea para el zoom en casi todos los casos. Todo ocurre sobre el gráfico de objetos cargado en la memoria; nada toca el disco hasta que se ejecuta SaveLoadedDocument, el mismo modelo de edición y guardado que utiliza la API de documentos cargados para sobrescribir el título, el autor y los metadatos XMP en archivos PDF cargados

Redireccionamiento de marcadores: saltos de página frente a acciones URI

Los marcadores existentes se pueden volver a enlazar sin necesidad de crearlos de nuevo, y HotPDF ofrece dos llamadas diferentes para ambos casos porque las construcciones PDF subyacentes son verdaderamente distintas. SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) reemplaza el /Dest de la entrada de nivel superior en el índice Index con una nueva matriz /XYZ, que es la herramienta ideal para el clásico fallo posterior a la fusión en el que cada marcador sigue apuntando a los números de página del archivo original antes de la fusión. SetLoadedOutlineURI(Index, URI) realiza algo estructuralmente diferente: adjunta un diccionario de acción /A con /S /URI, convirtiendo el marcador en un enlace web en lugar de un salto dentro del documento

Dos formas en que HotPDF recablea un marcador existente: SetLoadedOutlineDestination escribe un arreglo Dest XYZ dentro del documento para saltos de página, mientras SetLoadedOutlineURI adjunta un diccionario de acción A con S URI que abre una página web
SetLoadedOutlineDestination reescribe el array /Dest para saltos internos de página — la corrección para marcadores que tras la fusión siguen apuntando a páginas previas a la fusión — mientras que SetLoadedOutlineURI cambia a una acción /A /S /URI reservada para entradas hoja que abandonan el documento
// El merge corrió todo 10 páginas: reapunta el
// tercer bookmark de primer nivel a la página 12 (11 en base 0)
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Convierte la última entrada en un link externo
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Mantenga claros ambos mecanismos cuando diseñe el árbol. Un /Dest es pura navegación dentro del documento; una acción URI sale por completo del documento, y la norma ISO 32000-1 espera que una entrada de esquema navegue a través de uno de los dos mecanismos, pero no ambos. Por lo tanto, reserve SetLoadedOutlineURI para las entradas que no tengan ya un destino de página, como un elemento hoja del tipo "Página de inicio del producto" o "Informar de un problema" al final del árbol, en lugar de un encabezado de capítulo convertido. La acción URI escrita aquí es la misma construcción /S /URI que se utiliza para las anotaciones de enlace en el contenido de las páginas, analizadas con mayor detalle en la guía para crear hipervínculos en documentos PDF con HotPDF

¿Cómo funcionan los destinos con nombre en un PDF cargado?

Los destinos con nombre representan el mecanismo de anclaje estable de la norma ISO 32000-1 §12.3.2.3: en lugar de incrustar una referencia de página en cada enlace, el documento mantiene un mapa de nombre a destino en el árbol de nombres /Names /Dests del catálogo, y los enlaces hacen referencia a las entradas por su nombre. ResolveLoadedNamedDestination(Name) busca un nombre en ese árbol y devuelve el índice de página basado en cero al que apunta, o -1 si el nombre no existe. El resolvedor maneja ambas formas de almacenamiento que la especificación permite para el valor mapeado: una matriz de destino directa como [página /XYZ x y zoom] y la forma de diccionario que envuelve la matriz bajo una clave /D

AddLoadedNamedDestination(Name, DestPageIndex, X, Y) realiza el proceso inverso: registra un nuevo anclaje en el árbol /Names /Dests, creando el propio árbol cuando el documento no tiene ninguno, y devuelve True si tiene éxito. En conjunto, el par cubre el flujo de trabajo en el que un documento fusionado debe respetar los anclajes escritos por herramientas anteriores y publicar otros nuevos para que sirvan de destino a los enlaces posteriores

var
  PageIdx: Integer;
begin
  // Respeta un ancla que otra herramienta escribió en /Names /Dests
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publica una nueva ancla para que otros links apunten ahí
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

Los límites sinceros: los destinos que escribe HotPDF, tanto para marcadores como para anclajes con nombre, son destinos /XYZ. Los otros tipos explícitos que define la especificación, como /Fit, /FitH, /FitB y similares, no se emiten a través de estas llamadas, aunque /XYZ con zoom cero cubre el caso de uso predominante de "ir a este lugar, mantener mi zoom". And GetLoadedBookmarkPageIndex(Title), la búsqueda de conveniencia que recibe el título de un marcador y devuelve su página de destino, escanea el nivel superior del árbol de esquemas, por lo que debe utilizarla para verificar las entradas a nivel de capítulo que acaba de crear en lugar de hojas profundamente anidadas

La trampa de /Count: cuenta descendientes, no entradas

La clave /Count en la raíz del esquema no significa lo que la mayoría de los desarrolladores suponen, y una suposición errónea produce fallos en las pruebas que parecen corrupción de datos. La norma ISO 32000-1 §12.3.3 define /Count como el número total de descendientes visibles en todos los niveles, no el número de entradas de nivel superior. En una entrada individual, el signo también tiene significado: un valor positivo indica que la entrada está abierta y esa cantidad de descendientes se muestra en la barra lateral, mientras que un valor negativo indica que la entrada está colapsada y los descendientes |N| están ocultos dentro de ella

Esto surgió del propio conjunto de pruebas de regresión de HotPDF mientras se construían las API de marcadores de documentos cargados. Un documento de prueba contenía tres marcadores de nivel superior, uno de ellos con un hijo; después de eliminar una entrada de nivel superior, GetLoadedOutlineCount, que lee la propiedad de la raíz /Count, informó 1 en lugar del 2 esperado. Nada se había corrompido: el valor inicial de 3 nunca había sido "tres entradas de nivel superior" en primer lugar, y el cálculo correcto debe recorrer la cadena de nivel superior acumulando cada nodo más su valor /Count positivo, omitiendo los descendientes de los nodos colados. La lección para su propio código es directa: nunca realice aserciones sobre diferencias de /Count después de realizar modificaciones estructurales, debido a que el valor cambia de forma no lineal con los descendientes visibles. En su lugar, verifique la estructura por el contenido

HotPDF: la semántica de /Count de los esquemas PDF: un Count positivo marca una entrada abierta cuyos hijos permanecen visibles y contados, un Count negativo pliega los descendientes, y eliminar un marcador de nivel superior mueve el conteo de la raíz de cuatro a tres en lugar de uno por fila
El /Count de la raíz suma los descendientes visibles en todos los niveles, así que el signo y el anidamiento deciden cómo se mueve — una aserción de delta tras eliminaciones falla, y la verificación por título con GetLoadedBookmarkPageIndex es la comprobación honesta
// Después de editar, verifica buscando por título, no por deltas de /Count
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Dónde encaja esto dentro del conjunto de herramientas para documentos cargados

La creación de marcadores y destinos con nombre completa una funcionalidad que la API de documentos cargados ha estado desarrollando desde hace tiempo: el mismo ciclo de cargar-editar-guardar ya reescribe los metadatos del documento, agrega anotaciones de enlace y realiza el viaje de ida y vuelta de anotaciones a través de la importación y exportación XFDF. El patrón es uniforme: LoadFromFile, una secuencia de llamadas del tipo *Loaded* sobre el gráfico de objetos en memoria y, luego, un método SaveLoadedDocument, lo cual facilita la integración de la construcción del esquema en un flujo de fusión o sellado existente como un paso adicional antes de guardar

Las API analizadas aquí, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination, y GetLoadedBookmarkPageIndex, se incluyen en la versión estándar de HotPDF Delphi Component para Delphi y C++Builder, sin dependencias externas y sin requerir un visor independiente en tiempo de ejecución