Artículo técnico

Copia de objetos PDF entre documentos en Delphi: ciclos

Fusiona dos PDF a mano, mueve un único objeto de página al documento destino, y la copia choca de frente con una violación de acceso. PDFlibPas lo resuelve en CopyForeignObject: copia en profundidad un objeto indirecto más todo su cierre de referencias y resuelve las referencias cíclicas hacia atrás como /Parent a null en lugar de recursar

¿Por qué copiar una página entre documentos provoca un cuelgue?

Porque un árbol de páginas PDF solo es un árbol si lo lees hacia abajo. Recórrelo como lo hace un copiador recursivo, siguiendo cada valor de cada diccionario, y el diccionario de página te entrega /Parent, que apunta de vuelta al nodo /Pages del que llegaste, y ese nodo te entrega /Kids, que apunta de vuelta a la página. ISO 32000-1 §7.7.3 exige /Parent en cada nodo del árbol de páginas salvo la raíz, así que no se trata de un archivo malformado que puedas rechazar: es la forma normal de cualquier documento que te pongan delante

La segunda mitad del problema es la numeración. Los objetos indirectos se identifican por un número de objeto local a un archivo (ISO 32000-1 §7.3.10), así que un objeto arrastrado del documento A al documento B hay que renumerarlo, y cada referencia a él dentro del cierre copiado hay que renumerarla de la misma manera, o dos referencias que antes apuntaban a una fuente compartida acaban apuntando a dos cosas sin relación. Esa renumeración es el mismo trabajo que hace una fusión rápida a nivel de bytes, y merece la pena leer ambos en paralelo: el desplazamiento de referencias a nivel de byte para fusionar PDF rápidamente lo resuelve traduciendo archivos enteros, mientras que una copia a nivel de objeto tiene que resolverlo una arista cada vez

Por qué una copia PDF entre documentos en Delphi exige cuidado: el diccionario de página y su nodo /Pages cierran un ciclo a través de /Parent y /Kids, el cierre de una fuente desciende y termina, y PDFlibPas reasigna cada número de objeto local al archivo
El árbol de páginas cierra un bucle a través de /Parent y /Kids mientras los cierres de contenido terminan, y cada número de objeto copiado debe reasignarse en el camino

Qué copia realmente CopyForeignObject de PDFlibPas

TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) clona un objeto indirecto y todo lo alcanzable desde él — diccionarios anidados, arrays, cadenas, nombres, números y flujos con sus diccionarios intactos — en el documento seleccionado en ese momento, y devuelve un handle distinto de cero a la nueva referencia indirecta. Los números de objeto de origen se reasignan mediante un mapa vivo que se mantiene durante la llamada, de modo que un objeto alcanzado dos veces en el cierre se clona una vez y se comparte dos. Devuelve cero, sin lanzar excepciones, cuando el ID del documento de origen es desconocido, cuando el origen es el propio documento seleccionado, o cuando ObjectNumber es menor que 1

var
  Lib: TPDFlib;
  SourceDoc, TargetDoc, Handle: Integer;
begin
  Lib := TPDFlib.Create;
  try
    TargetDoc := Lib.NewDocument;
    if Lib.LoadFromFile('source.pdf', '') <> 1 then
      Exit;                              // LoadFromFile devuelve 1 si tiene éxito
    SourceDoc := Lib.SelectedDocument;   // la carga seleccionó lo que cargó
    Lib.SelectDocument(TargetDoc);       // la copia apunta al documento seleccionado
    Handle := Lib.CopyForeignObject(SourceDoc, 12);
    if Handle = 0 then
      raise Exception.Create('cross-document copy rejected');
  finally
    Lib.Free;
  end;
end;

Dos detalles muerden a todo el mundo en la primera ejecución. LoadFromFile responde 1 o 0, no un ID de documento, así que el handle que necesitas sale de SelectedDocument justo después de la carga; y la copia siempre escribe en lo último que SelectDocument dejó como actual, nunca en el documento desde el que cargaste. Internamente la recursión lleva además un límite duro de profundidad de 64, que es un cinturón de seguridad contra anidamientos patológicos, no el mecanismo que gestiona los ciclos: el tratamiento de ciclos es distinto y deliberado

¿Por qué reservar una entrada Nil no rompe el ciclo?

Porque Nil en la tabla de mapeo significa dos cosas distintas a la vez, y el código no puede distinguirlas. La defensa obvia contra un ciclo es añadir la entrada al mapa antes de recursar en el objeto, de modo que cualquier vuelta hacia atrás encuentre la entrada y se detenga. Pero la entrada no puede contener todavía el destino real — el destino no existe hasta que el cierre por debajo se ha escrito — así que contiene Nil, y la búsqueda que debería detectar la arista de retorno lee Nil y concluye que el objeto nunca fue mapeado

// Roto: un destino Nil reservado es indistinguible de "aún sin mapear"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // reservado, sigue siendo Nil
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // solo se rellena al salir
end;

Sigue ese flujo por el bucle de páginas. El clon de la página alcanza /Parent, recursa en el nodo /Pages, que alcanza /Kids, que recursa de vuelta en la página — cuya entrada reservada sigue leyendo Nil, así que se clona una segunda vez, y una tercera, y cada nivel apila un marco nuevo y un objeto nuevo a medio construir. Lo que observas tampoco es un desbordamiento de pila limpio: los marcos exteriores están posados sobre referencias cuyos destinos nunca se asignaron, así que la primera escritura a través de uno de esos huecos es una violación de acceso en un sitio que no se parece en nada a la copia de página que la provocó

Por qué reservar un destino Nil en el mapa no detiene el ciclo en una copia PDF entre documentos de PDFlibPas: la búsqueda no distingue una entrada reservada de una sin mapear, así que el copiador desciende por marcos a medio construir cada vez más profundos hasta que una escritura revienta
Como un destino Nil responde a dos preguntas distintas a la vez, la arista de retorno nunca se reconoce y la página se clona otra vez en cada pasada

La solución: un estado in-progress explícito

La reparación consiste en dejar de sobrecargar Nil y hacer la pregunta directamente. Una entrada del mapa cuyo destino sigue sin asignar significa este objeto se está clonando ahora mismo, y un predicado InProgress comprueba exactamente eso antes de que corra la búsqueda ordinaria. Cuando es verdadero, la arista es un ciclo de vuelta hacia un ancestro del clon actual, y PDFlibPas emite para ella un objeto null en lugar de seguirla

// Una entrada del mapa con destino Nil marca un clon en curso
function InProgress(Num: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to High(Map) do
    if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
      Exit(True);
end;

// ... dentro de CloneObject, para una referencia indirecta:
if InProgress(SrcRef.ObjNum) then
  Exit(FStructure.NewNull);              // arista de retorno cíclica, no recursar
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
  SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
  if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
    Exit(FStructure.NewNull);            // referencia de origen colgante
  SetLength(Map, Length(Map) + 1);
  Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
  Map[High(Map)].Target := nil;          // reservar, luego recursar
  NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
  Map[High(Map)].Target := NewRef;       // rellenar
end;
Exit(NewRef);

Esto solo es seguro de generalizar por un hecho estructural del PDF: los ciclos del grafo de objetos aparecen en los enlaces de retorno, no en las aristas de contenido. /Parent en el árbol de páginas y /Prev en una cadena de esquema apuntan hacia arriba o hacia atrás a algo ya visitado; el cierre de una fuente, de un XObject de imagen o de un XObject de formulario desciende y termina. Así, la copia de un descriptor de fuente, de un espacio de color o de un diccionario de sombreado no se ve afectada por la sustitución por null: nada en esos cierres llega a toparse con InProgress. El coste, dicho sin rodeos, es que la arista cíclica no sobrevive a la copia. Un diccionario de página clonado así llega con /Parent como objeto null, que ISO 32000-1 §7.3.9 equipara a una entrada ausente, de modo que la página copiada es un objeto válido que no pertenece a ningún árbol de páginas hasta que la vincules tú mismo al nodo /Pages destino y corrijas /Count. Un elemento de esquema copiado pierde su /Prev de la misma manera y necesita que se reconstruya la cadena de hermanos. Ese es el trueque honesto: CopyForeignObject te da un cierre correcto y deja el re-enganche estructural a quien llama, que es el mismo perímetro dentro del que trabaja reemplazar páginas conservando los números de objeto

La solución en CopyForeignObject de PDFlibPas para Delphi: una prueba InProgress explícita corre antes de la búsqueda en el mapa, una arista de retorno cíclica se convierte en un objeto null, y quien llama reincorpora después la página copiada al árbol de páginas destino
Un estado in-progress explícito sustituye al Nil sobrecargado, así que la arista de retorno se resuelve a null y a quien llama le queda una única reparación estructural por hacer

Por qué la entrada del mapa debe reservarse antes de NewObjRef

Una alternativa obvia esquivaría toda la danza del in-progress: asignar primero un objeto cascarón vacío, registrar su número real en el mapa y rellenar el cascarón cuando los hijos estén clonados. Aquí eso no funciona, porque TPDFIndObj.Obj es de solo lectura y su contenido no puede reemplazarse tras la construcción: no hay cascarón que rellenar. El número y el contenido los decide juntos NewObjRef, lo que significa que la entrada del mapa debe crearse antes de la llamada recursiva y completarse después, y el intervalo entre esos dos momentos es precisamente lo que InProgress tiene que cubrir. Una consecuencia que conviene conocer antes de comparar salidas: como NewObjRef corre después de que el cierre hijo se haya escrito, la numeración en el destino sale de abajo hacia arriba, y los números de objeto no reflejarán el orden del origen. Nada en el formato de archivo le importa, pero una comparación de bytes contra una expectativa construida a mano sí. Si una ejecución deja objetos que decidiste no vincular a nada, quedan sin referenciar y no corruptos, y la recolección mark-and-sweep de objetos PDF inalcanzables es la herramienta que los limpia antes de guardar

La regresión que cubre esto necesita un detalle que sorprende a quienes escriben pruebas contra TPDFlib: el constructor ya contiene un documento por defecto, así que DocumentCount empieza en 1 y un fixture de dos documentos debe afirmar >= 2, no = 2. Junto a la copia con éxito, la prueba fija los tres rechazos — un ID de origen desconocido, el documento seleccionado como su propio origen y un número de objeto cero — todos devolviendo 0 en lugar de lanzar excepciones, porque un bucle de fusión es un mal lugar para descubrir que una cláusula de guarda lanza

Dónde encaja esto en una canalización de fusión

La copia a nivel de objeto es la primitiva a la que acudes cuando la fusión de archivos completos es demasiado basta: extraer un programa de fuente de una plantilla, traer un único XObject de formulario a un documento de sellado, o mover una anotación con sus flujos de apariencia entre archivos sin arrastrar el resto de la página. PDFlibPas lo expone como una única llamada sobre documentos cargados, y puedes ver cómo encaja con el resto de la API de objetos de bajo nivel en la referencia de PDFlibPas Delphi PDF Library