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}