Skip to main content

stet_pdf_reader/
form_fields.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF interactive form fields (AcroForm).
6//!
7//! AcroForm declares interactive fields — text inputs, checkboxes,
8//! radio buttons, choice lists, signatures — at the document level.
9//! Each terminal field has a fully-qualified name (parent names joined
10//! with `.`) and one or more *widget* annotations that give it a
11//! visible presence on a page.
12//!
13//! [`FormCatalog`] is the top-level container; [`FormField`] is a
14//! single field (terminal or non-terminal container) with type-tagged
15//! data in [`FieldKind`].
16//!
17//! ## Cross-linking with annotations
18//!
19//! Each terminal field carries a list of widget object numbers
20//! ([`FormField::widget_obj_nums`]). Consumers that want the
21//! corresponding [`Annotation`] data look up matching annotations on
22//! each page via [`PdfDocument::page_annotations`]; the annotation
23//! whose source object number equals the widget's is the one to use.
24//!
25//! [`Annotation`]: crate::Annotation
26//! [`PdfDocument::page_annotations`]: crate::PdfDocument::page_annotations
27
28use std::collections::HashSet;
29
30use crate::diagnostics::{LocationHint, ParsePhase, Severity, WarningSink};
31use crate::metadata::pdf_string_to_rust_pub;
32use crate::objects::{PdfDict, PdfObj};
33use crate::resolver::Resolver;
34
35/// Maximum form-field tree depth.
36///
37/// Real-world AcroForms rarely exceed depth 4. 32 is generous.
38const MAX_FIELD_DEPTH: u32 = 32;
39
40/// Maximum total fields parsed from a single AcroForm.
41const MAX_FORM_FIELDS: usize = 100_000;
42
43/// Top-level AcroForm dictionary.
44#[derive(Debug, Clone, Default)]
45pub struct FormCatalog {
46    /// Field tree (top-level fields and their descendants).
47    pub fields: Vec<FormField>,
48    /// `/NeedAppearances` — viewer must regenerate appearance
49    /// streams. Default `false`.
50    pub need_appearances: bool,
51    /// `/SigFlags` — bit 0 = signatures exist; bit 1 = append-only.
52    pub sig_flags: SigFlags,
53    /// `/CO` — array of fully-qualified field names defining
54    /// calculation order for fields with calculation actions.
55    pub calculation_order: Vec<String>,
56    /// `/DA` — default appearance string used by text fields lacking
57    /// their own.
58    pub default_appearance: Option<String>,
59    /// `/Q` — default quadding (0 = left, 1 = center, 2 = right).
60    pub quadding: u8,
61    /// Whether `/XFA` is present (XFA forms — deprecated in PDF 2.0
62    /// but still seen in the wild). The XFA payload itself is not
63    /// parsed; consumers can fetch it via the resolver if needed.
64    pub has_xfa: bool,
65}
66
67/// `/SigFlags` bit field.
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct SigFlags {
70    /// Bit 0: at least one signature field exists in the document.
71    pub signatures_exist: bool,
72    /// Bit 1: incremental updates only — modifying the document
73    /// outside of new signatures must be done via append.
74    pub append_only: bool,
75}
76
77impl SigFlags {
78    fn from_bits(bits: i64) -> Self {
79        Self {
80            signatures_exist: bits & 0x01 != 0,
81            append_only: bits & 0x02 != 0,
82        }
83    }
84}
85
86/// One form field — terminal (has a value) or non-terminal (container).
87#[derive(Debug, Clone)]
88pub struct FormField {
89    /// Fully-qualified field name: parent names joined with `.`.
90    /// Empty for fields lacking `/T`.
91    pub name: String,
92    /// Partial field name `/T` — just this node's name segment.
93    pub partial_name: String,
94    /// `/TU` — alternate (tooltip) name.
95    pub alternate_name: Option<String>,
96    /// `/TM` — mapping name for export.
97    pub mapping_name: Option<String>,
98    /// Common field flags from `/Ff` bits 1, 2, 3.
99    pub flags: FieldFlags,
100    /// Field type and subtype-specific data. `Container` for
101    /// non-terminal nodes that exist purely to namespace children.
102    pub kind: FieldKind,
103    /// Current value (`/V`).
104    pub value: FieldValue,
105    /// Default value (`/DV`).
106    pub default_value: FieldValue,
107    /// Object numbers of widget annotations attached to this field.
108    /// Cross-link with [`PdfDocument::page_annotations`] to fetch the
109    /// renderable widgets.
110    ///
111    /// [`PdfDocument::page_annotations`]: crate::PdfDocument::page_annotations
112    pub widget_obj_nums: Vec<u32>,
113    /// Child fields (for container nodes; empty for terminal fields
114    /// in the common single-widget case).
115    pub children: Vec<FormField>,
116    /// `/AA` additional-actions presence — bookkeeping flag, not a
117    /// parsed action set.
118    pub has_additional_actions: bool,
119}
120
121/// `/Ff` flags shared by all field types.
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
123pub struct FieldFlags {
124    /// Bit 1 — `ReadOnly`.
125    pub read_only: bool,
126    /// Bit 2 — `Required` (must have a value when form is submitted).
127    pub required: bool,
128    /// Bit 3 — `NoExport` (do not include when exporting form data).
129    pub no_export: bool,
130}
131
132impl FieldFlags {
133    fn from_bits(bits: i64) -> Self {
134        Self {
135            read_only: bits & 0x0000_0001 != 0,
136            required: bits & 0x0000_0002 != 0,
137            no_export: bits & 0x0000_0004 != 0,
138        }
139    }
140}
141
142/// Field type plus subtype-specific data.
143#[derive(Debug, Clone)]
144#[non_exhaustive]
145pub enum FieldKind {
146    /// `/FT /Btn`.
147    Button(ButtonField),
148    /// `/FT /Tx`.
149    Text(TextField),
150    /// `/FT /Ch`.
151    Choice(ChoiceField),
152    /// `/FT /Sig`.
153    Signature(SignatureField),
154    /// Non-terminal container — no `/FT`, exists to namespace children.
155    Container,
156    /// `/FT` is present but the value isn't one we recognise; raw
157    /// name preserved.
158    Other { ft: String },
159}
160
161/// `/FT /Btn` — pushbutton, checkbox, or radio button.
162#[derive(Debug, Clone, Default)]
163pub struct ButtonField {
164    pub button_type: ButtonType,
165    /// `/Opt` — for radio groups, the export value of each child
166    /// widget in declaration order.
167    pub options: Vec<String>,
168    /// Bit 15 — `NoToggleToOff` (radio): one button must always be on.
169    pub no_toggle_to_off: bool,
170    /// Bit 16 — `Radio`: this is a radio group (else checkbox).
171    pub is_radio: bool,
172    /// Bit 17 — `Pushbutton`: action button, no value.
173    pub is_pushbutton: bool,
174    /// Bit 26 — `RadiosInUnison`: radios with same /V toggle together.
175    pub radios_in_unison: bool,
176}
177
178/// Resolved button kind (pre-classified for caller convenience).
179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180#[non_exhaustive]
181pub enum ButtonType {
182    /// Default kind: not pushbutton, not radio.
183    #[default]
184    Checkbox,
185    Radio,
186    Pushbutton,
187}
188
189/// `/FT /Tx` — text input.
190#[derive(Debug, Clone, Default)]
191pub struct TextField {
192    /// `/MaxLen` — maximum character count; `None` for no limit.
193    pub max_length: Option<u32>,
194    /// Bit 13 — `Multiline`.
195    pub multiline: bool,
196    /// Bit 14 — `Password`.
197    pub password: bool,
198    /// Bit 21 — `FileSelect` (input is a file path).
199    pub file_select: bool,
200    /// Bit 23 — `DoNotSpellCheck`.
201    pub do_not_spell_check: bool,
202    /// Bit 24 — `DoNotScroll`.
203    pub do_not_scroll: bool,
204    /// Bit 25 — `Comb` (fixed-width per-character).
205    pub comb: bool,
206    /// Bit 26 — `RichText`.
207    pub rich_text: bool,
208    /// `/DA` — default appearance string.
209    pub default_appearance: Option<String>,
210    /// `/Q` — quadding override.
211    pub quadding: Option<u8>,
212    /// `/RV` — rich-text value (XHTML/XFA fragment).
213    pub rich_value: Option<String>,
214}
215
216/// `/FT /Ch` — list box or combo box.
217#[derive(Debug, Clone, Default)]
218pub struct ChoiceField {
219    /// `/Opt` — choices.
220    pub options: Vec<ChoiceOption>,
221    /// `/TI` — top index for scrolling list boxes.
222    pub top_index: u32,
223    /// `/I` — currently-selected indices (for multi-select).
224    pub selected_indices: Vec<u32>,
225    /// Bit 18 — `Combo` (else list box).
226    pub combo: bool,
227    /// Bit 19 — `Edit` (combo with editable text field).
228    pub edit: bool,
229    /// Bit 20 — `Sort`.
230    pub sort: bool,
231    /// Bit 22 — `MultiSelect`.
232    pub multi_select: bool,
233    /// Bit 23 — `DoNotSpellCheck`.
234    pub do_not_spell_check: bool,
235    /// Bit 27 — `CommitOnSelChange`.
236    pub commit_on_sel_change: bool,
237}
238
239/// One entry in `/Opt`.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct ChoiceOption {
242    /// Internal export value (often equals `display` if the option is
243    /// just a single string).
244    pub export: String,
245    /// User-visible label.
246    pub display: String,
247}
248
249/// `/FT /Sig` — digital signature.
250#[derive(Debug, Clone, Default)]
251pub struct SignatureField {
252    /// `/Lock` dict presence (locks fields after signing).
253    pub has_lock: bool,
254    /// `/SV` (seed value) dict presence.
255    pub has_seed_value: bool,
256}
257
258/// Field value. PDF stores values type-erased; the form-walker maps
259/// each `/V` (or `/DV`) to one of these variants based on the field
260/// type and the value's PDF type.
261#[derive(Debug, Clone, Default, PartialEq)]
262#[non_exhaustive]
263pub enum FieldValue {
264    /// `/V` is absent or `null`.
265    #[default]
266    None,
267    /// Text-field value or rich-text fallback.
268    Text(String),
269    /// Checkbox / radio name (e.g. `/Yes`, `/Off`).
270    Name(String),
271    /// Multi-select list — array of strings.
272    Array(Vec<String>),
273    /// Boolean (rare; some signature seed-values use booleans).
274    Bool(bool),
275    /// Integer (rare).
276    Integer(i64),
277}
278
279impl FieldValue {
280    fn from_pdf_object(obj: &PdfObj) -> FieldValue {
281        match obj {
282            PdfObj::Null => FieldValue::None,
283            PdfObj::Bool(b) => FieldValue::Bool(*b),
284            PdfObj::Int(n) => FieldValue::Integer(*n),
285            PdfObj::Real(r) => FieldValue::Integer(*r as i64),
286            PdfObj::Name(n) => FieldValue::Name(String::from_utf8_lossy(n).into_owned()),
287            PdfObj::Str(s) => FieldValue::Text(crate::metadata::decode_pdf_text_string_pub(s)),
288            PdfObj::Array(arr) => {
289                let strings: Vec<String> = arr
290                    .iter()
291                    .filter_map(|o| match o {
292                        PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
293                        PdfObj::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
294                        _ => None,
295                    })
296                    .collect();
297                if strings.is_empty() {
298                    FieldValue::None
299                } else {
300                    FieldValue::Array(strings)
301                }
302            }
303            _ => FieldValue::None,
304        }
305    }
306}
307
308/// Parse the document's `/AcroForm` if present.
309///
310/// Returns `None` when the catalog has no `/AcroForm` entry. Always
311/// returns a populated value otherwise; malformed sub-entries are
312/// tolerated (defaulted) rather than fatal, and structural truncations
313/// (cycle, depth-cap, field-cap) push [`ParseWarning`]s into `sink`.
314///
315/// [`ParseWarning`]: crate::ParseWarning
316pub fn parse_acroform(resolver: &Resolver, sink: &WarningSink) -> Option<FormCatalog> {
317    let catalog = catalog_dict(resolver)?;
318    let acroform_obj = catalog.get(b"AcroForm")?;
319    let acroform = resolver.deref(acroform_obj).ok()?;
320    let dict = acroform.as_dict()?;
321
322    let mut form = FormCatalog {
323        need_appearances: dict
324            .get(b"NeedAppearances")
325            .and_then(as_bool)
326            .unwrap_or(false),
327        sig_flags: SigFlags::from_bits(dict.get_int(b"SigFlags").unwrap_or(0)),
328        default_appearance: dict.get(b"DA").and_then(pdf_string_to_rust_pub),
329        quadding: dict.get_int(b"Q").unwrap_or(0).clamp(0, 2) as u8,
330        has_xfa: dict.get(b"XFA").is_some(),
331        ..Default::default()
332    };
333
334    if let Some(co_arr) = dict.get_array(b"CO") {
335        form.calculation_order = co_arr
336            .iter()
337            .filter_map(|o| match o {
338                PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
339                _ => None,
340            })
341            .collect();
342    }
343
344    if let Some(fields_arr) = dict.get_array(b"Fields") {
345        let mut visited = HashSet::new();
346        let mut total_fields = 0usize;
347        let mut roots = Vec::with_capacity(fields_arr.len());
348        for field_obj in fields_arr {
349            if let Some(field) = walk_field(
350                resolver,
351                field_obj,
352                "",
353                &FieldDefaults::from_form(&form),
354                &mut visited,
355                &mut total_fields,
356                0,
357                sink,
358            ) {
359                roots.push(field);
360            }
361        }
362        form.fields = roots;
363    }
364
365    Some(form)
366}
367
368/// Inheritable defaults that flow from the AcroForm or a parent field
369/// down to terminal children. Currently we don't expose these on
370/// children that override them; the per-field structs carry their
371/// own values when present, parent inheritance happens at parse time.
372#[derive(Clone, Default)]
373struct FieldDefaults {
374    da: Option<String>,
375    q: u8,
376}
377
378impl FieldDefaults {
379    fn from_form(form: &FormCatalog) -> Self {
380        Self {
381            da: form.default_appearance.clone(),
382            q: form.quadding,
383        }
384    }
385
386    fn merge(&self, dict: &PdfDict) -> Self {
387        Self {
388            da: dict
389                .get(b"DA")
390                .and_then(pdf_string_to_rust_pub)
391                .or_else(|| self.da.clone()),
392            q: dict
393                .get_int(b"Q")
394                .map(|n| n.clamp(0, 2) as u8)
395                .unwrap_or(self.q),
396        }
397    }
398}
399
400#[allow(clippy::too_many_arguments)] // recursive walker; context struct doesn't pay off
401fn walk_field(
402    resolver: &Resolver,
403    field_obj: &PdfObj,
404    parent_qualified_name: &str,
405    parent_defaults: &FieldDefaults,
406    visited: &mut HashSet<u32>,
407    total_fields: &mut usize,
408    depth: u32,
409    sink: &WarningSink,
410) -> Option<FormField> {
411    if depth >= MAX_FIELD_DEPTH {
412        sink.record(
413            ParsePhase::Form,
414            Some(LocationHint::FieldName(parent_qualified_name.to_string())),
415            Severity::Error,
416            format!(
417                "form-field depth limit {MAX_FIELD_DEPTH} reached; \
418                 deeper sub-fields dropped"
419            ),
420        );
421        return None;
422    }
423    if *total_fields >= MAX_FORM_FIELDS {
424        sink.record(
425            ParsePhase::Form,
426            None,
427            Severity::Error,
428            format!(
429                "form-field count limit {MAX_FORM_FIELDS} reached; \
430                 remaining fields dropped"
431            ),
432        );
433        return None;
434    }
435    let obj_num = field_obj.as_ref().map(|(n, _)| n);
436    if let Some(n) = obj_num
437        && !visited.insert(n)
438    {
439        sink.record(
440            ParsePhase::Form,
441            Some(LocationHint::Object {
442                obj_num: n,
443                gen_num: 0,
444            }),
445            Severity::Warning,
446            "form-field cycle detected; sub-tree truncated",
447        );
448        return None;
449    }
450
451    let resolved = resolver.deref(field_obj).ok()?;
452    let dict = resolved.as_dict()?;
453    *total_fields += 1;
454
455    let partial_name = dict
456        .get(b"T")
457        .and_then(pdf_string_to_rust_pub)
458        .unwrap_or_default();
459    let qualified_name = if parent_qualified_name.is_empty() {
460        partial_name.clone()
461    } else if partial_name.is_empty() {
462        parent_qualified_name.to_string()
463    } else {
464        format!("{parent_qualified_name}.{partial_name}")
465    };
466
467    let alternate_name = dict.get(b"TU").and_then(pdf_string_to_rust_pub);
468    let mapping_name = dict.get(b"TM").and_then(pdf_string_to_rust_pub);
469    let ff_bits = dict.get_int(b"Ff").unwrap_or(0);
470    let flags = FieldFlags::from_bits(ff_bits);
471    let has_additional_actions = dict.get(b"AA").is_some();
472
473    let value = dict
474        .get(b"V")
475        .map(FieldValue::from_pdf_object)
476        .unwrap_or_default();
477    let default_value = dict
478        .get(b"DV")
479        .map(FieldValue::from_pdf_object)
480        .unwrap_or_default();
481
482    let merged_defaults = parent_defaults.merge(dict);
483
484    let mut widget_obj_nums = Vec::new();
485    let mut children = Vec::new();
486
487    let ft = dict.get_name(b"FT").map(|n| n.to_vec());
488    let kind = match ft.as_deref() {
489        Some(b"Btn") => FieldKind::Button(parse_button(dict, ff_bits)),
490        Some(b"Tx") => FieldKind::Text(parse_text(dict, ff_bits, &merged_defaults)),
491        Some(b"Ch") => FieldKind::Choice(parse_choice(dict, ff_bits)),
492        Some(b"Sig") => FieldKind::Signature(SignatureField {
493            has_lock: dict.get(b"Lock").is_some(),
494            has_seed_value: dict.get(b"SV").is_some(),
495        }),
496        Some(other) => FieldKind::Other {
497            ft: String::from_utf8_lossy(other).into_owned(),
498        },
499        None => FieldKind::Container,
500    };
501
502    // Self-as-widget: if the field dict carries /Subtype /Widget, the
503    // field itself is its sole widget.
504    if dict.get_name(b"Subtype") == Some(b"Widget")
505        && let Some(n) = obj_num
506    {
507        widget_obj_nums.push(n);
508    }
509
510    // Walk /Kids: each kid is either a child field (has /T or /FT) or
511    // a widget annotation (just /Subtype /Widget) attached to this
512    // terminal field.
513    if let Some(kids_arr) = dict.get_array(b"Kids") {
514        for kid_obj in kids_arr {
515            let Ok(kid_resolved) = resolver.deref(kid_obj) else {
516                continue;
517            };
518            let Some(kid_dict) = kid_resolved.as_dict() else {
519                continue;
520            };
521            let kid_obj_num = kid_obj.as_ref().map(|(n, _)| n);
522            let kid_is_widget = kid_dict.get_name(b"Subtype") == Some(b"Widget");
523            let kid_has_field_keys = kid_dict.get(b"T").is_some() || kid_dict.get(b"FT").is_some();
524
525            if kid_is_widget && !kid_has_field_keys {
526                if let Some(n) = kid_obj_num {
527                    widget_obj_nums.push(n);
528                }
529                continue;
530            }
531
532            // Recurse — kid is a sub-field (possibly itself a widget).
533            if let Some(child) = walk_field(
534                resolver,
535                kid_obj,
536                &qualified_name,
537                &merged_defaults,
538                visited,
539                total_fields,
540                depth + 1,
541                sink,
542            ) {
543                children.push(child);
544            }
545        }
546    }
547
548    Some(FormField {
549        name: qualified_name,
550        partial_name,
551        alternate_name,
552        mapping_name,
553        flags,
554        kind,
555        value,
556        default_value,
557        widget_obj_nums,
558        children,
559        has_additional_actions,
560    })
561}
562
563fn parse_button(dict: &PdfDict, ff: i64) -> ButtonField {
564    let no_toggle_to_off = ff & (1 << 14) != 0;
565    let is_radio = ff & (1 << 15) != 0;
566    let is_pushbutton = ff & (1 << 16) != 0;
567    let radios_in_unison = ff & (1 << 25) != 0;
568
569    let button_type = if is_pushbutton {
570        ButtonType::Pushbutton
571    } else if is_radio {
572        ButtonType::Radio
573    } else {
574        ButtonType::Checkbox
575    };
576
577    let options = dict
578        .get_array(b"Opt")
579        .map(|arr| {
580            arr.iter()
581                .filter_map(|o| match o {
582                    PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
583                    PdfObj::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
584                    _ => None,
585                })
586                .collect()
587        })
588        .unwrap_or_default();
589
590    ButtonField {
591        button_type,
592        options,
593        no_toggle_to_off,
594        is_radio,
595        is_pushbutton,
596        radios_in_unison,
597    }
598}
599
600fn parse_text(dict: &PdfDict, ff: i64, defaults: &FieldDefaults) -> TextField {
601    TextField {
602        max_length: dict.get_int(b"MaxLen").and_then(|n| u32::try_from(n).ok()),
603        multiline: ff & (1 << 12) != 0,
604        password: ff & (1 << 13) != 0,
605        file_select: ff & (1 << 20) != 0,
606        do_not_spell_check: ff & (1 << 22) != 0,
607        do_not_scroll: ff & (1 << 23) != 0,
608        comb: ff & (1 << 24) != 0,
609        rich_text: ff & (1 << 25) != 0,
610        default_appearance: dict
611            .get(b"DA")
612            .and_then(pdf_string_to_rust_pub)
613            .or_else(|| defaults.da.clone()),
614        quadding: dict.get_int(b"Q").map(|n| n.clamp(0, 2) as u8),
615        rich_value: dict.get(b"RV").and_then(pdf_string_to_rust_pub),
616    }
617}
618
619fn parse_choice(dict: &PdfDict, ff: i64) -> ChoiceField {
620    let options = dict
621        .get_array(b"Opt")
622        .map(|arr| {
623            arr.iter()
624                .filter_map(|o| match o {
625                    PdfObj::Str(s) => {
626                        let v = crate::metadata::decode_pdf_text_string_pub(s);
627                        Some(ChoiceOption {
628                            export: v.clone(),
629                            display: v,
630                        })
631                    }
632                    PdfObj::Name(n) => {
633                        let v = String::from_utf8_lossy(n).into_owned();
634                        Some(ChoiceOption {
635                            export: v.clone(),
636                            display: v,
637                        })
638                    }
639                    PdfObj::Array(pair) if pair.len() == 2 => {
640                        let export = match &pair[0] {
641                            PdfObj::Str(s) => crate::metadata::decode_pdf_text_string_pub(s),
642                            PdfObj::Name(n) => String::from_utf8_lossy(n).into_owned(),
643                            _ => return None,
644                        };
645                        let display = match &pair[1] {
646                            PdfObj::Str(s) => crate::metadata::decode_pdf_text_string_pub(s),
647                            PdfObj::Name(n) => String::from_utf8_lossy(n).into_owned(),
648                            _ => return None,
649                        };
650                        Some(ChoiceOption { export, display })
651                    }
652                    _ => None,
653                })
654                .collect()
655        })
656        .unwrap_or_default();
657
658    let selected_indices = dict
659        .get_array(b"I")
660        .map(|arr| {
661            arr.iter()
662                .filter_map(|o| o.as_int().and_then(|n| u32::try_from(n).ok()))
663                .collect()
664        })
665        .unwrap_or_default();
666
667    ChoiceField {
668        options,
669        top_index: dict
670            .get_int(b"TI")
671            .and_then(|n| u32::try_from(n).ok())
672            .unwrap_or(0),
673        selected_indices,
674        combo: ff & (1 << 17) != 0,
675        edit: ff & (1 << 18) != 0,
676        sort: ff & (1 << 19) != 0,
677        multi_select: ff & (1 << 21) != 0,
678        do_not_spell_check: ff & (1 << 22) != 0,
679        commit_on_sel_change: ff & (1 << 26) != 0,
680    }
681}
682
683fn as_bool(obj: &PdfObj) -> Option<bool> {
684    match obj {
685        PdfObj::Bool(b) => Some(*b),
686        _ => None,
687    }
688}
689
690fn catalog_dict(resolver: &Resolver) -> Option<PdfDict> {
691    if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
692        && let Ok(obj) = resolver.resolve(num, gen_num)
693        && let Some(dict) = obj.as_dict()
694    {
695        return Some(dict.clone());
696    }
697    crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn field_flags_decode() {
706        let f = FieldFlags::from_bits(0x07);
707        assert!(f.read_only && f.required && f.no_export);
708        let f = FieldFlags::from_bits(0x02);
709        assert!(!f.read_only && f.required && !f.no_export);
710    }
711
712    #[test]
713    fn sig_flags_decode() {
714        let s = SigFlags::from_bits(0x03);
715        assert!(s.signatures_exist && s.append_only);
716        let s = SigFlags::from_bits(0x01);
717        assert!(s.signatures_exist && !s.append_only);
718    }
719
720    #[test]
721    fn button_type_classification() {
722        // Pushbutton bit dominates radio bit.
723        let b = parse_button(&PdfDict::new(), (1 << 15) | (1 << 16));
724        assert_eq!(b.button_type, ButtonType::Pushbutton);
725        // Radio without pushbutton.
726        let b = parse_button(&PdfDict::new(), 1 << 15);
727        assert_eq!(b.button_type, ButtonType::Radio);
728        // Neither = checkbox.
729        let b = parse_button(&PdfDict::new(), 0);
730        assert_eq!(b.button_type, ButtonType::Checkbox);
731    }
732
733    #[test]
734    fn text_field_flags() {
735        let t = parse_text(
736            &PdfDict::new(),
737            (1 << 12) | (1 << 13),
738            &FieldDefaults::default(),
739        );
740        assert!(t.multiline && t.password);
741        assert!(!t.comb);
742    }
743
744    #[test]
745    fn choice_options_pair_form() {
746        let mut dict = PdfDict::new();
747        dict.insert(
748            b"Opt".to_vec(),
749            PdfObj::Array(vec![
750                PdfObj::Str(b"R".to_vec()),
751                PdfObj::Array(vec![
752                    PdfObj::Str(b"G".to_vec()),
753                    PdfObj::Str(b"Green".to_vec()),
754                ]),
755                PdfObj::Str(b"B".to_vec()),
756            ]),
757        );
758        let ch = parse_choice(&dict, 0);
759        assert_eq!(ch.options.len(), 3);
760        assert_eq!(ch.options[0].export, "R");
761        assert_eq!(ch.options[0].display, "R");
762        assert_eq!(ch.options[1].export, "G");
763        assert_eq!(ch.options[1].display, "Green");
764        assert_eq!(ch.options[2].export, "B");
765    }
766
767    #[test]
768    fn field_value_from_pdf() {
769        assert_eq!(FieldValue::from_pdf_object(&PdfObj::Null), FieldValue::None);
770        assert_eq!(
771            FieldValue::from_pdf_object(&PdfObj::Str(b"Scott".to_vec())),
772            FieldValue::Text("Scott".to_string())
773        );
774        assert_eq!(
775            FieldValue::from_pdf_object(&PdfObj::Name(b"Yes".to_vec())),
776            FieldValue::Name("Yes".to_string())
777        );
778        assert_eq!(
779            FieldValue::from_pdf_object(&PdfObj::Array(vec![
780                PdfObj::Str(b"a".to_vec()),
781                PdfObj::Str(b"b".to_vec()),
782            ])),
783            FieldValue::Array(vec!["a".to_string(), "b".to_string()])
784        );
785    }
786}