A list box export value made of Deep, a line feed and Blue is perfectly legal PDF, and so is a second option called Red. Pick both, flatten the selection into one LF-separated string for an exchange file, and the far side receives Deep\nBlue\nRed: two selections or three, and nothing in the bytes says which. Multi-select values come back intact from FDF or XFDF only when they stay arrays the whole way, from the moment they leave the PDF until they are written back into it
Why can a line-break join never carry a multi-select value?
A joined string loses the boundaries between values, and because a value is allowed to contain the separator, no importer can put those boundaries back. ISO 32000-1 §12.7.4.4 lets the /V entry of a choice field hold either a single text string or an array of text strings; a list box with the MultiSelect flag (bit 22 of /Ff) uses the array form as soon as more than one option is picked. The same section defines /I, an array of zero-based option indices in ascending order, which is how a viewer tells apart two options that share an export value. Neither structure fits through a string-typed API. GetFormFieldValue in HotPDF returns an AnsiString and reads the string form of /V, so any export built on it can only ever write one scalar per field
The import side has the mirror-image problem. XFDF writes each selection as its own <value> element, and an importer that hands those to a scalar setter has to glue them together first, usually with LF. With the Deep/Blue option above, that glue is indistinguishable from the data. HotPDF therefore keeps scalars out of the middle of the round trip: ExportLoadedFormToFDF, ExportLoadedInterchangeToFDF and ExportLoadedFormToXFDF read the array straight from the field, and the matching import methods build a new array without ever passing through a joined string
What the exported FDF and XFDF files contain
On disk the boundaries stay visible. In FDF a multi-select field becomes a typed array, and every item keeps the spelling it had in the source PDF: hexadecimal strings go out as hex, literal strings go out as literals with CR and LF escaped as \r and \n. In the file written by ExportLoadedFormToFDF, a rich-text /RV or a nonzero local /Ff on the field rides along in the same field dictionary. In XFDF the root element declares xml:space="preserve", which ISO 19444-1 requires and which turns every whitespace character inside a text element into data. That is a trap for pretty-printing. HotPDF writes the start tag, the escaped text and the end tag of each <value> on one line, keeps indentation outside the element, and encodes CR, LF and TAB as character references so that an XML parser applying line-ending normalisation cannot rewrite a CR LF pair into a lone LF
<!-- FDF: one typed array per field -->
<< /T (options) /V [(Deep\nBlue) (Red)] >>
<< /T (region) /V [<45553132>] >>
<!-- XFDF: one <value> per selection -->
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
<fields>
<field name="options">
<value>Deep
Blue</value>
<value>Red</value>
</field>
</fields>
</xfdf>
Two export edge cases matter when you write the calling code. ExportLoadedFormToFDF assembles the complete FDF body in memory and only then creates the target file, so a value that cannot be exported (an array holding something other than strings, for instance) raises while the previous .fdf on disk is still intact. The second case is a genuine gap in XFDF itself: an empty selection on a list box that also offers an empty-string export value. <value/> could mean "nothing selected" or "the empty option selected", and the format has no third spelling. ExportLoadedFormToXFDF raises in that situation, before the target file is opened. FDF has no such problem, because /V [] and /V [()] are different objects. All three exporters skip widget-only terminals that have no /T name, since no importer could ever match such an entry back to a field
var
Pdf: THotPDF;
Written: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
begin
// Multi-select list boxes are written as /V [(...) (...)]
Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
try
Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
except
on E: Exception do
// Empty selection plus an empty export option: XFDF cannot tell
// them apart, and the existing .xfdf file is left untouched
ShowMessage('XFDF export refused: ' + E.Message);
end;
end;
finally
Pdf.Free;
end;
end;
How is an imported selection checked against /Opt?
HotPDF accepts an incoming array only when the target is a choice field with the MultiSelect flag set and every value matches an export value in the field's /Opt array (for a two-element option pair, the first element). Each option slot can be used once. A list with two options that both export b therefore accepts [<62> <62>] as two distinct selections and rejects a third b, because there is no unused slot left for it. The rebuilt /I follows /Opt order regardless of the order the values arrived in, which keeps the indices ascending as §12.7.4.4 requires. The new /V and /I are built as detached objects and assigned only after every value has passed, so a rejected value cannot leave half an array or stale indices on the field
After the assignment the field gets a fresh appearance stream. That step is easy to forget in a hand-rolled importer, and the symptom is confusing: viewers draw the stored /AP, not /V, so the old selection stays on screen and in print even though the data changed. The list box also joins the changed-field set, which means a parameterless call to RecalculateLoadedFormFieldsIncremental reruns exactly the calculations that read it. Hex spellings that arrive from FDF stay hex through the save. If you only need to set one scalar value, setting one form field value in a loaded PDF covers that path, which by design does not deal with multiple selections
One input pattern deserves special handling. A PDF can store a plain ASCII export value as a hex string without a byte order mark, <416272> for Abr, and an exporter that copies the PDF string body verbatim produces XFDF whose element text is the raw digits 416272 rather than the decoded text. XFDF that carries a hex-encoded export value as its raw digits cannot match /Opt by spelling: a strict comparison finds no option called 416272 and the import fails. When the XFDF importer in HotPDF receives such a file, it retries the unmatched value once by decoding it as a hex payload. The retry is deliberately narrow. It runs only for a value with no exact match at all; every exact match is reserved first, so a rescued spelling can never take an option slot that a later, exactly spelled value needs; and the stored /V item is the option's own PDF string, so /V and /I describe the same selection. That is the right amount of leniency: it fires only where the import would otherwise have raised, and a value that already matched is never reinterpreted. Matching itself goes through one Unicode decoder that understands PDFDocEncoding, UTF-16 with either byte order mark and UTF-8, so the same logical text matches the same option even in documents that mix encodings
Two FDF parsing traps: hex terminators and foreign object numbers
The first trap is lexical. In << /T (region) /V <416273>>> the first > closes the hex string and the next two close the dictionary. A scanner that only looks for >> pairs ends the dictionary one character early and loses the field. HotPDF's file-level FDF importer tracks whether it is inside a hex string or a literal while it slices the /Fields array, and the stream-based reader behind ImportLoadedInterchangeFromFDF tokenises the whole file as PDF syntax, so both see three closing brackets where a naive scan sees two
The second trap is about references. An FDF file is a small PDF-syntax document with its own object numbering (ISO 32000-1 §12.7.7), so /V [11 0 R] means object 11 of the FDF file, never object 11 of the PDF being filled. Resolving it against the target document would quietly pull in whatever object happens to carry that number there. In Delphi 2009 and later builds, the interchange reader decodes the FDF as a complete object graph, follows the reference inside that graph, validates the resulting strings like any inline array, and raises if the FDF does not contain object 11. The file-level ImportLoadedFormFromFDF reads each field dictionary on its own without the FDF's object table; an array of references fails its strings-only check, and that field is skipped
Picking an import route: skip, raise, or roll back
All three import routes validate multi-select values the same way. They differ in what happens when a value fails. ImportLoadedFormFromFDF skips any field that fails and returns the number of fields it did apply, so a count lower than the number of entries you sent is the only sign of trouble. ImportLoadedFormFromXFDF and ImportLoadedInterchangeFromFDF raise at the first rejected field, and each call runs as one transaction on the loaded document: when the exception leaves the method, every field (and, for the interchange route, every annotation) that the call had already changed is rolled back. An interchange import that returns False is rolled back as well, with Status and the info record cleared
For data that a person reviewed and sent back, use one of the raising routes. A half-applied order form is worse than a rejected one, because nothing in the saved PDF shows which list boxes came from the reviewed file and which kept their old values. The skip-and-count behaviour of ImportLoadedFormFromFDF fits a different job: best-effort prefill from an FDF that may have been generated for a slightly different revision of the form
var
Pdf: THotPDF;
Source: TMemoryStream;
Status: AnsiString;
Info: THPDFFDFInterchangeInfo;
begin
Pdf := THotPDF.Create(nil);
Source := TMemoryStream.Create;
try
Source.LoadFromFile('order-form-reviewed.fdf');
if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
try
// Fields only. A value outside /Opt, or several values aimed at a
// single-select list, raises and rolls back the whole import
if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
begin
// Rerun only the calculations that read the imported fields
Pdf.RecalculateLoadedFormFieldsIncremental;
Pdf.SaveLoadedDocument('order-form-filled.pdf');
end;
except
on E: Exception do
ShowMessage('Import rejected, document unchanged: ' + E.Message);
end;
finally
Source.Free;
Pdf.Free;
end;
end;
Binding your own data store to the XFDF array callbacks
The HPDFXFDF unit, which converts between XFDF and any field store you can describe with callbacks, carries the array support in a separate record, THPDFXFDFArrayAccess, and in extra overloads of HPDFXFDFExportFields and HPDFXFDFImportFields. Appending new slots to the existing THPDFXFDFAccess record would have been shorter and wrong. Callers often declare that record as a local variable and fill in only the slots they know, so a new function pointer at the end would hold stack garbage that the library would mistake for a callback. With the separate record, existing code keeps its layout and its overloads, and those overloads pass an all-nil array record internally
The two import overloads behave differently on repeated <value> elements. The original scalar overload still joins them with LF and calls SetFormFieldValue, because existing callers depend on that convention. The array-aware overload never joins: it hands the list to SetFormFieldValueArray, falls back to the scalar setter only for a single value your callback declines, and raises when your callback declines a list of two or more. On export, start from Default(THPDFXFDFArrayAccess) so the slots you do not assign (including the optional SetFormFieldRV) stay nil. Return True from GetFormFieldValueArray for any list-valued field, including one with nothing selected, and False to let the scalar callback handle the field. An empty array goes out as a single empty <value/>, so a store that offers an empty-string option should refuse that case inside the callback, the same way ExportLoadedFormToXFDF does
uses HPDFXFDF;
// Plain function pointer, not "of object": Context carries your own store.
// TFormStore and its methods are your code, not part of HotPDF
function StoreGetSelections(Context: Pointer; FieldIndex: Integer;
out Values: THPDFXFDFValueArray): Boolean;
begin
Result := TFormStore(Context).IsListField(FieldIndex);
if Result then
Values := TFormStore(Context).Selections(FieldIndex);
end;
procedure ExportStore(Store: TFormStore; out Bytes: TBytes);
var
Access: THPDFXFDFAccess;
ArrayAccess: THPDFXFDFArrayAccess;
begin
Access := MakeStoreAccess(Store); // your existing scalar bindings
ArrayAccess := Default(THPDFXFDFArrayAccess); // every unused slot is nil
ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;
Everything above assumes list boxes that already exist and already have the MultiSelect bit set in /Ff. Creating choice fields and setting their flag bits is covered in adding ListBox and other AcroForm fields to a loaded PDF, and comment markup that travels through the <annots> part of XFDF is covered in XFDF annotation import and export in HotPDF. The method reference for every call shown here, plus a trial build to run them against your own forms, is on the HotPDF Delphi PDF component page