Skip to main content

pdfrum_form/
hit.rs

1//! Which annotation is under a point, and in what order they are considered.
2//!
3//! # There are two different lookups, and the asymmetry is deliberate
4//!
5//! **Hovering** uses plain rectangle containment over **every** annotation
6//! subtype except pop-ups. That is what makes a bare pointer move over a
7//! highlight annotation raise its pop-up — six pixel fixtures in the corpus
8//! do nothing else — and it is why hover cannot simply reuse the click test.
9//!
10//! **Clicking** goes through a widget hit test, which additionally rejects
11//! signature widgets, invisible widgets, **read-only widgets**, and, for
12//! anything that is not a push button, a document whose permissions grant
13//! neither form filling nor annotation modification.
14//!
15//! Read-only deserves a second look because it is the rule most likely to be
16//! read as a bug: a read-only widget is not clickable *at all*. That is not
17//! in tension with a read-only check box consuming a Return — there the
18//! widget already had focus and the event arrived from the keyboard, which
19//! never consults this test.
20//!
21//! # Layout order is not annotation order
22//!
23//! Annotations are considered in a stable sort by layout band — pop-ups
24//! first, then widgets, then everything else — which preserves file order
25//! within each band. The focused annotation is then moved: to the **front**
26//! for hit testing, so it wins an overlap tie, and to the **end** for
27//! drawing, so it paints on top. Same list, two arrangements, opposite ends.
28
29use crate::session::AnnotId;
30use crate::tab::Rect;
31
32/// How far a focused widget's box is grown beyond its rectangle.
33///
34/// A focused widget draws a focus ring, so its clickable box is one unit
35/// larger on every side than its `/Rect`.
36pub const FOCUS_INFLATION: f32 = 1.0;
37
38/// Which layout band an annotation sorts into.
39///
40/// The three values are the oracle's own, and the gaps in them are its own
41/// too: everything that is neither a pop-up nor a widget shares the last
42/// band.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum LayoutBand {
45    /// A pop-up note. Drawn under everything.
46    Popup = 1,
47    /// A form widget.
48    Widget = 2,
49    /// Every other subtype.
50    Other = 5,
51}
52
53/// What the hit test needs to know about one annotation.
54///
55/// # Build these from the raw `/Annots` array
56///
57/// A candidate's `id` carries a **raw** `/Annots` index, pop-ups counted —
58/// see [`AnnotId`]. Pop-ups appear here as ordinary candidates in the
59/// [`LayoutBand::Popup`] band rather than being filtered out, precisely so
60/// that a caller can walk the array once and index it directly. Filtering
61/// them out on the way in and then reporting positions in the filtered list
62/// is the mistake this type is shaped to prevent: the appearance overlay is
63/// keyed by the raw index, so every widget after a pop-up would be off by
64/// one.
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub(crate) struct Candidate {
67    /// Which annotation, by its raw `/Annots` index.
68    pub(crate) id: AnnotId,
69    /// Its rectangle, as the file wrote it. Need not be normalized:
70    /// [`contains`] normalizes before comparing.
71    pub(crate) rect: Rect,
72    /// Which band it sorts into.
73    pub(crate) band: LayoutBand,
74    /// Whether it is a widget the click test may accept.
75    pub(crate) widget: Option<WidgetHit>,
76}
77
78/// The widget-only half of a candidate.
79///
80/// Four independent gates, each read from a different place in the file — two
81/// from the annotation, two from the field — and each one a hard rejection on
82/// its own. Collapsing them into a mask would lose which gate rejected a
83/// click, which is exactly the question a reader of a failing hit test asks.
84#[allow(clippy::struct_excessive_bools)]
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct WidgetHit {
87    /// Whether the widget is a signature, which is never hit-testable.
88    pub signature: bool,
89    /// Whether any of the invisible, hidden or no-view flags is set.
90    pub hidden: bool,
91    /// Whether the field refuses edits.
92    pub read_only: bool,
93    /// Whether the field is a push button, which is hit-testable regardless
94    /// of the document's permissions.
95    pub push_button: bool,
96}
97
98impl WidgetHit {
99    /// Whether this widget accepts a click, given the document's permissions.
100    ///
101    /// The order matters only for readability — every gate is a hard `false`
102    /// — but it is the oracle's order, so a reader comparing the two sees the
103    /// same sequence.
104    #[must_use]
105    pub fn accepts_click(self, permissions: Permissions) -> bool {
106        if self.signature || self.hidden || self.read_only {
107            return false;
108        }
109        self.push_button || permissions.may_interact()
110    }
111}
112
113/// The document permission bits this crate consults.
114///
115/// Only two of them matter, and **either one suffices**: a document that
116/// grants form filling *or* annotation modification is interactive.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct Permissions {
119    /// Whether the document permits filling in form fields.
120    pub fill_form: bool,
121    /// Whether it permits modifying annotations.
122    pub modify_annotation: bool,
123}
124
125impl Permissions {
126    /// A document that permits everything, which is what an unencrypted one
127    /// and an owner-authenticated one both amount to.
128    pub const ALL: Permissions = Permissions {
129        fill_form: true,
130        modify_annotation: true,
131    };
132
133    /// A document that permits neither.
134    pub const NONE: Permissions = Permissions {
135        fill_form: false,
136        modify_annotation: false,
137    };
138
139    /// Whether a non-push-button widget may be clicked.
140    #[must_use]
141    pub fn may_interact(self) -> bool {
142        self.fill_form || self.modify_annotation
143    }
144}
145
146/// Whether a point lies inside a rectangle.
147///
148/// Inclusive on every edge, which is what makes a click exactly on a widget's
149/// boundary land in it.
150///
151/// **Normalizes first**, so a rectangle a file wrote inside out is still
152/// hit-testable — which is what the oracle's own containment does, by
153/// copying and normalizing before it compares. Leaving this as a
154/// precondition on the caller would make an inverted `/Rect` silently
155/// unclickable, and real files contain them.
156pub(crate) fn contains(rect: Rect, x: f32, y: f32) -> bool {
157    let rect = crate::geom::normalize(rect);
158    x >= rect.left && x <= rect.right && y >= rect.bottom && y <= rect.top
159}
160
161/// Grows a rectangle by one unit on every side.
162pub(crate) fn inflate(rect: Rect, by: f32) -> Rect {
163    Rect::new(
164        rect.left - by,
165        rect.bottom - by,
166        rect.right + by,
167        rect.top + by,
168    )
169}
170
171/// Orders candidates for hit testing: by band, then file order, with the
172/// focused annotation moved to the front so it wins an overlap tie.
173pub(crate) fn hit_order(candidates: &[Candidate], focused: Option<AnnotId>) -> Vec<Candidate> {
174    let mut ordered = band_sorted(candidates);
175    if let Some(focused) = focused
176        && let Some(at) = ordered.iter().position(|c| c.id == focused)
177    {
178        let moved = ordered.remove(at);
179        ordered.insert(0, moved);
180    }
181    ordered
182}
183
184/// Orders candidates for drawing: the same band sort, with the focused
185/// annotation moved to the **end** so it paints on top.
186///
187/// The painting half of the band sort — [`hit_order`] is the hit-testing half.
188/// Only the crate's own tests call this one, and it lives under `cfg(test)`
189/// for that reason; the pair is the invariant.
190#[cfg(test)]
191pub(crate) fn draw_order(candidates: &[Candidate], focused: Option<AnnotId>) -> Vec<Candidate> {
192    let mut ordered = band_sorted(candidates);
193    if let Some(focused) = focused
194        && let Some(at) = ordered.iter().position(|c| c.id == focused)
195    {
196        let moved = ordered.remove(at);
197        ordered.push(moved);
198    }
199    ordered
200}
201
202/// The shared stable sort by band, preserving file order within each.
203fn band_sorted(candidates: &[Candidate]) -> Vec<Candidate> {
204    let mut ordered = candidates.to_vec();
205    ordered.sort_by_key(|c| c.band);
206    ordered
207}
208
209/// The annotation under a point for **hover** purposes.
210///
211/// Rectangle containment over every subtype but pop-ups. This is what raises
212/// a highlight annotation's pop-up on a bare pointer move, and it is
213/// deliberately more permissive than [`widget_at_point`].
214pub(crate) fn annot_at_point(
215    candidates: &[Candidate],
216    focused: Option<AnnotId>,
217    x: f32,
218    y: f32,
219) -> Option<AnnotId> {
220    hit_order(candidates, focused)
221        .into_iter()
222        .find(|c| c.band != LayoutBand::Popup && contains(c.rect, x, y))
223        .map(|c| c.id)
224}
225
226/// The widget under a point for **click** purposes.
227///
228/// Every gate of [`WidgetHit::accepts_click`] applies, and the box tested is
229/// grown by [`FOCUS_INFLATION`] for the focused widget, which therefore has a
230/// slightly larger target than its neighbours.
231pub(crate) fn widget_at_point(
232    candidates: &[Candidate],
233    focused: Option<AnnotId>,
234    permissions: Permissions,
235    x: f32,
236    y: f32,
237) -> Option<AnnotId> {
238    hit_order(candidates, focused)
239        .into_iter()
240        .find(|c| {
241            let Some(widget) = c.widget else {
242                return false;
243            };
244            if !widget.accepts_click(permissions) {
245                return false;
246            }
247            let box_ = if Some(c.id) == focused {
248                inflate(c.rect, FOCUS_INFLATION)
249            } else {
250                c.rect
251            };
252            contains(box_, x, y)
253        })
254        .map(|c| c.id)
255}
256
257/// The z-order index of the widget under a point, or `None` when there is
258/// none.
259///
260/// The index is into the band-sorted list, which is what the oracle reports.
261///
262/// The z-ordered form of [`widget_at_point`], under `cfg(test)` for the same
263/// reason as [`draw_order`].
264#[cfg(test)]
265pub(crate) fn widget_z_order_at_point(
266    candidates: &[Candidate],
267    permissions: Permissions,
268    x: f32,
269    y: f32,
270) -> Option<usize> {
271    band_sorted(candidates).into_iter().position(|c| {
272        c.widget.is_some_and(|w| w.accepts_click(permissions)) && contains(c.rect, x, y)
273    })
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn plain_widget() -> WidgetHit {
281        WidgetHit {
282            signature: false,
283            hidden: false,
284            read_only: false,
285            push_button: false,
286        }
287    }
288
289    fn widget(index: u32, rect: Rect) -> Candidate {
290        Candidate {
291            id: AnnotId::new(0, index),
292            rect,
293            band: LayoutBand::Widget,
294            widget: Some(plain_widget()),
295        }
296    }
297
298    fn other(index: u32, rect: Rect) -> Candidate {
299        Candidate {
300            id: AnnotId::new(0, index),
301            rect,
302            band: LayoutBand::Other,
303            widget: None,
304        }
305    }
306
307    fn popup(index: u32, rect: Rect) -> Candidate {
308        Candidate {
309            id: AnnotId::new(0, index),
310            rect,
311            band: LayoutBand::Popup,
312            widget: None,
313        }
314    }
315
316    fn box_at(left: f32, bottom: f32) -> Rect {
317        Rect::new(left, bottom, left + 100.0, bottom + 50.0)
318    }
319
320    /// A file may write a rectangle inside out, and the widget is still
321    /// clickable — which is what the oracle does and what this crate would
322    /// otherwise get silently wrong, since every comparison fails when the
323    /// edges are swapped.
324    #[test]
325    fn an_inside_out_rectangle_is_still_hit_testable() {
326        // The shape a real corpus field has: top written below bottom.
327        let inverted = Rect::new(100.0, 100.0, 200.0, -130.0);
328        assert!(contains(inverted, 150.0, 0.0));
329        assert!(contains(inverted, 150.0, -100.0));
330        assert!(!contains(inverted, 150.0, 200.0));
331
332        let mut candidate = widget(0, inverted);
333        candidate.rect = inverted;
334        assert_eq!(
335            widget_at_point(&[candidate], None, Permissions::ALL, 150.0, 0.0),
336            Some(AnnotId::new(0, 0)),
337            "an inverted rect must not make a widget unclickable"
338        );
339    }
340
341    #[test]
342    fn containment_includes_every_edge() {
343        let rect = Rect::new(10.0, 20.0, 30.0, 40.0);
344        assert!(contains(rect, 20.0, 30.0));
345        assert!(contains(rect, 10.0, 20.0), "the corner is inside");
346        assert!(contains(rect, 30.0, 40.0), "the far corner is inside");
347        assert!(!contains(rect, 9.9, 30.0));
348        assert!(!contains(rect, 20.0, 40.1));
349    }
350
351    /// Hover matches any subtype but a pop-up, which is what raises a
352    /// highlight annotation's pop-up on a bare pointer move.
353    #[test]
354    fn hover_matches_a_non_widget_annotation() {
355        let candidates = [other(0, box_at(100.0, 700.0))];
356        assert_eq!(
357            annot_at_point(&candidates, None, 128.0, 713.0),
358            Some(AnnotId::new(0, 0))
359        );
360        // …while the click test finds nothing there at all.
361        assert_eq!(
362            widget_at_point(&candidates, None, Permissions::ALL, 128.0, 713.0),
363            None
364        );
365    }
366
367    #[test]
368    fn hover_skips_popups() {
369        let candidates = [popup(0, box_at(0.0, 0.0))];
370        assert_eq!(annot_at_point(&candidates, None, 50.0, 25.0), None);
371    }
372
373    /// A read-only widget is not clickable at all — a separate rule from a
374    /// read-only control consuming a keystroke, which never reaches here.
375    #[test]
376    fn a_read_only_widget_is_not_clickable() {
377        let mut candidate = widget(0, box_at(0.0, 0.0));
378        candidate.widget = Some(WidgetHit {
379            read_only: true,
380            ..plain_widget()
381        });
382        assert_eq!(
383            widget_at_point(&[candidate], None, Permissions::ALL, 50.0, 25.0),
384            None
385        );
386    }
387
388    #[test]
389    fn a_signature_or_hidden_widget_is_not_clickable() {
390        for hit in [
391            WidgetHit {
392                signature: true,
393                ..plain_widget()
394            },
395            WidgetHit {
396                hidden: true,
397                ..plain_widget()
398            },
399        ] {
400            let mut candidate = widget(0, box_at(0.0, 0.0));
401            candidate.widget = Some(hit);
402            assert_eq!(
403                widget_at_point(&[candidate], None, Permissions::ALL, 50.0, 25.0),
404                None
405            );
406        }
407    }
408
409    /// Either permission suffices, and a push button needs neither.
410    #[test]
411    fn permissions_gate_everything_but_a_push_button() {
412        let ordinary = plain_widget();
413        assert!(!ordinary.accepts_click(Permissions::NONE));
414        assert!(ordinary.accepts_click(Permissions::ALL));
415        assert!(ordinary.accepts_click(Permissions {
416            fill_form: true,
417            modify_annotation: false
418        }));
419        assert!(ordinary.accepts_click(Permissions {
420            fill_form: false,
421            modify_annotation: true
422        }));
423
424        let button = WidgetHit {
425            push_button: true,
426            ..plain_widget()
427        };
428        assert!(button.accepts_click(Permissions::NONE));
429    }
430
431    /// The focused widget wins an overlap tie, because it is moved to the
432    /// front of the hit order.
433    #[test]
434    fn the_focused_widget_wins_an_overlap() {
435        let candidates = [widget(0, box_at(0.0, 0.0)), widget(1, box_at(0.0, 0.0))];
436
437        // With nothing focused, file order decides.
438        assert_eq!(
439            widget_at_point(&candidates, None, Permissions::ALL, 50.0, 25.0),
440            Some(AnnotId::new(0, 0))
441        );
442
443        // Focusing the second one moves it in front.
444        assert_eq!(
445            widget_at_point(
446                &candidates,
447                Some(AnnotId::new(0, 1)),
448                Permissions::ALL,
449                50.0,
450                25.0
451            ),
452            Some(AnnotId::new(0, 1))
453        );
454    }
455
456    /// Hit order and draw order move the focused annotation to opposite ends
457    /// of the same list.
458    #[test]
459    fn hit_order_and_draw_order_are_mirror_images() {
460        let candidates = [
461            widget(0, box_at(0.0, 0.0)),
462            widget(1, box_at(0.0, 0.0)),
463            widget(2, box_at(0.0, 0.0)),
464        ];
465        let focused = Some(AnnotId::new(0, 1));
466
467        let hit: Vec<u32> = hit_order(&candidates, focused)
468            .iter()
469            .map(|c| c.id.index)
470            .collect();
471        let draw: Vec<u32> = draw_order(&candidates, focused)
472            .iter()
473            .map(|c| c.id.index)
474            .collect();
475
476        assert_eq!(hit, vec![1, 0, 2], "focused first for hit testing");
477        assert_eq!(draw, vec![0, 2, 1], "focused last for drawing");
478    }
479
480    /// The bands sort pop-up, widget, then everything else, and file order
481    /// survives within each.
482    #[test]
483    fn the_band_sort_is_stable_within_each_band() {
484        let candidates = [
485            other(0, box_at(0.0, 0.0)),
486            widget(1, box_at(0.0, 0.0)),
487            popup(2, box_at(0.0, 0.0)),
488            widget(3, box_at(0.0, 0.0)),
489            other(4, box_at(0.0, 0.0)),
490        ];
491        let ordered: Vec<u32> = hit_order(&candidates, None)
492            .iter()
493            .map(|c| c.id.index)
494            .collect();
495        assert_eq!(ordered, vec![2, 1, 3, 0, 4]);
496    }
497
498    /// A focused widget's target is one unit larger on every side, so a click
499    /// just outside its rectangle still lands in it.
500    #[test]
501    fn a_focused_widget_has_a_slightly_larger_target() {
502        let candidates = [widget(0, Rect::new(10.0, 10.0, 20.0, 20.0))];
503        let just_outside = (20.5, 15.0);
504
505        assert_eq!(
506            widget_at_point(
507                &candidates,
508                None,
509                Permissions::ALL,
510                just_outside.0,
511                just_outside.1
512            ),
513            None
514        );
515        assert_eq!(
516            widget_at_point(
517                &candidates,
518                Some(AnnotId::new(0, 0)),
519                Permissions::ALL,
520                just_outside.0,
521                just_outside.1
522            ),
523            Some(AnnotId::new(0, 0))
524        );
525    }
526
527    /// A point over nothing is a miss, not a panic and not a default.
528    #[test]
529    fn a_point_over_nothing_hits_nothing() {
530        let candidates = [widget(0, box_at(100.0, 100.0))];
531        assert_eq!(
532            widget_at_point(&candidates, None, Permissions::ALL, 1.0, 1.0),
533            None
534        );
535        assert_eq!(annot_at_point(&candidates, None, 1.0, 1.0), None);
536        assert_eq!(
537            widget_z_order_at_point(&candidates, Permissions::ALL, 1.0, 1.0),
538            None
539        );
540    }
541
542    #[test]
543    fn an_empty_page_hits_nothing() {
544        assert_eq!(widget_at_point(&[], None, Permissions::ALL, 0.0, 0.0), None);
545        assert_eq!(annot_at_point(&[], None, 0.0, 0.0), None);
546    }
547
548    /// The case the whole index-space contract exists for: a page whose
549    /// first `/Annots` entry is a pop-up. A hit on the widget at raw index 1
550    /// must report **1**, not the 0 it would occupy in a pop-up-filtered
551    /// list — the appearance overlay is keyed by the raw index, so reporting
552    /// the filtered position would draw this widget's appearance onto the
553    /// pop-up.
554    #[test]
555    fn a_page_with_a_popup_reports_raw_annots_indices() {
556        // /Annots = [ popup, widget, widget ]
557        let candidates = [
558            popup(0, box_at(0.0, 600.0)),
559            widget(1, box_at(100.0, 400.0)),
560            widget(2, box_at(100.0, 200.0)),
561        ];
562
563        // The first widget is at raw index 1 even though it is the list's
564        // first *widget*.
565        assert_eq!(
566            widget_at_point(&candidates, None, Permissions::ALL, 150.0, 425.0),
567            Some(AnnotId::new(0, 1))
568        );
569        assert_eq!(
570            widget_at_point(&candidates, None, Permissions::ALL, 150.0, 225.0),
571            Some(AnnotId::new(0, 2))
572        );
573
574        // Hover skips the pop-up, and still answers in the raw index space.
575        assert_eq!(annot_at_point(&candidates, None, 50.0, 625.0), None);
576        assert_eq!(
577            annot_at_point(&candidates, None, 150.0, 425.0),
578            Some(AnnotId::new(0, 1))
579        );
580    }
581
582    /// Sorting into bands must not renumber anything: the pop-up moves to the
583    /// front of the order while every id keeps the raw index it arrived with.
584    #[test]
585    fn the_band_sort_reorders_without_renumbering() {
586        let candidates = [
587            widget(0, box_at(0.0, 0.0)),
588            popup(1, box_at(0.0, 0.0)),
589            widget(2, box_at(0.0, 0.0)),
590        ];
591        let ordered: Vec<u32> = hit_order(&candidates, None)
592            .iter()
593            .map(|c| c.id.index)
594            .collect();
595
596        // The pop-up sorts first, but it is still annotation 1.
597        assert_eq!(ordered, vec![1, 0, 2]);
598    }
599
600    #[test]
601    fn z_order_counts_from_the_band_sorted_list() {
602        let candidates = [
603            other(0, box_at(0.0, 0.0)),
604            widget(1, box_at(0.0, 0.0)),
605            popup(2, box_at(0.0, 0.0)),
606        ];
607        // Band order is popup(2), widget(1), other(0); the widget is index 1.
608        assert_eq!(
609            widget_z_order_at_point(&candidates, Permissions::ALL, 50.0, 25.0),
610            Some(1)
611        );
612    }
613}