Technical Article

PDF Checkbox Flattening: Field Value vs Widget in Delphi

Checkboxes and radio buttons flatten as unchecked when the appearance state /AS is never synchronized with the field value /V. PDFium Component, the PDFium-based VCL and LCL component for Delphi, C++Builder, and Lazarus, reads that value with FPDFAnnot_GetFormFieldValue, which resolves the parent field dictionary instead of the widget annotation

The symptom is the kind you distrust at first. You flatten a signed consent form, open the result, and every checkbox is empty. Open the source file in Acrobat and the boxes are visibly ticked. Read the source file back and the field values are correct. Only the flattened output loses them, and only for checkboxes and radio buttons: text fields on the same page come out fine

Why are checkboxes unchecked after flattening?

Because flattening never looks at /V. FPDFPage_Flatten bakes the widget appearance stream into page content, and the appearance it picks is the one named by /AS. If /AS still says /Off while the field value says the box is on, flattening faithfully bakes the off appearance. The value was never lost; it was never consulted

FPDFPage_Flatten in Delphi bakes the checkbox appearance stream named by /AS into the page while the field value /V is never consulted, so a stale /AS /Off renders the flattened box unchecked
Flattening reads the /AS selector and never consults /V, so the stale Off appearance gets baked even though the field value still says the box is on

ISO 32000-1 §12.5.5 defines the appearance dictionary /AP with three possible entries, /N, /R, and /D. For a check box or radio button the /N entry is not a stream but a subdictionary whose keys are appearance state names, and §12.5.2 makes /AS the required selector when /N is a subdictionary. So a checkbox carries two prebuilt appearances and one pointer. Get the pointer wrong and the rendering is wrong in a way no amount of correct /V will repair. This is also why the failure mode differs from text fields, which have no prebuilt appearance to select at all: a text field /N is a single stream that must be regenerated from scratch after the value changes, so GenerateFormAppearances handles the two cases through completely separate code paths

Where does the checkbox value actually live?

On the field dictionary, not on the widget. ISO 32000-1 §12.7.5.2 describes check boxes and radio buttons as button fields whose /V is a name object naming the current appearance state, and §12.7.3.1 places /V among the entries common to all field dictionaries. The widget annotation defined in §12.5.6.19 contributes /AS and /AP. Nothing in the specification obliges a widget to carry /V

Checkbox field object model in Delphi: the parent field dictionary owns /V /On while kid widget annotations carry only /AS and /AP, so FPDFAnnot_GetStringValue finds nothing and FPDFAnnot_GetFormFieldValue resolves the parent and returns On
The value lives on the parent field dictionary while kid widgets carry only /AS and /AP, so a widget-level read of /V comes back empty and the form-field API resolves the parent instead
// Wrong: reads the widget annotation dictionary directly
buflen := FPDFAnnot_GetStringValue(Annot, 'V', nil, 0);
// For most real forms buflen comes back as 2 (an empty UTF-16 string),
// so /AS is never written and the box flattens as Off

{ What the two objects look like when the field has several widgets:

  12 0 obj                          % field dictionary (the parent)
  << /FT /Btn  /T (Consent)  /V /On
     /Kids [ 13 0 R 14 0 R ] >>
  endobj

  13 0 obj                          % widget annotation (a kid)
  << /Type /Annot  /Subtype /Widget  /Parent 12 0 R
     /AS /Off
     /AP << /N << /On 20 0 R  /Off 21 0 R >> >> >>
  endobj }

FPDFAnnot_GetStringValue is not defective. Its contract is exactly what its name says: fetch a string entry from the annotation dictionary you handed it. Asking it for /V on object 13 returns nothing because object 13 genuinely has no /V. The trap is a caller that assumes a flat object model that ISO 32000-1 never promised

When do field and widget share one dictionary?

Whenever a field has exactly one widget. §12.5.6.19 permits the field dictionary and its single widget annotation to be merged into one object, and most authoring tools take that shortcut. In a merged object /FT, /T, /V, /AS, and /AP all sit side by side, so a widget-level read of /V succeeds and the mistake stays invisible

The moment a field owns two or more widgets the merge is impossible, and §12.7.3.1 requires the widgets to become /Kids of a separate field dictionary. Every radio group is in this shape by construction. So are consent checkboxes repeated in a header and a footer, and any field an authoring tool has copied to a second page. That is why a widget-level read looks correct on single-widget sample forms and fails on real-world files with split fields. If you walk widgets yourself rather than relying on the component, the same asymmetry shows up in enumeration order, and the notes on PDF form field navigation with PDFium Component cover how a page-level annotation walk relates to the document-level field tree

Reading the value the way PDFium intends

FPDFAnnot_GetFormFieldValue is the correct API. It takes the form handle as well as the annotation, which is the signal that matters: with the form-fill environment available, PDFium resolves the annotation to its form control and reads the value from the field object, so it returns the right answer for merged and split layouts alike

FPDF_FORMFIELD_CHECKBOX, FPDF_FORMFIELD_RADIOBUTTON:
  begin
    // /AP is prebuilt per state; only /AS has to be synchronised with /V.
    // FPDFAnnot_GetFormFieldValue resolves the parent field dictionary,
    // which is where ISO 32000-1 12.7.5.2 keeps the value.
    buflen := FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, nil, 0);
    if buflen >= 4 then
    begin
      SetLength(OrigVal, buflen div 2 - 1);
      FPDFAnnot_GetFormFieldValue(FFormHandle, Annot, PWideChar(OrigVal), buflen);
      FPDFAnnot_SetStringValue(Annot, 'AS', Pointer(OrigVal));
    end;
  end;

Two details in that snippet are easy to get wrong. The returned length is a byte count for UTF-16 text including the terminator, so the character count is buflen div 2 - 1 and a value of 2 means an empty string. The guard buflen >= 4 therefore means at least one real character, which is what keeps a field with no /V at all from having its /AS overwritten with an empty name

What /AS and /AP /N really agree on

They agree on a name, and the name is chosen by whoever produced the file. §12.7.5.2 requires the off state to be called /Off, and leaves the on state entirely to the producer. /Yes is a convention, not a rule. Acrobat writes /Yes, but plenty of generators write /On, /1, /Choice1, or a localized word, and a radio group normally gives every kid a distinct on-state name so the group can express which button is selected. This is precisely why copying /V verbatim into /AS is the right operation rather than a hack: for a checked control PDFium reports the on-state name that the file itself defines, and for an unchecked one it reports Off, so the value you write into /AS is guaranteed to be a key that exists in that widget /AP /N subdictionary. Hard-coding /Yes would work on Acrobat output and silently break everywhere else

Order of operations, and what to check before flattening

The sequence is fixed and unforgiving: enable form fill, assign values, regenerate appearances, flatten, then save. Skip the regeneration step and FPDFPage_Flatten finds empty or stale appearance streams and bakes them without complaint, which is a silent data loss rather than an error return

Fixed order of operations for form flattening in Delphi: enable form fill, assign field values, regenerate appearances, flatten, then save, with silent data loss when appearance regeneration is skipped
Form fill must be on before values are assigned, GenerateFormAppearances must sync /AS before flattening, and skipping that step lets flattening bake stale streams without an error
Pdf.FileName := FormPath;
Pdf.FormFill := True;          // required: FormHandle must exist
Pdf.Active := True;

Pdf.FormField[0] := 'On';      // writes /V only

Pdf.GenerateFormAppearances;   // syncs /AS for buttons, rebuilds /AP for text
if Pdf.FlattenAllPages(FLAT_PRINT) then
  Pdf.SaveAs('consent-flat.pdf');

Two cases deserve a closer look. First, radio groups: each kid normally defines its own on-state name, and under §12.5.5 a widget whose /AS matches no entry in its /AP /N has no appearance to select, so auditing a radio group with FPDFAnnot_GetFormControlIndex before flattening is worth the few lines. Second, none of this applies to XFA, where the value lives in an XML data packet rather than the AcroForm dictionaries, a separation covered in the notes on XFA field edits that are not persisted. The general lesson is worth keeping past this one case: whenever an API takes the form handle in addition to the annotation, it is telling you it will resolve the field hierarchy for you, and whenever it takes only the annotation it will read exactly the object you passed. That distinction also governs data exchange, since exporting and importing XFDF form data works in fully qualified field names, never in widget positions

Form flattening is one of those features that looks like a single API call and turns out to be a contract between three dictionaries. If you would rather work against a component that already encodes that contract, the PDFium Component for Delphi and C++Builder ships the appearance regeneration, flattening, and form field access described here as ordinary properties and methods