pdfrum_form/page.rs
1//! One page's annotations, read once into the shapes routing needs.
2//!
3//! # Why this exists as its own pass
4//!
5//! Every other module in this crate is a pure function over values, and stays
6//! that way because *something* has to turn a document into those values.
7//! This is that something: it walks a page's `/Annots` array once and answers
8//! with `Candidate`s for the hit test, `Focusable`s for the tab ring, and
9//! enough per-widget configuration to build a field's interaction state the
10//! first time one is touched.
11//!
12//! Keeping the walk here rather than inside the router is what lets every
13//! routing decision stay testable on hand-built values: the tests in `hit`,
14//! `tab` and `field` never open a file, and the tests here never route an
15//! event.
16//!
17//! # The index space is the raw array
18//!
19//! The walk is over `/Annots` **as the file writes it**, pop-ups included and
20//! counted. That is what [`AnnotId`] promises and what the appearance overlay
21//! a caller draws through is keyed by. `pdfrum-doc`'s own `AnnotList` drops
22//! pop-ups and would renumber everything after the first one, so this does
23//! not use it — it reads the array directly and keeps each entry's position.
24//!
25//! # Fields are identified by name, not by dictionary
26//!
27//! Two widgets can be two controls of one field — a radio group is the
28//! ordinary case — and they must share one interaction state, or clicking the
29//! second forgets what the first did. So a [`FieldId`] is allocated per
30//! **fully qualified field name**, and two widgets that resolve to the same
31//! name get the same id. A widget with no name at all is its own field, keyed
32//! by its raw index, because nothing else can distinguish it.
33//!
34//! # Two field index spaces, and they are not the same one
35//!
36//! A [`FieldId`] is **page-local**: it is allocated as *this* page's
37//! `/Annots` are walked, so page 2's third field and page 1's third field are
38//! both `FieldId(2)` and neither is "the third field of the form". It is the
39//! right key for interaction state, which is per session and per widget, and
40//! it is the wrong key for anything a script says.
41//!
42//! Everything a *script* names a field by is document-wide: `/AcroForm /CO`
43//! holds positions in the form's terminal-field list, `Doc.numFields` counts
44//! that list, and `Doc.getNthFieldName(n)` indexes it. So
45//! [`WidgetInfo::field_index`] carries that second number alongside the first
46//! — the widget's field's position in the flat `/Fields` walk — and
47//! [`PageForm::field_of_index`] converts back.
48//!
49//! Conflating them is invisible on a single-page form whose widgets appear in
50//! `/Fields` order, which is most fixtures, and wrong on every other file: a
51//! calculation would write page-local field 3 where `/CO` named form field 3.
52
53use pdfrum_common::{Diagnostics, Limits, PageIndex};
54use pdfrum_doc::form::{FieldFlags, FieldKind};
55use pdfrum_doc::{Subtype, ap};
56use pdfrum_object::{Dict, Name, Resolve, names as obj_names};
57
58use crate::field::{ChoiceConfig, ChoiceOption, TextConfig};
59
60/// `/MaxLen` — a text field's character cap.
61///
62/// Spelled here rather than imported: `pdfrum-doc`'s name table is private to
63/// that crate, and three constants are cheaper than widening its surface.
64const MAX_LEN: &Name = &Name::from_static(b"MaxLen");
65/// `/TI` — the first visible row of a list box.
66const TI: &Name = &Name::from_static(b"TI");
67/// `/Fields` — the form's field array, under the catalog's `/AcroForm`.
68const FIELDS: &Name = &Name::from_static(b"Fields");
69/// `/Tabs` — the page's declared focus-traversal order.
70///
71/// Read from the page dictionary **directly**, not inherited from the page
72/// tree: `CPDFSDK_AnnotIterator::GetTabOrder` calls `GetByteStringFor` on the
73/// page's own dictionary, so a `/Tabs` on `/Pages` reaches no page.
74const TABS: &Name = &Name::from_static(b"Tabs");
75use crate::geom::Rotation;
76use crate::hit::{Candidate, LayoutBand, WidgetHit};
77use crate::session::{AnnotId, FieldId};
78use crate::tab::{Focusable, Rect, TabOrder};
79
80/// Everything one page contributes to routing.
81///
82/// Built once per page per event replay. The three lists are parallel views
83/// of the same walk rather than three walks: `candidates` is what the hit
84/// test reads, `focusables` is what the tab ring reads, and `widgets` is what
85/// a field's state is built from.
86#[derive(Debug, Clone, Default)]
87pub struct PageForm {
88 /// Which page this describes.
89 pub page: PageIndex,
90 /// Every annotation, in raw `/Annots` order, for the hit test.
91 pub(crate) candidates: Vec<Candidate>,
92 /// Every annotation as a focus-ring candidate, paired with its subtype
93 /// so the caller's `focusable` list can filter them.
94 pub(crate) focusables: Vec<(Subtype, Focusable)>,
95 /// The traversal order this page's `/Tabs` asks for.
96 ///
97 /// A property of the **page**, not of the session: `annotiter.pdf` is
98 /// three pages of identical annotations under `/R`, `/C` and `/S`, and
99 /// the first Tab lands on a different one on each. Defaults to
100 /// [`TabOrder::Structure`], which is also what an unrecognized spelling
101 /// means.
102 pub tab_order: TabOrder,
103 /// The widgets, with what a field's interaction state needs.
104 pub widgets: Vec<WidgetInfo>,
105 /// Every annotation's dictionary, keyed by its raw `/Annots` index.
106 ///
107 /// A `BTreeMap` rather than a `Vec` because the walk skips entries it
108 /// cannot read as dictionaries, so the indices have gaps and a positional
109 /// list would silently shift everything after one.
110 pub dicts: std::collections::BTreeMap<u32, Dict>,
111 /// The page's height in PDF units, for the one thing that needs it: how
112 /// much room a combo box has to open its dropdown into.
113 ///
114 /// The room is measured against a rectangle taken from the **origin**,
115 /// whatever the crop box says, so this is the display height and the
116 /// comparison on the other side is against zero. Reproduced rather than
117 /// corrected: a page whose crop box starts away from the origin gets the
118 /// oracle's answer, right or wrong, because the popup's position is what
119 /// a golden pins.
120 pub page_height: f32,
121}
122
123impl PageForm {
124 /// The page-local [`FieldId`] for a document-wide field position, when a
125 /// widget of that field is on this page.
126 ///
127 /// The inverse of [`WidgetInfo::field_index`], and the conversion a
128 /// calculation's writes need: `/CO` names its targets in the document's
129 /// space and the session stores state in this one. `None` is the honest
130 /// answer for a field whose widgets are all on other pages — this page
131 /// has no state to write, and inventing a `FieldId` from the number would
132 /// write some *other* field.
133 #[must_use]
134 pub fn field_of_index(&self, index: u32) -> Option<FieldId> {
135 self.widgets
136 .iter()
137 .find(|widget| widget.field_index == Some(index))
138 .map(|widget| widget.field)
139 }
140}
141
142/// One widget annotation, read far enough to build its field's state.
143#[derive(Debug, Clone, PartialEq)]
144pub struct WidgetInfo {
145 /// Which annotation, by raw `/Annots` index.
146 pub id: AnnotId,
147 /// Which field it is a control of, **on this page**.
148 ///
149 /// A page-local id. See [`WidgetInfo::field_index`] for the document-wide
150 /// one, and the module documentation for why both exist.
151 pub field: FieldId,
152 /// Where this widget's field sits in the document's flat terminal-field
153 /// list — the `/AcroForm /Fields` walk `pdfrum_doc::form::Form::fields`
154 /// performs, which is the space `/CO`, `Doc.numFields` and
155 /// `Doc.getNthFieldName` all count in.
156 ///
157 /// `None` for a widget whose field the form does not list: an unnamed
158 /// widget, or one under an `/AcroForm` that does not reach it. Such a
159 /// field exists for interaction and is invisible to a script, which is
160 /// also what the oracle answers — `GetFieldByDict` returns null and
161 /// `CountFields` never counted it.
162 pub field_index: Option<u32>,
163 /// The field's fully qualified name, empty when it has none.
164 pub name: String,
165 /// What kind of field it is, when the classifier could name one.
166 pub kind: Option<FieldKind>,
167 /// The inherited `/Ff`.
168 pub flags: FieldFlags,
169 /// The widget's `/Rect` as written, in this crate's private `f32`.
170 pub(crate) rect: Rect,
171 /// The widget's `/MK /R`, as the quadrant the appearance stream is set
172 /// into.
173 ///
174 /// Folded by `pdfrum_doc::geom::WidgetRotation::from_degrees`, which is
175 /// the same call `ap::widget::rotated_rect` makes — routing and the
176 /// generator must agree about which box a click lands in, so there is one
177 /// normalization and not two. An angle that is not a multiple of 90 names
178 /// no quadrant and is upright.
179 pub rotation: Rotation,
180 /// The widget's dictionary, for the readers that want the long tail.
181 pub dict: Dict,
182 /// The field dictionary the **value** is read from, when that is not the
183 /// widget itself.
184 pub valued: Dict,
185}
186
187impl WidgetInfo {
188 /// The field's stored value.
189 #[must_use]
190 pub fn value<R: Resolve>(&self, r: &R) -> String {
191 ap::field_body::field_value(&self.valued, r)
192 }
193
194 /// The field's options, for a choice field.
195 #[must_use]
196 pub fn options<R: Resolve>(&self, r: &R) -> Vec<ChoiceOption> {
197 ap::field_body::options(&self.valued, r)
198 .into_iter()
199 .map(|choice| ChoiceOption {
200 label: choice.label,
201 value: choice.value,
202 })
203 .collect()
204 }
205
206 /// Which options the file says are selected, as **interaction** reads it.
207 ///
208 /// Deliberately not `ap::field_body::selected_indices`, and the two are
209 /// both right — a list box's selection has two readers that disagree on
210 /// purpose:
211 ///
212 /// - the **appearance** reader takes `/V` first and matches it as text.
213 /// That is what draws a file with no `/AP`, and it is what
214 /// `ap::field_body::selected_indices` reproduces.
215 /// - the **interaction** reader, this one, consults `/I` first as integer
216 /// indices and falls back to `/V` only when `/I` is not *usable*. That
217 /// is what a session's state must be seeded from.
218 ///
219 /// They agree except on one shape — `/I` present, `/V` absent — where the
220 /// first selects nothing and the second selects the rows `/I` names.
221 #[must_use]
222 pub fn selected<R: Resolve>(&self, r: &R) -> Vec<usize> {
223 let values: Vec<String> = ap::field_body::options(&self.valued, r)
224 .into_iter()
225 .map(|choice| choice.value)
226 .collect();
227 pdfrum_doc::form::selected_indices_for_interaction(&self.valued, &values, r)
228 }
229
230 /// The text configuration, read from the flags and `/MaxLen`.
231 #[must_use]
232 pub fn text_config<R: Resolve>(&self, r: &R) -> TextConfig {
233 let max_len =
234 inherited_int(&self.dict, MAX_LEN, r).and_then(|value| u32::try_from(value).ok());
235 TextConfig::read(self.flags, max_len)
236 }
237
238 /// The choice configuration, read from the flags.
239 #[must_use]
240 pub fn choice_config(&self) -> ChoiceConfig {
241 ChoiceConfig::read(self.flags)
242 }
243
244 /// The row a list box starts drawing at, from `/TI`.
245 #[must_use]
246 pub fn top_index<R: Resolve>(&self, r: &R) -> usize {
247 inherited_int(&self.dict, TI, r)
248 .and_then(|value| usize::try_from(value).ok())
249 .unwrap_or(0)
250 }
251}
252
253/// Reads one page's annotations.
254///
255/// `page_dict` is the page, `catalog` the document catalog — the form's
256/// `/Fields` array is reached through it, which is what resolves a widget
257/// that is a second control of an earlier field.
258#[must_use]
259pub fn read<R: Resolve>(
260 page: impl Into<PageIndex>,
261 page_dict: &Dict,
262 catalog: &Dict,
263 r: &R,
264) -> PageForm {
265 let page = page.into();
266 let mut form = PageForm {
267 page,
268 tab_order: TabOrder::from_tabs(page_dict.byte_string(TABS, r).as_deref()),
269 page_height: page_height(page_dict, r),
270 ..PageForm::default()
271 };
272 let Some(annots) = page_dict.array(obj_names::ANNOTS, r) else {
273 return form;
274 };
275
276 // A field id per distinct field name, so two controls of one field share
277 // one interaction state. Allocated in first-seen order, which makes the
278 // ids stable for a given file.
279 let mut names: Vec<String> = Vec::new();
280 // The document-wide field list, walked once per page rather than once per
281 // widget. Empty for a document with no `/AcroForm`, which leaves every
282 // `field_index` `None` — the same answer the oracle's `GetFieldByDict`
283 // gives for a widget the form does not reach.
284 let form_fields = document_field_names(catalog, r);
285
286 for index in 0..annots.len() {
287 let Some(dict) = annots.dict_at(index, r) else {
288 continue;
289 };
290 let id = AnnotId::new(page, u32::try_from(index).unwrap_or(u32::MAX));
291 let subtype =
292 Subtype::from_bytes(&dict.byte_string(obj_names::SUBTYPE, r).unwrap_or_default());
293 let rect = to_rect(dict.rect(obj_names::RECT, r));
294 let band = band_of(subtype);
295
296 let widget = (subtype == Subtype::Widget).then(|| read_widget(&dict, r));
297 form.candidates.push(Candidate {
298 id,
299 rect,
300 band,
301 widget: widget.as_ref().map(|(hit, _)| *hit),
302 });
303
304 if let Some((_, info)) = widget {
305 let name = pdfrum_doc::form::full_name(&dict, r);
306 let field = field_id_of(&mut names, &name, index);
307 let field_index = position_in_form(&form_fields, &name);
308 let valued = value_dict_of(&dict, catalog, r).unwrap_or_else(|| dict.clone());
309 form.widgets.push(WidgetInfo {
310 id,
311 field,
312 field_index,
313 name,
314 kind: info.kind,
315 flags: info.flags,
316 rect,
317 rotation: pdfrum_doc::ap::widget::widget_rotation(&dict, r),
318 dict: dict.clone(),
319 valued,
320 });
321 }
322
323 // The focus ring's membership is the caller's choice of subtypes, so
324 // every annotation is offered here and the ring filters.
325 form.focusables.push((subtype, Focusable { id, rect }));
326 form.dicts.insert(id.index, dict);
327 }
328 form
329}
330
331/// The page's display height, the one number a dropdown's placement needs.
332///
333/// `/MediaBox` and `/CropBox` are inheritable (ISO 32000-1 §7.7.3.4), so the
334/// walk climbs `/Parent` for a page that states neither — `derive_boxes`
335/// takes the closure that does the climbing, and applies `/Rotate` after it,
336/// because a quarter-turned page's *height* is its crop box's width and the
337/// popup's room is measured on the page as shown.
338fn page_height<R: Resolve>(page_dict: &Dict, r: &R) -> f32 {
339 let inherited = |key: &Name| -> Option<pdfrum_object::Object> {
340 let mut node = page_dict.clone();
341 // The same bound `PageDict::inherited` uses; a `/Parent` cycle in a
342 // damaged file would otherwise spin here.
343 for _ in 0..64 {
344 if let Some(value) = node.get(key, r) {
345 return Some(value.get().clone());
346 }
347 node = node.dict(obj_names::PARENT, r)?;
348 }
349 None
350 };
351 // The boxes are derived, not read: a missing or degenerate `/MediaBox` is
352 // US Letter rather than nothing, which is the size the oracle would have
353 // measured the room against too.
354 let mut diags = Diagnostics::default();
355 let (_, height) = pdfrum_page::display_size_from_dict(page_dict, inherited, r, &mut diags);
356 #[expect(
357 clippy::cast_possible_truncation,
358 reason = "a page taller than f32 has already lost meaning; the value \
359 only decides which side a dropdown opens on"
360 )]
361 let height = height as f32;
362 height
363}
364
365/// What reading a widget's own dictionary answers.
366struct WidgetRead {
367 kind: Option<FieldKind>,
368 flags: FieldFlags,
369}
370
371/// Reads the four hit-test gates and the field classification.
372fn read_widget<R: Resolve>(dict: &Dict, r: &R) -> (WidgetHit, WidgetRead) {
373 let flags = FieldFlags::from_bits(inherited_int(dict, obj_names::FF, r).unwrap_or(0));
374 let field_type = inherited_name(dict, obj_names::FT, r).unwrap_or_default();
375 let kind = FieldKind::classify(&field_type, flags);
376 let annot_flags = pdfrum_doc::AnnotFlags::from_bits(dict.int(obj_names::F, r).unwrap_or(0));
377
378 let hit = WidgetHit {
379 signature: kind == Some(FieldKind::Signature),
380 // Any of the three "do not show this" bits, which is the oracle's own
381 // disjunction rather than the hidden bit alone.
382 hidden: annot_flags.is_hidden()
383 || annot_flags.no_view()
384 || annot_flags.contains(pdfrum_doc::AnnotFlags::INVISIBLE),
385 read_only: flags.is_read_only(),
386 push_button: kind == Some(FieldKind::Button),
387 };
388 (hit, WidgetRead { kind, flags })
389}
390
391/// Which band a subtype sorts into.
392fn band_of(subtype: Subtype) -> LayoutBand {
393 match subtype {
394 Subtype::Popup => LayoutBand::Popup,
395 Subtype::Widget => LayoutBand::Widget,
396 _ => LayoutBand::Other,
397 }
398}
399
400/// The field id for a name, allocating one the first time it is seen.
401///
402/// A widget with no name cannot be grouped with anything, so it becomes its
403/// own field keyed by its raw index — offset past the named ids so the two
404/// spaces cannot collide.
405fn field_id_of(names: &mut Vec<String>, name: &str, index: usize) -> FieldId {
406 if name.is_empty() {
407 // Unnamed widgets are their own fields. The offset keeps them out of
408 // the named range, which grows from zero.
409 return FieldId(u32::MAX - u32::try_from(index).unwrap_or(0));
410 }
411 if let Some(at) = names.iter().position(|known| known == name) {
412 return FieldId(u32::try_from(at).unwrap_or(0));
413 }
414 names.push(name.to_string());
415 FieldId(u32::try_from(names.len() - 1).unwrap_or(0))
416}
417
418/// The dictionary a widget's field **value** is read from.
419///
420/// Usually the widget itself. The exception is two `/Fields` entries sharing
421/// a `/T` with no parent between them: the second is a second *control* of
422/// the first's field, and both show the first dictionary's value.
423fn value_dict_of<R: Resolve>(dict: &Dict, catalog: &Dict, r: &R) -> Option<Dict> {
424 if dict.contains_key(obj_names::PARENT) {
425 return None;
426 }
427 let name = dict.byte_string(obj_names::T, r)?;
428 let form = catalog.dict(obj_names::ACRO_FORM, r)?;
429 let fields = form.array(FIELDS, r)?;
430 let first = (0..fields.len())
431 .filter_map(|index| fields.dict_at(index, r))
432 .find(|entry| entry.byte_string(obj_names::T, r).as_deref() == Some(name.as_slice()))?;
433 (first != *dict).then_some(first)
434}
435
436/// The document's terminal fields' fully-qualified names, in `/Fields` order.
437///
438/// The same walk `pdfrum_doc::form::Form::load` performs and in the same
439/// order, because that is the list `/CO`'s indices, `Doc.numFields` and
440/// `Doc.getNthFieldName` all count — reusing it rather than restating the
441/// traversal is what keeps the two spaces from drifting apart.
442fn document_field_names<R: Resolve>(catalog: &Dict, r: &R) -> Vec<String> {
443 let (limits, mut diags) = (Limits::default(), Diagnostics::default());
444 pdfrum_doc::form::Form::load(catalog, r, &limits, &mut diags)
445 .map(|form| form.fields.iter().map(|field| field.name.clone()).collect())
446 .unwrap_or_default()
447}
448
449/// Where a fully-qualified name sits in the document's field list.
450///
451/// Matched by name rather than by dictionary because that is what both ends
452/// of the conversion have: a widget knows its own qualified name, and so does
453/// every terminal field. An unnamed widget matches nothing, which is correct
454/// — a script cannot name it either.
455fn position_in_form(fields: &[String], name: &str) -> Option<u32> {
456 if name.is_empty() {
457 return None;
458 }
459 fields
460 .iter()
461 .position(|field| field == name)
462 .and_then(|index| u32::try_from(index).ok())
463}
464
465/// An inherited integer field attribute.
466fn inherited_int<R: Resolve>(dict: &Dict, key: &pdfrum_object::Name, r: &R) -> Option<i64> {
467 let (limits, mut diags) = (Limits::default(), Diagnostics::default());
468 pdfrum_doc::form::field_attr(dict, key, r, &limits, &mut diags)?.as_int()
469}
470
471/// An inherited name-valued field attribute, as bytes.
472fn inherited_name<R: Resolve>(dict: &Dict, key: &pdfrum_object::Name, r: &R) -> Option<Vec<u8>> {
473 let (limits, mut diags) = (Limits::default(), Diagnostics::default());
474 Some(pdfrum_doc::form::field_attr(dict, key, r, &limits, &mut diags)?.to_byte_string())
475}
476
477/// A `kurbo` rectangle onto this crate's. The rect half of `Point::narrow`.
478pub(crate) fn to_rect(rect: kurbo::Rect) -> Rect {
479 #[expect(
480 clippy::cast_possible_truncation,
481 reason = "page coordinates beyond f32 have already lost meaning, and every \
482 geometric query in this crate is f32"
483 )]
484 Rect::new(
485 rect.x0 as f32,
486 rect.y0 as f32,
487 rect.x1 as f32,
488 rect.y1 as f32,
489 )
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn an_unnamed_widget_is_its_own_field() {
498 let mut names = Vec::new();
499 let first = field_id_of(&mut names, "", 0);
500 let second = field_id_of(&mut names, "", 1);
501 assert_ne!(first, second, "two unnamed widgets are two fields");
502 assert!(names.is_empty(), "and neither claims a named id");
503 }
504
505 /// The rule a radio group depends on: two controls of one field share one
506 /// interaction state, so clicking the second remembers what the first
507 /// did.
508 #[test]
509 fn two_widgets_with_one_name_are_one_field() {
510 let mut names = Vec::new();
511 let first = field_id_of(&mut names, "Group", 0);
512 let second = field_id_of(&mut names, "Group", 3);
513 assert_eq!(first, second);
514 assert_eq!(names.len(), 1);
515 }
516
517 #[test]
518 fn distinct_names_take_distinct_ids_in_first_seen_order() {
519 let mut names = Vec::new();
520 assert_eq!(field_id_of(&mut names, "A", 0), FieldId(0));
521 assert_eq!(field_id_of(&mut names, "B", 1), FieldId(1));
522 assert_eq!(field_id_of(&mut names, "A", 2), FieldId(0));
523 assert_eq!(field_id_of(&mut names, "C", 3), FieldId(2));
524 }
525
526 /// The named and unnamed id spaces must not collide, or an unnamed widget
527 /// would share a field with a named one.
528 #[test]
529 fn the_unnamed_id_space_does_not_meet_the_named_one() {
530 let mut names = Vec::new();
531 let loose = field_id_of(&mut names, "", 0);
532 for index in 0..64 {
533 let titled = field_id_of(&mut names, &format!("field{index}"), index);
534 assert_ne!(titled, loose);
535 }
536 }
537
538 #[test]
539 fn subtypes_sort_into_the_three_bands() {
540 assert_eq!(band_of(Subtype::Popup), LayoutBand::Popup);
541 assert_eq!(band_of(Subtype::Widget), LayoutBand::Widget);
542 assert_eq!(band_of(Subtype::Link), LayoutBand::Other);
543 assert_eq!(band_of(Subtype::Highlight), LayoutBand::Other);
544 }
545
546 /// An empty page answers with empty lists rather than declining.
547 #[test]
548 fn a_page_with_no_annots_reads_as_empty() {
549 let page = Dict::new();
550 let catalog = Dict::new();
551 let form = read(0, &page, &catalog, &pdfrum_object::NoResolve);
552 assert!(form.candidates.is_empty());
553 assert!(form.widgets.is_empty());
554 assert!(form.focusables.is_empty());
555 }
556}