Artículo técnico

Injertar campos AcroForm entre PDF en Delphi con PDFiumPas

Trasladar un bloque de campos de formulario de la plantilla del año pasado al diseño de este año es donde los viajes de ida y vuelta con FDF y XFDF dejan de bastar: los valores llegan, pero las secuencias de apariencia, las acciones de cálculo y los recursos por defecto no. PDFiumPas responde a ese caso con GraftPdfAcroForm, que clona el grafo completo de objetos del campo de un PDF y lo escribe en otro

La razón por la que una exportación a nivel de datos no puede hacer esto es estructural. Un campo no es un registro, es un subgrafo. ISO 32000-1 §12.7 define el diccionario de formulario interactivo que contiene /Fields, /CO, /DR y /DA, §12.7.3 define los diccionarios de campo que cuelgan de él, y §12.5.6.19 define las anotaciones de widget que dan a esos campos una caja visible en una página. XFDF transporta las hojas de esa estructura. El injerto transporta la estructura en sí

Por qué copiar el array /Fields nunca es suficiente

Copiar /Fields de un documento a otro produce un formulario roto de todas las maneras interesantes, porque el array contiene referencias indirectas y nada más. ISO 32000-1 §7.3.10 hace direccionable un objeto indirecto por número de objeto más generación, y esos números solo tienen significado dentro del archivo del que provienen. Pega el array al otro lado y cada referencia de él o queda colgante o, peor, resuelve silenciosamente a un objeto sin relación que casualmente ocupa ese hueco en el destino. Debajo de cada referencia hay un grafo a la vez compartido y cíclico. Un diccionario de campo apunta a sus hijos, cada hijo apunta de vuelta a su /Parent, un widget apunta a sus secuencias de apariencia y a la página que lo porta mediante /P, las secuencias de apariencia apuntan a fuentes del diccionario de recursos por defecto del formulario, y los diccionarios de acción adicional bajo /AA apuntan a más objetos. Dos widgets en páginas distintas comparten rutinariamente una fuente y un XObject de apariencia. Así que un injerto correcto tiene que recorrer ese grafo, clonar cada objeto alcanzable exactamente una vez, redirigir el /P de cada widget a la página de destino mapeada y añadir el widget clonado al array /Annots de esa página — de lo contrario el campo existe en el formulario y es invisible en la página. Si alguna vez has perseguido la diferencia entre un campo, su widget y la anotación de página que lo muestra, nuestra nota sobre índice de widget frente a índice de anotación cubre exactamente esa distinción

El grafo de objetos tras un campo de formulario PDF mientras PDFiumPas lo injerta en Delphi: el diccionario de formulario, el campo, las anotaciones de widget, los arrays de anotaciones de la página de destino y la secuencia de apariencia y la fuente que ambos widgets comparten, además de la referencia inversa al padre que cierra el ciclo
Un campo es un subgrafo cíclico compartido, razón por la que copiar el array /Fields entre documentos deja cada referencia colgante

¿Qué necesita GraftPdfAcroForm de ti?

Necesita tres flujos distintos y un mapeo de páginas explícito. GraftPdfAcroForm toma Source, Destination y Output como instancias TStream separadas, un array TPdfGraftPageMappings, un registro TPdfAcroFormGraftOptions, un TPdfCrossDocumentGraftMap opcional y un parámetro de salida TPdfAcroFormGraftReport. Devuelve Boolean en lugar de lanzar excepciones, y en caso de fallo el informe porta la razón en ErrorMessage. El mapeo de páginas es uno-basado en ambos lados y no se infiere: toda página de origen que porte un widget que pretendas injertar debe aparecer en él. Pasar nil como mapa de injerto es legítimo — la función crea y libera entonces uno privado durante la llamada — y TPdfAcroFormGraftOptions.Default te da CollisionPolicy con el valor pagcpReject, RenamePrefix con Imported_, MaxObjects de 100000, MaxDepth de 128 y AllowSignedDestination con False. Esos tres últimos son presupuestos, y existen porque el grafo de objetos que estás a punto de recorrer proviene de un archivo que no escribiste tú

uses
  Classes, SysUtils, FPdfCompress;

var
  Source, Destination, Output: TMemoryStream;
  Options: TPdfAcroFormGraftOptions;
  Mappings: TPdfGraftPageMappings;
  Report: TPdfAcroFormGraftReport;
begin
  Source := TMemoryStream.Create;
  Destination := TMemoryStream.Create;
  Output := TMemoryStream.Create;
  try
    Source.LoadFromFile('claim-template-2025.pdf');
    Destination.LoadFromFile('claim-layout-2026.pdf');
    Source.Position := 0;
    Destination.Position := 0;

    Options := TPdfAcroFormGraftOptions.Default;

    SetLength(Mappings, 2);
    Mappings[0].SourcePageNumber := 1;
    Mappings[0].DestinationPageNumber := 1;
    Mappings[1].SourcePageNumber := 2;
    Mappings[1].DestinationPageNumber := 3;

    if GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, nil, Report) then
      Output.SaveToFile('claim-2026-with-fields.pdf')
    else
      raise Exception.Create(Report.ErrorMessage);
  finally
    Output.Free;
    Destination.Free;
    Source.Free;
  end;
end;

¿Cómo evita el mapa de injerto clonar dos veces una fuente compartida?

TPdfCrossDocumentGraftMap mantiene una tabla de referencias origen-destino cuyas claves llevan número de objeto y generación, y el clonador recursivo la consulta antes de descender. El orden de las operaciones es lo que hace seguros los ciclos: el clonador asigna el número de objeto de destino y registra el mapeo primero, y después recorre las referencias hijas del objeto de origen. Un padre que alcanza a un hijo que apunta de vuelta a su padre encuentra al padre ya registrado y devuelve la referencia de destino existente en lugar de recursar. La misma búsqueda es la que hace que una fuente, una secuencia de apariencia o una acción compartida por seis widgets se clone una vez y se referencie seis veces. El mapa queda ligado al documento de origen mediante un hash SHA-256 de los bytes de origen, expuesto como SourceIdentity. Si entregas a GraftPdfAcroForm un mapa cuya identidad no coincide con el origen que pasaste, rechaza la llamada en lugar de reutilizar referencias que nunca fueron válidas para este archivo. Los mapeos de páginas se siembran en el mismo mapa antes de que empiece la clonación, y esa es precisamente la manera en que el /P de un widget acaba apuntando a la página de destino: el objeto de página de origen ya resuelve al objeto de página de destino mapeado, así que la pasada ordinaria de reescritura de referencias lo gestiona sin caso especial

El mapa de injerto entre documentos de PDFiumPas en Delphi indexa cada referencia de origen por número de objeto y generación, registra el mapeo de destino antes de descender de modo que una referencia inversa al padre termina, y devuelve la entrada existente de modo que una fuente compartida se clona solo una vez
Registrar el mapeo antes de recorrer los hijos es lo que hace seguro un grafo cíclico y clona un objeto compartido exactamente una vez
uses
  Classes, SysUtils, FPdfCompress, FPdfSha256;

var
  GraftMap: TPdfCrossDocumentGraftMap;
  SourceBytes: TBytes;
  EntriesBefore: Integer;
begin
  SetLength(SourceBytes, Source.Size);
  Source.Position := 0;
  if Length(SourceBytes) > 0 then
    Source.ReadBuffer(SourceBytes[0], Length(SourceBytes));

  GraftMap := TPdfCrossDocumentGraftMap.Create(
    AnsiString(SHA256Hex(SHA256Bytes(SourceBytes))));
  try
    EntriesBefore := GraftMap.Count;
    Source.Position := 0;
    if not GraftPdfAcroForm(Source, Destination, Output, Mappings,
      Options, GraftMap, Report) then
    begin
      // Las entradas añadidas por esta llamada se han revertido;
      // todo lo registrado antes sigue intacto.
      Assert(GraftMap.Count = EntriesBefore);
      WriteLn('graft refused: ', Report.ErrorMessage);
    end;
  finally
    GraftMap.Free;
  end;
end;

Esa reversión es la razón de ser de poseer el mapa tú mismo. PDFiumPas trata un mapa suministrado por el llamador de forma transaccional: un injerto fallido descarta las entradas que esa llamada añadió y conserva todo mapeo existente de antes, de modo que un rechazo nunca deja atrás una caché de referencias a objetos que nunca se escribieron. Eso sí, mantén un mapa por documento de destino — el lado de destino de cada entrada es un número de objeto en ese archivo concreto, y no significa nada en otro distinto

Colisiones de nombres de campo: rechazar o renombrar

Los nombres de campo completos deben permanecer únicos dentro de un formulario, y PDFiumPas no va a adivinar qué querías decir cuando chocan. TPdfAcroFormCollisionPolicy ofrece exactamente dos respuestas. Con pagcpReject, el valor por defecto, el primer campo de origen cuyo título ya exista en el destino aborta todo el injerto con un error y deja el flujo de salida vacío. Con pagcpRename, el campo de origen en colisión se renombra anteponiendo RenamePrefix y el injerto continúa, con Report.RenamedFieldCount diciéndote cuántas veces ocurrió

Options := TPdfAcroFormGraftOptions.Default;
Options.CollisionPolicy := pagcpRename;
Options.RenamePrefix := 'Y2025_';
Options.MaxObjects := 20000;
Options.MaxDepth := 64;

if GraftPdfAcroForm(Source, Destination, Output, Mappings,
  Options, nil, Report) then
begin
  WriteLn('source fields  : ', Report.SourceFieldCount);
  WriteLn('existing fields: ', Report.DestinationFieldCount);
  WriteLn('grafted fields : ', Report.GraftedFieldCount);
  WriteLn('renamed fields : ', Report.RenamedFieldCount);
  WriteLn('cloned objects : ', Report.GraftedObjectCount);
  WriteLn('reused objects : ', Report.ReusedObjectCount);
  WriteLn('mapped pages   : ', Report.MappedPageCount);
  WriteLn('output bytes   : ', Report.OutputByteCount);
end
else
  WriteLn('graft refused  : ', Report.ErrorMessage);

Renombrar no es gratis, y deberías decidirlo deliberadamente en lugar de recurrir a ello para que un error desaparezca. Un campo renombrado es un campo distinto: cualquier JavaScript en el destino que lo dirija por nombre, cualquier entrada de cálculo en /CO que alguien escribiera contra el nombre antiguo, y cualquier consumidor posterior que dependa del nombre del campo necesitará saber del prefijo. Si los dos documentos describen genuinamente el mismo campo, el arreglo honesto suele ser reconciliar los nombres aguas arriba, no en el momento del injerto. Una vez aterrizado el injerto, recorrer el formulario fusionado para confirmar lo que realmente obtuviste es el paso natural siguiente, y la navegación de campos de formulario en PDFiumPas cubre ese recorrido

Dónde el injerto falla cerrado deliberadamente

Toda condición ambigua es un error, nunca un resultado de mejor esfuerzo, y esa es una decisión de diseño que conviene entender antes de que te sorprenda en producción. GraftPdfAcroForm devuelve False, reinicia el flujo de salida e informa de la razón cuando se topa con cualquiera de estas

  • El formulario de origen lleva una entrada /XFA — los paquetes XFA son un modelo de formulario paralelo y no pueden reducirse a diccionarios de campo AcroForm
  • Un widget vive en una página de origen sin entrada en el mapeo de páginas, lo que de otro modo descartaría el campo en silencio o lo adjuntaría a la página equivocada
  • Los mapeos de páginas están fuera de rango, o dos mapeos reutilizan la misma página de origen o de destino
  • Ambos formularios definen un diccionario de recursos por defecto /DR, porque fusionar dos espacios de nombres de recursos arriesgaría redirigir un nombre existente a una fuente distinta
  • El grafo de objetos excede MaxObjects o la recursión excede MaxDepth
  • El destino contiene una firma y AllowSignedDestination es False
  • El mapa de injerto suministrado pertenece a otro documento de origen, o una referencia de origen cuelga

La ruta de escritura es igual de conservadora. PDFiumPas emite el resultado como una revisión incremental dispersa añadida al destino, después rematerializa la salida escrita y relee su formulario: si el recuento de campos del resultado no es igual al recuento original de campos del destino más el del origen, todo el injerto se rechaza y la salida se limpia. Nunca obtienes un archivo parcialmente injertado. El coste de esa política es real — una colisión de /DR o un destino firmado te detiene de raíz, y tienes que resolverlo tú mismo en lugar de aceptar una aproximación fusionada — pero la alternativa es un formulario que abre bien y calcula mal

Cómo GraftPdfAcroForm de PDFiumPas falla cerrado en Delphi: la revisión escrita se relee y se verifica su recuento de campos, cualquier condición ambigua como XFA o una página sin mapear rechaza la llamada, y un rechazo descarta solo las entradas del mapa que esa llamada añadió
La ruta de escritura verificada y el mapa transaccional son la razón por la que un injerto rechazado nunca deja atrás un archivo parcialmente fusionado

Cuándo el injerto no es la herramienta correcta

El injerto mueve estructura, así que úsalo cuando la estructura es lo que te falta. Si ambos documentos ya llevan el mismo conjunto de campos y solo necesitas mover valores y anotaciones entre ellos, la ruta de exportación e importación del artículo de datos de formulario XFDF es más ligera, estándar y reversible. Recurre a GraftPdfAcroForm cuando el destino no tiene campos en absoluto, o tiene un conjunto distinto, y necesitas que los widgets, las secuencias de apariencia, las acciones y el orden de cálculo lleguen intactos. Una última nota práctica sobre identidad: como el mapa de injerto indexa por número de objeto más generación y queda ligado a un SHA-256 de los bytes de origen, volver a guardar u optimizar el origen entre ejecuciones produce una identidad distinta y un mapa que ya no aplica. Haz una instantánea del origen desde el que injertas y mantenlo estable para el lote; trátalo como un artefacto de entrada, no como algo que un trabajo nocturno pueda reescribir a su antojo

GraftPdfAcroForm, TPdfCrossDocumentGraftMap y el toolkit PDF circundante a nivel de flujo se incluyen con el PDFiumPas Delphi PDFium Component para Delphi, C++Builder y Lazarus, donde la página de producto lleva la referencia completa de la API para las opciones de injerto, los campos del informe y el resto de la superficie de edición de documentos