Un informe etiquetado al que se le recorta la portada puede abrir con la cantidad correcta de páginas y seguir roto en tres lugares: el marcador "Contents" no hace nada al hacer clic, el verificador de accesibilidad reporta un elemento de estructura que no pertenece a ninguna página, y un validador estricto marca una referencia a un objeto libre. El árbol de páginas está bien. ISO 32000-1 §7.7.3 solo pide que la página salga de /Kids y que /Count baje en uno, y un archivo puede cumplir esa cláusula con medio catálogo todavía apuntando a la página desaparecida
Siete estructuras pueden guardar una referencia a una sola página
Solo una de ellas es el árbol de páginas. Las demás se reparten entre el catálogo, el outline, la estructura lógica y las anotaciones de otras páginas, y todas se resuelven a null en cuanto el objeto de página desaparece del archivo. Lo que un visor haga con ese null depende del visor, así que el mismo archivo recortado puede portarse distinto en dos lectores
- 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
/Destsanterior a la 1.2, directo en el catálogo, guarda arrays del mismo tipo indexados por nombre - Los ítems del outline (§12.3.3) llegan a una página a través de un
/Destinline o de una acción/Acon/S /GoToy un array/D - Los elementos de estructura (§14.7.2) llevan una clave
/Pgque nombra la página donde vive su contenido marcado, y sus hijos/Kpueden ser referencias de contenido marcado y referencias de objeto (§14.7.4.3) atadas a esa página - El
ParentTree(§14.7.4.4) mapea los números/StructParentsde páginas y anotaciones de vuelta a elementos de estructura, y un elemento puede vivir ahí sin aparecer en ninguna parte de la cadena/Kdesde la raíz - Las anotaciones de link en otras páginas (§12.5.6.5) llevan un
/Desto una acción/GoToapuntando a la página, y el/OpenActiondel catálogo puede hacer lo mismo
Lo que THotPDF.DeletePage hace antes de tocar /Kids
THotPDF.DeletePage(PageIndex) sobre un documento cargado barre primero cada una de esas estructuras y reescribe el árbol de páginas al final. El índice es base cero. Dos guardas corren antes de que cambie nada: un índice fuera de rango lanza Invalid page number, y un pedido de eliminar la única página que queda también lanza una excepción, porque un nodo /Pages con cero hijos no describe un documento válido
El orden del barrido es fijo: el árbol /Names /Dests, el diccionario /Dests heredado del catálogo, /OpenAction, el outline, /StructTreeRoot junto con su ParentTree, y al final los arrays /Annots de cada página que queda. Solo después se marca el objeto de página como borrado, se desenganchan sus anotaciones widget del árbol de campos del AcroForm, y se reescriben /Kids, /Count y el /Parent de cada página sobreviviente. Del lado del formulario, un campo padre que se queda sin hijos cuando su widget se va también se elimina, todo el camino hasta /Fields, así que un formulario recortado no conserva ninguna entrada de campo sin nada en pantalla detrás
DeletePages toma un string de rangos base uno como '1,3-5,7-', la misma notación que usan las otras operaciones de página sobre documentos cargados, y elimina desde la página seleccionada más alta hacia abajo. Ir de arriba abajo mantiene válidos los números que usted escribió mientras las páginas desaparecen debajo de ellos. Un rango que cubre todas las páginas lanza una excepción por la misma razón por la que DeletePage rechaza la última, y la función devuelve cuántas páginas eliminó
var
Pdf: THotPDF;
Removed: Integer;
begin
Pdf := THotPDF.Create(nil);
try
// LoadFromFile devuelve la cantidad de páginas que cargó
if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
begin
// Base cero: la página 0 es la portada
Pdf.DeletePage(0);
// Rango base uno, numerado contra el documento tal como está ahora
Removed := Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
Los nombres desaparecen, los marcadores se mueven
Los destinos con nombre que apuntan a la página borrada se eliminan. Los marcadores que apuntan a ella se reapuntan. En el árbol /Names /Dests se visita cada nodo y cada destino se prueba en las dos formas que la especificación permite, el array pelado y el diccionario con clave /D; el par nombre/valor se va cuando el primer elemento del array es la página borrada. Un nodo intermedio que se queda sin /Names ni /Kids se desengancha de su padre, así que el árbol no conserva hojas huecas. El diccionario /Dests heredado del catálogo recibe la misma prueba, clave por clave, y un /OpenAction del catálogo que abría en la página borrada se descarta, lo que deja la vista inicial en el valor por omisión del visor
Un nodo que pierde entradas también pierde su array /Limits. Las claves mínima y máxima nuevas no se recalculan, y los visores resuelven nombres sin ellas. §7.9.6 sí exige /Limits en los nodos intermedios y hoja, así que si un paso de preflight en su flujo de trabajo hace cumplir esa regla de forma estricta, pase el archivo recortado por él como parte del mismo trabajo
El outline se recorre a través de /First y /Next con una lista de visitados y un límite de profundidad de 128, así que una cadena /Next corrupta que se cicla no puede colgar la llamada. Para cada array /Dest o array /D de acción /GoTo apuntando a la página borrada, el primer elemento se reemplaza con la página que le seguía, o con la página anterior cuando la borrada era la última. Los parámetros de vista después de la referencia de página quedan como estaban, así que un salto /XYZ conserva sus valores de izquierda, arriba y zoom, y los aplica a la página nueva
La asimetría es deliberada. Un nombre que no resuelve a nada es legal e inofensivo; un GoTo que nombra un destino inexistente simplemente no hace nada. Un marcador con destino null es peor: sigue sentado en la barra lateral, parece clickeable y falla. Caer en la página sobreviviente más cercana es el mejor de dos resultados imperfectos. El reapuntado cubre solo arrays de destino explícitos. Un ítem del outline cuyo /Dest es un string de nombre resuelve a través del árbol de nombres, y una vez que el barrido eliminó ese nombre el marcador no lleva a ninguna parte, así que los documentos que usan marcadores con nombre intensivamente merecen una verificación después del borrado. La verificación de abajo lee nombres con ResolveLoadedNamedDestination, que mira solo el array /Names del nodo raíz; da una respuesta con sentido para un árbol de nombres plano, mientras que un árbol dividido en /Kids devuelve -1 para todo nombre. La estructura del propio outline, incluida la semántica poco obvia de /Count, está cubierta en la guía para agregar marcadores y destinos con nombre en un PDF cargado
// Verifique el resultado en lugar de asumirlo.
// Memo1 es un TMemo en su propio formulario.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination lee solo el array /Names de la raíz,
// así que esta prueba vale para un árbol de nombres plano; en un árbol
// con /Kids devuelve -1 para todo nombre, borrado o no
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// Un marcador que apuntaba a la portada ahora resuelve a la página
// que le seguía, índice base cero 0 después del borrado
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
[Pdf.GetLoadedBookmarkPageIndex('Contents')]));
Podar el árbol de estructura y el ParentTree
Los elementos de estructura que existían solo por la página borrada se eliminan, y los elementos que abarcan varias páginas pierden su clave /Pg pero conservan sus hijos. El recorrido arranca en /StructTreeRoot, baja por /K hasta una profundidad de 128, y acepta tanto la forma de array como la forma de diccionario único de /K que §14.7.2 permite. Los hijos de cada elemento se podan antes de juzgar al elemento mismo. Si la poda dejó su /K vacío, el elemento se elimina y su padre lo descarta. Si el /Pg del propio elemento nombra la página borrada pero todavía tiene hijos y un padre /P, solo se va /Pg, porque un /Pg en un elemento es apenas la página por omisión para hijos de contenido marcado que no nombran el suyo, y esos hijos bien pueden vivir en otras páginas. Un elemento cuyo /Pg es la página borrada y que no tiene nada debajo se elimina de plano; los diccionarios de referencia de contenido marcado cuyo /Pg nombra la página caen bajo esa última regla
El ParentTree recibe el mismo trato, por una razón fácil de pasar por alto: un elemento puede ser alcanzable desde el ParentTree y desde ningún otro lado. Ese number tree mapea enteros /StructParents a un solo elemento o a un array de elementos. Cada valor se poda con las reglas de arriba, los valores eliminados salen de sus arrays, un par /Nums cuyo array termina vacío se borra, y un nodo sin /Nums ni /Kids se desengancha. Sáltese esta pasada y los huérfanos siguen apuntando a una página liberada a través de /Pg. Para quien extrae texto en orden de etiquetas esto es directo: la extracción de texto en orden de estructura recorre exactamente estos árboles, y un elemento con /Pg en null es un párrafo que se cae del orden de lectura
¿Por qué los links en las páginas sobrevivientes se eliminan en vez de reapuntarse?
Una anotación de link en una página retenida cuyo array /Dest o acción /GoTo apunta a la página borrada se elimina de los /Annots de esa página, y su etiqueta se va con ella. El diccionario OBJR cuyo /Obj nombraba la anotación se saca de su elemento de estructura; si ese OBJR era el único hijo del elemento, el elemento también se elimina, y el ParentTree se poda otra vez. Dejar el OBJR en su lugar rompería §14.7.4.3, que espera que /Obj referencie un objeto real, y un verificador PDF/UA vería un elemento Link cuya referencia de objeto no lleva a ninguna parte
Los marcadores se mueven; los links no. Una referencia cruzada en el cuerpo del texto que dice "ver página 3" está mal una vez que la página 3 no está, y reapuntarla en silencio a la página 4 convierte un link roto en un link engañoso. Un marcador que cae en el capítulo siguiente es navegación; un link del cuerpo es una afirmación sobre el contenido. La eliminación es el valor por omisión más seguro. Si su flujo de trabajo necesita que esos links sobrevivan, reescriba sus destinos antes de llamar a DeletePage, porque después de la llamada ya salieron de los arrays /Annots
Guardados incrementales y la trampa del hijo directo
DeletePage se comporta igual entre BeginIncrementalUpdate y SaveIncrementalUpdate. La sección agregada contiene los objetos que el barrido cambió más entradas libres para los objetos eliminados que tenían número de objeto propio, como la página y las anotaciones de link borradas. Todo lo que está antes del %%EOF original queda byte a byte, como exige §7.5.6
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// Los elementos de estructura que perdieron un hijo /MCR directo
// se agregan reescritos con un array /K más corto
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
Este es el punto donde un podador escrito a mano se equivoca. Las referencias de contenido marcado (/Type /MCR) y las referencias de objeto (/Type /OBJR) suelen ser diccionarios directos sentados dentro del array /K de su elemento padre. Un objeto directo no tiene número de objeto ni entrada en el xref, así que no puede liberarse por sí solo. La jugada obvia es "liberar" de todos modos cada hijo eliminado, y cualquier escritor que mapee un objeto directo al objeto indirecto que lo contiene terminará liberando al contenedor, que es el elemento de estructura retenido. Ese elemento sale como null, y el contenido etiquetado de páginas que nunca se tocaron pierde sus etiquetas. HotPDF manda un objeto eliminado a la lista de libres solo cuando tiene número de objeto; para un hijo directo marca el elemento contenedor como cambiado, así el elemento se reescribe con el hijo fuera y la lista de libres queda limpia
La misma precaución decide lo que DeletePage deja tranquilo en un documento cargado. Los flujos de contenido, los XObjects y las anotaciones que no son widget de la página borrada se quedan como objetos, porque un archivo cargado puede compartir cualquiera de ellos con una página que permanece y no hay forma barata de probar lo contrario en el momento del borrado. Sacar la referencia del árbol de páginas alcanza para un archivo correcto. Cuántos bytes cuestan todavía esos restos es una medición aparte, y el grafo de dependencias de objetos y el análisis de bytes retenidos es la herramienta para eso
¿DeletePage o DeleteLoadedPage?
Use DeletePage para cualquier borrado de página que un usuario pidió. THotPDF.DeleteLoadedPage(PageIndex) es la variante liviana: aplica las mismas dos guardas, desplaza la lista de páginas cargadas, reescribe /Kids y /Count, invalida la caché de páginas renderizadas y dispara OnLoadedDocumentModified. No toca el árbol de nombres, el outline, el árbol de estructura ni las anotaciones de otras páginas, y no marca el objeto de página como borrado. ComposeLoadedPagesNUp es el trabajo donde encaja: agrega hojas recién compuestas que dibujan las páginas fuente como form XObjects, y después descarta cada original con DeleteLoadedPage(0), porque las hojas se refieren al contenido de las páginas y no a los objetos de página. Para un "sáqueme la página 7 de este contrato" sobre un archivo etiquetado, con marcadores y con links cruzados, DeletePage es la llamada que deja toda referencia consistente, tanto si guarda con SaveLoadedDocument como si agrega con SaveIncrementalUpdate
Ambos métodos, el DeletePages basado en rangos y la ruta de actualización incremental usada arriba son parte del HotPDF Delphi Component, que edita PDFs cargados desde código Delphi y C++Builder sin un runtime de visor en la máquina