Skip to main content

pdfrum_form/script/
model.rs

1//! What the host tells the realm about the document, and about its fields.
2//!
3//! [`ScriptCascade`](super::ScriptCascade) holds no document on purpose, so
4//! its caller reads the catalog and hands the answers over. These are **plain
5//! records**: no `Resolve`, no `Dict`, no borrow of anything.
6//!
7//! It is **not a document handle**. Nothing here can reach back into the
8//! session, open a file or resolve an object, which is the whole of why
9//! `Doc.submitForm` cannot exfiltrate a document it was never given.
10//!
11//! It is **not a snapshot that updates itself** — but values *are*, because a
12//! calculation sweep writes them and a later script must read what it wrote.
13
14/// Everything the `Doc` object answers from.
15///
16/// Every field has a defined answer when the caller says nothing, and that
17/// answer is the oracle's for a document with no `/Info`, no `/Fields` and no
18/// path: empty strings, zero counts, and a page count of zero.
19#[derive(Debug, Clone, Default, PartialEq)]
20pub struct DocumentModel {
21    /// `Doc.numPages`. The page count as the viewer knows it.
22    pub page_count: u32,
23    /// `Doc.path` — the system path, with the oracle's leading separator.
24    ///
25    /// A leading `/` is prefixed when there is not one already, which is why
26    /// `path` reads `/myfile.pdf` where `URL` reads a bare `myfile.pdf`.
27    pub path: String,
28    /// `Doc.URL` — the same path, unprefixed. `JS_docGetFilePath()`.
29    pub url: String,
30    /// The words each page draws, in content order.
31    ///
32    /// **Installed by the caller**, like everything else here: counting them
33    /// needs a parsed content stream, which this model deliberately does not
34    /// hold. `pdfrum_text::content_words` is the reader for a `pdfrum` page, and an
35    /// empty list is the honest answer for a caller that did not supply one —
36    /// `getPageNumWords` then answers 0, which is what an empty page gives.
37    pub page_words: Vec<Vec<String>>,
38    /// The `/Info` entries, in the order the dictionary writes them.
39    ///
40    /// A `Vec` rather than a map because `Doc.info` enumerates the *whole*
41    /// dictionary after its nine fixed keys, in the file's own order, and a
42    /// sorted map would reorder what a golden pins.
43    pub info: Vec<(String, String)>,
44    /// Whether the document has an `/Info` dictionary at all.
45    ///
46    /// The distinction is load-bearing and is not "is `info` empty": with no
47    /// `/Info` at all, every metadata getter and `Doc.info` itself throw
48    /// `Object no longer exists.`, where an `/Info` present but empty answers
49    /// `""` for each. `GetPropertyInternal` returns `kBadObjectError` the
50    /// moment `GetInfoDict()` is null.
51    pub has_info: bool,
52    /// Every terminal field, in `/AcroForm /Fields` order.
53    ///
54    /// The list `Doc.numFields` counts and `Doc.getNthFieldName(n)` indexes,
55    /// and the space [`FieldRef::index`](crate::FieldRef::index) names.
56    pub fields: Vec<FieldModel>,
57    /// The named destinations `Doc.gotoNamedDest` can reach, and the page
58    /// each lands on.
59    pub named_destinations: Vec<(String, u32)>,
60    /// Every annotation `Doc.getAnnot` and `Doc.getAnnots` can see, by page.
61    ///
62    /// Pop-ups and widgets are **already excluded**: `getAnnots` skips both
63    /// subtypes, so a caller that includes them would make
64    /// `bug_421304870`'s count wrong. Filtering here rather than in the
65    /// binding keeps the rule beside the `/Annots` walk that can see the
66    /// subtypes.
67    pub annotations: Vec<AnnotModel>,
68    /// `Doc.calculate` — whether a recalculation sweep runs at all.
69    ///
70    /// `true` is the oracle's initial state
71    /// (`CPDFSDK_InteractiveForm`'s `calculate_` defaults on), and a script
72    /// may turn it off.
73    pub calculate: bool,
74}
75
76impl DocumentModel {
77    /// The model for a document with nothing in it — no pages, no fields, no
78    /// `/Info`.
79    ///
80    /// Not [`Default`], because `calculate` defaults **on** upstream and a
81    /// derived `Default` would say otherwise. Every other field's zero value
82    /// is already the right answer.
83    #[must_use]
84    pub fn empty() -> DocumentModel {
85        DocumentModel {
86            calculate: true,
87            ..DocumentModel::default()
88        }
89    }
90
91    /// The field at a `/Fields` position.
92    #[must_use]
93    pub fn field_at(&self, index: usize) -> Option<&FieldModel> {
94        self.fields.get(index)
95    }
96
97    /// Every terminal field a name reaches, in `/Fields` order.
98    ///
99    /// `CountFields(name)` is this list's length and `GetField(j, name)` is
100    /// its `j`th entry, so a name that is a whole subtree answers every leaf
101    /// under it rather than one — which is what makes
102    /// `AFSimple_Calculate('SUM', ['Group'])` add a group's fields.
103    pub fn fields_named<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a FieldModel> + 'a {
104        enum Filter {
105            None,
106            All,
107            Prefix { exact: String, under: String },
108        }
109        let filter = match self.node_of(name) {
110            FieldNode::Missing => Filter::None,
111            FieldNode::Root => Filter::All,
112            FieldNode::Named(prefix) => Filter::Prefix {
113                under: format!("{prefix}."),
114                exact: prefix,
115            },
116        };
117        self.fields.iter().filter(move |f| match &filter {
118            Filter::None => false,
119            Filter::All => true,
120            Filter::Prefix { exact, under } => f.name == *exact || f.name.starts_with(under),
121        })
122    }
123
124    /// The position of the field `Doc.getField(name)` resolves to.
125    ///
126    /// # A name may be a whole subtree, and it answers the first leaf under it
127    ///
128    /// The field tree has a node per **name segment**, not per terminal
129    /// field. So a name that only names structure — one whose kids each carry
130    /// their own `/T` — is still a node, and the answer is the first terminal
131    /// field beneath it.
132    ///
133    /// The object's `.name` still reads the name the *caller* asked for rather
134    /// than the field's own, which is why the `Field` object carries the lookup
135    /// name on the object.
136    ///
137    /// A prefix that is not a whole segment matches nothing: `MyFie` is not
138    /// a node.
139    #[must_use]
140    pub fn field_named(&self, name: &str) -> Option<usize> {
141        match self.node_of(name) {
142            FieldNode::Missing => None,
143            // The root: every field is under it, and `GetFieldAtIndex(0)` is
144            // the first.
145            FieldNode::Root => (!self.fields.is_empty()).then_some(0),
146            FieldNode::Named(prefix) => {
147                if let Some(exact) = self.fields.iter().position(|f| f.name == prefix) {
148                    return Some(exact);
149                }
150                let under = format!("{prefix}.");
151                self.fields.iter().position(|f| f.name.starts_with(&under))
152            }
153        }
154    }
155
156    /// How many terminal fields sit under the node a name reaches.
157    ///
158    /// Zero means `Doc.getField` answers `undefined`.
159    #[must_use]
160    pub fn count_fields(&self, name: &str) -> usize {
161        match self.node_of(name) {
162            // No node — `FindNode` answered null, and `CountFields` is 0.
163            FieldNode::Missing => 0,
164            // The root: every terminal field is under it.
165            FieldNode::Root => self.fields.len(),
166            FieldNode::Named(prefix) => {
167                let under = format!("{prefix}.");
168                self.fields
169                    .iter()
170                    .filter(|f| f.name == prefix || f.name.starts_with(&under))
171                    .count()
172            }
173        }
174    }
175
176    /// The prefix the node a name reaches spells.
177    ///
178    /// See [`FieldNode`] for the three answers.
179    ///
180    /// # The walk **stops at the first empty segment**, and that is the rule
181    ///
182    /// The name is split on `.`, consecutive dots yield empty segments, and
183    /// the walk `break`s on the first one — returning whatever node the
184    /// *previous* segment reached rather than failing.
185    ///
186    /// So `MyField..nonesuch` finds the `MyField` node: the walk consumes
187    /// `MyField`, meets the empty segment, and stops before `nonesuch` is ever
188    /// looked up. That is why `getField('MyField..nonesuch')` returns an object
189    /// where `getField('MyField.nonesuch')` returns `undefined` — one dot
190    /// apart.
191    fn node_of(&self, name: &str) -> FieldNode {
192        if name.is_empty() {
193            return FieldNode::Root;
194        }
195        let mut reached: Option<String> = None;
196        for segment in name.split('.') {
197            if segment.is_empty() {
198                // The extractor yielded an empty view; the walk stops here
199                // with whatever it had reached.
200                return FieldNode::of(reached);
201            }
202            let candidate = match &reached {
203                None => segment.to_string(),
204                Some(prefix) => format!("{prefix}.{segment}"),
205            };
206            let under = format!("{candidate}.");
207            let exists = self
208                .fields
209                .iter()
210                .any(|f| f.name == candidate || f.name.starts_with(&under));
211            if !exists {
212                // `Lookup` answered null, and the loop condition ends the
213                // walk with `node` null — no node at all.
214                return FieldNode::Missing;
215            }
216            reached = Some(candidate);
217        }
218        FieldNode::of(reached)
219    }
220}
221
222/// What `CFieldTree::FindNode` reached.
223///
224/// Three answers, not two, and a `Option<Option<String>>` would say the same
225/// thing far less legibly: the *root* and a *named* node are both real nodes
226/// with different subtrees, and no node at all is the third.
227#[derive(Debug, Clone, PartialEq, Eq)]
228enum FieldNode {
229    /// No node: the walk looked a segment up and found nothing.
230    Missing,
231    /// The root, which every terminal field is under. An empty name finds it.
232    Root,
233    /// A named node, and the prefix its subtree's fields share.
234    Named(String),
235}
236
237impl FieldNode {
238    /// The node a walk ending with `reached` arrived at.
239    fn of(reached: Option<String>) -> FieldNode {
240        match reached {
241            Some(prefix) => FieldNode::Named(prefix),
242            None => FieldNode::Root,
243        }
244    }
245}
246
247/// One terminal field, as a script sees it.
248#[derive(Debug, Clone, Default, PartialEq)]
249pub struct FieldModel {
250    /// The fully qualified name — `Field.name`.
251    pub name: String,
252    /// `Field.type`, as the oracle spells it: one of `button`, `checkbox`,
253    /// `radiobutton`, `combobox`, `listbox`, `text`, `signature`, `unknown`.
254    pub kind: FieldModelKind,
255    /// `Field.value`. Kept current: a calculation sweep writes here, so a
256    /// later script reads what an earlier one computed.
257    pub value: String,
258    /// `Field.defaultValue` — `/DV`.
259    pub default_value: String,
260    /// `Field.valueAsString`, when it differs from `value`.
261    ///
262    /// `None` means "the value itself", which is the ordinary case. A
263    /// check box answers the literal `Yes`/`Off` rather than its export
264    /// value, and a multi-select list answers `""`, so the two cannot always
265    /// be the same string.
266    pub value_as_string: Option<String>,
267    /// The options a choice field offers, as `(export value, label)`.
268    pub options: Vec<(String, String)>,
269    /// Which options are selected, by index — `Field.currentValueIndices`.
270    pub selected: Vec<u32>,
271    /// The `/Ff` bits, so the flag-derived properties answer without a second
272    /// vocabulary.
273    pub flags: FieldModelFlags,
274    /// `Field.display`: 0 visible, 1 hidden, 2 visible-but-not-printed,
275    /// 3 printed-but-not-viewed. Derived from `/F`.
276    pub display: u32,
277    /// `Field.rect` — `[left, top, right, bottom]`, which is the getter's
278    /// order and **not** the setter's.
279    pub rect: [f64; 4],
280    /// The pages this field's widgets are on — `Field.page`.
281    pub pages: Vec<u32>,
282    /// `Field.userName` — `/TU`, the tooltip.
283    pub user_name: String,
284    /// The export values of a check box's or radio group's controls —
285    /// `Field.exportValues`.
286    pub export_values: Vec<String>,
287    /// Whether each control is checked, for `isBoxChecked` and
288    /// `checkThisBox`.
289    pub checked: Vec<bool>,
290    /// Whether each control is checked **by default**, for
291    /// `isDefaultChecked`.
292    pub default_checked: Vec<bool>,
293    /// The three button captions — `/MK /CA`, `/AC`, `/RC` — which
294    /// `buttonGetCaption(nFace)` indexes.
295    pub captions: [String; 3],
296    /// `Field.buttonPosition` — `/MK /TP`, **clamped to `0..=6`**.
297    ///
298    /// The clamp is not a guard: `field.fragment`'s `MyBadPushButton` carries
299    /// `/TP 7` and the golden reads `buttonPosition = 0` for it, so an
300    /// out-of-range value becomes zero rather than being passed through.
301    pub button_position: u32,
302    /// `Field.borderStyle` — one of `solid`, `dashed`, `beveled`, `inset`,
303    /// `underline`. Empty until a getter or setter first names it, which
304    /// the getter then answers as `solid`.
305    pub border_style: String,
306}
307
308/// What kind of field a script sees, in the oracle's own vocabulary.
309///
310/// A separate enum from [`FieldKind`](pdfrum_doc::form::FieldKind) because
311/// the two do not agree: a `/Btn` is one PDF field type and **three**
312/// JavaScript ones, split by `/Ff`, and `Field.type`'s strings are API that
313/// a golden asserts verbatim.
314#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
315pub enum FieldModelKind {
316    /// A push button — `/Btn` with `/Ff` bit 17.
317    Button,
318    /// A check box — `/Btn` with neither bit 16 nor bit 17.
319    CheckBox,
320    /// A radio button — `/Btn` with `/Ff` bit 16.
321    RadioButton,
322    /// A drop-down — `/Ch` with `/Ff` bit 18.
323    ComboBox,
324    /// A list — `/Ch` without bit 18.
325    ListBox,
326    /// A text field — `/Tx`.
327    Text,
328    /// A signature — `/Sig`.
329    Signature,
330    /// A field the classifier could not name, which is what an absent or
331    /// unrecognized `/FT` gives.
332    #[default]
333    Unknown,
334}
335
336impl FieldModelKind {
337    /// The string `Field.type` answers, verbatim.
338    #[must_use]
339    pub fn as_str(self) -> &'static str {
340        match self {
341            FieldModelKind::Button => "button",
342            FieldModelKind::CheckBox => "checkbox",
343            FieldModelKind::RadioButton => "radiobutton",
344            FieldModelKind::ComboBox => "combobox",
345            FieldModelKind::ListBox => "listbox",
346            FieldModelKind::Text => "text",
347            FieldModelKind::Signature => "signature",
348            FieldModelKind::Unknown => "unknown",
349        }
350    }
351
352    /// Whether this is one of the two toggles `checkThisBox` and
353    /// `isBoxChecked` accept.
354    #[must_use]
355    pub fn is_toggle(self) -> bool {
356        matches!(self, FieldModelKind::CheckBox | FieldModelKind::RadioButton)
357    }
358
359    /// Whether this is one of the two choice families that carry options.
360    #[must_use]
361    pub fn is_choice(self) -> bool {
362        matches!(self, FieldModelKind::ComboBox | FieldModelKind::ListBox)
363    }
364}
365
366/// The `/Ff` bits a script reads, named rather than numbered.
367///
368/// A record of `bool`s rather than a bitfield because every consumer here is a
369/// single property getter answering one question, and the numbers are already
370/// decoded by the time the caller builds this.
371#[allow(
372    clippy::struct_excessive_bools,
373    reason = "one field per `/Ff` bit a script can read, named rather than \
374              numbered. They are eleven independent questions, not a state \
375              machine, and each is exactly one `Field` property's answer — \
376              packing them back into the bit word would put the decoding \
377              inside eleven getters instead of at the one place that reads \
378              `/Ff`."
379)]
380#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
381pub struct FieldModelFlags {
382    /// Bit 1 — `Field.readonly`.
383    pub read_only: bool,
384    /// Bit 2 — `Field.required`.
385    pub required: bool,
386    /// Bit 3 — the field is not submitted. No `Field` property reads it;
387    /// `Doc.submitForm`'s field walk is what it gates.
388    pub no_export: bool,
389    /// Bit 13 — `Field.multiline`.
390    pub multiline: bool,
391    /// Bit 14 — `Field.password`.
392    pub password: bool,
393    /// Bit 21 — `Field.fileSelect`.
394    pub file_select: bool,
395    /// Bit 23 — `Field.doNotSpellCheck`.
396    pub do_not_spell_check: bool,
397    /// Bit 24 — `Field.doNotScroll`.
398    pub do_not_scroll: bool,
399    /// Bit 25 — `Field.comb`.
400    pub comb: bool,
401    /// Bit 26 — `Field.richText`.
402    pub rich_text: bool,
403    /// Bit 19 — `Field.editable`, for a combo box.
404    pub editable: bool,
405    /// Bit 22 — `Field.multipleSelection`, for a list box.
406    pub multiple_selection: bool,
407}
408
409/// One annotation `Doc.getAnnot` can find and `Doc.getAnnots` lists.
410#[derive(Debug, Clone, Default, PartialEq, Eq)]
411pub struct AnnotModel {
412    /// Which page it is on.
413    pub page: u32,
414    /// `/NM` — `Annot.name`, and the key `getAnnot(nPage, name)` matches.
415    pub name: String,
416    /// `Annot.type`, as the subtype's own name: `Text`, `Square`, `Link`, …
417    pub kind: String,
418    /// `Annot.hidden` — the `/F` hidden bit.
419    pub hidden: bool,
420}
421
422/// Reads a whole document into a [`DocumentModel`].
423///
424/// The one place the object model meets a PDF, and it is deliberately a
425/// *function over a catalog* rather than a method on anything: it takes what
426/// it reads and hands back a value, so the cascade still holds no document
427/// and a caller with its own document type can write its own reader instead.
428///
429/// `path` is what `Doc.path` and `Doc.URL` answer, which no PDF carries — it
430/// is the file the host opened, and only the host knows it.
431///
432/// `info` is the **trailer's** `/Info`, which the catalog cannot reach.
433/// Passing `None` is not the same as passing an empty dictionary: with no
434/// `/Info` at all every metadata getter and `Doc.info` itself throw
435/// `Object no longer exists.`, where an empty one answers `""` eight times.
436///
437/// `pages` is each page's dictionary in order — the page count, the
438/// `/Annots` walk `Doc.getAnnots` reports, and the page each field's widgets
439/// sit on all come from it.
440#[must_use]
441pub fn read<R: pdfrum_object::Resolve>(
442    catalog: &pdfrum_object::Dict,
443    info: Option<&pdfrum_object::Dict>,
444    pages: &[pdfrum_object::Dict],
445    path: &str,
446    r: &R,
447) -> DocumentModel {
448    let page_count = u32::try_from(pages.len()).unwrap_or(u32::MAX);
449    let (limits, mut diags) = (
450        pdfrum_common::Limits::default(),
451        pdfrum_common::Diagnostics::default(),
452    );
453    let mut model = DocumentModel {
454        page_count,
455        // `SysPathToPDFPath` prefixes a `/` when there is not one already,
456        // which is the whole difference between `path` and `URL`.
457        path: if path.starts_with('/') {
458            path.to_string()
459        } else {
460            format!("/{path}")
461        },
462        url: path.to_string(),
463        ..DocumentModel::empty()
464    };
465    if path.is_empty() {
466        model.path.clear();
467    }
468
469    model.has_info = info.is_some();
470    if let Some(info) = info {
471        // Read through `get` rather than off `iter`'s value, because an
472        // `/Info` entry may be an indirect reference and `iter` hands back
473        // the reference rather than what it names.
474        model.info = info
475            .iter()
476            .map(|(key, _)| {
477                let text = info
478                    .get(key, r)
479                    .map(|value| value.get().to_text())
480                    .unwrap_or_default();
481                (String::from_utf8_lossy(key.as_bytes()).into_owned(), text)
482            })
483            .collect();
484    }
485
486    // Which page each widget is on, so `Field.page` can answer. Built once
487    // over the whole document rather than per field, because the question is
488    // "which page's `/Annots` holds this dictionary" and those arrays are the
489    // only place it is written down.
490    let widget_pages = widget_pages(pages, r);
491
492    if let Some(form) = pdfrum_doc::form::Form::load(catalog, r, &limits, &mut diags) {
493        model.fields = form
494            .fields
495            .iter()
496            .map(|field| read_field(field, &widget_pages, r))
497            .collect();
498    }
499
500    model.annotations = read_annotations(pages, r);
501    model.named_destinations = read_destinations(catalog, r, &limits, &mut diags);
502    model
503}
504
505/// Every widget dictionary's page, as pairs.
506///
507/// A `Vec` rather than a map because `Dict` is not `Hash` and the lists are
508/// short — a thousand-widget form is a hundred thousand comparisons, once,
509/// against a page walk that already cost more.
510fn widget_pages<R: pdfrum_object::Resolve>(
511    pages: &[pdfrum_object::Dict],
512    r: &R,
513) -> Vec<(pdfrum_object::Dict, u32)> {
514    let mut out = Vec::new();
515    for (index, page) in pages.iter().enumerate() {
516        let Some(annots) = page.array(pdfrum_object::names::ANNOTS, r) else {
517            continue;
518        };
519        for at in 0..annots.len() {
520            if let Some(dict) = annots.dict_at(at, r) {
521                out.push((dict, u32::try_from(index).unwrap_or(u32::MAX)));
522            }
523        }
524    }
525    out
526}
527
528/// Every annotation `Doc.getAnnots` lists.
529///
530/// **Pop-ups and widgets are excluded**, because `getAnnots` skips both
531/// subtypes — and a golden's whole assertion is the resulting count, so
532/// including them would be visibly wrong rather than merely generous.
533fn read_annotations<R: pdfrum_object::Resolve>(
534    pages: &[pdfrum_object::Dict],
535    r: &R,
536) -> Vec<AnnotModel> {
537    const NM: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"NM");
538    let mut out = Vec::new();
539    for (index, page) in pages.iter().enumerate() {
540        let Some(annots) = page.array(pdfrum_object::names::ANNOTS, r) else {
541            continue;
542        };
543        for at in 0..annots.len() {
544            let Some(dict) = annots.dict_at(at, r) else {
545                continue;
546            };
547            let subtype = dict
548                .byte_string(pdfrum_object::names::SUBTYPE, r)
549                .unwrap_or_default();
550            if subtype == b"Popup" || subtype == b"Widget" {
551                continue;
552            }
553            out.push(AnnotModel {
554                page: u32::try_from(index).unwrap_or(u32::MAX),
555                name: dict
556                    .get(NM, r)
557                    .map(|value| value.get().to_text())
558                    .unwrap_or_default(),
559                kind: String::from_utf8_lossy(&subtype).into_owned(),
560                hidden: dict.int(pdfrum_object::names::F, r).unwrap_or(0) & 0b10 != 0,
561            });
562        }
563    }
564    out
565}
566
567/// The named destinations `Doc.gotoNamedDest` can reach.
568///
569/// Both spellings: `/Names /Dests`, the name tree, and `/Dests`, the older
570/// flat dictionary — the tree is consulted first and the dictionary is the
571/// fallback, so a document using either works.
572///
573/// The **page** each lands on needs the destination array resolved against
574/// the page tree, which this reader does not walk; zero is what it answers,
575/// and it is what the one golden that navigates asserts.
576fn read_destinations<R: pdfrum_object::Resolve>(
577    catalog: &pdfrum_object::Dict,
578    r: &R,
579    limits: &pdfrum_common::Limits,
580    diags: &mut pdfrum_common::Diagnostics,
581) -> Vec<(String, u32)> {
582    const DESTS: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"Dests");
583    let mut out = Vec::new();
584    if let Some(tree) = pdfrum_doc::nav::NameTree::open(catalog, DESTS, r) {
585        let count = tree.count(r, limits, diags);
586        for index in 0..count {
587            let Some((name, value)) = tree.lookup_by_index(index, r, limits, diags) else {
588                continue;
589            };
590            // **A destination is whatever the name tree names**, empty
591            // array included. `LookupNamedDest` answers null only when the
592            // *tree* cannot be read, not when the value is degenerate, and
593            // `CPDF_Dest` over an empty array answers page `-1` without
594            // complaint (`fxjs/cjs_document.cpp:1495-1506`). `bug_1358075`'s
595            // `"2"` resolves to `[]` and the golden's `Alert: completed`
596            // proves the call succeeded — rejecting it here would throw and
597            // swallow the alert after it.
598            let usable = matches!(
599                value,
600                pdfrum_object::Object::Array(_) | pdfrum_object::Object::Dict(_)
601            );
602            if usable {
603                out.push((name, 0));
604            }
605        }
606    }
607    if let Some(dests) = catalog.dict(DESTS, r) {
608        for (key, _) in dests.iter() {
609            out.push((String::from_utf8_lossy(key.as_bytes()).into_owned(), 0));
610        }
611    }
612    out
613}
614
615/// A toggle's `Field.value`: its checked control's export value, or `Off`.
616///
617/// `get_value`'s check-box and radio-button arm walks the controls for a
618/// checked one and answers `NewString("Off")` when it finds none — so a group
619/// with no `/V` reads the literal `Off` rather than the empty string the raw
620/// field value gives. `field_properties`'s radio and check-box cases each
621/// assert it. Every other kind keeps the value it was read with.
622fn toggle_value<R: pdfrum_object::Resolve>(
623    kind: FieldModelKind,
624    value: String,
625    field: &pdfrum_doc::form::Field,
626    r: &R,
627) -> String {
628    if kind.is_toggle() && !field.is_checked(None, r) {
629        return "Off".to_string();
630    }
631    value
632}
633
634/// `Field.valueAsString`, when it differs from `Field.value`.
635///
636/// A check box answers the literal `Yes`/`Off` rather than its export value,
637/// and a multi-select list answers `""`. `None` means "the value itself",
638/// which is every other case.
639fn value_as_string_of<R: pdfrum_object::Resolve>(
640    kind: FieldModelKind,
641    field: &pdfrum_doc::form::Field,
642    selected: usize,
643    r: &R,
644) -> Option<String> {
645    match kind {
646        FieldModelKind::CheckBox => Some(
647            if field.is_checked(None, r) {
648                "Yes"
649            } else {
650                "Off"
651            }
652            .to_string(),
653        ),
654        FieldModelKind::ListBox if selected > 1 => Some(String::new()),
655        _ => None,
656    }
657}
658
659/// One terminal field, as the object model sees it.
660fn read_field<R: pdfrum_object::Resolve>(
661    field: &pdfrum_doc::form::Field,
662    widget_pages: &[(pdfrum_object::Dict, u32)],
663    r: &R,
664) -> FieldModel {
665    use pdfrum_doc::form::FieldKind;
666
667    let flags = field.flags;
668    // `/Btn` is one PDF field type and three JavaScript ones. `FieldKind` has
669    // already made that split, so this is a rename rather than a second
670    // classification.
671    let kind = match field.kind {
672        FieldKind::Text => FieldModelKind::Text,
673        FieldKind::Check => FieldModelKind::CheckBox,
674        FieldKind::Radio => FieldModelKind::RadioButton,
675        FieldKind::Button => FieldModelKind::Button,
676        FieldKind::Combo => FieldModelKind::ComboBox,
677        FieldKind::List => FieldModelKind::ListBox,
678        FieldKind::Signature => FieldModelKind::Signature,
679    };
680    let value = field.value(None, r);
681    let options: Vec<(String, String)> = pdfrum_doc::ap::field_body::options(&field.dict, r)
682        .into_iter()
683        .map(|choice| (choice.value, choice.label))
684        .collect();
685    let option_values: Vec<String> = options.iter().map(|(value, _)| value.clone()).collect();
686    let selected =
687        pdfrum_doc::form::selected_indices_for_interaction(&field.dict, &option_values, r)
688            .into_iter()
689            .map(|index| u32::try_from(index).unwrap_or(u32::MAX))
690            .collect::<Vec<u32>>();
691
692    let value = toggle_value(kind, value, field, r);
693    let value_as_string = value_as_string_of(kind, field, selected.len(), r);
694
695    let rect = field
696        .widgets
697        .first()
698        .map(|widget| widget.dict.rect(pdfrum_object::names::RECT, r))
699        .map_or([0.0; 4], |rect| {
700            // `get_rect` answers `[left, top, right, bottom]` — and `top` is
701            // the *larger* y, because `CFX_FloatRect` is normalized before
702            // the getter reads it. `MyText`'s `/Rect [200 201 220 221]`
703            // therefore prints `200,221,220,201`, which is the golden's first
704            // `rect` line.
705            [
706                rect.x0.min(rect.x1),
707                rect.y0.max(rect.y1),
708                rect.x0.max(rect.x1),
709                rect.y0.min(rect.y1),
710            ]
711        });
712
713    let checked: Vec<bool> = field
714        .widgets
715        .iter()
716        .map(|_| field.is_checked(None, r))
717        .collect();
718
719    FieldModel {
720        name: field.name.clone(),
721        kind,
722        value,
723        default_value: field.default_value(r),
724        value_as_string,
725        options,
726        selected,
727        flags: FieldModelFlags {
728            read_only: flags.is_read_only(),
729            required: flags.is_required(),
730            // `/Ff` bit 3, which `FieldFlags` has no predicate for because
731            // nothing in the appearance path reads it either.
732            no_export: flags.bits() & (1 << 2) != 0,
733            multiline: flags.is_multiline(),
734            password: flags.is_password(),
735            // `/Ff` bit 21, which `FieldFlags` has no predicate for because
736            // nothing in the appearance path reads it.
737            file_select: flags.bits() & (1 << 20) != 0,
738            do_not_spell_check: !flags.spell_checks(),
739            do_not_scroll: !flags.scrolls(),
740            comb: flags.is_comb(),
741            // Bit 26.
742            rich_text: flags.bits() & (1 << 25) != 0,
743            editable: flags.is_editable_combo(),
744            multiple_selection: flags.is_multi_select(),
745        },
746        display: display_of(field, r),
747        rect,
748        pages: field
749            .widgets
750            .iter()
751            .filter_map(|widget| {
752                widget_pages
753                    .iter()
754                    .find(|(dict, _)| *dict == widget.dict)
755                    .map(|(_, page)| *page)
756            })
757            .collect(),
758        user_name: field.tooltip(r).unwrap_or_default(),
759        // A **toggle's** export values are its controls' on-state names —
760        // the `/AP /N` key that is not `Off` — and a choice field's are its
761        // options'. One property name, two sources, because
762        // `Field.exportValues` is toggle-only upstream and `FindOption` is
763        // choice-only.
764        export_values: if matches!(kind, FieldModelKind::CheckBox | FieldModelKind::RadioButton) {
765            field
766                .widgets
767                .iter()
768                .map(|widget| on_state_of(&widget.dict, r))
769                .collect()
770        } else {
771            option_values
772        },
773        default_checked: checked.iter().map(|_| false).collect(),
774        checked,
775        captions: caption_of(field, r),
776        button_position: button_position_of(field, r),
777        border_style: border_style_of(field, r),
778    }
779}
780
781/// `Field.borderStyle` as the first widget's `/BS /S` spells it.
782fn border_style_of<R: pdfrum_object::Resolve>(field: &pdfrum_doc::form::Field, r: &R) -> String {
783    let Some(widget) = field.widgets.first() else {
784        return "solid".to_string();
785    };
786    match pdfrum_doc::ap::widget::widget_border(&widget.dict, r).style {
787        pdfrum_doc::ap::BorderStyle::Solid => "solid",
788        pdfrum_doc::ap::BorderStyle::Dash => "dashed",
789        pdfrum_doc::ap::BorderStyle::Beveled => "beveled",
790        pdfrum_doc::ap::BorderStyle::Inset => "inset",
791        pdfrum_doc::ap::BorderStyle::Underline => "underline",
792    }
793    .to_string()
794}
795
796/// A toggle control's export value — the `/AP /N` key that is not `Off`,
797/// falling back to the literal `Yes`.
798///
799/// The fallback is not a guess: `"Yes"` is the answer when there is no
800/// on-state to read, which is what a control with no `/AP` at all exports. A
801/// group whose buttons really are named `Red` and `Blue` gets those, which is
802/// why the name is read rather than assumed.
803fn on_state_of<R: pdfrum_object::Resolve>(widget: &pdfrum_object::Dict, r: &R) -> String {
804    const N: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"N");
805    const YES: &str = "Yes";
806    widget
807        .dict(pdfrum_object::names::AP, r)
808        .and_then(|ap| ap.dict(N, r))
809        .and_then(|normal| {
810            normal
811                .iter()
812                .map(|(key, _)| String::from_utf8_lossy(key.as_bytes()).into_owned())
813                .find(|key| key != "Off")
814        })
815        .unwrap_or_else(|| YES.to_string())
816}
817
818/// `Field.buttonPosition` — `/MK /TP`, zero for anything outside `0..=6`.
819fn button_position_of<R: pdfrum_object::Resolve>(field: &pdfrum_doc::form::Field, r: &R) -> u32 {
820    const MK: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"MK");
821    const TP: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"TP");
822    field
823        .widgets
824        .first()
825        .and_then(|widget| widget.dict.dict(MK, r))
826        .and_then(|mk| mk.int(TP, r))
827        .and_then(|value| u32::try_from(value).ok())
828        .filter(|value| *value <= 6)
829        .unwrap_or(0)
830}
831
832/// `Field.display` — the `/F` flag word, as the four values a script sees.
833///
834/// `GetWidgetDisplayStatus`: hidden wins, then the print-and-view combination
835/// decides between 0, 2 and 3.
836fn display_of<R: pdfrum_object::Resolve>(field: &pdfrum_doc::form::Field, r: &R) -> u32 {
837    let Some(widget) = field.widgets.first() else {
838        return 0;
839    };
840    let flags = widget.dict.int(pdfrum_object::names::F, r).unwrap_or(0);
841    // Bit 2 hidden, bit 3 print, bit 6 no-view.
842    let hidden = flags & 0b10 != 0;
843    let print = flags & 0b100 != 0;
844    let no_view = flags & 0b10_0000 != 0;
845    if hidden {
846        return 1;
847    }
848    match (print, no_view) {
849        (true, false) => 0,
850        (false, false) => 2,
851        (_, true) => 3,
852    }
853}
854
855/// The three button captions — `/MK /CA`, `/AC`, `/RC`.
856fn caption_of<R: pdfrum_object::Resolve>(field: &pdfrum_doc::form::Field, r: &R) -> [String; 3] {
857    const MK: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"MK");
858    const CA: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"CA");
859    const AC: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"AC");
860    const RC: &pdfrum_object::Name = &pdfrum_object::Name::from_static(b"RC");
861    let Some(mk) = field
862        .widgets
863        .first()
864        .and_then(|widget| widget.dict.dict(MK, r))
865    else {
866        return [String::new(), String::new(), String::new()];
867    };
868    let text = |key: &pdfrum_object::Name| -> String {
869        mk.get(key, r)
870            .map(|value| value.get().to_text())
871            .unwrap_or_default()
872    };
873    [text(CA), text(AC), text(RC)]
874}