Artículo técnico

Borrar páginas de un PDF en Delphi sin referencias colgantes

HotPDF Delphi Component borra una página de un PDF cargado a través de THotPDF.DeletePage, y desde la versión 2.751.0 esa llamada poda además cada referencia a nivel de documento que aún apunte a la página: los destinos con nombre del árbol /Names /Dests, el diccionario /Dests heredado del catálogo, las acciones /GoTo de los bookmarks, los elementos de estructura bajo /StructTreeRoot, el ParentTree, las entradas OBJR de las anotaciones, y las anotaciones de enlace de las páginas que sobreviven. El árbol de páginas se reconstruye al final, cuando ya nada más puede alcanzar el objeto borrado

El fallo que esto evita es fácil de reproducir y difícil de diagnosticar. Borra la portada de un informe etiquetado, guarda y abre el resultado: Acrobat muestra el número de páginas correcto, pero el bookmark «Contents» ya no lleva a ninguna parte, el comprobador de accesibilidad reporta un elemento de estructura sin página, y un validador estricto lista una referencia a un objeto libre. Nada del árbol de páginas está mal. El problema es que una página PDF no es solo una hoja de /Pages; es un destino al que apunta medio catálogo, y quitar la hoja deja colgando cada uno de esos punteros

¿Por qué no basta con quitar una página de /Kids?

Porque la ISO 32000-1 permite que al menos siete estructuras independientes guarden una referencia a un objeto página, y solo una de ellas es el árbol de páginas. Quitar la página de /Kids y decrementar /Count satisface la §7.7.3, y cada una de las otras referencias se convierte en un puntero a un objeto que o bien está liberado en la xref o simplemente no aparece en el archivo reescrito. Un visor que siga uno de esos punteros obtiene null, y lo que haga con ese null es cosa suya

  • El árbol de nombres bajo /Names /Dests (§7.7.4, §12.3.2.3) mapea nombres a arrays de destino cuyo primer elemento es la página
  • El diccionario /Dests anterior a la 1.2, directamente en el catálogo, guarda arrays del mismo tipo indexados por nombre
  • Los elementos de outline (§12.3.3) llegan a una página o bien por un /Dest inline o bien por una acción /A con /S /GoTo y un array /D
  • Los elementos de estructura (§14.7.2) llevan una clave /Pg que nombra la página en la que vive su contenido marcado, y sus hijos /K pueden ser referencias a contenido marcado y referencias a objetos (§14.7.4.3) atadas a esa página
  • El ParentTree (§14.7.4.4) mapea los números /StructParents de páginas y anotaciones de vuelta a elementos de estructura, y un elemento puede vivir ahí sin aparecer en absoluto en la cadena /K desde la raíz
  • Las anotaciones de enlace en otras páginas (§12.5.6.5) llevan un /Dest o una acción /GoTo que apunta a la página, y el /OpenAction del catálogo puede hacer lo mismo
Por qué no basta con quitar una página de HotPDF de /Kids: la ISO 32000-1 permite que el árbol de nombres /Names /Dests, el diccionario /Dests heredado del catálogo, los elementos de outline, los elementos de estructura con /Pg, el ParentTree, las anotaciones de enlace y /OpenAction guarden todos una referencia al mismo objeto página, y solo se reconstruye el árbol de páginas
Una página PDF es un destino al que apunta medio catálogo: quitar la hoja deja satisfecho el árbol de páginas mientras todos los demás punteros resuelven a null, así que un informe recortado pierde su bookmark Contents y falla la comprobación de accesibilidad

¿Qué limpia THotPDF.DeletePage antes de tocar el árbol de páginas?

THotPDF.DeletePage(PageIndex) sobre un documento cargado ejecuta primero todo el barrido de referencias, luego marca el objeto página como borrado con DeleteObj, desengancha las anotaciones widget del árbol de campos del AcroForm, desplaza el array interno de páginas, y por último llama a RebuildLoadedPageTree para reescribir /Kids, /Count y el /Parent de cada página superviviente. El barrido visita el catálogo en un orden fijo: el árbol de nombres /Names /Dests, el diccionario /Dests al estilo antiguo, /OpenAction, el árbol de outline, /StructTreeRoot con su ParentTree, y por último los arrays /Annots de cada página que se queda. Cada paso decide si una referencia se elimina, se redirige o se deja en paz según lo que la especificación permita hacer a esa estructura sin la página. Antes de que corra nada de eso hay dos guardias: DeletePage lanza Invalid page number ante un índice fuera de rango y se niega a borrar la última página, porque un nodo /Pages sin hijos no es un PDF válido, mientras que DeletePages acepta la misma notación basada en uno "1,3-5,7-" que el resto de operaciones de página sobre documentos cargados e itera desde el índice seleccionado más alto hacia abajo para que los índices que escribiste sigan siendo válidos mientras trabaja

El barrido fijo de referencias que THotPDF.DeletePage ejecuta antes de tocar el árbol de páginas: las guardias rechazan un índice fuera de rango o la última página, luego se podan /Names /Dests y el /Dests heredado, se descarta /OpenAction, los outlines se redirigen a NearestRetainedPage, se podan StructTreeRoot y ParentTree, se eliminan los enlaces de las páginas retenidas, y RebuildLoadedPageTree corre al final
Cada estructura recibe el trato que la especificación permite: los nombres desaparecen, los bookmarks aterrizan en la página retenida más cercana, los elementos de estructura pierden /Pg o desaparecen, y la reescritura de /Kids ocurre solo cuando nada más puede alcanzar el objeto borrado
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // Basado en cero: borrar la portada. Los destinos con nombre,
      // los bookmarks, el árbol de estructura, el ParentTree y las
      // anotaciones de enlace que apuntaban a ella se podan antes
      // de reconstruir el árbol /Pages.
      Pdf.DeletePage(0);
      // Sintaxis de rangos basada en uno para lotes, internamente
      // del índice más alto al más bajo para que los anteriores sigan válidos.
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

¿En qué se tratan de forma distinta los destinos con nombre y los bookmarks?

Los destinos con nombre se eliminan y los bookmarks se redirigen, porque un nombre que ya no existe es un resultado aceptable mientras que un bookmark sin destino es un defecto visible. En el árbol /Names /Dests HotPDF recorre cada nodo, comprueba cada destino, tanto en la forma de array pelado como en la forma de diccionario con clave /D, contra la página borrada, y elimina el par nombre/valor cuando el primer elemento del array es esa página. Un nodo cuyos /Names y /Kids acaban ambos vacíos se marca como borrado y se desengancha de su padre, así que el árbol nunca guarda hojas huecas. La misma comprobación corre sobre el diccionario /Dests del catálogo al estilo antiguo, y el /OpenAction del catálogo simplemente se descarta si abría en la página borrada. Un límite aquí: cuando un nodo del árbol de nombres pierde entradas, HotPDF borra el par /Limits de ese nodo en lugar de recalcular las nuevas claves mínima y máxima, y aunque los visores resuelven los nombres sin problema sin él, un comprobador de conformidad estricto que lea la ISO 32000-1 §7.9.6 puede marcar un nodo no raíz al que le falta /Limits

Los elementos de outline van al revés. RetargetOutlineDestinations recorre /First y /Next desde la raíz del outline, con una lista de visitados y un límite de profundidad de 128 para que un árbol cíclico corrupto no pueda colgar la llamada, y para cada array /Dest o acción /GoTo con array /D que apunte a la página reemplaza el primer elemento con NearestRetainedPage: la página que seguía a la borrada, o la anterior cuando la borrada era la última. Los parámetros de vista que van después de la referencia de página se dejan como estaban. Un bookmark que apuntaba a la portada de un capítulo borrado aterriza por tanto en la primera página de lo que queda en lugar de desaparecer de la barra lateral, que es el comportamiento que espera quien revisa un documento recortado. Eso sí, la comprobación de destino solo casa con arrays explícitos: un elemento de outline cuyo /Dest es una cadena de nombre que antes resolvía a la página borrada no se redirige, porque la entrada del árbol de nombres ya no está y la referencia ahora no resuelve a nada en lugar de resolver a un objeto liberado, así que el visor la trata como un bookmark muerto. La mecánica del propio árbol de outline, /First, /Next y la poco obvia semántica de /Count, está cubierta en la guía para añadir bookmarks y destinos con nombre en un PDF cargado

// Verificar el barrido en lugar de fiarse de él.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// Un bookmark que apuntaba a la portada ahora resuelve a la
// página que la seguía (índice 0 basado en cero tras el borrado).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

¿Qué pasa con el árbol de estructura y el ParentTree?

Los elementos de estructura que existen solo por la página borrada se eliminan, y los elementos que abarcan varias páginas pierden su clave /Pg pero conservan sus hijos. PruneStructureElement desciende la cadena /K desde /StructTreeRoot con una profundidad de 128, manejando tanto la forma de array como la forma de diccionario único de /K que permite la §14.7.2. Para cada elemento poda primero los hijos y luego evalúa el elemento en sí: si la poda dejó su /K vacío, el elemento se marca como borrado y su padre lo suelta. Si el /Pg del propio elemento nombra la página borrada y el elemento aún tiene hijos más un padre /P, solo se elimina /Pg, porque un /Pg en un elemento es la página por defecto de sus hijos de contenido marcado y esos hijos pueden referenciar otras páginas de forma explícita. Solo se elimina del todo un elemento cuyo /Pg es la página borrada y que no tiene nada debajo

El ParentTree recibe el mismo trato, y la razón es la que mordió durante el desarrollo: se puede llegar a un elemento de estructura desde el ParentTree y desde ningún otro sitio. El number tree mapea enteros /StructParents a un único elemento o a un array de elementos, y PruneParentTreeNode ejecuta PruneStructureElement sobre cada valor que encuentra, elimina los valores que quedaron podados, borra un par /Nums cuando su array de valores está vacío, y desengancha un nodo cuyos /Nums y /Kids ya no están. Podar solo los descendientes de /K habría dejado a esos elementos huérfanos apuntando a una página liberada a través de /Pg y a referencias a contenido marcado liberadas a través de sus hijos /MCR. Si extraes texto en orden de estructura, eso importa directamente: la extracción de texto en orden de estructura recorre exactamente estos árboles, y un elemento con /Pg nulo es un párrafo que se cae en silencio del orden de lectura

¿Qué anotaciones de enlace se eliminan en las páginas que sobreviven?

Cualquier anotación de enlace de una página retenida cuyo array /Dest o acción /GoTo apunte a la página borrada se elimina junto con su pertenencia al árbol de estructura. RemoveRetainedPageDestinationAnnotations recorre el array /Annots de cada página que no sea el objetivo, aplica la misma comprobación de destino que se usa para los outlines, marca la anotación que casa como borrada, la quita del array, y luego llama a PruneAnnotationReferencesInStructureTree para que el diccionario OBJR cuyo /Obj nombraba esa anotación se elimine de su elemento de estructura, con el elemento en sí eliminado si el OBJR era su único hijo. Dejar el OBJR en su sitio violaría la §14.7.4.3, que exige que /Obj referencie un objeto existente, y aparecería en una revisión PDF/UA como un enlace etiquetado sin anotación detrás. Fíjate en la asimetría con los bookmarks: los enlaces se eliminan, no se redirigen. Una referencia cruzada en el cuerpo del texto que decía «see page 3» está mal en cuanto la página 3 desaparece, y apuntarla a la página 4 sería una mentira de una forma en que no lo es un bookmark que aterriza en el capítulo más cercano, así que si tu flujo necesita conservar esos enlaces, redirígelos tú antes de llamar a DeletePage

¿Por qué un /MCR o un /OBJR eliminado no debe registrarse nunca como libre?

Porque las referencias a contenido marcado y las referencias a objetos suelen ser diccionarios directos dentro del array /K de su elemento padre, y el registro de cambios incrementales resuelve un objeto directo al objeto indirecto más cercano que lo contiene. Cuando RemoveArrayItem suelta un hijo de un array /K, libera el objeto en memoria solo si era un THPDFLink o un valor no indirecto, y MarkRemovedObject registra un objeto para la lista de libres solo cuando su número de objeto es mayor que cero. La primera versión de este barrido no hacía esa distinción, y el efecto en un guardado incremental fue exactamente lo que el registro está diseñado para hacer: RegisterIncrementalChange subía desde el /MCR directo hasta su raíz de transacción de grafo, que era el elemento de estructura retenido que lo poseía, y escribía ese elemento como null. Un documento que perdía una página volvía con el contenido etiquetado de las otras páginas sin etiquetar en silencio. El único movimiento correcto para un hijo directo es marcar su contenedor como sucio a través de TouchContainer para que el contenedor se reescriba, y dejar la lista de libres en paz

Por qué un hijo /MCR u OBJR eliminado no debe registrarse nunca como libre en HotPDF: el registro de cambios incrementales resuelve un diccionario directo al contenedor indirecto más cercano, así que la primera versión escribía el elemento de estructura retenido como null y desetiquetaba en silencio las páginas que sobrevivían, mientras TouchContainer ahora reescribe el contenedor y deja la lista de libres en paz
Liberar el hijo en memoria queda reservado para THPDFLink o valores no indirectos y para números de objeto mayores que cero, así que un guardado incremental añade solo los contenedores tocados y el objeto página liberado
// Actualización incremental: solo los contenedores tocados y el
// objeto página liberado aterrizan en la sección añadida.
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Los elementos de estructura retenidos cuyo /K perdió un /MCR
  // directo se reescriben in situ, nunca se escriben como null.
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

La misma cautela marca lo que DeletePage deliberadamente no libera en un documento cargado. Los content streams, los XObjects y las anotaciones que no son widget de la página borrada se dejan como objetos, porque un archivo cargado puede compartir cualquiera de ellos con una página que se queda y no hay forma barata de demostrar lo contrario en el momento del borrado. Quitar la referencia del árbol de páginas basta para la corrección; los bytes que esos objetos siguen ocupando son otra cuestión, y el grafo de dependencias de objetos y el análisis de bytes retenidos es la herramienta para medir qué sigue cargando un documento recortado

DeletePage frente a DeleteLoadedPage: ¿a cuál llamas?

Llama a DeletePage para cualquier borrado de página de cara al usuario, y reserva DeleteLoadedPage para el caso en que se está rehaciendo el documento entero y no merece la pena conservar ninguna referencia a nivel de documento. THotPDF.DeleteLoadedPage(PageIndex), añadido en la versión 2.508.0, es la variante ligera: desplaza el array interno de páginas, llama a RebuildLoadedKidsArray para reescribir /Kids y /Count, invalida la caché de páginas renderizadas, y dispara OnLoadedDocumentModified. No recorre el árbol de nombres, ni los outlines, ni el árbol de estructura, ni las anotaciones de otras páginas, y no marca el objeto página como borrado. Esa es la herramienta correcta dentro de la imposición N-up, donde HotPDF añade hojas recién compuestas y luego suelta cada página original con DeleteLoadedPage(0): las páginas de origen se reemplazan en bloque, y el contenido de la hoja se refiere a sus recursos y no a los objetos página. Para el trabajo corriente de «quitar la página 7 de este contrato», DeletePage es la única llamada que deja un documento etiquetado, con bookmarks y con referencias cruzadas lo bastante consistente como para pasar un validador, tanto en una reescritura completa mediante SaveLoadedDocument como en una actualización incremental mediante SaveIncrementalUpdate. Los dos métodos vienen en el HotPDF Delphi Component para Delphi y C++Builder, sin necesidad de ningún runtime de visor externo ni dependencia alguna