use pdfcraft_render::{PageText, TextGlyph, text::fold_case}; use std::ops::Range; fn page(parts: &[&str]) -> PageText { PageText { glyphs: parts.iter().map(|text| TextGlyph { text: (*text).into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(), line_of: vec![0; parts.len()], space_before: vec![false; parts.len()], } } // The original straightforward search is an independent oracle for valid page metadata. fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec> { let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() || limit == 0 { return Vec::new(); } let mut chars = Vec::new(); for (i, glyph) in page.glyphs.iter().enumerate() { if i > 0 && (page.space_before[i] || page.line_of[i] != page.line_of[i - 1]) && chars.last().is_some_and(|&(c, _)| c != ' ') { chars.push((' ', i)); } for c in fold(&glyph.text).chars() { let c = if c.is_whitespace() { ' ' } else { c }; if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') { chars.push((c, i)); } } } let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric()); let mut hits = Vec::new(); let mut start = 0; while start + needle.len() <= chars.len() { let end = start + needle.len(); if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) { hits.push(chars[start].1..chars[end - 1].1 + 1); if hits.len() == limit { break; } start = end; } else { start += 1; } } hits } #[test] fn search_tolerates_missing_layout_metadata() { let mut text = page(&["a", "b", "c"]); text.line_of.clear(); text.space_before.clear(); assert_eq!(text.find("abc"), vec![0..3]); text.space_before = vec![false, true]; assert_eq!(text.find("a bc"), vec![0..3]); text.line_of = vec![0, 1]; assert_eq!(text.find("a bc"), vec![0..3]); } #[test] fn whole_word_rejection_keeps_overlapping_candidates() { let text = page(&["xaa-aa-aa"]); assert_eq!(text.find_opts("aa-aa", true, true), vec![0..1]); // Accepted matches still do not overlap, even within one multi-character glyph. assert_eq!(page(&["aaaaa"]).find("aaa"), vec![0..1]); assert_eq!(page(&["aaaaa"]).find("aa"), vec![0..1, 0..1]); } #[test] fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() { let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية"]; let mut seed = 1234567u64; let mut next = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); (seed >> 32) as usize }; for case in 0..2000 { let parts: Vec<_> = (0..10 + case % 70).map(|_| alphabet[next() % alphabet.len()]).collect(); let query = if case % 2 == 0 { parts.iter().skip(case % 8).take(1 + case % 9).copied().collect::() } else { (0..1 + case % 9).map(|_| alphabet[next() % alphabet.len()]).collect::() }; let mut text = page(&parts); for i in 1..parts.len() { text.space_before[i] = next() % 11 == 0; text.line_of[i] = text.line_of[i - 1] + u32::from(next() % 17 == 0); } for sensitive in [false, true] { for whole in [false, true] { for limit in [0, 1, 2, usize::MAX] { assert_eq!( text.find_opts_limited(&query, sensitive, whole, limit), reference(&text, &query, sensitive, whole, limit), "case {case}: {query:?}, sensitive={sensitive}, whole={whole}, limit={limit}" ); } } } } } #[test] fn long_repeated_prefix_search_preserves_the_last_match_and_limit() { let mut parts = vec!["a"; 131072]; parts.push("b"); let text = page(&parts); let query = format!("{}b", "a".repeat(4095)); assert_eq!(text.find_opts_limited(&query, false, false, 1), vec![126977..131073]); assert!(text.find(&format!("{}c", "a".repeat(4095))).is_empty()); }