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 named 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 beneath it, 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 leaves off
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 receive the same treatment here, because producers sharing a choice list across children store /Opt on the group. A reader checking only 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 in the same way. It looks for the key in the field's own dictionary and, finding it there, resolves the indirect reference and stops. Otherwise it follows /Parent and tries again, up to 128 levels at most. That cap is not decoration: /Parent pointers in damaged files can form a loop having nothing to do with /Kids, and a reader trusting them unconditionally hangs on the first such file it meets
One rule in that walk is easy to get backwards: the first dictionary containing 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 rather than climbing on towards a value the parent might hold. Treating empty as "missing" would make a cleared child display its parent's selection — 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. Reading the child of the survey group therefore 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 lacks /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, its kids then being 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 beyond 128 levels. Suppose the group is object 5 and lists itself twice amongst 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. Were the second walk to use the tempting /FT rule whilst 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
The deletion walk therefore applies the same child-field test and the same visited set as discovery. It collects the widget annotations belonging 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, each catching 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 amongst 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 touching 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 retains the hexadecimal flag of the original
- Choice fields with an array default get a new array of new strings; an array containing 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 pointing 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 small 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
Where 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 yet 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 then 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 that 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, where present, 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 has even been saved
Scalar getters and other edges to plan around
The string getters stay scalar, and code comparing 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 differ: the selection belongs to the field owning the group, so address that field rather than one of its kids
Each call also commits one field in its own right. A loop of resets over twenty fields is twenty independent edits; if the fifth 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 needing to add new fields to an existing document, as shown in adding AcroForm fields to a loaded PDF in Delphi