pdfrum_form/tab.rs
1//! Focus traversal order: which annotation Tab moves to next.
2//!
3//! This is **not** the order annotations are drawn or hit-tested in. Those
4//! sort by a layout band and put the focused annotation first or last; this
5//! one reads the page's own `/Tabs` entry and produces a ring that focus walks
6//! and never wraps.
7//!
8//! # Three orders, and only two of them sort anything
9//!
10//! - **Structure** — plain annotation order, with no sorting at all. This is
11//! the default, and it is what *anything other than* `R` or `C` selects.
12//! The `S` spelling the specification defines is not special-cased: it
13//! falls through to the same branch that an absent entry does.
14//! - **Row** — reading order across the page: repeatedly take the topmost
15//! remaining annotation, then take everything whose vertical centre falls
16//! strictly inside its band, left to right.
17//! - **Column** — the same idea rotated: leftmost first, banding on
18//! horizontal centres.
19//!
20//! # Why this terminates and the original does not
21//!
22//! The C++'s row and column loops both contain `if (index < 0) continue;`
23//! inside a `while (!list.empty())` that erases nothing on that path — so a
24//! page whose every remaining annotation has a non-positive top spins
25//! forever. It is not reachable from any file in the corpus, and no test
26//! asserts it, which is why it has survived.
27//!
28//! This banding is a fold that consumes its input: when no candidate is
29//! found, the remainder is appended in index order and the caller is told.
30//! **A library that can hang on input is a bug regardless of what the oracle
31//! does**, and this is the one place in the crate where the behaviour is
32//! deliberately better rather than identical.
33
34// The banding arithmetic mirrors the oracle's bit for bit: sums halved rather
35// than `midpoint`, and comparisons left strict. Both lints below would suggest
36// changes that move annotations between bands.
37#![allow(clippy::manual_midpoint, clippy::float_cmp)]
38
39use crate::session::AnnotId;
40
41/// A rectangle in page space, as a focusable annotation's `/Rect`, in this
42/// crate's own `f32`.
43///
44/// The raw rectangle, deliberately: the ring is built from `/Rect` and not
45/// from the inflated box a focused widget draws into.
46///
47/// **Private.** The public vocabulary is [`kurbo::Rect`]; `page::to_rect`
48/// narrows into this one on the way in and `PopupGeometry` widens back out
49/// on the way out. It stays `f32` because [`Rect::center_y`]'s banding
50/// midpoint is compared *strictly* against other `f32` edges: widening it
51/// would move annotations between bands, and it is fed only by widget
52/// `/Rect`s, never by an event point.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub(crate) struct Rect {
55 /// Left edge.
56 pub(crate) left: f32,
57 /// Bottom edge.
58 pub(crate) bottom: f32,
59 /// Right edge.
60 pub(crate) right: f32,
61 /// Top edge.
62 pub(crate) top: f32,
63}
64
65impl Rect {
66 /// A rectangle from its four edges.
67 pub(crate) fn new(left: f32, bottom: f32, right: f32, top: f32) -> Rect {
68 Rect {
69 left,
70 bottom,
71 right,
72 top,
73 }
74 }
75
76 /// The vertical midpoint, which row banding tests.
77 ///
78 /// Written as the oracle writes it — sum then halve — rather than as
79 /// `f32::midpoint`, which rounds differently at the extremes. The banding
80 /// comparisons are strict, so a midpoint that differs in the last bit
81 /// moves an annotation between bands.
82 pub(crate) fn center_y(self) -> f32 {
83 (self.top + self.bottom) / 2.0
84 }
85
86 /// The horizontal midpoint, which column banding tests.
87 ///
88 /// Sum then halve, for the reason [`Rect::center_y`] gives.
89 pub(crate) fn center_x(self) -> f32 {
90 (self.left + self.right) / 2.0
91 }
92}
93
94/// Which traversal order a page asks for.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum TabOrder {
97 /// Annotation order, unsorted. The default, and what any unrecognized
98 /// `/Tabs` value means.
99 #[default]
100 Structure,
101 /// Reading order across rows.
102 Row,
103 /// Reading order down columns.
104 Column,
105}
106
107impl TabOrder {
108 /// Reads the order from a page's `/Tabs` value.
109 ///
110 /// Only `R` and `C` are recognized. Everything else — including the `S`
111 /// the specification defines for structure order, and an absent entry —
112 /// is structure order, because that is the branch the oracle falls
113 /// through to.
114 #[must_use]
115 pub fn from_tabs(tabs: Option<&[u8]>) -> TabOrder {
116 match tabs {
117 Some(b"R") => TabOrder::Row,
118 Some(b"C") => TabOrder::Column,
119 _ => TabOrder::Structure,
120 }
121 }
122}
123
124/// One annotation eligible for focus.
125///
126/// The `id` carries a **raw** `/Annots` index, pop-ups counted — see
127/// [`AnnotId`]. Unlike hit testing, pop-ups never appear here at all: they
128/// are not a focusable subtype. That makes it tempting to number the ring
129/// from its own positions, and it would be wrong for the same reason —
130/// whatever the ring hands back is used to key an appearance.
131#[derive(Debug, Clone, Copy, PartialEq)]
132pub(crate) struct Focusable {
133 /// Which annotation, by its raw `/Annots` index.
134 pub(crate) id: AnnotId,
135 /// Its rectangle, from the raw `/Rect` rather than a focused widget's
136 /// inflated box.
137 pub(crate) rect: Rect,
138}
139
140/// The focus ring for one page, in traversal order.
141///
142/// `degenerate` reports that the banding could not make progress and the
143/// remainder was appended in index order — the recovery that replaces the
144/// oracle's hang.
145#[derive(Debug, Clone, PartialEq)]
146pub struct FocusRing {
147 /// The annotations, in traversal order.
148 pub order: Vec<AnnotId>,
149 /// Whether the banding degenerated.
150 pub degenerate: bool,
151}
152
153impl FocusRing {
154 /// Builds the ring for a page.
155 ///
156 /// `annots` arrives in annotation order and must already be filtered to
157 /// the focusable subtypes — signature widgets excluded, which the caller
158 /// does because only it knows which widgets are signatures.
159 pub(crate) fn build(annots: &[Focusable], order: TabOrder) -> FocusRing {
160 match order {
161 TabOrder::Structure => FocusRing {
162 order: annots.iter().map(|a| a.id).collect(),
163 degenerate: false,
164 },
165 TabOrder::Row => band(annots, Axis::Row),
166 TabOrder::Column => band(annots, Axis::Column),
167 }
168 }
169
170 /// The annotation after `current`, or `None` at the end.
171 ///
172 /// Focus does not wrap: the last annotation has no next, which is what
173 /// makes a fifth Tab across four fields report the event unconsumed.
174 #[must_use]
175 pub fn next(&self, current: AnnotId) -> Option<AnnotId> {
176 let at = self.order.iter().position(|a| *a == current)?;
177 self.order.get(at + 1).copied()
178 }
179
180 /// The annotation before `current`, or `None` at the start.
181 #[must_use]
182 pub fn prev(&self, current: AnnotId) -> Option<AnnotId> {
183 let at = self.order.iter().position(|a| *a == current)?;
184 self.order.get(at.checked_sub(1)?).copied()
185 }
186
187 /// The first annotation, which a forward Tab from nothing lands on.
188 #[must_use]
189 pub fn first(&self) -> Option<AnnotId> {
190 self.order.first().copied()
191 }
192
193 /// The last annotation, which a backward Tab from nothing lands on.
194 ///
195 /// That the two differ is the whole reason both exist: with nothing
196 /// focused, forward and backward Tab land on *different* annotations,
197 /// because the cursor starts between the ends rather than before them.
198 #[must_use]
199 pub fn last(&self) -> Option<AnnotId> {
200 self.order.last().copied()
201 }
202
203 /// How many annotations are in the ring.
204 #[must_use]
205 pub fn len(&self) -> usize {
206 self.order.len()
207 }
208
209 /// Whether nothing is focusable.
210 #[must_use]
211 pub fn is_empty(&self) -> bool {
212 self.order.is_empty()
213 }
214}
215
216/// Which axis a banding pass runs along.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum Axis {
219 Row,
220 Column,
221}
222
223/// The banding fold, shared by the row and column orders.
224///
225/// Each pass picks a seed — the topmost remaining for rows, the leftmost for
226/// columns — emits it, then emits every remaining annotation whose centre on
227/// the cross axis lies **strictly** inside the seed's extent, in index order.
228/// The comparisons are strict at both ends, so an annotation exactly level
229/// with a band's edge starts a new band rather than joining that one.
230//
231// [oracle-bug] cpdfsdk_annotiterator.cpp:137-147 cannot terminate when no
232// candidate remains. The row pass seeds `float fTop = 0.0f;` (:137) and tests
233// `rcAnnot.top > fTop` (:140), so a page whose remaining annotations all have
234// a non-positive top leaves `nLeftTopIndex` at -1; the `continue` at :146 then
235// re-enters `while (!sa.empty())` (:135) without erasing anything, so the loop
236// state is bit-identical on every pass and the iterator spins forever. A
237// non-positive top is ordinary: §12.5.5 places `/Tabs R` order in the page's
238// own coordinate space, which a `/MediaBox` with a negative or zero origin
239// puts entirely at or below zero. pdf.js implements no `/Tabs` order at all —
240// every widget gets `tabIndex = 0` (annotation_layer.js:412) and the DOM
241// decides — so it cannot hang here either. We terminate instead: with no
242// candidate, the remainder is appended in index order and the ring records
243// `degenerate`. A library that hangs on input its own spec admits is a bug
244// whatever the oracle does.
245fn band(annots: &[Focusable], axis: Axis) -> FocusRing {
246 // Sort by the axis's primary key, keeping annotation order within ties.
247 let mut remaining: Vec<Focusable> = annots.to_vec();
248 match axis {
249 Axis::Row => remaining.sort_by(|a, b| {
250 a.rect
251 .left
252 .partial_cmp(&b.rect.left)
253 .unwrap_or(std::cmp::Ordering::Equal)
254 }),
255 Axis::Column => remaining.sort_by(|a, b| {
256 b.rect
257 .top
258 .partial_cmp(&a.rect.top)
259 .unwrap_or(std::cmp::Ordering::Equal)
260 }),
261 }
262
263 let mut order = Vec::with_capacity(remaining.len());
264 let mut degenerate = false;
265
266 while !remaining.is_empty() {
267 let Some(seed) = seed_index(&remaining, axis) else {
268 // No candidate: this is the `[oracle-bug]` above, at the line
269 // where it bites — `cpdfsdk_annotiterator.cpp:145-147` spins
270 // forever here. Append what is left in index order and say so.
271 degenerate = true;
272 order.extend(remaining.iter().map(|a| a.id));
273 break;
274 };
275
276 let Some(head) = remaining.get(seed).copied() else {
277 degenerate = true;
278 order.extend(remaining.iter().map(|a| a.id));
279 break;
280 };
281 remaining.remove(seed);
282 order.push(head.id);
283
284 // Everything whose cross-axis centre is strictly inside the seed's
285 // extent joins this band, in index order.
286 let mut kept = Vec::with_capacity(remaining.len());
287 for annot in remaining.drain(..) {
288 let joins = match axis {
289 Axis::Row => {
290 annot.rect.center_y() > head.rect.bottom
291 && annot.rect.center_y() < head.rect.top
292 }
293 Axis::Column => {
294 annot.rect.center_x() > head.rect.left
295 && annot.rect.center_x() < head.rect.right
296 }
297 };
298 if joins {
299 order.push(annot.id);
300 } else {
301 kept.push(annot);
302 }
303 }
304 remaining = kept;
305 }
306
307 FocusRing { order, degenerate }
308}
309
310/// Picks the next band's seed, or `None` when nothing qualifies.
311///
312/// The scan runs **downward** with a strict comparison, so a tie never
313/// displaces the running best — and the running best when a tie is met was
314/// set by a *higher* index. So the winner among ties is the **highest** index,
315/// which after the left-ascending sort is the **rightmost** of the tied
316/// annotations for a row pass.
317///
318/// "Lowest index, i.e. leftmost" is the natural guess and it is wrong;
319/// `row_order_seeds_each_band_with_the_rightmost_of_the_topmost` is the test
320/// that fails if this is changed to it.
321fn seed_index(remaining: &[Focusable], axis: Axis) -> Option<usize> {
322 match axis {
323 Axis::Row => {
324 let mut best: Option<usize> = None;
325 let mut top = 0.0f32;
326 for (i, annot) in remaining.iter().enumerate().rev() {
327 if annot.rect.top > top {
328 best = Some(i);
329 top = annot.rect.top;
330 }
331 }
332 best
333 }
334 Axis::Column => {
335 // Two upstream quirks are reproduced here rather than tidied,
336 // because both are reachable and both change the order.
337 //
338 // The guard is `left < 0`, which reads as "nothing chosen yet"
339 // only while coordinates are positive. A page whose annotations
340 // sit at negative x re-enters it on later iterations, and each
341 // time it does it seeds **index zero** rather than the index it
342 // is looking at. Neither is what the row pass does, and the
343 // difference is why the two passes are not one function with a
344 // flipped axis.
345 let mut best: Option<usize> = None;
346 let mut left = -1.0f32;
347 for (i, annot) in remaining.iter().enumerate().rev() {
348 if left < 0.0 {
349 best = Some(0);
350 left = annot.rect.left;
351 } else if annot.rect.left < left {
352 best = Some(i);
353 left = annot.rect.left;
354 }
355 }
356 best
357 }
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 fn annot(index: u32, left: f32, bottom: f32, right: f32, top: f32) -> Focusable {
366 Focusable {
367 id: AnnotId::new(0, index),
368 rect: Rect::new(left, bottom, right, top),
369 }
370 }
371
372 fn ids(ring: &FocusRing) -> Vec<u32> {
373 ring.order.iter().map(|a| a.index).collect()
374 }
375
376 /// Only `R` and `C` are recognized; `S` is not special-cased and lands in
377 /// the same branch as an absent entry.
378 #[test]
379 fn the_tabs_entry_recognizes_exactly_two_spellings() {
380 assert_eq!(TabOrder::from_tabs(Some(b"R")), TabOrder::Row);
381 assert_eq!(TabOrder::from_tabs(Some(b"C")), TabOrder::Column);
382 assert_eq!(TabOrder::from_tabs(Some(b"S")), TabOrder::Structure);
383 assert_eq!(TabOrder::from_tabs(Some(b"r")), TabOrder::Structure);
384 assert_eq!(TabOrder::from_tabs(Some(b"")), TabOrder::Structure);
385 assert_eq!(TabOrder::from_tabs(None), TabOrder::Structure);
386 }
387
388 #[test]
389 fn structure_order_sorts_nothing() {
390 let annots = [
391 annot(0, 500.0, 500.0, 600.0, 600.0),
392 annot(1, 0.0, 0.0, 100.0, 100.0),
393 annot(2, 200.0, 700.0, 300.0, 800.0),
394 ];
395 let ring = FocusRing::build(&annots, TabOrder::Structure);
396 assert_eq!(ids(&ring), vec![0, 1, 2]);
397 assert!(!ring.degenerate);
398 }
399
400 /// Focus does not wrap in either direction.
401 #[test]
402 fn the_ring_has_two_ends_and_no_wrap() {
403 let annots = [
404 annot(0, 0.0, 0.0, 10.0, 10.0),
405 annot(1, 0.0, 20.0, 10.0, 30.0),
406 annot(2, 0.0, 40.0, 10.0, 50.0),
407 ];
408 let ring = FocusRing::build(&annots, TabOrder::Structure);
409
410 assert_eq!(ring.next(AnnotId::new(0, 0)), Some(AnnotId::new(0, 1)));
411 assert_eq!(ring.next(AnnotId::new(0, 2)), None);
412 assert_eq!(ring.prev(AnnotId::new(0, 2)), Some(AnnotId::new(0, 1)));
413 assert_eq!(ring.prev(AnnotId::new(0, 0)), None);
414 }
415
416 /// With nothing focused, forward and backward Tab land on different
417 /// annotations — the cursor starts between the ends, not before them.
418 #[test]
419 fn the_two_ends_are_different_annotations() {
420 let annots = [
421 annot(0, 0.0, 0.0, 10.0, 10.0),
422 annot(1, 0.0, 20.0, 10.0, 30.0),
423 ];
424 let ring = FocusRing::build(&annots, TabOrder::Structure);
425 assert_eq!(ring.first(), Some(AnnotId::new(0, 0)));
426 assert_eq!(ring.last(), Some(AnnotId::new(0, 1)));
427 assert_ne!(ring.first(), ring.last());
428 }
429
430 /// Row order reads across the page, one band at a time — but the band's
431 /// *seed* is the rightmost of the annotations tied for topmost, not the
432 /// leftmost, and the reading order that follows is relative to it.
433 ///
434 /// Two rows of two, indices deliberately not in reading order, gives
435 /// `3, 1, 0, 2` rather than the `1, 3, 2, 0` a reader expects. The seed
436 /// scan runs from the highest index down with a strict comparison, so an
437 /// annotation only displaces the running best by being *strictly* higher;
438 /// the tied one visited first — which after the left-ascending sort is
439 /// the rightmost — keeps the seat. This is the oracle's order and it is
440 /// what the goldens contain.
441 #[test]
442 fn row_order_seeds_each_band_with_the_rightmost_of_the_topmost() {
443 let annots = [
444 annot(0, 300.0, 100.0, 400.0, 150.0), // bottom row, right
445 annot(1, 100.0, 500.0, 200.0, 550.0), // top row, left
446 annot(2, 100.0, 100.0, 200.0, 150.0), // bottom row, left
447 annot(3, 300.0, 500.0, 400.0, 550.0), // top row, right
448 ];
449 let ring = FocusRing::build(&annots, TabOrder::Row);
450 assert_eq!(ids(&ring), vec![3, 1, 0, 2]);
451 assert!(!ring.degenerate);
452 }
453
454 /// With no tie the seed is simply the topmost, and the band that follows
455 /// it reads left to right.
456 #[test]
457 fn an_untied_row_band_reads_left_to_right() {
458 let annots = [
459 annot(0, 300.0, 500.0, 400.0, 540.0), // top row, right, lower top
460 annot(1, 100.0, 500.0, 200.0, 550.0), // top row, left, highest
461 annot(2, 100.0, 100.0, 200.0, 150.0), // bottom row
462 ];
463 let ring = FocusRing::build(&annots, TabOrder::Row);
464 assert_eq!(ids(&ring), vec![1, 0, 2]);
465 }
466
467 /// Column order bands on horizontal centres, and its seed rule is the
468 /// quirky one: the guard reads "nothing chosen yet" as `left < 0`, and
469 /// when it fires it seeds index **zero** rather than the index being
470 /// looked at. Both are reproduced, so this order is what the oracle
471 /// produces rather than what a rotated row pass would.
472 #[test]
473 fn column_order_reads_down_bands() {
474 let annots = [
475 annot(0, 300.0, 100.0, 400.0, 150.0), // right column, bottom
476 annot(1, 100.0, 500.0, 200.0, 550.0), // left column, top
477 annot(2, 100.0, 100.0, 200.0, 150.0), // left column, bottom
478 annot(3, 300.0, 500.0, 400.0, 550.0), // right column, top
479 ];
480 let ring = FocusRing::build(&annots, TabOrder::Column);
481 assert_eq!(ids(&ring), vec![1, 2, 3, 0]);
482 assert!(!ring.degenerate);
483 }
484
485 /// The banding comparison is strict at both ends, so an annotation whose
486 /// centre sits exactly on a band edge starts its own band.
487 #[test]
488 fn a_centre_exactly_on_a_band_edge_does_not_join_it() {
489 // The second annotation's centre y is exactly the first's bottom.
490 let annots = [
491 annot(0, 0.0, 100.0, 50.0, 200.0),
492 annot(1, 100.0, 50.0, 150.0, 150.0),
493 ];
494 let ring = FocusRing::build(&annots, TabOrder::Row);
495 assert_eq!(annots[1].rect.center_y(), annots[0].rect.bottom);
496 // Both are still emitted, but as two bands rather than one.
497 assert_eq!(ids(&ring), vec![0, 1]);
498 }
499
500 /// The case the oracle spins on: every annotation's top is non-positive,
501 /// so its downward scan never finds a candidate and its loop erases
502 /// nothing. Here the remainder is appended and the caller is told.
503 #[test]
504 fn banding_terminates_where_the_oracle_would_hang() {
505 let annots = [
506 annot(0, 10.0, -200.0, 20.0, -100.0),
507 annot(1, 30.0, -400.0, 40.0, -300.0),
508 ];
509 let ring = FocusRing::build(&annots, TabOrder::Row);
510
511 assert!(ring.degenerate, "the recovery should be reported");
512 assert_eq!(ring.len(), 2, "no annotation may be dropped");
513 assert_eq!(ids(&ring), vec![0, 1]);
514 }
515
516 /// A top of exactly zero is the boundary case, and it degenerates too:
517 /// the comparison is strict.
518 #[test]
519 fn a_zero_top_is_on_the_hanging_side_of_the_comparison() {
520 let annots = [annot(0, 0.0, -10.0, 10.0, 0.0)];
521 let ring = FocusRing::build(&annots, TabOrder::Row);
522 assert!(ring.degenerate);
523 assert_eq!(ids(&ring), vec![0]);
524 }
525
526 /// Whatever the geometry, every annotation comes out exactly once and the
527 /// pass returns. This is the property the oracle cannot state.
528 #[test]
529 fn banding_always_terminates_and_preserves_every_annotation() {
530 for seed in 0..200u32 {
531 let mut bits = seed.wrapping_mul(2_654_435_761);
532 let mut annots = Vec::new();
533 for i in 0..6u32 {
534 let mut next = || {
535 bits = bits.wrapping_mul(1_103_515_245).wrapping_add(12_345);
536 // Bounded to 0..1000, so the conversion is exact.
537 f32::from(u16::try_from((bits >> 16) % 1000).unwrap_or(0)) - 500.0
538 };
539 let (x, y) = (next(), next());
540 annots.push(annot(i, x, y, x + 20.0, y + 20.0));
541 }
542
543 for order in [TabOrder::Row, TabOrder::Column, TabOrder::Structure] {
544 let ring = FocusRing::build(&annots, order);
545 assert_eq!(ring.len(), 6, "annotation lost at seed {seed}");
546
547 let mut seen: Vec<u32> = ring.order.iter().map(|a| a.index).collect();
548 seen.sort_unstable();
549 assert_eq!(seen, vec![0, 1, 2, 3, 4, 5], "duplicate at seed {seed}");
550 }
551 }
552 }
553
554 /// The ring is built only from focusable annotations, but it reports the
555 /// raw `/Annots` indices those annotations have — so a page whose
556 /// pop-ups and non-widgets leave gaps in the numbering keeps the gaps.
557 #[test]
558 fn the_ring_keeps_the_gaps_that_unfocusable_annotations_leave() {
559 // /Annots = [ popup, widget, highlight, widget ]; only 1 and 3 are
560 // focusable, so those are the indices the ring must carry.
561 let annots = [
562 annot(1, 100.0, 400.0, 200.0, 450.0),
563 annot(3, 100.0, 200.0, 200.0, 250.0),
564 ];
565 let ring = FocusRing::build(&annots, TabOrder::Structure);
566
567 assert_eq!(ids(&ring), vec![1, 3], "the ring is not renumbered");
568 assert_eq!(ring.first(), Some(AnnotId::new(0, 1)));
569 assert_eq!(ring.next(AnnotId::new(0, 1)), Some(AnnotId::new(0, 3)));
570 assert_eq!(ring.next(AnnotId::new(0, 3)), None);
571 }
572
573 #[test]
574 fn an_empty_page_has_an_empty_ring() {
575 let ring = FocusRing::build(&[], TabOrder::Row);
576 assert!(ring.is_empty());
577 assert_eq!(ring.first(), None);
578 assert_eq!(ring.last(), None);
579 assert!(!ring.degenerate);
580 }
581
582 /// An annotation the ring does not contain has neither a next nor a
583 /// previous, rather than defaulting to an end.
584 #[test]
585 fn an_unknown_annotation_has_no_neighbours() {
586 let annots = [annot(0, 0.0, 0.0, 10.0, 10.0)];
587 let ring = FocusRing::build(&annots, TabOrder::Structure);
588 assert_eq!(ring.next(AnnotId::new(0, 99)), None);
589 assert_eq!(ring.prev(AnnotId::new(0, 99)), None);
590 }
591
592 mod annotiter {
593 //! Ported focus-traversal assertions.
594 //!
595 //! In-crate rather than in `tests/`, because the fixture is built from
596 //! [`Focusable`] and [`Rect`] — the crate's own `f32` geometry, not its
597 //! public vocabulary. Nothing else about them moved.
598 //!
599 //! The fixture is the oracle's own `annotiter.pdf`, transcribed rather than
600 //! parsed: one field with four widget kids, on three pages that differ only
601 //! in the traversal order they ask for. Its geometry is the point — the four
602 //! widgets sit at the corners of a square, in an annotation order that is
603 //! none of the three traversal orders, so each order produces a different
604 //! answer and a wrong implementation cannot accidentally agree.
605 //!
606 //! ```text
607 //! (201,400) LeftTop #2 #1 RightTop (401,401)
608 //!
609 //! (200,200) LeftBottom #0 #3 RightBottom (400,201)
610 //! ```
611
612 use super::*;
613
614 /// The four widgets of `annotiter.pdf`, in the order its `/Annots` array
615 /// lists them.
616 fn annotiter_widgets() -> Vec<Focusable> {
617 [
618 (0, 200.0, 200.0, 220.0, 220.0), // Sub_LeftBottom
619 (1, 401.0, 401.0, 421.0, 421.0), // Sub_RightTop
620 (2, 201.0, 400.0, 221.0, 420.0), // Sub_LeftTop
621 (3, 400.0, 201.0, 420.0, 221.0), // Sub_RightBottom
622 ]
623 .into_iter()
624 .map(|(index, left, bottom, right, top)| Focusable {
625 id: AnnotId::new(0, index),
626 rect: Rect::new(left, bottom, right, top),
627 })
628 .collect()
629 }
630
631 fn row_ring() -> FocusRing {
632 FocusRing::build(&annotiter_widgets(), TabOrder::Row)
633 }
634
635 fn indices(ring: &FocusRing) -> Vec<u32> {
636 ring.order.iter().map(|a| a.index).collect()
637 }
638
639 /// `FormFillFirstTab`: a forward tab with nothing focused lands on annot 1.
640 #[test]
641 fn first_tab_lands_on_annot_one() {
642 assert_eq!(row_ring().first(), Some(AnnotId::new(0, 1)));
643 }
644
645 /// `FormFillFirstShiftTab`: a backward tab with nothing focused lands on
646 /// annot 0 — a *different* annotation, because the cursor starts between the
647 /// ends rather than before them.
648 #[test]
649 fn first_shift_tab_lands_on_annot_zero() {
650 assert_eq!(row_ring().last(), Some(AnnotId::new(0, 0)));
651 }
652
653 /// `FormFillContinuousTab`: four forward tabs visit 1, 2, 3, 0 and the fifth
654 /// is not handled, because focus does not wrap.
655 #[test]
656 fn continuous_tab_visits_one_two_three_zero_then_stops() {
657 let ring = row_ring();
658 let mut at = ring.first().expect("the ring is not empty");
659 assert_eq!(at, AnnotId::new(0, 1));
660
661 let mut visited = vec![at.index];
662 for _ in 0..3 {
663 at = ring.next(at).expect("a next annotation");
664 visited.push(at.index);
665 }
666 assert_eq!(visited, vec![1, 2, 3, 0]);
667
668 assert_eq!(ring.next(at), None, "the fifth tab is not handled");
669 }
670
671 /// `FormFillContinuousShiftTab`: four backward tabs visit 0, 3, 2, 1 and the
672 /// fifth is not handled.
673 #[test]
674 fn continuous_shift_tab_visits_zero_three_two_one_then_stops() {
675 let ring = row_ring();
676 let mut at = ring.last().expect("the ring is not empty");
677 assert_eq!(at, AnnotId::new(0, 0));
678
679 let mut visited = vec![at.index];
680 for _ in 0..3 {
681 at = ring.prev(at).expect("a previous annotation");
682 visited.push(at.index);
683 }
684 assert_eq!(visited, vec![0, 3, 2, 1]);
685
686 assert_eq!(ring.prev(at), None, "the fifth shift-tab is not handled");
687 }
688
689 /// The forward and backward walks are exact reverses of one another over this
690 /// fixture, which is what makes the two "first tab" answers differ.
691 #[test]
692 fn the_backward_walk_reverses_the_forward_one() {
693 let ring = row_ring();
694 let forward = indices(&ring);
695 let mut backward = forward.clone();
696 backward.reverse();
697
698 let mut walked = vec![ring.last().expect("a last").index];
699 let mut at = ring.last().expect("a last");
700 while let Some(prev) = ring.prev(at) {
701 walked.push(prev.index);
702 at = prev;
703 }
704 assert_eq!(walked, backward);
705 }
706
707 /// The three pages of the fixture differ only in the order they ask for, and
708 /// each produces a different answer over the same four widgets — which is
709 /// what makes the fixture able to tell the orders apart at all.
710 #[test]
711 fn the_three_orders_disagree_over_this_fixture() {
712 let widgets = annotiter_widgets();
713 let row = indices(&FocusRing::build(&widgets, TabOrder::Row));
714 let column = indices(&FocusRing::build(&widgets, TabOrder::Column));
715 let structure = indices(&FocusRing::build(&widgets, TabOrder::Structure));
716
717 assert_eq!(structure, vec![0, 1, 2, 3], "structure order sorts nothing");
718 assert_eq!(row, vec![1, 2, 3, 0]);
719 assert_ne!(row, column);
720 assert_ne!(row, structure);
721 assert_ne!(column, structure);
722 }
723
724 /// Every order is a permutation of the annotations: none is dropped and none
725 /// is visited twice, whichever way the page asks for.
726 #[test]
727 fn every_order_is_a_permutation() {
728 let widgets = annotiter_widgets();
729 for order in [TabOrder::Row, TabOrder::Column, TabOrder::Structure] {
730 let ring = FocusRing::build(&widgets, order);
731 let mut seen = indices(&ring);
732 seen.sort_unstable();
733 assert_eq!(seen, vec![0, 1, 2, 3], "{order:?} is not a permutation");
734 assert!(!ring.degenerate, "{order:?} should not degenerate");
735 }
736 }
737 }
738
739 mod never_panics {
740 //! The property that outranks every behavioural one, for the geometry
741 //! this crate keeps private: **no input panics.**
742 //!
743 //! In-crate rather than in `tests/never_panics.rs`, because these three
744 //! generate [`Plate`]s, [`Candidate`]s and [`Focusable`]s out of
745 //! `f32` [`Rect`]s — the crate's own geometry, not its public
746 //! vocabulary. The rest of that file's properties are over public
747 //! types and stayed where they were.
748
749 use crate::event::Point;
750 use crate::geom::{Plate, Rotation};
751 use crate::hit::{Candidate, LayoutBand, Permissions, WidgetHit, widget_at_point};
752 use crate::session::AnnotId;
753 use crate::tab::{FocusRing, Focusable, Rect, TabOrder};
754
755 /// A small deterministic generator: a counter run through a mixing
756 /// step. Enough spread to reach the awkward cases, and reproducible
757 /// when one fails.
758 struct Gen(u32);
759
760 impl Gen {
761 fn next(&mut self) -> u32 {
762 self.0 = self.0.wrapping_mul(1_103_515_245).wrapping_add(12_345);
763 self.0
764 }
765
766 fn coord(&mut self) -> f32 {
767 // Bounded to -1000..1000, so the conversion is exact.
768 let raw = i16::try_from(self.next() % 2000).unwrap_or(0) - 1000;
769 f32::from(raw) / 2.0
770 }
771
772 fn below(&mut self, n: u32) -> u32 {
773 if n == 0 { 0 } else { self.next() % n }
774 }
775 }
776
777 /// Rectangles that would break a naive implementation: inverted, degenerate,
778 /// negative, and a one-by-one box a real corpus file contains.
779 fn awkward_rects() -> Vec<Rect> {
780 vec![
781 Rect::new(0.0, 0.0, 0.0, 0.0),
782 Rect::new(10.0, 10.0, 10.0, 10.0),
783 Rect::new(1.0, 1.0, 2.0, 2.0),
784 // Written inside out, as bug_889099's field is.
785 Rect::new(100.0, 100.0, 200.0, -130.0),
786 Rect::new(200.0, 200.0, 100.0, 100.0),
787 Rect::new(-500.0, -500.0, -400.0, -400.0),
788 Rect::new(0.0, 0.0, 1e6, 1e6),
789 ]
790 }
791
792 /// The plate transform survives every awkward rectangle and every rotation,
793 /// and never produces a value that is not a number.
794 #[test]
795 fn the_plate_transform_never_panics_or_produces_nonsense() {
796 let mut rng = Gen(1);
797 for rect in awkward_rects() {
798 for rotation in [
799 Rotation::None,
800 Rotation::Quarter,
801 Rotation::Half,
802 Rotation::ThreeQuarter,
803 ] {
804 let plate = Plate::new(rect, rotation);
805 for _ in 0..20 {
806 let at = Point::new(rng.coord(), rng.coord());
807 let there = plate.to_plate(at);
808 let back = plate.to_page(there);
809 assert!(there.x.is_finite() && there.y.is_finite());
810 assert!(back.x.is_finite() && back.y.is_finite());
811 }
812 assert!(plate.width().is_finite());
813 assert!(plate.height().is_finite());
814 assert!(
815 plate.width() >= 0.0,
816 "a normalized box has no negative width"
817 );
818 assert!(plate.height() >= 0.0);
819 }
820 }
821 }
822
823 /// Hit testing over generated geometry returns, and never names an
824 /// annotation that is not in the list.
825 #[test]
826 fn hit_testing_never_panics_and_never_invents_an_annotation() {
827 let mut rng = Gen(7);
828 for _ in 0..200 {
829 let count = rng.below(6);
830 let candidates: Vec<Candidate> = (0..count)
831 .map(|i| {
832 let (x, y) = (rng.coord(), rng.coord());
833 Candidate {
834 id: AnnotId::new(rng.below(3), i),
835 rect: Rect::new(x, y, x + rng.coord(), y + rng.coord()),
836 band: match rng.below(3) {
837 0 => LayoutBand::Popup,
838 1 => LayoutBand::Widget,
839 _ => LayoutBand::Other,
840 },
841 widget: (rng.below(2) == 0).then(|| WidgetHit {
842 signature: rng.below(2) == 0,
843 hidden: rng.below(2) == 0,
844 read_only: rng.below(2) == 0,
845 push_button: rng.below(2) == 0,
846 }),
847 }
848 })
849 .collect();
850
851 let focused = candidates.first().map(|c| c.id);
852 for permissions in [Permissions::ALL, Permissions::NONE] {
853 let hit = widget_at_point(
854 &candidates,
855 focused,
856 permissions,
857 rng.coord(),
858 rng.coord(),
859 );
860 if let Some(hit) = hit {
861 assert!(
862 candidates.iter().any(|c| c.id == hit),
863 "hit test named an annotation that is not in the list"
864 );
865 }
866 }
867 }
868 }
869
870 /// The tab-order banding terminates over any geometry, in every order, and
871 /// emits each annotation exactly once. The upstream loop hangs here.
872 #[test]
873 fn the_focus_ring_always_terminates_and_is_always_a_permutation() {
874 let mut rng = Gen(13);
875 for _ in 0..300 {
876 let count = rng.below(8);
877 let annots: Vec<Focusable> = (0..count)
878 .map(|i| {
879 let (x, y) = (rng.coord(), rng.coord());
880 Focusable {
881 id: AnnotId::new(0, i),
882 // Deliberately including zero-area and inverted boxes.
883 rect: Rect::new(x, y, x + rng.coord(), y + rng.coord()),
884 }
885 })
886 .collect();
887
888 for order in [TabOrder::Row, TabOrder::Column, TabOrder::Structure] {
889 let built = FocusRing::build(&annots, order);
890 assert_eq!(built.len(), annots.len(), "{order:?} lost an annotation");
891
892 let mut seen: Vec<u32> = built.order.iter().map(|a| a.index).collect();
893 seen.sort_unstable();
894 let expected: Vec<u32> = (0..count).collect();
895 assert_eq!(seen, expected, "{order:?} is not a permutation");
896
897 // Walking the ring from either end terminates.
898 if let Some(mut at) = built.first() {
899 let mut steps = 0;
900 while let Some(next) = built.next(at) {
901 at = next;
902 steps += 1;
903 assert!(
904 steps <= count as usize,
905 "the forward walk did not terminate"
906 );
907 }
908 }
909 }
910 }
911 }
912 }
913}