Skip to main content

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}