Technical Article

FPDF_FORMFILLINFO Version 2 in Delphi: Follow the DLL ABI

An AcroForm invoice with a handful of text fields and no XFA content opens fine through a plain pdfium.dll, yet the same file can make FPDFDOC_InitFormFillEnvironment return NULL once the process is running the XFA-enabled pdfium.v8.dll. The bytes of the document are identical in both runs. What differs is one integer: XFA builds of PDFium require FPDF_FORMFILLINFO.version to be 2, and a binding that writes 1 for documents without XFA is turned away before PDFium looks at a single callback

Why does FPDFDOC_InitFormFillEnvironment reject a plain AcroForm PDF?

The rejection comes from the binary, and the document has no say in it. The form-fill header, fpdf_formfill.h in PDFium and its Pascal translation in FPdfFormFill.pas, states that a build compiled with the XFA module requires version 2, and it says so twice in the same comment block. A build without XFA accepts 1 or 2. Under a binding that sends version 1 for non-XFA files, only one of the four combinations of binary and document fails: a version-1 record handed to pdfium.v8.dll together with an ordinary AcroForm file. The plain DLL takes that record. The V8 DLL takes a version-2 record for any document. A real XFA form on the V8 DLL is never sent version 1 by a binding that keys the version off the document, so the failing cell is precisely the one nobody thinks to test

A hand-written binding sees FPDFDOC_InitFormFillEnvironment return NULL at that point and has nothing more to go on. TPdf in PDFium Component would report a refused environment as an EPdfError during Active := True, but it always writes version 2, so this cell never comes up there. Getting into that cell is easy. A host sets the global EnableV8Engine to True because it wants AcroForm JavaScript, and from then on every PDF in the process goes through the V8 binary. Or the host never touches the flag, but the first file it opens has an /XFA entry in its AcroForm dictionary or /NeedsRendering true in its catalog: when the PDFium library has not been loaded yet, TPdf scans the file for those two markers and switches EnableV8Engine on before loading the DLL. Either way the choice holds for the rest of the process, and every plain AcroForm file opened afterwards meets the XFA build. Which binary to ship in the first place is covered in the guide to deploying the PDFium DLL and diagnosing load failures; everything below assumes the V8 build is already loaded

PDFium Component diagram of the four combinations of plain pdfium.dll and XFA-enabled pdfium.v8.dll against AcroForm and XFA documents: a version 1 record fails only on the V8 build with a plain form, in FPDFDOC_InitFormFillEnvironment, while a version 2 record opens all four
Tying the ABI version to the document means the process-wide choice of the V8 binary turns every later plain PDF into a failed environment initialization

What the version field in FPDF_FORMFILLINFO promises

FPDF_FORMFILLINFO.version tells PDFium how much of the record it may read. Version 1 covers the stable part: Release, the callbacks from FFI_Invalidate through FFI_DoGoToAction, and the m_pJsPlatform pointer. Version 2 adds an experimental tail. A build without XFA that receives version 2 will also call the experimental callbacks it finds there; a build with XFA will accept nothing less. The header never mentions the document at all. By writing 2 you are promising that the memory after m_pJsPlatform exists and that every slot in it holds either a valid cdecl function or NULL

That tail starts with xfa_disabled, an FPDF_BOOL that is ignored below version 2, followed by seventeen function pointers from FFI_DisplayCaret to FFI_DoURIActionWithKeyboardModifier. Fifteen of them, FFI_DisplayCaret through FFI_PutRequestURL, are documented as "Required for XFA, otherwise set to NULL". The last two, FFI_OnFocusChange and FFI_DoURIActionWithKeyboardModifier, are optional for every host, and the header adds that a version-2 embedder which leaves FFI_DoURIActionWithKeyboardModifier unset gets its URI clicks redirected to FFI_DoURIAction. Put together, NULL is the specified value for a host that does not drive XFA. A record zeroed with FillChar and stamped with version 2 meets the contract on a plain build exactly as a version-1 record does, and it is also the one record an XFA build will accept

PDFium Component diagram of the FPDF_FORMFILLINFO record in Delphi: version 1 covers the FFI_Invalidate through FFI_DoGoToAction callbacks plus m_pJsPlatform, version 2 adds xfa_disabled and seventeen FFI_DisplayCaret era pointers, FillChar clears every byte, and NULL slots are the documented state for a host that is not driving XFA
The Pascal record is always the complete version 2 layout, so an XFA-enabled build accepts it and a plain build simply never calls the experimental slots that stay NULL

The JavaScript side follows the same pattern. m_pJsPlatform is unused by builds without V8; on a V8 build a NULL pointer stops JavaScript from running while the document renders. The IPDF_JsPlatform record it points to has its own version field, and the header says that one currently must be 2

The trap: letting the document choose the ABI version

The mistake is one conditional that reads sensibly on its own. A hand-written binding already has to decide whether the XFA callbacks are worth wiring, and it is tempting to let the same test pick the version number

// A tempting shape for a hand-written binding: the version follows the document
FillChar(FormInfo, SizeOf(FormInfo), 0);
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
if IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable then
  FormInfo.version := 2
else
  FormInfo.version := 1;   // every non-XFA document gets 1

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// on pdfium.v8.dll with a plain AcroForm file, FormHandle is nil here

The intent behind that branch is defensive: keep the version at 1 so an XFA build never reads slots the host did not fill. But FillChar on the line above has already filled them with NULL, which the header defines as the correct value for a host that is not running XFA. The branch guards against a hazard the declaration has removed and walks straight into the one the header warns about. It also cannot be caught by testing XFA files, since those take the version-2 path every time

PDFium Component builds the record the other way round. Whenever TPdf.FormFill is True, opening a document clears the full record, writes version 2 and xfa_disabled := 1, and only then asks what the document needs. Stated as a rule for any binding: set the version from the record you declared, and let nothing else touch it. Here is that shape for your own code, with a THostFormFillInfo wrapper declared further down

// FormInfo is a THostFormFillInfo field of your viewer object, so it outlives
// the form handle; HostInvalidate, HostGetPage and the rest are your own
// cdecl callbacks
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;

FormInfo.Info.version := 2;        // matches the declared layout, always
FormInfo.Info.xfa_disabled := 1;   // XFA stays off unless proven usable

FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... the other version 1 callbacks

IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
RuntimeReady := IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
begin
  FormInfo.Info.xfa_disabled := 0;
  FormInfo.Info.FFI_DisplayCaret := HostDisplayCaret;
  FormInfo.Info.FFI_PageEvent := HostPageEvent;
  // ... the remaining version 2 slots
end;

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo.Info);
if FormHandle = nil then
  raise Exception.Create('Cannot initialize form fill environment');
XfaRunning := RuntimeReady and (FPDF_LoadXFA(Doc) <> 0);

Where RuntimeReady and V8FeaturesAvailable belong

Capability checks decide which slots get filled, and in PDFium Component they never touch the layout. TPdf computes readiness from three facts: the document reports an XFA form type through TPdf.XFA, the global XfaFeaturesAvailable finds the XFA string helpers exported by the loaded DLL, and V8FeaturesAvailable finds the V8 exports. The version-1 callbacks are wired for every document, because AcroForm and XFA both rely on them. All seventeen version-2 slots are assigned, and xfa_disabled cleared, only when all three facts hold. After the environment exists, FPDF_LoadXFA runs only for that case, and only a nonzero return makes TPdf.XfaRuntimeAvailable report True. FPDF_LoadXFA is exported even by builds without the XFA engine and simply returns false there, which is why the export check alone is not enough

When the document is XFA but the runtime is missing, the record stays at version 2 with xfa_disabled at 1 and the tail left NULL. The environment opens, the XFA content stays static, and TPdf raises OnXfaRuntimeMissing so the host can suggest restarting on pdfium.v8.dll. m_pJsPlatform follows a third rule: it is attached whenever V8FeaturesAvailable is true, for plain AcroForm files too, which is what lets document-level scripts, field calculations and app.alert run under the V8 build. PageCount reads the static page tree until XFA repaginates the document through FFI_PageEvent; at that point the new total is taken from FPDF_GetPageCount and reported through OnXfaPageCountChanged

On the host side, two properties answer two different questions. XFA tells you what the file contains, from FPDF_GetFormType, regardless of the binary. XfaRuntimeAvailable tells you whether the engine actually started for this document. Read the second one after Active goes True, as the article on detecting XFA forms and extracting XFA packets does, and leave the version field out of the reasoning entirely

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Raised while the document opens when it is XFA but the loaded DLL cannot
  // run the engine. The form environment still exists, with XFA disabled
  StatusBar.SimpleText :=
    'XFA form detected; restart with pdfium.v8.dll for dynamic rendering';
end;

procedure TMainForm.OpenDocument(const FileName: string);
begin
  Pdf.Active := False;
  Pdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  Pdf.FormFill := True;
  Pdf.FileName := FileName;
  Pdf.Active := True;   // plain AcroForm files open under pdfium.v8.dll too
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaNotice;   // your own UI routine
end;

Layout mistakes that break the record before the version is read

A correct version number only helps if the bytes behind it sit where the C compiler put them. Several binding errors produce a record that PDFium misreads no matter which version it carries, and one of them stays invisible on Win32. It is declaring FPDF_FORMFILLINFO as a packed record. On Win32 every field is four bytes, so packed and aligned layouts happen to coincide. On Win64 the C struct pads four bytes after version so that Release lands on an 8-byte boundary, and pads again after xfa_disabled; a packed Pascal declaration drops both gaps and shifts every callback pointer by four bytes. The declaration in FPdfFormFill.pas is a plain record for that reason

The second error is a truncated record: a translation that stops at m_pJsPlatform but writes version 2. PDFium then reads the tail out of whatever memory follows the record. The third is lifetime. The header says the record passed to FPDFDOC_InitFormFillEnvironment must stay valid until the returned form handle is closed, so a record held in a local variable of the procedure that opens the document is a dangling pointer the moment that procedure returns. TPdf keeps its record as a field of the component, wrapped in an outer record that places the PDFium struct at offset zero and a back-pointer to the owning TPdf right after it, which is how every static callback finds its object again

type
  // Wrong on Win64: packing removes the padding the C struct has after
  // version, so Release sits at offset 4 here and at offset 8 in PDFium
  TBadFormFillInfo = packed record
    version: Integer;
    Release: Pointer;
    // ... remaining fields
  end;

  // Your own wrapper: the PDFium record first, your back-pointer after it
  THostFormFillInfo = record
    Info: FPDF_FORMFILLINFO;   // offset 0, so pThis casts straight back
    Owner: TObject;
  end;
  PHostFormFillInfo = ^THostFormFillInfo;

procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
  left, top, right, bottom: Double); cdecl;
begin
  try
    // TMyViewer and InvalidatePdfRect stand for your own viewer class
    TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
      page, left, top, right, bottom);
  except
    // a Pascal exception must not unwind through PDFium's C++ frames
  end;
end;

Two more slots deserve care. FFI_GetLocalTime returns a 16-byte FPDF_SYSTEMTIME by value, and Delphi and MSVC disagree on Win32 about where the hidden result pointer goes, so a Pascal implementation would corrupt the caller's FPDF_FORMFILLINFO. Current PDFium marks the member unused, and the Pascal declaration carries a comment to leave it nil; PDFium Component does. Every callback the component does install catches Pascal exceptions and returns the documented failure value instead, because the C ABI has no way to carry an exception back across the boundary. The write-up on hardening the PDFium ABI in Delphi walks through that discipline using FFI_OpenFile as the example

Protocol version and feature availability are two axes

A version field in a callback structure answers how big the record is and what may be read from it. Feature detection answers which of those slots will do anything. The first is fixed by the native binary and by the declaration you compiled against; the second varies per document, per DLL export table and per host setting. The XFA case needs both answers at once, which makes it tempting to fold them into one boolean, and that fold breaks the moment a build enforces a minimum version. XFA forms, which ISO 32000-1 §12.7.8 describes as an XML payload carried alongside the AcroForm dictionary, are the feature in this story. The record layout is the protocol, and PDFium is entitled to check the layout before it reads the file

PDFium Component diagram separating the two axes behind FPDF_FORMFILLINFO: the protocol version fixed by the record layout and the native binary, and feature availability, where RuntimeReady gates xfa_disabled, the seventeen version 2 slots and FPDF_LoadXFA while V8FeaturesAvailable decides m_pJsPlatform, per document and per host
A version field describes the memory the other side may read, capability checks decide which slots do anything useful, and collapsing the two into one boolean breaks the build that enforces a minimum

The same structure turns up wherever a C library versions its structs: FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, any callback table with a size or version header. Declare the newest layout you understand, clear it completely, write the matching version unconditionally, and fill slots according to what the document and the DLL can actually do. If a future fpdf_formfill.h introduces version 3, the work is a new declaration and one changed assignment. A branch keyed on document content would have to be revisited for every combination of build and file, and the one it gets wrong is usually the one nobody opened

The record handling described here is part of PDFium Component for Delphi, Lazarus and C++Builder, with the same declaration serving Win32 and Win64 builds. An application that already ships pdfium.v8.dll for JavaScript-driven AcroForms can open the rest of its archive, XFA or not, through that one binary without special handling for the form environment