Articolo tecnico

Innestare campi AcroForm tra PDF in Delphi con PDFiumPas

Spostare un blocco di campi formulario dal modello dell'anno scorso sul layout di quest'anno è il punto in cui i round-trip FDF e XFDF smettono di bastare: i valori arrivano, ma gli stream di aspetto, le azioni di calcolo e le risorse predefinite no. PDFiumPas risponde a quel caso con GraftPdfAcroForm, che clona l'intero grafo di oggetti dei campi da un PDF e lo scrive in un altro

Il motivo per cui un'esportazione a livello di dati non può farlo è strutturale. Un campo non è un record, è un sottografo. ISO 32000-1 §12.7 definisce il dizionario del formulario interattivo che tiene /Fields, /CO, /DR e /DA, il §12.7.3 definisce i dizionari di campo che pendono sotto di esso, e il §12.5.6.19 definisce le annotazioni widget che danno a quei campi un riquadro visibile su una pagina. XFDF porta le foglie di quella struttura. L'innesto porta la struttura stessa

Perché copiare l'array /Fields non basta mai

Copiare /Fields da un documento a un altro produce un formulario rotto in ogni modo interessante, perché l'array tiene riferimenti indiretti e nient'altro. ISO 32000-1 §7.3.10 rende un oggetto indiretto indirizzabile per numero di oggetto più generazione, e quei numeri sono significativi solo dentro il file da cui vengono. Incollate l'array altrove e ogni riferimento in esso o pende o, peggio, si risolve silenziosamente su un oggetto non correlato che capita di occupare quello slot nella destinazione. Sotto ogni riferimento siede un grafo insieme condiviso e ciclico. Un dizionario di campo punta ai suoi figli, ogni figlio punta indietro al suo /Parent, un widget punta ai suoi stream di aspetto e alla pagina che lo trasporta tramite /P, gli stream di aspetto puntano ai font nel dizionario di risorse predefinite del formulario, e i dizionari di azioni aggiuntive sotto /AA puntano ad altri oggetti ancora. Due widget su pagine diverse condividono di routine un font e un XObject di aspetto. Quindi un innesto corretto deve percorrere quel grafo, clonare ogni oggetto raggiungibile esattamente una volta, ridirigere il /P di ogni widget sulla pagina di destinazione mappata, e aggiungere il widget clonato all'array /Annots di quella pagina — altrimenti il campo esiste nel formulario ed è invisibile sulla pagina. Se avete inseguito la differenza tra un campo, il suo widget e l'annotazione di pagina che lo mostra, la nostra nota sull'indice widget contro indice annotazione copre esattamente quella separazione

Il grafo di oggetti dietro a un campo formulario PDF mentre PDFiumPas lo innesta in Delphi: il dizionario del formulario, il campo, le annotazioni widget, gli array di annotazioni delle pagine di destinazione e lo stream di aspetto e il font che entrambi i widget condividono, più il riferimento indietro al parent che chiude il ciclo
Un campo è un sottografo ciclico condiviso, ed è per questo che copiare l'array /Fields tra documenti lascia ogni riferimento pendente

Cosa richiede GraftPdfAcroForm a voi?

Richiede tre stream distinti e una mappatura di pagina esplicita. GraftPdfAcroForm prende Source, Destination e Output come istanze TStream separate, un array TPdfGraftPageMappings, un record TPdfAcroFormGraftOptions, una TPdfCrossDocumentGraftMap opzionale, e un TPdfAcroFormGraftReport in uscita. Restituisce Boolean invece di sollevare eccezioni, e al fallimento il report porta il motivo in ErrorMessage. La mappatura delle pagine è a base uno su entrambi i lati e non viene inferita: ogni pagina sorgente che trasporta un widget che intendete innestare deve comparirvi. Passare nil per la graft map è legittimo — la funzione allora crea e libera una privata per la durata della chiamata — e TPdfAcroFormGraftOptions.Default vi dà CollisionPolicy impostato a pagcpReject, RenamePrefix impostato a Imported_, MaxObjects di 100000, MaxDepth di 128 e AllowSignedDestination impostato a False. Quegli ultimi tre sono budget, ed esistono perché il grafo di oggetti che state per percorrere viene da un file che non avete scritto voi

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;

Come evita la graft map di clonare due volte un font condiviso?

TPdfCrossDocumentGraftMap tiene una tabella di riferimenti da sorgente a destinazione le cui chiavi portano sia numero di oggetto sia generazione, e il cloner ricorsivo la consulta prima di scendere. L'ordine delle operazioni è ciò che rende sicuri i cicli: il cloner alloca il numero di oggetto di destinazione e registra la mappatura prima, poi percorre i riferimenti figli dell'oggetto sorgente. Un parent che raggiunge un figlio che punta indietro al suo parent trova il parent già registrato e restituisce il riferimento di destinazione esistente invece di ricorrere. La stessa lookup è ciò che fa sì che un font, uno stream di aspetto o un'azione condivisi da sei widget vengano clonati una volta e referenziati sei. La mappa è vincolata al documento sorgente da un hash SHA-256 dei byte sorgente, esposto come SourceIdentity. Se consegnate a GraftPdfAcroForm una mappa la cui identità non corrisponde alla sorgente passata, rifiuta la chiamata anziché riusare riferimenti che non sono mai stati validi per questo file. Le mappature di pagina vengono seminate nella stessa mappa prima che la clonazione cominci, che è precisamente il modo in cui il /P di un widget finisce per puntare alla pagina di destinazione: l'oggetto pagina sorgente si risolve già sull'oggetto pagina di destinazione mappato, quindi il comune pass di riscrittura dei riferimenti lo gestisce senza alcun caso speciale

La graft map cross-document di PDFiumPas in Delphi con chiave ogni riferimento sorgente per numero di oggetto e generazione, registra la mappatura di destinazione prima di scendere così un riferimento indietro al parent termina, e restituisce la voce esistente così un font condiviso viene clonato una sola volta
Registrare la mappatura prima di percorrere i figli è ciò che rende sicuro un grafo ciclico e fa clonare esattamente una volta un oggetto condiviso
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
      // Le voci aggiunte da questa chiamata sono state ripristinate;
      // tutto ciò che era registrato prima è ancora intatto.
      Assert(GraftMap.Count = EntriesBefore);
      WriteLn('graft refused: ', Report.ErrorMessage);
    end;
  finally
    GraftMap.Free;
  end;
end;

Quel rollback è il motivo per cui possedere la mappa voi stessi. PDFiumPas tratta una mappa fornita dal chiamante in modo transazionale: un innesto fallito scarta le voci che quella chiamata ha aggiunto e conserva ogni mappatura esistente in precedenza, così un rifiuto non lascia mai dietro una cache di riferimenti a oggetti che non sono mai stati scritti. Tenete però una mappa per documento di destinazione — il lato destinazione di ogni voce è un numero di oggetto in quel particolare file, e non significa nulla in uno diverso

Collisioni di nomi di campo: rifiutare o rinominare

I nomi di campo pienamente qualificati devono restare unici dentro un formulario, e PDFiumPas non indovinerà cosa intendevate quando collidono. TPdfAcroFormCollisionPolicy offre esattamente due risposte. Con pagcpReject, il default, il primo campo sorgente il cui titolo esiste già nella destinazione abortisce l'intero innesto con un errore e lascia lo stream di output vuoto. Con pagcpRename, il campo sorgente in collisione viene rinominato anteponendo RenamePrefix e l'innesto continua, con Report.RenamedFieldCount che vi dice quante volte è successo

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);

Rinominare non è gratis, e dovreste decidere deliberatamente anziché arraffarlo per far sparire un errore. Un campo rinominato è un campo diverso: qualsiasi JavaScript nella destinazione che lo indirizza per nome, qualsiasi voce di calcolo in /CO che un essere umano ha scritto contro il vecchio nome, e qualsiasi consumatore a valle che fa chiave sul nome del campo dovrà sapere del prefisso. Se i due documenti descrivono genuinamente lo stesso campo, la correzione onesta di solito è riconciliare i nomi a monte, non al momento dell'innesto. Una volta che l'innesto è atterrato, percorrere il formulario fuso per confermare cosa avete effettivamente ottenuto è il passo naturale successivo, e la navigazione dei campi formulario in PDFiumPas copre quella traversata

Dove l'innesto fallisce deliberatamente in modo sicuro

Ogni condizione ambigua è un errore, mai un risultato del meglio possibile, e quella è una decisione di progetto che vale la pena capire prima che vi sorprenda in produzione. GraftPdfAcroForm restituisce False, azzera lo stream di output e riporta il motivo quando incontra una di queste

  • Il formulario sorgente porta una voce /XFA — i pacchetti XFA sono un modello di formulario parallelo e non si possono ridurre a dizionari di campi AcroForm
  • Un widget vive su una pagina sorgente che non ha alcuna voce nella mappa delle pagine, il che altrimenti lascerebbe cadere il campo silenziosamente o lo attaccerebbe alla pagina sbagliata
  • Le mappature di pagina sono fuori intervallo, o due mappature riusano la stessa pagina sorgente o destinazione
  • Entrambi i formulario definiscono un dizionario di risorse predefinito /DR, perché fondere due spazi di nomi di risorse rischierebbe di ripuntare un nome esistente su un font diverso
  • Il grafo di oggetti supera MaxObjects o la ricorsione supera MaxDepth
  • La destinazione contiene una firma e AllowSignedDestination è False
  • La graft map fornita appartiene a un documento sorgente diverso, o un riferimento sorgente pende

Il percorso di scrittura è altrettanto conservativo. PDFiumPas emette il risultato come una revisione incrementale sparsa aggiunta alla destinazione, poi rimaterializza l'output scritto e rilegge il suo formulario: se il conteggio dei campi del risultato non eguaglia il conteggio originale dei campi della destinazione più quello della sorgente, l'intero innesto viene rifiutato e l'output viene azzerato. Non ottenete mai un file parzialmente innestato. Il costo di quella policy è reale — una collisione di /DR o una destinazione firmata vi blocca del tutto, e dovete risolverla voi anziché accettare un'approssimazione fusa — ma l'alternativa è un formulario che si apre bene e calcola male

Come PDFiumPas GraftPdfAcroForm fallisce in modo sicuro in Delphi: la revisione scritta viene riletta e il suo conteggio di campi verificato, qualsiasi condizione ambigua come XFA o una pagina non mappata rifiuta la chiamata, e un rifiuto scarta solo le voci di mappa che quella chiamata ha aggiunto
Il percorso di scrittura verificato e la mappa transazionale sono il motivo per cui un innesto rifiutato non lascia mai dietro un file parzialmente fuso

Quando l'innesto è lo strumento sbagliato

L'innesto sposta struttura, quindi usatelo quando la struttura è ciò che vi manca. Se entrambi i documenti portano già lo stesso set di campi e dovete solo spostare valori e annotazioni tra loro, il percorso di esportazione e importazione nell'articolo sui dati formulario XFDF è più leggero, standard e reversibile. Arraffate GraftPdfAcroForm quando la destinazione non ha affatto campi, o ne ha un set diverso, e vi servono i widget, gli stream di aspetto, le azioni e l'ordine di calcolo che attraversino intatti. Un'ultima nota pratica sull'identità: poiché la graft map fa chiave su numero di oggetto più generazione ed è vincolata a un SHA-256 dei byte sorgente, ri-salvare o ottimizzare la sorgente tra un run e l'altro produce un'identità diversa e una mappa che non si applica più. Scattate uno snapshot della sorgente da cui innestate e tenetela stabile per il batch; trattatela come un artefatto di input, non come qualcosa che un job notturno è libero di riscrivere

GraftPdfAcroForm, TPdfCrossDocumentGraftMap e il toolkit PDF a livello di stream circostante arrivano con il componente PDFium PDFiumPas per Delphi per Delphi, C++Builder e Lazarus, dove la pagina del prodotto porta il riferimento API completo per le opzioni di innesto, i campi del report e il resto della superficie di editing dei documenti