pdfrum-form 0.1.0

Form interaction: events, focus, the edit control, the commit cascade
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
//! One page's annotations, read once into the shapes routing needs.
//!
//! # Why this exists as its own pass
//!
//! Every other module in this crate is a pure function over values, and stays
//! that way because *something* has to turn a document into those values.
//! This is that something: it walks a page's `/Annots` array once and answers
//! with `Candidate`s for the hit test, `Focusable`s for the tab ring, and
//! enough per-widget configuration to build a field's interaction state the
//! first time one is touched.
//!
//! Keeping the walk here rather than inside the router is what lets every
//! routing decision stay testable on hand-built values: the tests in `hit`,
//! `tab` and `field` never open a file, and the tests here never route an
//! event.
//!
//! # The index space is the raw array
//!
//! The walk is over `/Annots` **as the file writes it**, pop-ups included and
//! counted. That is what [`AnnotId`] promises and what the appearance overlay
//! a caller draws through is keyed by. `pdfrum-doc`'s own `AnnotList` drops
//! pop-ups and would renumber everything after the first one, so this does
//! not use it — it reads the array directly and keeps each entry's position.
//!
//! # Fields are identified by name, not by dictionary
//!
//! Two widgets can be two controls of one field — a radio group is the
//! ordinary case — and they must share one interaction state, or clicking the
//! second forgets what the first did. So a [`FieldId`] is allocated per
//! **fully qualified field name**, and two widgets that resolve to the same
//! name get the same id. A widget with no name at all is its own field, keyed
//! by its raw index, because nothing else can distinguish it.
//!
//! # Two field index spaces, and they are not the same one
//!
//! A [`FieldId`] is **page-local**: it is allocated as *this* page's
//! `/Annots` are walked, so page 2's third field and page 1's third field are
//! both `FieldId(2)` and neither is "the third field of the form". It is the
//! right key for interaction state, which is per session and per widget, and
//! it is the wrong key for anything a script says.
//!
//! Everything a *script* names a field by is document-wide: `/AcroForm /CO`
//! holds positions in the form's terminal-field list, `Doc.numFields` counts
//! that list, and `Doc.getNthFieldName(n)` indexes it. So
//! [`WidgetInfo::field_index`] carries that second number alongside the first
//! — the widget's field's position in the flat `/Fields` walk — and
//! [`PageForm::field_of_index`] converts back.
//!
//! Conflating them is invisible on a single-page form whose widgets appear in
//! `/Fields` order, which is most fixtures, and wrong on every other file: a
//! calculation would write page-local field 3 where `/CO` named form field 3.

use pdfrum_common::{Diagnostics, Limits, PageIndex};
use pdfrum_doc::form::{FieldFlags, FieldKind};
use pdfrum_doc::{Subtype, ap};
use pdfrum_object::{Dict, Name, Resolve, names as obj_names};

use crate::field::{ChoiceConfig, ChoiceOption, TextConfig};

/// `/MaxLen` — a text field's character cap.
///
/// Spelled here rather than imported: `pdfrum-doc`'s name table is private to
/// that crate, and three constants are cheaper than widening its surface.
const MAX_LEN: &Name = &Name::from_static(b"MaxLen");
/// `/TI` — the first visible row of a list box.
const TI: &Name = &Name::from_static(b"TI");
/// `/Fields` — the form's field array, under the catalog's `/AcroForm`.
const FIELDS: &Name = &Name::from_static(b"Fields");
/// `/Tabs` — the page's declared focus-traversal order.
///
/// Read from the page dictionary **directly**, not inherited from the page
/// tree: `CPDFSDK_AnnotIterator::GetTabOrder` calls `GetByteStringFor` on the
/// page's own dictionary, so a `/Tabs` on `/Pages` reaches no page.
const TABS: &Name = &Name::from_static(b"Tabs");
use crate::geom::Rotation;
use crate::hit::{Candidate, LayoutBand, WidgetHit};
use crate::session::{AnnotId, FieldId};
use crate::tab::{Focusable, Rect, TabOrder};

/// Everything one page contributes to routing.
///
/// Built once per page per event replay. The three lists are parallel views
/// of the same walk rather than three walks: `candidates` is what the hit
/// test reads, `focusables` is what the tab ring reads, and `widgets` is what
/// a field's state is built from.
#[derive(Debug, Clone, Default)]
pub struct PageForm {
    /// Which page this describes.
    pub page: PageIndex,
    /// Every annotation, in raw `/Annots` order, for the hit test.
    pub(crate) candidates: Vec<Candidate>,
    /// Every annotation as a focus-ring candidate, paired with its subtype
    /// so the caller's `focusable` list can filter them.
    pub(crate) focusables: Vec<(Subtype, Focusable)>,
    /// The traversal order this page's `/Tabs` asks for.
    ///
    /// A property of the **page**, not of the session: `annotiter.pdf` is
    /// three pages of identical annotations under `/R`, `/C` and `/S`, and
    /// the first Tab lands on a different one on each. Defaults to
    /// [`TabOrder::Structure`], which is also what an unrecognized spelling
    /// means.
    pub tab_order: TabOrder,
    /// The widgets, with what a field's interaction state needs.
    pub widgets: Vec<WidgetInfo>,
    /// Every annotation's dictionary, keyed by its raw `/Annots` index.
    ///
    /// A `BTreeMap` rather than a `Vec` because the walk skips entries it
    /// cannot read as dictionaries, so the indices have gaps and a positional
    /// list would silently shift everything after one.
    pub dicts: std::collections::BTreeMap<u32, Dict>,
    /// The page's height in PDF units, for the one thing that needs it: how
    /// much room a combo box has to open its dropdown into.
    ///
    /// The room is measured against a rectangle taken from the **origin**,
    /// whatever the crop box says, so this is the display height and the
    /// comparison on the other side is against zero. Reproduced rather than
    /// corrected: a page whose crop box starts away from the origin gets the
    /// oracle's answer, right or wrong, because the popup's position is what
    /// a golden pins.
    pub page_height: f32,
}

impl PageForm {
    /// The page-local [`FieldId`] for a document-wide field position, when a
    /// widget of that field is on this page.
    ///
    /// The inverse of [`WidgetInfo::field_index`], and the conversion a
    /// calculation's writes need: `/CO` names its targets in the document's
    /// space and the session stores state in this one. `None` is the honest
    /// answer for a field whose widgets are all on other pages — this page
    /// has no state to write, and inventing a `FieldId` from the number would
    /// write some *other* field.
    #[must_use]
    pub fn field_of_index(&self, index: u32) -> Option<FieldId> {
        self.widgets
            .iter()
            .find(|widget| widget.field_index == Some(index))
            .map(|widget| widget.field)
    }
}

/// One widget annotation, read far enough to build its field's state.
#[derive(Debug, Clone, PartialEq)]
pub struct WidgetInfo {
    /// Which annotation, by raw `/Annots` index.
    pub id: AnnotId,
    /// Which field it is a control of, **on this page**.
    ///
    /// A page-local id. See [`WidgetInfo::field_index`] for the document-wide
    /// one, and the module documentation for why both exist.
    pub field: FieldId,
    /// Where this widget's field sits in the document's flat terminal-field
    /// list — the `/AcroForm /Fields` walk `pdfrum_doc::form::Form::fields`
    /// performs, which is the space `/CO`, `Doc.numFields` and
    /// `Doc.getNthFieldName` all count in.
    ///
    /// `None` for a widget whose field the form does not list: an unnamed
    /// widget, or one under an `/AcroForm` that does not reach it. Such a
    /// field exists for interaction and is invisible to a script, which is
    /// also what the oracle answers — `GetFieldByDict` returns null and
    /// `CountFields` never counted it.
    pub field_index: Option<u32>,
    /// The field's fully qualified name, empty when it has none.
    pub name: String,
    /// What kind of field it is, when the classifier could name one.
    pub kind: Option<FieldKind>,
    /// The inherited `/Ff`.
    pub flags: FieldFlags,
    /// The widget's `/Rect` as written, in this crate's private `f32`.
    pub(crate) rect: Rect,
    /// The widget's `/MK /R`, as the quadrant the appearance stream is set
    /// into.
    ///
    /// Folded by `pdfrum_doc::geom::WidgetRotation::from_degrees`, which is
    /// the same call `ap::widget::rotated_rect` makes — routing and the
    /// generator must agree about which box a click lands in, so there is one
    /// normalization and not two. An angle that is not a multiple of 90 names
    /// no quadrant and is upright.
    pub rotation: Rotation,
    /// The widget's dictionary, for the readers that want the long tail.
    pub dict: Dict,
    /// The field dictionary the **value** is read from, when that is not the
    /// widget itself.
    pub valued: Dict,
}

impl WidgetInfo {
    /// The field's stored value.
    #[must_use]
    pub fn value<R: Resolve>(&self, r: &R) -> String {
        ap::field_body::field_value(&self.valued, r)
    }

    /// The field's options, for a choice field.
    #[must_use]
    pub fn options<R: Resolve>(&self, r: &R) -> Vec<ChoiceOption> {
        ap::field_body::options(&self.valued, r)
            .into_iter()
            .map(|choice| ChoiceOption {
                label: choice.label,
                value: choice.value,
            })
            .collect()
    }

    /// Which options the file says are selected, as **interaction** reads it.
    ///
    /// Deliberately not `ap::field_body::selected_indices`, and the two are
    /// both right — a list box's selection has two readers that disagree on
    /// purpose:
    ///
    /// - the **appearance** reader takes `/V` first and matches it as text.
    ///   That is what draws a file with no `/AP`, and it is what
    ///   `ap::field_body::selected_indices` reproduces.
    /// - the **interaction** reader, this one, consults `/I` first as integer
    ///   indices and falls back to `/V` only when `/I` is not *usable*. That
    ///   is what a session's state must be seeded from.
    ///
    /// They agree except on one shape — `/I` present, `/V` absent — where the
    /// first selects nothing and the second selects the rows `/I` names.
    #[must_use]
    pub fn selected<R: Resolve>(&self, r: &R) -> Vec<usize> {
        let values: Vec<String> = ap::field_body::options(&self.valued, r)
            .into_iter()
            .map(|choice| choice.value)
            .collect();
        pdfrum_doc::form::selected_indices_for_interaction(&self.valued, &values, r)
    }

    /// The text configuration, read from the flags and `/MaxLen`.
    #[must_use]
    pub fn text_config<R: Resolve>(&self, r: &R) -> TextConfig {
        let max_len =
            inherited_int(&self.dict, MAX_LEN, r).and_then(|value| u32::try_from(value).ok());
        TextConfig::read(self.flags, max_len)
    }

    /// The choice configuration, read from the flags.
    #[must_use]
    pub fn choice_config(&self) -> ChoiceConfig {
        ChoiceConfig::read(self.flags)
    }

    /// The row a list box starts drawing at, from `/TI`.
    #[must_use]
    pub fn top_index<R: Resolve>(&self, r: &R) -> usize {
        inherited_int(&self.dict, TI, r)
            .and_then(|value| usize::try_from(value).ok())
            .unwrap_or(0)
    }
}

/// Reads one page's annotations.
///
/// `page_dict` is the page, `catalog` the document catalog — the form's
/// `/Fields` array is reached through it, which is what resolves a widget
/// that is a second control of an earlier field.
#[must_use]
pub fn read<R: Resolve>(
    page: impl Into<PageIndex>,
    page_dict: &Dict,
    catalog: &Dict,
    r: &R,
) -> PageForm {
    let page = page.into();
    let mut form = PageForm {
        page,
        tab_order: TabOrder::from_tabs(page_dict.byte_string(TABS, r).as_deref()),
        page_height: page_height(page_dict, r),
        ..PageForm::default()
    };
    let Some(annots) = page_dict.array(obj_names::ANNOTS, r) else {
        return form;
    };

    // A field id per distinct field name, so two controls of one field share
    // one interaction state. Allocated in first-seen order, which makes the
    // ids stable for a given file.
    let mut names: Vec<String> = Vec::new();
    // The document-wide field list, walked once per page rather than once per
    // widget. Empty for a document with no `/AcroForm`, which leaves every
    // `field_index` `None` — the same answer the oracle's `GetFieldByDict`
    // gives for a widget the form does not reach.
    let form_fields = document_field_names(catalog, r);

    for index in 0..annots.len() {
        let Some(dict) = annots.dict_at(index, r) else {
            continue;
        };
        let id = AnnotId::new(page, u32::try_from(index).unwrap_or(u32::MAX));
        let subtype =
            Subtype::from_bytes(&dict.byte_string(obj_names::SUBTYPE, r).unwrap_or_default());
        let rect = to_rect(dict.rect(obj_names::RECT, r));
        let band = band_of(subtype);

        let widget = (subtype == Subtype::Widget).then(|| read_widget(&dict, r));
        form.candidates.push(Candidate {
            id,
            rect,
            band,
            widget: widget.as_ref().map(|(hit, _)| *hit),
        });

        if let Some((_, info)) = widget {
            let name = pdfrum_doc::form::full_name(&dict, r);
            let field = field_id_of(&mut names, &name, index);
            let field_index = position_in_form(&form_fields, &name);
            let valued = value_dict_of(&dict, catalog, r).unwrap_or_else(|| dict.clone());
            form.widgets.push(WidgetInfo {
                id,
                field,
                field_index,
                name,
                kind: info.kind,
                flags: info.flags,
                rect,
                rotation: pdfrum_doc::ap::widget::widget_rotation(&dict, r),
                dict: dict.clone(),
                valued,
            });
        }

        // The focus ring's membership is the caller's choice of subtypes, so
        // every annotation is offered here and the ring filters.
        form.focusables.push((subtype, Focusable { id, rect }));
        form.dicts.insert(id.index, dict);
    }
    form
}

/// The page's display height, the one number a dropdown's placement needs.
///
/// `/MediaBox` and `/CropBox` are inheritable (ISO 32000-1 §7.7.3.4), so the
/// walk climbs `/Parent` for a page that states neither — `derive_boxes`
/// takes the closure that does the climbing, and applies `/Rotate` after it,
/// because a quarter-turned page's *height* is its crop box's width and the
/// popup's room is measured on the page as shown.
fn page_height<R: Resolve>(page_dict: &Dict, r: &R) -> f32 {
    let inherited = |key: &Name| -> Option<pdfrum_object::Object> {
        let mut node = page_dict.clone();
        // The same bound `PageDict::inherited` uses; a `/Parent` cycle in a
        // damaged file would otherwise spin here.
        for _ in 0..64 {
            if let Some(value) = node.get(key, r) {
                return Some(value.get().clone());
            }
            node = node.dict(obj_names::PARENT, r)?;
        }
        None
    };
    // The boxes are derived, not read: a missing or degenerate `/MediaBox` is
    // US Letter rather than nothing, which is the size the oracle would have
    // measured the room against too.
    let mut diags = Diagnostics::default();
    let (_, height) = pdfrum_page::display_size_from_dict(page_dict, inherited, r, &mut diags);
    #[expect(
        clippy::cast_possible_truncation,
        reason = "a page taller than f32 has already lost meaning; the value \
                  only decides which side a dropdown opens on"
    )]
    let height = height as f32;
    height
}

/// What reading a widget's own dictionary answers.
struct WidgetRead {
    kind: Option<FieldKind>,
    flags: FieldFlags,
}

/// Reads the four hit-test gates and the field classification.
fn read_widget<R: Resolve>(dict: &Dict, r: &R) -> (WidgetHit, WidgetRead) {
    let flags = FieldFlags::from_bits(inherited_int(dict, obj_names::FF, r).unwrap_or(0));
    let field_type = inherited_name(dict, obj_names::FT, r).unwrap_or_default();
    let kind = FieldKind::classify(&field_type, flags);
    let annot_flags = pdfrum_doc::AnnotFlags::from_bits(dict.int(obj_names::F, r).unwrap_or(0));

    let hit = WidgetHit {
        signature: kind == Some(FieldKind::Signature),
        // Any of the three "do not show this" bits, which is the oracle's own
        // disjunction rather than the hidden bit alone.
        hidden: annot_flags.is_hidden()
            || annot_flags.no_view()
            || annot_flags.contains(pdfrum_doc::AnnotFlags::INVISIBLE),
        read_only: flags.is_read_only(),
        push_button: kind == Some(FieldKind::Button),
    };
    (hit, WidgetRead { kind, flags })
}

/// Which band a subtype sorts into.
fn band_of(subtype: Subtype) -> LayoutBand {
    match subtype {
        Subtype::Popup => LayoutBand::Popup,
        Subtype::Widget => LayoutBand::Widget,
        _ => LayoutBand::Other,
    }
}

/// The field id for a name, allocating one the first time it is seen.
///
/// A widget with no name cannot be grouped with anything, so it becomes its
/// own field keyed by its raw index — offset past the named ids so the two
/// spaces cannot collide.
fn field_id_of(names: &mut Vec<String>, name: &str, index: usize) -> FieldId {
    if name.is_empty() {
        // Unnamed widgets are their own fields. The offset keeps them out of
        // the named range, which grows from zero.
        return FieldId(u32::MAX - u32::try_from(index).unwrap_or(0));
    }
    if let Some(at) = names.iter().position(|known| known == name) {
        return FieldId(u32::try_from(at).unwrap_or(0));
    }
    names.push(name.to_string());
    FieldId(u32::try_from(names.len() - 1).unwrap_or(0))
}

/// The dictionary a widget's field **value** is read from.
///
/// Usually the widget itself. The exception is two `/Fields` entries sharing
/// a `/T` with no parent between them: the second is a second *control* of
/// the first's field, and both show the first dictionary's value.
fn value_dict_of<R: Resolve>(dict: &Dict, catalog: &Dict, r: &R) -> Option<Dict> {
    if dict.contains_key(obj_names::PARENT) {
        return None;
    }
    let name = dict.byte_string(obj_names::T, r)?;
    let form = catalog.dict(obj_names::ACRO_FORM, r)?;
    let fields = form.array(FIELDS, r)?;
    let first = (0..fields.len())
        .filter_map(|index| fields.dict_at(index, r))
        .find(|entry| entry.byte_string(obj_names::T, r).as_deref() == Some(name.as_slice()))?;
    (first != *dict).then_some(first)
}

/// The document's terminal fields' fully-qualified names, in `/Fields` order.
///
/// The same walk `pdfrum_doc::form::Form::load` performs and in the same
/// order, because that is the list `/CO`'s indices, `Doc.numFields` and
/// `Doc.getNthFieldName` all count — reusing it rather than restating the
/// traversal is what keeps the two spaces from drifting apart.
fn document_field_names<R: Resolve>(catalog: &Dict, r: &R) -> Vec<String> {
    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
    pdfrum_doc::form::Form::load(catalog, r, &limits, &mut diags)
        .map(|form| form.fields.iter().map(|field| field.name.clone()).collect())
        .unwrap_or_default()
}

/// Where a fully-qualified name sits in the document's field list.
///
/// Matched by name rather than by dictionary because that is what both ends
/// of the conversion have: a widget knows its own qualified name, and so does
/// every terminal field. An unnamed widget matches nothing, which is correct
/// — a script cannot name it either.
fn position_in_form(fields: &[String], name: &str) -> Option<u32> {
    if name.is_empty() {
        return None;
    }
    fields
        .iter()
        .position(|field| field == name)
        .and_then(|index| u32::try_from(index).ok())
}

/// An inherited integer field attribute.
fn inherited_int<R: Resolve>(dict: &Dict, key: &pdfrum_object::Name, r: &R) -> Option<i64> {
    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
    pdfrum_doc::form::field_attr(dict, key, r, &limits, &mut diags)?.as_int()
}

/// An inherited name-valued field attribute, as bytes.
fn inherited_name<R: Resolve>(dict: &Dict, key: &pdfrum_object::Name, r: &R) -> Option<Vec<u8>> {
    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
    Some(pdfrum_doc::form::field_attr(dict, key, r, &limits, &mut diags)?.to_byte_string())
}

/// A `kurbo` rectangle onto this crate's. The rect half of `Point::narrow`.
pub(crate) fn to_rect(rect: kurbo::Rect) -> Rect {
    #[expect(
        clippy::cast_possible_truncation,
        reason = "page coordinates beyond f32 have already lost meaning, and every \
                  geometric query in this crate is f32"
    )]
    Rect::new(
        rect.x0 as f32,
        rect.y0 as f32,
        rect.x1 as f32,
        rect.y1 as f32,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_unnamed_widget_is_its_own_field() {
        let mut names = Vec::new();
        let first = field_id_of(&mut names, "", 0);
        let second = field_id_of(&mut names, "", 1);
        assert_ne!(first, second, "two unnamed widgets are two fields");
        assert!(names.is_empty(), "and neither claims a named id");
    }

    /// The rule a radio group depends on: two controls of one field share one
    /// interaction state, so clicking the second remembers what the first
    /// did.
    #[test]
    fn two_widgets_with_one_name_are_one_field() {
        let mut names = Vec::new();
        let first = field_id_of(&mut names, "Group", 0);
        let second = field_id_of(&mut names, "Group", 3);
        assert_eq!(first, second);
        assert_eq!(names.len(), 1);
    }

    #[test]
    fn distinct_names_take_distinct_ids_in_first_seen_order() {
        let mut names = Vec::new();
        assert_eq!(field_id_of(&mut names, "A", 0), FieldId(0));
        assert_eq!(field_id_of(&mut names, "B", 1), FieldId(1));
        assert_eq!(field_id_of(&mut names, "A", 2), FieldId(0));
        assert_eq!(field_id_of(&mut names, "C", 3), FieldId(2));
    }

    /// The named and unnamed id spaces must not collide, or an unnamed widget
    /// would share a field with a named one.
    #[test]
    fn the_unnamed_id_space_does_not_meet_the_named_one() {
        let mut names = Vec::new();
        let loose = field_id_of(&mut names, "", 0);
        for index in 0..64 {
            let titled = field_id_of(&mut names, &format!("field{index}"), index);
            assert_ne!(titled, loose);
        }
    }

    #[test]
    fn subtypes_sort_into_the_three_bands() {
        assert_eq!(band_of(Subtype::Popup), LayoutBand::Popup);
        assert_eq!(band_of(Subtype::Widget), LayoutBand::Widget);
        assert_eq!(band_of(Subtype::Link), LayoutBand::Other);
        assert_eq!(band_of(Subtype::Highlight), LayoutBand::Other);
    }

    /// An empty page answers with empty lists rather than declining.
    #[test]
    fn a_page_with_no_annots_reads_as_empty() {
        let page = Dict::new();
        let catalog = Dict::new();
        let form = read(0, &page, &catalog, &pdfrum_object::NoResolve);
        assert!(form.candidates.is_empty());
        assert!(form.widgets.is_empty());
        assert!(form.focusables.is_empty());
    }
}