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