Artículo técnico

Injertar campos AcroForm entre PDFs en Delphi con PDFiumPas

Mover un bloque de campos de formulario de la plantilla del año pasado al layout de este año es donde los round-trips de FDF y XFDF dejan de bastar: los valores llegan, pero los streams 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 de campos 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 debajo, y §12.5.6.19 define las anotaciones 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 misma

Por qué copiar el arreglo /Fields nunca basta

Copiar /Fields de un documento a otro produce un formulario roto en todas las formas interesantes, porque el arreglo 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. Pegue el arreglo al otro lado y cada referencia en él queda colgante o, peor, resuelve en silencio a un objeto no relacionado que casualmente ocupa ese slot en el destino. Debajo de cada referencia vive un grafo a la vez compartido y cíclico. Un diccionario de campo apunta a sus kids, cada kid apunta de vuelta a su /Parent, un widget apunta a sus streams de apariencia y a la página que lo porta vía /P, los streams de apariencia apuntan a fuentes en el diccionario de recursos por defecto del formulario, y los diccionarios de acción adicional bajo /AA apuntan a más objetos todavía. 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 destino mapeada y añadir el widget clonado al arreglo /Annots de esa página — de lo contrario el campo existe en el formulario y es invisible en la página. Si han perseguido la diferencia entre un campo, su widget y la anotación de página que lo muestra, nuestra nota sobre el índice de widget frente al índice de anotación cubre exactamente esa división

El grafo de objetos detrás de un campo de formulario PDF mientras PDFiumPas lo injerta en Delphi: el diccionario del formulario, el campo, las anotaciones widget, los arreglos de anotaciones de la página destino y el stream de apariencia y la fuente que ambos widgets comparten, más la referencia de vuelta al padre que cierra el ciclo
Un campo es un subgrafo compartido y cíclico, por eso copiar el arreglo /Fields entre documentos deja cada referencia colgante

¿Qué necesita GraftPdfAcroForm de ustedes?

Necesita tres streams distintos y un mapeo de páginas explícito. GraftPdfAcroForm toma Source, Destination y Output como instancias separadas de TStream, un arreglo TPdfGraftPageMappings, un record TPdfAcroFormGraftOptions, un TPdfCrossDocumentGraftMap opcional y un TPdfAcroFormGraftReport de salida. Devuelve Boolean en lugar de lanzar, y en caso de fallo el reporte lleva la razón en ErrorMessage. El mapeo de páginas es 1-based en ambos lados y no se infiere: toda página fuente que porte un widget que piensen injertar debe aparecer en él. Pasar nil para el graft map es legítimo — la función crea y libera uno privado durante la llamada — y TPdfAcroFormGraftOptions.Default les da CollisionPolicy en pagcpReject, RenamePrefix en Imported_, MaxObjects de 100000, MaxDepth de 128 y AllowSignedDestination en False. Esos tres últimos son presupuestos, y existen porque el grafo de objetos que están por recorrer provino de un archivo que no escribieron

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 graft map clonar dos veces una fuente compartida?

TPdfCrossDocumentGraftMap sostiene una tabla de referencias fuente-a-destino cuyas claves llevan número de objeto y generación, y el clonador recursivo la consulta antes de descender. El orden de operaciones es lo que hace seguros los ciclos: el clonador asigna el número de objeto destino y registra el mapeo primero, luego recorre las referencias hijas del objeto fuente. Un padre que alcanza un kid que apunta de vuelta a su padre encuentra al padre ya registrado y devuelve la referencia destino existente en lugar de recursar. La misma búsqueda es la que hace que una fuente, un stream de apariencia o una acción compartidos por seis widgets se clonen una vez y se referencien seis veces. El mapa queda ligado al documento fuente por un hash SHA-256 de los bytes de la fuente, expuesto como SourceIdentity. Si le entregan a GraftPdfAcroForm un mapa cuya identidad no coincide con la fuente que pasaron, 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 el clonado, que es precisamente cómo el /P de un widget termina apuntando a la página destino: el objeto de página fuente ya resuelve al objeto de página destino mapeado, así que la pasada ordinaria de reescritura de referencias lo maneja sin caso especial

El graft map entre documentos de PDFiumPas en Delphi indexa cada referencia fuente por número de objeto y generación, registra el mapeo destino antes de descender para que una referencia de vuelta al padre termine, y devuelve la entrada existente para que una fuente compartida se clone 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 fueron revertidas;
      // 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 el punto de ser dueños del mapa. PDFiumPas trata un mapa suministrado por el llamador de manera transaccional: un injerto fallido descarta las entradas que esa llamada añadió y conserva todo mapeo preexistente, así que un rechazo jamás deja atrás una caché de referencias a objetos que nunca se escribieron. Eso sí, mantengan un mapa por documento destino — el lado 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 campos: rechazar o renombrar

Los nombres de campo totalmente calificados deben permanecer únicos dentro de un formulario, y PDFiumPas no adivinará lo que quisieron decir cuando chocan. TPdfAcroFormCollisionPolicy ofrece exactamente dos respuestas. Bajo pagcpReject, el valor por defecto, el primer campo fuente cuyo nombre ya exista en el destino aborta todo el injerto con un error y deja el stream de salida vacío. Bajo pagcpRename, el campo fuente en colisión se renombra anteponiendo RenamePrefix y el injerto continúa, con Report.RenamedFieldCount diciéndoles 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 deben decidirlo deliberadamente en lugar de recurrir a ello para hacer desaparecer un error. 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 una persona escribió contra el nombre viejo, y cualquier consumidor aguas abajo que se indexe por el 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 al momento del injerto. Una vez que el injerto aterriza, recorrer el formulario fusionado para confirmar lo que realmente obtuvieron es el siguiente paso natural, y la navegación de campos de formulario en PDFiumPas cubre ese recorrido

Dónde el injerto deliberadamente falla cerrado

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

  • El formulario fuente 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 fuente sin entrada en el mapeo de páginas, lo que de otro modo dejaría caer 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 fuente o destino
  • Ambos formularios definen un diccionario de recursos por defecto /DR, porque fusionar dos espacios de nombres de recursos arriesgaría re-apuntar 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 graft map suministrado pertenece a un documento fuente distinto, o una referencia fuente cuelga

La ruta de escritura es igual de conservadora. PDFiumPas emite el resultado como una revisión incremental dispersa añadida al destino, luego re-materializa la salida escrita y vuelve a leer su formulario: si el conteo de campos del resultado no es igual al conteo original del destino más el de la fuente, todo el injerto se rechaza y la salida se limpia. Nunca obtendrán un archivo parcialmente injertado. El costo de esa política es real — una colisión de /DR o un destino firmado los detiene de plano, y tienen que resolverlo ustedes mismos en lugar de aceptar una aproximación fusionada — pero la alternativa es un formulario que abre bien y calcula mal

Cómo PDFiumPas GraftPdfAcroForm falla cerrado en Delphi: la revisión escrita se relee y se verifica su conteo 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 jamás deja un archivo parcialmente fusionado

Cuándo injertar es la herramienta equivocada

Injertar mueve estructura, así que úsenlo cuando la estructura es lo que falta. Si ambos documentos ya llevan el mismo conjunto de campos y solo necesitan 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. Acudan a GraftPdfAcroForm cuando el destino no tiene campos en absoluto, o tiene un conjunto distinto, y necesitan que los widgets, los streams de apariencia, las acciones y el orden de cálculo lleguen intactos. Una última nota práctica sobre identidad: como el graft map se indexa por número de objeto más generación y queda ligado a un SHA-256 de los bytes de la fuente, re-guardar u optimizar la fuente entre corridas produce una identidad distinta y un mapa que ya no aplica. Hagan una instantánea de la fuente desde la que injertan y manténganla estable para el lote; trátenla como un artefacto de entrada, no como algo que un trabajo nocturno puede reescribir a su antojo

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