In PDFium Component, the PDFium-based VCL/LCL component for Delphi, C++Builder, and Lazarus, a form field index is not an annotation index. A page carries Link, Text, and Ink annotations next to its widgets, so field enumeration must filter on FPDFAnnot_GetSubtype and expose a zero-based logical index, mapped back to a real annotation position only at the native call
The bug that exposes this is unmistakable once you have seen it. A tester presses Tab in a filled invoice form and the caret vanishes, because focus went to a hyperlink in the footer. Or worse, nothing happens at all: your code records field 3 as focused, the UI panel updates, and FORM_SetFocusedAnnot quietly returned false the whole time. Both symptoms come from the same design mistake, and one of them has a second root cause hiding underneath
The two index spaces PDFium hands you
PDFium exposes two numbering schemes over the same page, and they only coincide on documents that happen to contain nothing but form widgets. The first is the annotation index: a position in the page /Annots array, which is what FPDFPage_GetAnnotCount counts and what FPDFPage_GetAnnot takes (ISO 32000-1 §12.5.2). The second is the logical field index that an application-level API should offer, running from zero over the interactive fields a user can actually reach. ISO 32000-1 §12.5.6.19 defines widget annotations as the visual representation of interactive form fields, and §12.7 defines the form itself. Everything else on the page is a different subtype with different semantics: a Link annotation has a destination, an Ink annotation has a stroke list, a Text annotation is a sticky note. None of them belong in a field count, and none of them can accept form focus. Yet in the /Annots array they sit interleaved with the widgets in whatever order the producing application wrote them, which is frequently not the order anything else about the document suggests
Why does Tab land on a hyperlink instead of the next field?
Because the field count was really an annotation count. The original implementation returned FPDFPage_GetAnnotCount directly from FormFieldCount, while the field information accessor, the tab order helper, and the focus helper all treated that same integer as a widget position. On a clean AcroForm page with six widgets and nothing else, six equals six and every test passes. Add a hyperlink in the footer and a reviewer comment in the margin, and the count reports eight fields, indices 6 and 7 resolve to non-form objects, and Tab walks straight into them
The fix at the enumeration end is to count subtypes rather than annotations. Open each annotation, ask for its subtype, keep the widgets, and close the handle in a finally block, because FPDFPage_GetAnnot returns an owned handle that must go back through FPDFPage_CloseAnnot
function WidgetCountForPage(Page: FPDF_PAGE): Integer;
var
Count, I: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := 0;
if Page = nil then
Exit;
Count := FPDFPage_GetAnnotCount(Page); // every annotation, not just fields
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
Inc(Result);
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
Note what this deliberately does not do. It does not ask the form-fill environment anything, and it does not need a form handle, because the subtype lives in the annotation dictionary and is readable from the page alone. That matters for ordering: the count is available before you have decided whether the document even deserves a form-fill environment, which the article on AcroForm JavaScript and host events covers as a security decision rather than a convenience one
Mapping the logical index back at the native boundary
The rule that keeps the two spaces from leaking into each other is simple: the logical index is the only number that crosses your public API, and it is converted to an annotation index in the last function before the native call. One mapping helper, used by field info, focus, flag setters, and tab order alike, is what makes that rule enforceable
function AnnotationIndexForField(Page: FPDF_PAGE;
FieldIndex: Integer): Integer;
var
Count, I, Current: Integer;
Annot: FPDF_ANNOTATION;
begin
Result := -1;
if (Page = nil) or (FieldIndex < 0) then
Exit;
Count := FPDFPage_GetAnnotCount(Page);
Current := 0;
for I := 0 to Count - 1 do
begin
Annot := FPDFPage_GetAnnot(Page, I);
if Annot = nil then
Continue;
try
if FPDFAnnot_GetSubtype(Annot) = FPDF_ANNOT_WIDGET then
begin
if Current = FieldIndex then
Exit(I); // real /Annots position: native calls only
Inc(Current);
end;
finally
FPDFPage_CloseAnnot(Annot);
end;
end;
end;
Two properties of this helper are worth stating plainly. It is a linear scan, so a naive loop over every field costs a quadratic number of annotation opens on a page with hundreds of widgets; if you are enumerating the whole page, walk the annotations once and collect the widget handles as you go instead of calling the mapper per field. And it returns -1 rather than raising, which lets the caller decide whether a stale index is a programming error worth an exception or a race worth ignoring, for example after an edit removed an annotation that a cached UI list still refers to
Why does FORM_SetFocusedAnnot fail on a headless page?
Because PDFium refuses to focus a widget whose page view was never marked valid. FORM_SetFocusedAnnot resolves the annotation to a page view inside the form-fill environment, and if that page view does not exist it returns false without any diagnostic. Correcting the index mapping alone therefore fixes Tab landing on a hyperlink but leaves the second symptom untouched: your logical focus record says field 3, the native focused widget is still nothing, and every accessor built on the native focus, focused text, focused value, choice selection state, keeps returning empty. The page view is created by FORM_OnAfterLoadPage and destroyed by FORM_OnBeforeClosePage. In a viewer built around a visual control those calls happen as part of displaying a page, which is why the failure so often looks like a headless-only bug: the same code that works in the GUI demo fails in the batch tool. The lifecycle belongs to the document object, not the viewer, so PDFium Component now issues both calls whenever a page is loaded or unloaded with a form handle present. The C signature takes the page first and the form handle second, which is easy to invert when writing the binding by hand
procedure ReportFirstField(const FileName: string);
var
Pdf: TPdf;
Idx: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FormFill := True; // form-fill environment, before Active
Pdf.FileName := FileName;
Pdf.Active := True;
Pdf.PageNumber := 1; // page load also runs FORM_OnAfterLoadPage
Idx := Pdf.FocusNextFormField; // logical index, 0-based over widgets
if Idx < 0 then
Exit; // page holds no widget annotations
Writeln(string(Pdf.FormFieldInfo[Idx].Name), ' = ',
string(Pdf.FocusedFormFieldValue)); // reads the native focused widget
finally
Pdf.Free; // page unload runs FORM_OnBeforeClosePage
end;
end;
The check that proves the fix is the one that compares the two sides. Call FocusFormField with a logical index, then read a value through an accessor that goes through the native focused widget rather than through your own record, such as FocusedFormFieldValue or FocusedFormOptionSelected. If the logical index round-trips but the native accessor comes back empty, the page view is missing, not the mapping
What the logical field index does not promise
A zero-based field index is a convenience, not a semantic identity, and four limits follow from that. It is per page, not per document, so index 0 on page 2 is a different widget from index 0 on page 1 and comparing them is meaningless. It is positional, so inserting or deleting an annotation invalidates every cached index above the change; treat a stored index as valid only for as long as the page stays loaded and unedited
The third limit is the one that surprises people reviewing a field list. The index enumerates widgets, not fields. A radio group is one field with several widget kids, so a three-button group contributes three consecutive indices that all report the same Name. The TPdfFormFieldInfo record carries GroupCount and GroupIndex for exactly this case, and a list UI that ignores them shows the same field three times. The fourth limit concerns traversal order: the tab order exposed here is the widget enumeration order, which follows the /Annots array, not the page /Tabs entry (ISO 32000-1 §7.7.3.3) and not the AcroForm field tree. For most producers those agree; for a form laid out in two columns by a generator that emitted the right column first, they do not, and the keyboard path described in the form field navigation article will feel wrong even though every index is correct. When a customer file behaves oddly, dump both index spaces side by side before theorizing: the annotation view and the field view of the same page, printed together, usually make the cause obvious in one glance
procedure DumpIndexSpaces(Pdf: TPdf);
var
I: Integer;
Info: TPdfFormFieldInfo;
begin
for I := 0 to Pdf.AnnotationCount - 1 do
Writeln('annot ', I, ': subtype ', Ord(Pdf.Annotation[I].Subtype));
for I := 0 to Pdf.FormFieldCount - 1 do
begin
Info := Pdf.FormFieldInfo[I];
Writeln('field ', I, ': ', string(Info.Name),
' widget ', Info.GroupIndex, ' of ', Info.GroupCount);
end;
end;
An annotation count far above the field count means the page mixes subtypes, which is normal in reviewed documents and is exactly the situation the mapping exists for; the annotation review workflow article looks at the same page from the markup side. Equal counts on every test file, on the other hand, mean your fixtures cannot detect this class of bug at all, and the honest response is to add a form fixture that carries a link and a sticky note
The field enumeration, focus, and annotation APIs described here ship with PDFium Component for Delphi, C++Builder, and Lazarus, whose product page carries the full form-field reference including the field information record and the focus accessors