Skip to main content

ravenrs_report_viewer/
search.rs

1//! Finds text in a document, ignoring case and accents: "regiao" finds "Região".
2
3use ravenrs_report::{Document, Item};
4use ravenrs_text::fold;
5
6/// Where a search result is.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct Match {
9    /// The page.
10    pub page: usize,
11    /// The text block's position in the page's items.
12    pub item: usize,
13    /// The line within the block.
14    pub line: usize,
15    /// Byte range of the match in the line's text.
16    pub range: std::ops::Range<usize>,
17}
18
19/// Every match of `query`, in reading order of the document's blocks.
20pub 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    // Folded characters with the byte each came from, since folding can change lengths.
41    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}