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}