Reemplazar la página 3 de un contrato ya firmado no debería mover el índice. Elimina la página vieja, inserta la nueva, y cada marcador que solía apuntar allí ahora aterriza en otro sitio. La librería PDFlibPas Delphi PDF evita esto conservando el propio objeto de página destino y transfiriendo solo las entradas que llevan contenido visual
¿Por qué se rompen los marcadores tras reemplazar una página PDF?
Los marcadores se rompen porque un destino de PDF nombra una página mediante una referencia de objeto indirecta, no mediante un número de página. ISO 32000-1 §12.3.2.2 define un destino explícito como un array cuyo primer elemento es una referencia indirecta al objeto de página. Elimina ese objeto y añade un reemplazo, y la referencia queda colgando: la mayoría de los visores responden dejando al lector en la página 1, que es exactamente el síntoma que la gente reporta tras un reemplazo de eliminar-y-luego-insertar. El árbol de páginas parece perfecto, el recuento de páginas es correcto, el renderizado es correcto, y toda la capa de navegación está silenciosamente rota
Los destinos con nombre tampoco te rescatan. §12.3.2.3 dirige un nombre a través del árbol de nombres /Dests en el catálogo del documento, pero la hoja a la que ese nombre resuelve sigue siendo un array de destino explícito que contiene la misma referencia de página. Nombrar añade una capa de indirección por encima de la referencia de página, no alrededor de ella. El mismo razonamiento cubre el resto de la capa interactiva descrita en §12.5: una anotación de enlace lleva un /Dest o una acción GoTo /A cuyo /D es ese array, cada anotación puede llevar una entrada /P que es una referencia indirecta a su página, y un widget de campo de formulario es una anotación en exactamente el mismo pie de igualdad. Un intercambio de página ingenuo desconecta cuatro subsistemas a la vez, y si quieres verlos enumerados en un fichero real, el mismo grafo de objetos es lo que recorre la introspección de esquema y anotaciones
Qué entradas de página llevan identidad y cuáles llevan apariencia
Un diccionario de página mezcla dos tipos de entradas, y un reemplazo en el sitio tiene éxito precisamente cuando las separas. El lado de apariencia es finito y enumerable: /Contents, /Resources, las cinco cajas de página /MediaBox, /CropBox, /BleedBox, /TrimBox y /ArtBox, más /Rotate, /Group, /UserUnit y /BoxColorInfo. Esas once entradas deciden todo lo que un rasterizador produce para la página, y nada más en el fichero apunta a ellas por nombre
El lado de identidad es aquello a lo que el resto del documento se ha vinculado: el número de objeto y generación de la página, el enlace de retorno /Parent al árbol de páginas, y /Annots. PDFlibPas mantiene cada uno de ellos intacto. ReplacePageRanges purga las once entradas visuales del diccionario de página destino y las vuelve a añadir desde la página de origen importada, así que el objeto de página destino se muta en su sitio en lugar de reemplazarse. La estructura del árbol de páginas exigida por §7.7.3 también permanece idéntica byte a byte en su forma: el orden de /Kids, /Count, y cada /Parent superviviente son los mismos antes y después, porque ningún nodo se desenlazó jamás
¿Cómo reemplaza PDFlibPas una página sin renumerar objetos?
La llamada recibe un documento origen, una página de inicio destino indexada desde 1, una expresión de rango de origen, y un flag de opciones. Ambos documentos deben estar abiertos en la misma instancia, y el documento destino es el seleccionado. Como el recuento de páginas destino nunca cambia, el rango que solicitas tiene que encajar dentro del documento empezando en TargetStartPage, y eso se comprueba antes de que se cree un solo objeto
var
Lib: TPDFlib;
TargetDoc, SourceDoc: Integer;
begin
Lib := TPDFlib.Create;
try
// The document whose bookmarks and links must survive
if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
Exit;
TargetDoc := Lib.SelectedDocument;
// The revised clause page, rendered by whatever produced it
if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
Exit;
SourceDoc := Lib.SelectedDocument;
Lib.SelectDocument(TargetDoc);
// Source page 1 overwrites the visuals of target page 3.
// Page count, page 3 object number, bookmarks and annotations are kept.
if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
Lib.SaveToFile('contract-final.pdf');
finally
Lib.Free;
end;
end;
Internamente las páginas de origen no pueden simplemente leerse a través de los límites de documento, porque cada referencia indirecta dentro de ellas pertenece a la numeración de objetos de origen. Así que el rango de origen se importa primero de la forma ordinaria, como páginas temporales añadidas tras la última página real, lo cual ejecuta el remapeo completo del grafo de objetos: streams de contenido, fuentes, XObjects, shadings y espacios de color se renumeran todos hacia el documento destino. Solo entonces se copian las once entradas visuales desde cada página temporal hacia su página destino, y solo entonces se desenlazan las páginas temporales del árbol de páginas. El trabajo de remapeo ocurre donde es barato y seguro, y la edición destructiva se reduce a un intercambio a nivel de diccionario sobre páginas que ya existen
La ruta de eliminación que destruiría lo que acabas de transferir
Eliminar esas páginas temporales es el paso que parece trivial y no lo es. La ruta ordinaria de eliminación de página de la librería hace más que desenlazar un nodo: combina las capas de cada página que se elimina, vacía el primer stream de contenido, y recupera recursos que ninguna otra página comparte. Ese es el comportamiento correcto para una eliminación real, y catastrófico aquí, porque para cuando se eliminan las páginas temporales las páginas destino ya referencian exactamente esos streams de contenido y esos objetos de recurso. Vaciarlos dejaría en blanco la página que acabas de reemplazar, y el barrido de recursos recolectaría fuentes e imágenes que ahora tienen un propietario vivo
La corrección es un modo de preservar-objetos-referenciados en la ruta interna de eliminación. Cuando está activado, la eliminación se salta tanto el barrido de recursos no compartidos como el vaciado de streams de contenido, y no hace nada excepto desenganchar las páginas del árbol de páginas y arreglar la contabilidad del árbol. Los objetos transferidos sobreviven con un nuevo propietario, y la propiedad de objetos tras la operación es lo que dibujarías en una pizarra: un stream de contenido, una página propietaria, un número de objeto que nunca se movió. Las reglas de ciclo de vida relacionadas para crear, eliminar y reordenar páginas se cubren por separado en las notas sobre operaciones de ciclo de vida de documento y página
Orden, duplicados, y fallo de todo-o-nada
El flag de opciones selecciona cómo se interpreta el rango de origen. 0 ordena los números de página analizados y elimina duplicados, que es el valor por defecto sensato cuando el llamante pasa algo como '4-6,2' y simplemente significa esas cuatro páginas. 1 preserva el orden que escribiste y permite que una página se repita, así que '2,1,2' significa genuinamente tres reemplazos tomados de dos páginas de origen. La validación se ejecuta primero y se ejecuta por completo: la sintaxis del rango, cada número de página contra el recuento de páginas de origen, el propio valor de opción, y la capacidad destino se comprueban todos antes de que se cree un solo objeto. Una llamada rechazada pone LastErrorCode a 412, restaura la página previamente seleccionada, y deja el documento exactamente como estaba
var
Replaced: Integer;
begin
Lib.SelectDocument(TargetDoc);
// Options = 1: source order is preserved and repeats are allowed, so
// target pages 5, 6 and 7 receive source pages 2, 1 and 2 respectively
Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
if Replaced = 0 then
raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
[Lib.LastErrorCode]);
// On success the selection is the first replaced page
Assert(Lib.SelectedPage = 5);
end;
La atomicidad se extiende más allá de la validación hacia la propia transferencia. Antes de importar la primera página de origen, las once entradas visuales de cada página destino en el rango se capturan como valores codificados. Si la importación falla, o el recuento de páginas importadas no coincide con lo solicitado, las capturas se decodifican de vuelta sobre las páginas destino y las páginas temporales se eliminan, así que un fallo a mitad de camino sigue dejando las visuales originales en su sitio sobre sus objetos originales. Eso importa más de lo que suena: un rango de páginas medio reemplazado en un contrato es peor que una llamada fallida, porque nada en el fichero lo marca como a medio hacer
// Post-conditions worth asserting in a regression test
Lib.SelectPage(3);
// Geometry now comes from the source page
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Annotations that were already on target page 3 are still attached
WriteLn(Lib.AnnotationCount);
// The bookmark created before the replacement still resolves to page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// And the document is still the same length
WriteLn(Lib.PageCount);
¿Qué es lo que el reemplazo en el sitio todavía no hace por ti?
Las anotaciones de origen, los campos de formulario de origen y los esquemas de origen deliberadamente no se importan. Traer un widget sin su entrada de campo /AcroForm, o una anotación portadora de contenido marcado sin su propiedad del árbol de estructura, produce un objeto interactivo medio importado sobre el que ningún visor puede razonar, así que la operación transfiere solo apariencia. La consecuencia práctica es que si la página de reemplazo se supone que lleva nuevos campos de formulario o nuevos enlaces, los añades a la página destino después, contra el objeto de página destino que sigue ahí esperándolos
Merece la pena comprobar dos límites más en tus propios ficheros. Primero, /Annots se preserva pero la geometría de página no, así que reemplazar una página de 220 mm con una de 320 mm deja los rectángulos de anotación en sus antiguas coordenadas dentro de un /MediaBox de tamaño distinto; si la geometría cambia, reposiciona las anotaciones que conservaste. Segundo, las entradas fuera de las once claves visuales se quedan con la página destino por diseño, lo cual es correcto para /Trans o /AA y obsoleto para /Thumb, así que regenera las miniaturas después de un reemplazo. Los documentos etiquetados necesitan una consideración extra: los elementos de estructura siguen apuntando al objeto de página correcto a través de /Pg, pero sus identificadores de contenido marcado describen contenido que ya no está ahí, así que un intercambio de página dentro de un flujo de trabajo PDF/UA es también una edición del árbol de estructura además de una edición de contenido. Si tu trabajo es realmente composición en lugar de intercambio, superponer arte sobre páginas que conservas, el enfoque de cosido de páginas y plantillas es la herramienta más barata
Todo lo descrito aquí, incluida la sintaxis de expresión de rango, los valores de opción y la API de manipulación de páginas circundante, se incluye en la PDFlibPas Delphi PDF Library estándar para Delphi y C++Builder, cuya documentación de referencia incluye la entrada completa para la llamada de reemplazo de páginas y sus códigos de error