Technical Article

Atomic Batch PDF Form Filling in Delphi with PDFium

An import job that fills a 40-field claim template one assignment at a time raises on field 18: the CRM row says Expres, and the radio group on page 2 only knows Express. The exception unwinds the way it should. The document in memory, however, now carries 17 new values and 23 old ones, and if the save sits in a finally block, that mix is what lands on disk and goes to the printer

Why does a field-by-field import leave half a record behind?

Every assignment is its own commit, so an error in the middle has nothing to roll back to. In PDFium Component the classic route is the TPdf.FormField[Index] setter, which drives PDFium's interactive form-fill engine: focus the widget, select all, replace the selection, force focus off. Each write is final when it returns

A PolicyNumber field in the header of nine pages has already changed on all nine, and a checkbox has already flipped its appearance state. Restoring the template means capturing the old value of every field before the loop starts. In a viewer there is a second cost: the setter works through native focus, so it also ends whatever edit the user had open elsewhere

TPdf.SetFormFieldValues takes the other route. You hand it 1 to 256 typed requests, each addressed by page and widget, and it either applies every value in a single document replacement or applies none of them. For any job that runs without a person watching, this is the write path to use; keep FormField[] for interactive tools where a user sees each change as it happens

How SetFormFieldValues stages a batch and swaps it in

TPdf.SetFormFieldValues works on a copy: every request is staged against one snapshot, a second TPdf instance opens the result, and only then is it swapped in. Until that step the live document is untouched

The five-stage atomic batch form fill sequence in PDFium Component for Delphi, from preflight to verify and swap with all-or-nothing outcomes
every request is staged against one snapshot and the live document is touched only at the final verified swap
  1. Preflight: the document must be active, hold between 1 and 256 requests, have no open page-object edit or edit-journal transaction, have no native field focus, and grant the fill-forms or modify permission. XFA forms and signed documents stop here
  2. Per-request checks: page in range, non-negative widget index, input within budget; FailedRequest tracks the request being checked
  3. Staging: each request is resolved to its terminal field and checked against the field type and flags, then the component writes /V, /I for choice selections, and /AS on every widget of a button field
  4. Publishing: the changed objects are appended to the snapshot as one incremental update in the sense of ISO 32000-1 §7.5.6, so every object the batch did not touch keeps its original bytes
  5. Verify and swap: a separate instance loads the candidate bytes; only after that load succeeds does the component unload the live document and load the new one, reloading the original snapshot if the second load fails

Writing dictionaries directly, instead of through PDFium's annotation setters, works around a limit in PDFium's public API. FPDFAnnot_SetStringValue always writes a PDF string, while a checkbox or radio button needs a name object in /V and /AS (ISO 32000-1 §12.7.4.2), and a list box needs an integer array in /I. A string-only setter writes values that look right in a hex dump and select nothing in a viewer

Automatic lifecycle actions are off during the swap, so a document-open script cannot rewrite fields or grab focus mid-replacement. The swap is the same step the undo and crash-recovery journal uses for its snapshots, which is why a batch refuses to run inside an open journal transaction

Turning field names into typed requests

A batch is a TPdfFormValueRequests dynamic array of TPdfFormValueRequest records. Each record holds a one-based Page, a zero-based page-local FieldIndex, and a TPdfFormValue whose Kind is fvkText, fvkChoice, fvkCheckBox or fvkRadioButton. The kind decides which of Text, ChoiceIndices and Checked is read

Import data arrives keyed by field name, so names have to become addresses. FieldIndex counts widget annotations in /Annots order, the order FormFieldCount and FormFieldInfo use, so a page scan finds them. Both routines below are helpers you write yourself

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

// Your own helper: returns the first widget that carries FieldName
function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // first widget only: one request per field
      end;
  end;
end;

// Your own helper: an unknown name fails here, before the batch exists
procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

AddRequest raises on an unknown name instead of skipping it. The batch validates addresses, and any address that points at a real widget passes, so a misspelled template name has to fail in your code. Building and committing the values is then short

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // export value of the button to select
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // zero-based index into the field /Opt array
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
    begin
      if not Pdf.SaveAs('claim-filled.pdf') then
        raise Exception.Create('Save failed');
    end
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

Every request is checked against the field it lands on before anything is written. The rules follow the field types in ISO 32000-1 §12.7.4:

FieldAccepted kindWhat the batch writes or refuses
Text (/Tx)fvkText/V as a UTF-16 text string; refuses text longer than /MaxLen (counted in UTF-16 units) and unpaired surrogates
List box or combo box (/Ch)fvkChoice, or fvkText for an editable combo boxExport values in /V and indices in /I; indices must be valid, unique and ascending, more than one needs MultiSelect, a combo box takes at most one
Checkbox (/Btn)fvkCheckBoxThe widget's on-state, or /Off when Checked is False
Radio group (/Btn, Radio flag)fvkRadioButtonThe button whose export value equals Text; an unknown export value refuses, and an empty one clears the group, which a NoToggleToOff group refuses
Push button, signature field, read-only fieldnoneRefused

One detail catches people. When a button field carries an /Opt array, /V becomes the index of the chosen widget (/0, /1 and so on) while each widget keeps its own on-state name in /AS, so code that reads /V back expecting Express gets a number

Why does writing a shared field twice fail the whole batch?

TPdf.SetFormFieldValues refuses two requests that resolve to the same terminal field, because both would write one value and either winner would hide a mistake in your mapping. The check runs on the field that owns the value, not on the widget, so two different widgets of one field on two different pages still count as a duplicate

The reason sits in the field model of ISO 32000-1 §12.7.3.1. A terminal field owns /V; its widget annotations are only places where that value is drawn. PolicyNumber in every page header is one field with several widgets, and a radio group has the same shape. Writing it through the page 1 widget and again through the page 9 widget writes one field twice, so the batch names the second request in FailedRequest

One terminal field owns the value, widgets on two pages display it, and a duplicate request fails the batch in PDFium Component for Delphi
one request per terminal field — the component writes /V once and sets /AS on every widget that shares it

The fix is upstream: resolve each field name to exactly one address, as FindFieldAddress does, and let the component reach the other widgets: it updates up to 4,096 widgets of the target field and sets /AS on each widget of a shared checkbox or radio group. The split between field value and widget state is covered in the article on checkbox field values versus widget states

Reading the report when a batch is refused

A refused batch returns False, raises nothing, and leaves the document and every field value as they were. TPdfFormValuesWriteReport then holds Succeeded = False, zero in both UpdatedFields and UpdatedWidgets, the zero-based index of the offending request in FailedRequest, and the reason in ErrorMessage

// Your own logging routine; Log stands for whatever your job writes to
procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

A FailedRequest of -1 means the refusal belongs to the transaction rather than to one entry: native focus was active, the document is signed or XFA, a permission check failed, or an edit transaction was still open. A non-negative value points at one request. For an aggregate budget overflow it points at the request that pushed the total over, not necessarily the one with bad data. A parallel array of source column names turns index 14 into row 2087, column ShipDate, in the import log

On success, UpdatedFields equals the request count and UpdatedWidgets counts every widget touched, shared ones included. For a template where PolicyNumber sits on nine pages and is the only request, expect 1 and 9

In a viewer the refusal you will meet most is native focus. If a user is typing in a field, SetFormFieldValues stops before capturing the snapshot and the half-typed text survives. Dropping that focus automatically would either commit or discard text the user has not finished, so the component leaves it to you: call ClearFormFieldFocus, which ends the edit the way a click outside the field would, and then run the batch

The budgets are fixed, so imports can be sized against them:

  • 1 to 256 requests per batch; a larger record splits into several batches, and atomicity then holds per batch
  • At most 262,144 UTF-16 units of text and 4,096 choice indices per request
  • At most 1,048,576 UTF-16 units of text and 16,384 choice indices across the whole batch
  • A snapshot of up to 64 MiB and 65,536 objects, with no single object larger than 8 MiB

A successful batch reloads the document underneath your TPdf. Page number, active TPdfView controls and logical field cursors are restored. Native handles, searches and selections from before the call are not: a text search started on page 3 before the import has to run again

Choosing between ImportXFDF and a batch write

TPdf.ImportXFDF and TPdf.SetFormFieldValues both put data into AcroForm fields, but XFDF import is a tolerant merge and the batch is a strict transaction. XFDF import matches values to fields by fully qualified name, skips names the form does not have, keeps the first of any duplicate names, and returns the number of field and annotation entries it read. For merging reviewer annotations, where a partial result still has value, that tolerance is right

The batch does the opposite on every point: explicit widget addresses, refusal of unknown or mistyped targets, duplicates as errors, no partial outcome. XFDF fits an interchange file produced by another tool; the batch fits when your own code owns the mapping from record to field

Decision matrix comparing XFDF import with the atomic SetFormFieldValues batch in PDFium Component for Delphi: targeting, unknown and duplicate targets, outcome and best fit
XFDF merges interchange files tolerantly while the batch refuses unknown names, duplicates and partial outcomes

Signed, encrypted and XFA forms, and what happens to appearances

The batch covers AcroForm documents without signatures, encrypted or not. XFA forms are refused, since their data lives in the XFA datasets packet rather than in AcroForm field dictionaries. A document that contains any signature dictionary is refused as well, because rewriting field dictionaries under an existing signature is a decision for a signing workflow with its own revision rules

Encrypted documents are written back encrypted, as a new revision under the same security handler. Before the swap, the result is reopened with the document's password in strict mode and must load without recovery, keep its page count, handler revision and permissions, and show every widget with the staged name, type, flags and value

Some forms store field dictionaries directly inside the AcroForm /Fields array. The batch promotes those to objects of their own before writing; a direct widget needs a unique /NM so it can be matched to its page annotation. The same layout explains field values that appear to revert

Appearances need one deliberate choice. The batch removes the stale /AP from every changed text and choice widget, and drops /RV so a rich-text value (ISO 32000-1 §12.7.3.4) cannot contradict the new one. A NeedAppearances of true is set to false, because leaving it on makes PDFium rebuild every widget in the form on load, including the ones nobody changed. PDFium draws the missing appearances when it renders. If the filled file goes to a print pipeline that never synthesizes appearances, flatten it through PDFium before it leaves your process

SetFormFieldValues, the TPdfFormValue records and the single-field SetFormFieldValue wrapper are part of PDFium Component for Delphi and Lazarus; the product page covers the rest of its AcroForm toolkit, from XFDF exchange to flattening, next to the rendering and viewer controls