A survey form shows two drop-down lists in Acrobat, group.a and group.b. Walk /AcroForm /Fields with code that treats any node carrying its own /FT as a finished field, and the count comes back as 1: a single field called group with two widgets. Ask for group.b by name and the lookup fails. Open the child's dictionary and there is no /FT in it at all, no /Ff, no option list. Nothing is broken. The type, the flags and the options sit one level up on the parent, and the PDF specification explicitly allows them to live there
The layout is legal and economical. An authoring tool writes a group node with /FT /Ch, /Ff 131078 and an /Opt array once, then hangs two named children underneath, each a merged field-plus-widget dictionary holding only /T, /Parent, /Rect and its own /V. Reading such a form correctly takes three things: following /Parent for every inheritable key, deciding where the field tree really ends, and making sure a reset writes a value of the right PDF type. This piece picks up where setting form field values in a loaded PDF with Delphi stops
Which AcroForm entries can a field inherit from its parent?
ISO 32000-1 §12.7.3.1 (Table 220) marks /FT, /Ff, /V and /DV as inheritable, and Table 229 in §12.7.4.3 does the same for a text field's /MaxLen. Option arrays get the same treatment here, because producers that share a choice list across children store /Opt on the group. A reader that only checks the local dictionary will therefore report the wrong type, flags of 0 and an empty value for a child that is perfectly valid
HotPDF Delphi Component answers every one of those reads the same way. It looks for the key in the field's own dictionary and, if the key is there, resolves the indirect reference and stops. Otherwise it follows /Parent and tries again, for at most 128 levels. That cap is not decoration. /Parent pointers in damaged files can form a loop that has nothing to do with /Kids, and a reader that trusts them unconditionally hangs on the first such file it meets
One rule in that walk is easy to get backwards: the first dictionary that contains the key wins, even when the value stored there is an empty string. A local /V () on group.b is the author saying "this child is blank", and the walk stops there instead of climbing on to a value the parent might hold. Treating empty as "missing" would make a cleared child display its parent's selection, which is the opposite of what the author wrote
The public getters all sit on that lookup: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue, and the option helpers GetLoadedFormFieldOptionCount and GetLoadedFormFieldOptions. So reading the child of the survey group needs no special code:
var
Pdf: THotPDF;
Field: THPDFLoadedFormField;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
// 'group' carries /FT /Ch, /Ff 131078 and /Opt; the child
// 'group.b' carries only /T, /Parent, /Rect and its own /V
Field := Pdf.GetFormField('group.b');
try
if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
begin
// 131078 = Combo (bit 18) + NoExport (bit 3) + Required (bit 2)
Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
Writeln(Pdf.IsFormFieldRequired(Field.Index)); // TRUE
Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
Writeln(Pdf.GetFormFieldValue(Field.Index)); // the local /V
end;
finally
Field.Free;
end;
finally
Pdf.Free;
end;
end;
GetFormField returns a new THPDFLoadedFormField wrapper each time and raises an exception when the name is unknown: keep the call outside the wrapper's try block and free the wrapper yourself
Finding where the field tree ends
A local /FT is the wrong test for a terminal field, because a parent can supply the type and still own named child fields. The tempting traversal stops descending whenever a node has its own /FT or has no /Kids. In the survey form, group has both /FT /Ch and /Kids, so that traversal registers it as one field with two widget annotations. The names group.a and group.b vanish. The field count is 1. A lookup by child name fails, and a value write can only land on the shared parent
HotPDF looks at the kids instead. A kid counts as a child field if it has its own /T, has its own /Kids, or is not a /Subtype /Widget dictionary. Only when no kid qualifies is the node terminal, and its kids are then treated as its widgets. Both edge cases behind that rule come from merged dictionaries, which §12.7.3.1 permits when a field has exactly one widget:
- A named merged dictionary carries
/Subtype /Widgetand is still a child field. The subtype alone cannot push it into the parent's anonymous widget list; the/Tdecides - Some producers repeat the parent's
/FTon every anonymous widget. So/FTon a widget cannot be read as "a new field starts here" either
The same classification drives the field index, FormFieldExists and RemoveFormField. Each of those walks also records the dictionaries it has already visited and gives up past 128 levels. Suppose the group is object 5 and lists itself twice among its own kids: /Kids [5 0 R 5 0 R 6 0 R 7 0 R]. It still reports exactly two fields, the children 6 and 7. No infinite recursion, and no phantom third field from the self-reference
How does RemoveFormField avoid deleting sibling fields?
RemoveFormField deletes only the child you name because discovery and deletion share one definition of a terminal field. The by-name overload resolves a field index from the field list, then walks /AcroForm /Fields a second time counting terminal fields to find the dictionary to delete. If the second walk used the tempting /FT rule while the first used the kid-based rule, the indexes would disagree: the deletion walk would see group as field 0, and removing group.a would take out the parent, the sibling and both widgets
So the deletion walk applies the same child-field test and the same visited set as discovery. It collects the widget annotations that belong to the removed child, strips only those from each page's /Annots, and removes the parent only when its /Kids array ends up empty. With group.b still listed, the parent stays, and the type, flags and options that group.b inherits stay with it
// Remove one named child; its sibling and the shared parent survive
Pdf.RemoveFormField('group.a');
// Sanity checks you would add yourself, not part of the library
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Type, flags and options are still resolved through the parent
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');
When you check a removal on your own forms, look in three places, because each one catches a different mistake: the parent's /Kids, the page /Annots, and the surviving sibling's value and appearance. Do it once after SaveLoadedDocument and once after SaveIncrementalUpdate, since an incremental save appends only the changed objects and the parent's /Kids must be among them
What ResetLoadedFormField copies from an inherited /DV
ResetLoadedFormField writes a local /V that is a fresh copy of the inherited /DV, with the same PDF object type, and it validates the complete default before it touches the field. A checkbox default is a name such as /Yes. A multi-select list box default is an array of strings. A text default may be a hexadecimal UTF-16 string like <FEFF...>. Round-trip any of those through GetLoadedFormFieldDefaultValue and SetFormFieldValue and you get a string where a name belongs, an empty string where an array was, or literal hex digits
The reset therefore branches on what the inherited /DV actually is:
- Text and choice fields with a string default get a new string object that keeps the hexadecimal flag of the original
- Choice fields with an array default get a new array of new strings; an array that contains anything other than strings raises an exception
- Checkboxes and radio buttons get a new name object
- A default of any other type raises an exception, and
/Vand/Istay exactly as they were
Copying instead of referencing is deliberate, and it is the only safe option. A child /V that pointed at the parent's /DV array would share one object between "current value" and "default value"; the next SetFormFieldValue on that child would quietly rewrite the default for every sibling. A few extra bytes per reset field is a cheap price for keeping the two apart. Pushbuttons, which have no value (Table 226, bit 17), and signature fields stay on a plain path that writes a string copy of their own /DV
When no /DV exists anywhere up the chain, the method writes a local empty string, or /Off for a checkbox or radio field. Deleting the child's /V would look tidier and give the wrong answer. If the parent holds a current value, removing the child's override brings that value straight back, and the "reset" field now shows something the user never typed into it
var
Field: THPDFLoadedFormField;
begin
// Parent 'palette' is a MultiSelect list box with
// /Opt [(b) (g) (r)] and /DV [(b) (r)]; the child holds only /T and /V
Field := Pdf.GetFormField('palette.primary');
try
try
Pdf.ResetLoadedFormField(Field.Index);
// The child now has its own /V [(b) (r)] and /I [0 2];
// the parent's /DV array is untouched
except
on E: Exception do
// Malformed default (wrong type, non-string array item):
// /V and /I were left as they were
Writeln('Reset skipped: ', E.Message);
end;
// The scalar getter cannot represent an array default
Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // empty
finally
Field.Free;
end;
Pdf.SaveLoadedDocument('survey-reset.pdf');
end;
A single-field reset is also a different thing from the ResetForm action of §12.7.5.3, which a viewer runs over a set of fields when the user clicks a button; that action is covered in building AcroForm fields and actions with HotPDF. ResetLoadedFormField edits one loaded field, follows its own rule for the no-default case, and marks the field as changed so incremental recalculation picks it up
Keeping /V, /I and /AS in agreement
A reset is only correct if the selection index and the appearance state follow the value, and ResetLoadedFormField finishes with the same two follow-up steps as SetFormFieldValue. For choice fields, it removes the child's local /I, matches each value against the export half of every /Opt entry, and writes a new sorted /I. Resetting to [(b) (r)] against options b, g, r yields /I [0 2]. The parent's inherited /I, if any, stays untouched
Buttons work from the inherited type. A child checkbox whose /FT /Btn lives on the parent still gets its /AS updated. On the write side, SetFormFieldValue and SetLoadedFormFieldDefaultValue store a name object for an inherited non-pushbutton button even when the child has no local entry to copy the type from. And when EnsureLoadedFieldAppearanceStream rebuilds button appearances, it writes /AS /Off unless the value matches the on state, and gives each state stream /Type /XObject, /Subtype /Form and a /BBox. Without that /Off check, regenerating appearances after a reset would show the box ticked again before the file is even saved
Scalar getters and other edges to plan around
The string getters stay scalar, and code that compares their output should know what they flatten. GetFormFieldValue and GetLoadedFormFieldDefaultValue return an empty string for an array value, turn numbers and booleans into 42 or true, and report a hex-encoded string in its hexadecimal spelling. If you need to know whether a list box default holds two selections or none, the default getter cannot tell you: both come back as an empty string
A /Parent cycle ends the lookup without raising, so a field whose type is lost inside a cycle reports lfftUnknown and flags of 0. Treat lfftUnknown on a named field as a sign of a damaged file worth logging. SetFormFieldValue and ResetLoadedFormField always write the child you address and never promote a value to the shared parent. That is right for independent children such as the two survey drop-downs. Radio groups are different: the selection belongs to the field that owns the group, so address that field rather than one of its kids
Each call also commits one field on its own. A loop of resets over twenty fields is twenty independent edits; if the fifth one raises on a malformed default, the first four are already applied. For batch work, read every target's type and default first, decide which fields you will touch, and only then start writing
Inherited attribute lookup, the kid-based field-tree classification and the typed reset all belong to the loaded-form API of HotPDF Delphi Component, which also covers Delphi and C++Builder projects that need to add new fields to an existing document, as shown in adding AcroForm fields to a loaded PDF in Delphi