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}