Al fusionar dos PDF a mano y mover un solo objeto de página al documento destino, la copia termina de inmediato en una violación de acceso. PDFlibPas resuelve esto en CopyForeignObject: copia en profundidad un objeto indirecto junto con todo su cierre de referencias y resuelve las referencias cíclicas como /Parent a null en lugar de entrar en recursión
¿Por qué falla al copiar una página entre documentos?
Porque un árbol de páginas PDF solo es un árbol si se lee hacia abajo. Si se recorre como lo hace un copiador recursivo, siguiendo cada valor de cada diccionario, el diccionario de página entrega /Parent, que apunta de vuelta al nodo /Pages por donde se llegó, y ese nodo 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 excepto la raíz, así que no se trata de un archivo malformado que se pueda rechazar — es la forma normal de todos los documentos que van a recibir
La otra mitad del problema es la numeración. Los objetos indirectos se identifican por un número de objeto local a un solo archivo (ISO 32000-1 §7.3.10), de modo que un objeto arrastrado del documento A al documento B debe renumerarse, y cada referencia a él dentro del cierre copiado debe renumerarse de la misma forma; si no, dos referencias que apuntaban a una misma fuente compartida terminarían 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 vale la pena leer ambos enfoques lado a lado: el desplazamiento de referencias a nivel de bytes para fusionar PDF rápido lo resuelve traduciendo archivos completos, mientras que una copia a nivel de objetos debe resolverlo una arista a la vez
Qué copia realmente PDFlibPas CopyForeignObject
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) clona un objeto indirecto y todo lo alcanzable desde él — diccionarios anidados, arrays, cadenas, nombres, números y streams con sus diccionarios intactos — dentro del documento seleccionado actualmente, y devuelve un handle distinto de cero para la nueva referencia indirecta. Los números de objeto de origen se remapean mediante un mapa vivo que dura lo que dura la llamada, de modo que un objeto alcanzado dos veces en el cierre se clona una vez y se comparte dos veces. 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 va 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 en la primera ejecución. LoadFromFile responde 1 o 0, no un ID de documento, así que el handle que se necesita sale de SelectedDocument justo después de la carga; y la copia siempre escribe en el documento que SelectDocument dejó como actual por última vez, nunca en el documento desde el que se cargó. Internamente la recursión también lleva un tope de profundidad fijo de 64, que es un respaldo frente a anidamientos patológicos, no el mecanismo que maneja los ciclos — el manejo de ciclos es independiente 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 agregar la entrada al mapa antes de descender recursivamente al objeto, de modo que cualquier retorno al mismo encuentre la entrada y se detenga. Pero la entrada aún no puede guardar el objetivo real — el objetivo no existe hasta que el cierre inferior se haya escrito — así que guarda 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 objetivo 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 en Nil
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // solo se rellena al salir
end;
Siga eso por el bucle de la página. El clon de la página llega a /Parent, desciende al nodo /Pages, que llega a /Kids, que desciende de vuelta a la página — cuya entrada reservada sigue leyendo Nil, así que se clona una segunda vez, y una tercera, con cada nivel empujando un marco nuevo y un objeto a medio construir nuevo. Lo que se observa tampoco es un desbordamiento de pila limpio: los marcos exteriores quedan apoyados en referencias cuyos objetivos nunca fueron asignados, así que la primera escritura a través de uno de esos espacios es una violación de acceso en un punto que no se parece en nada a la copia de página que la causó
La corrección: un estado de copia en curso explícito
La reparación consiste en dejar de sobrecargar Nil y hacer la pregunta directamente. Una entrada del mapa cuyo objetivo sigue sin asignar significa este objeto se está clonando ahora mismo, y un predicado InProgress prueba exactamente eso antes de que corra la búsqueda ordinaria. Cuando es verdadero, la arista es un ciclo de retorno hacia un ancestro del clon actual, y PDFlibPas emite un objeto null para ella en lugar de seguirla
// Una entrada del mapa con objetivo 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 cíclica de retorno, no descender
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 y luego descender
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 en el 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, un XObject de imagen o un XObject de formulario desciende y termina. Así, la copia de un descriptor de fuente, un espacio de color o un diccionario de sombreado no se ve afectada por la sustitución por null — nada en esos cierres llega a tocar InProgress. El costo, dicho sin rodeos, es que la arista cíclica no sobrevive a la copia. Un diccionario de página clonado de esta forma llega con /Parent como objeto null, que ISO 32000-1 §7.3.9 equipara a una entrada ausente, así que la página copiada es un objeto válido que no pertenece a ningún árbol de páginas hasta que ustedes mismos la enlacen al nodo /Pages destino y corrijan /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 intercambio honesto: CopyForeignObject entrega un cierre correcto y deja el reenganche estructural al llamador, que es la misma frontera dentro de la que trabaja reemplazar páginas conservando los números de objeto
Por qué la entrada del mapa debe reservarse antes de NewObjRef
Una alternativa obvia evitaría todo este baile de copia en curso: asignar primero un objeto vacío de armazón, registrar su número real en el mapa y luego rellenar el armazón cuando los hijos estén clonados. Eso no funciona aquí, porque TPDFIndObj.Obj es de solo lectura y su contenido no puede reemplazarse después de la construcción — no hay armazó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 debe cubrir. Una consecuencia que conviene saber antes de comparar la salida: como NewObjRef corre después de que se escribe el cierre de los hijos, la numeración en el destino sale de abajo hacia arriba, y los números de objeto no reflejarán el orden del origen. Al formato de archivo no le importa nada de esto, pero a una comparación byte a byte contra una expectativa construida a mano sí. Si una ejecución deja objetos que ustedes decidieron no enlazar a nada, quedan sin referencias 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 quien escribe pruebas contra TPDFlib: el constructor ya contiene un documento por defecto, así que DocumentCount arranca en 1 y un fixture de dos documentos debe verificar >= 2, no = 2. Junto con la copia exitosa, 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 excepciones
Dónde encaja esto en un pipeline de fusión
La copia a nivel de objetos es la primitiva a la que se recurre cuando la fusión de archivos completos es demasiado gruesa: extraer una fuente de una plantilla, traer un solo XObject de formulario a un documento de estampado o mover una anotación con sus streams de apariencia entre archivos sin arrastrar el resto de la página. PDFlibPas la expone como una sola llamada sobre documentos cargados, y pueden ver cómo encaja con el resto de la API de objetos de bajo nivel en la referencia de la PDFlibPas Delphi PDF Library