ravenrs_report_viewer/
search.rs1use ravenrs_report::{Document, Item};
4use ravenrs_text::fold;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct Match {
9 pub page: usize,
11 pub item: usize,
13 pub line: usize,
15 pub range: std::ops::Range<usize>,
17}
18
19pub fn find_matches(document: &Document, query: &str) -> Vec<Match> {
21 let needle: Vec<char> = query.trim().chars().flat_map(fold).collect();
22 let mut matches = Vec::new();
23 if needle.is_empty() {
24 return matches;
25 }
26 for (page_index, page) in document.pages.iter().enumerate() {
27 for (item_index, item) in page.items.iter().enumerate() {
28 let Item::Text(block) = item else { continue };
29 for (line_index, line) in block.lines.iter().enumerate() {
30 find_in_line(&line.text, &needle, |range| {
31 matches.push(Match { page: page_index, item: item_index, line: line_index, range });
32 });
33 }
34 }
35 }
36 matches
37}
38
39fn find_in_line(text: &str, needle: &[char], mut found: impl FnMut(std::ops::Range<usize>)) {
40 let folded: Vec<(char, usize)> =
42 text.char_indices().flat_map(|(at, c)| fold(c).map(move |f| (f, at))).collect();
43 let mut start = 0;
44 while start + needle.len() <= folded.len() {
45 if folded[start..start + needle.len()].iter().map(|(c, _)| c).eq(needle.iter()) {
46 let from = folded[start].1;
47 let last = folded[start + needle.len() - 1].1;
48 let end = last + text[last..].chars().next().map_or(0, char::len_utf8);
49 found(from..end);
50 start += needle.len();
51 } else {
52 start += 1;
53 }
54 }
55}
56
57#[cfg(test)]
58mod tests {
59 use ravenrs_report::{Band, Control, Report, ReportEngine};
60 use serde_json::json;
61
62 use super::*;
63
64 fn document() -> Document {
65 let report =
66 Report::new("T").detail(Band::new(20.0).control(Control::field("[A]").at(0.0, 0.0, 400.0, 20.0)));
67 ReportEngine::new().render(
68 &report,
69 &json!([{ "A": "Região Sul, região norte" }, { "A": "Ação" }, { "A": "R$\u{a0}10,00" }, { "A": "İstanbul" }]),
70 )
71 }
72
73 fn found(query: &str) -> Vec<String> {
74 let doc = document();
75 find_matches(&doc, query)
76 .into_iter()
77 .map(|m| {
78 let Item::Text(block) = &doc.pages[m.page].items[m.item] else { panic!("not text") };
79 block.lines[m.line].text[m.range].to_owned()
80 })
81 .collect()
82 }
83
84 #[test]
85 fn case_and_accents_do_not_matter() {
86 assert_eq!(found("regiao"), ["Região", "região"]);
87 assert_eq!(found("REGIÃO"), ["Região", "região"]);
88 assert_eq!(found("acao"), ["Ação"]);
89 }
90
91 #[test]
92 fn a_plain_space_finds_a_non_breaking_one() {
93 assert_eq!(found("r$ 10"), ["R$\u{a0}10"]);
94 }
95
96 #[test]
97 fn nothing_to_find_finds_nothing() {
98 assert!(found("").is_empty());
99 assert!(found(" ").is_empty());
100 assert!(found("xyz").is_empty());
101 }
102
103 #[test]
104 fn characters_that_change_length_when_folded_keep_valid_ranges() {
105 assert_eq!(found("stanbul"), ["stanbul"]);
106 assert_eq!(found("i"), ["i", "i", "İ"]);
107 }
108
109 #[test]
110 fn matches_do_not_overlap() {
111 let report =
112 Report::new("T").detail(Band::new(20.0).control(Control::field("[A]").at(0.0, 0.0, 400.0, 20.0)));
113 let doc = ReportEngine::new().render(&report, &json!([{ "A": "aaaa" }]));
114 assert_eq!(find_matches(&doc, "aa").len(), 2);
115 }
116}