Artículo técnico

Reemplazar páginas PDF en Delphi sin romper marcadores

Reemplazar la página 3 de un contrato ya firmado no debería mover la tabla de contenidos. Elimina la página vieja, inserta la nueva, y todo marcador que antes apuntaba ahí ahora cae en otro lugar. La librería PDFlibPas Delphi PDF evita esto conservando el propio objeto de página destino y transfiriendo solo las entradas que llevan el contenido visual

Por qué se rompen los marcadores al reemplazar una página de PDF

Los marcadores se rompen porque un destino de PDF nombra una página mediante una referencia indirecta a un objeto, no mediante un número de página. ISO 32000-1 §12.3.2.2 define un destino explícito como un arreglo cuyo primer elemento es una referencia indirecta al objeto de página. Elimina ese objeto y anexa 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 después de un reemplazo por eliminación e inserción. El árbol de páginas se ve perfecto, la cantidad de páginas es correcta, el renderizado es correcto, y toda la capa de navegación está silenciosamente rota

Los destinos con nombre tampoco te salvan. §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 arreglo 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 mismo arreglo, cualquier 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 pie de igualdad exacta. Un intercambio de página ingenuo desconecta cuatro subsistemas a la vez, y si quieres verlos enumerados sobre un archivo real, el mismo grafo de objetos es el que recorre la introspección de esquemas, anotaciones y acciones

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 lugar tiene éxito precisamente cuando se separan. El lado de la 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 archivo apunta a ellas por nombre

El lado de la identidad es aquello a lo que el resto del documento se ha vinculado: el número de objeto y la generación de la página, el enlace /Parent hacia atrás en el árbol de páginas, y /Annots. PDFlibPas conserva cada uno de ellos intacto. ReplacePageRanges purga las once entradas visuales del diccionario de la página destino y las vuelve a agregar desde la página de origen importada, de modo que el objeto de página destino se muta en el lugar en lugar de reemplazarse. La estructura del árbol de páginas exigida por §7.7.3 también permanece con forma idéntica byte a byte: el orden de /Kids, el /Count, y cada /Parent que sobrevive son los mismos antes y después, porque ningún nodo llegó a desvincularse

Cómo reemplaza PDFlibPas una página sin renumerar objetos

La llamada recibe un documento origen, una página inicial destino con base 1, una expresión de rango de origen, y un indicador de opciones. Ambos documentos deben estar abiertos en la misma instancia, y el documento destino es el que está seleccionado. Como la cantidad de páginas del destino nunca cambia, el rango que se solicita tiene que caber dentro del documento a partir de TargetStartPage, y eso se comprueba antes de que se cree nada

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 del documento, porque cada referencia indirecta dentro de ellas pertenece a la numeración de objetos del origen. Así que el rango de origen primero se importa de la manera habitual, como páginas temporales anexadas después de la última página real, lo que ejecuta el remapeo completo del grafo de objetos: los flujos de contenido, las fuentes, los XObjects, los shadings y los espacios de color se renumeran todos hacia el documento destino. Solo entonces se copian las once entradas visuales de cada página temporal hacia su página destino, y solo entonces se desvinculan 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áginas de la librería hace más que desvincular un nodo: combina las capas de cada página que se elimina, vacía el primer flujo de contenido, y recupera recursos que ninguna otra página comparte. Eso es un 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 hacen referencia exactamente a esos flujos de contenido y objetos de recursos. 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 solución es un modo de preservación de objetos referenciados en la ruta interna de eliminación. Cuando está activo, la eliminación omite tanto el barrido de recursos no compartidos como el vaciado de flujos de contenido, y no hace nada excepto separar las páginas del árbol de páginas y corregir la contabilidad del árbol. Los objetos transferidos sobreviven con un nuevo propietario, y la propiedad de objetos después de la operación es lo que dibujarías en una pizarra: un flujo 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 indicador 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 razonable cuando quien invoca pasa algo como '4-6,2' y simplemente quiere decir esas cuatro páginas. 1 conserva el orden tal como se escribió 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 la cantidad de páginas del origen, el propio valor de la opción, y la capacidad del destino se comprueban todos antes de que se cree un solo objeto. Una llamada rechazada establece LastErrorCode en 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 hasta la propia transferencia. Antes de que se importe la primera página de origen, las once entradas visuales de cada página destino dentro del rango se capturan como valores codificados. Si la importación falla, o la cantidad 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 igual deja las visuales originales en su lugar, sobre sus objetos originales. Eso importa más de lo que suena: un rango de páginas reemplazado a medias en un contrato es peor que una llamada fallida, porque nada en el archivo lo marca como hecho a medias

// 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é sigue sin hacer por ti el reemplazo en el lugar

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 que porta contenido marcado sin la propiedad de su árbol de estructura, produce un objeto interactivo importado a medias que ningún visor puede interpretar, así que la operación transfiere solo la apariencia. La consecuencia práctica es que si la página de reemplazo debe llevar campos de formulario o enlaces nuevos, los agregas después a la página destino, sobre el objeto de página destino que sigue ahí esperándolos

Vale la pena comprobar dos límites más en tus propios archivos. Primero, /Annots se preserva pero la geometría de la página no, así que reemplazar una página de 220 mm con una de 320 mm deja los rectángulos de las anotaciones en sus coordenadas antiguas 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 permanecen con la página destino por diseño, lo cual es correcto para /Trans o /AA y queda desactualizado para /Thumb, así que regenera las miniaturas después de un reemplazo. Los documentos etiquetados requieren una consideración adicional: los elementos de estructura siguen apuntando al objeto de página correcto mediante /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 tanto una edición del árbol de estructura como una edición de contenido. Si tu tarea en realidad es composición en lugar de intercambio, superponer artes sobre páginas que conservas, el enfoque de unión de páginas y plantillas es la herramienta más económica

Todo lo descrito aquí, incluida la sintaxis de la expresión de rango, los valores de las opciones y la API circundante de manipulación de páginas, 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