Skip to main content

ebook_rs/
search.rs

1use crate::cfi::Cfi;
2use crate::section::Section;
3use serde::{Deserialize, Serialize};
4
5/// A single search result item.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct SearchResult {
8    pub spine_index: usize,
9    pub snippet: String,
10    pub cfi: String,
11    pub char_offset: usize,
12}
13
14/// Full-text search engine across chapter sections.
15pub struct SearchEngine;
16
17impl SearchEngine {
18    /// Perform full-text search over a slice of sections.
19    /// Uses SIMD memmem search over pre-lowered section text for high-throughput, allocation-light scanning.
20    pub fn search(sections: &[Section], query: &str, case_sensitive: bool) -> Vec<SearchResult> {
21        if query.trim().is_empty() {
22            return Vec::new();
23        }
24
25        let query_pattern = if case_sensitive {
26            query.to_string()
27        } else {
28            query.to_lowercase()
29        };
30
31        let precompiled_re = if !case_sensitive && !query.is_ascii() {
32            regex::Regex::new(&format!("(?i){}", regex::escape(query))).ok()
33        } else {
34            None
35        };
36
37        #[cfg(feature = "parallel")]
38        {
39            use rayon::prelude::*;
40            sections
41                .par_iter()
42                .flat_map(|section| {
43                    Self::search_section_prepared(
44                        section,
45                        &query_pattern,
46                        case_sensitive,
47                        precompiled_re.as_ref(),
48                    )
49                })
50                .collect()
51        }
52
53        #[cfg(not(feature = "parallel"))]
54        {
55            let mut results = Vec::new();
56            for section in sections {
57                results.extend(Self::search_section_prepared(
58                    section,
59                    &query_pattern,
60                    case_sensitive,
61                    precompiled_re.as_ref(),
62                ));
63            }
64            results
65        }
66    }
67
68    /// Search a single section safely without cross-string offset mismatch panics.
69    pub fn search_section(
70        section: &Section,
71        query: &str,
72        case_sensitive: bool,
73    ) -> Vec<SearchResult> {
74        let query_pattern = if case_sensitive {
75            query.to_string()
76        } else {
77            query.to_lowercase()
78        };
79        let precompiled_re = if !case_sensitive && !query.is_ascii() {
80            regex::Regex::new(&format!("(?i){}", regex::escape(query))).ok()
81        } else {
82            None
83        };
84        Self::search_section_prepared(
85            section,
86            &query_pattern,
87            case_sensitive,
88            precompiled_re.as_ref(),
89        )
90    }
91
92    fn search_section_prepared(
93        section: &Section,
94        query: &str,
95        case_sensitive: bool,
96        precompiled_re: Option<&regex::Regex>,
97    ) -> Vec<SearchResult> {
98        let mut results = Vec::new();
99        if query.trim().is_empty() || section.plain_text.is_empty() {
100            return results;
101        }
102
103        let is_pure_ascii = section.plain_text.is_ascii() && query.is_ascii();
104
105        if case_sensitive || is_pure_ascii {
106            let target_text = if case_sensitive {
107                &section.plain_text
108            } else {
109                &section.plain_text_lower
110            };
111            let query_low = if case_sensitive {
112                query.to_string()
113            } else {
114                query.to_ascii_lowercase()
115            };
116
117            let finder = memchr::memmem::Finder::new(query_low.as_bytes());
118
119            for match_byte_idx in finder.find_iter(target_text.as_bytes()) {
120                let char_offset = if is_pure_ascii {
121                    match_byte_idx
122                } else {
123                    target_text[..match_byte_idx].chars().count()
124                };
125
126                let match_len = query_low.len();
127
128                let (before, matched, after, has_prefix, has_suffix) =
129                    extract_zero_alloc_snippet(&section.plain_text, match_byte_idx, match_len);
130
131                let prefix = if has_prefix { "..." } else { "" };
132                let suffix = if has_suffix { "..." } else { "" };
133
134                let snippet = format!(
135                    "{}{}<mark>{}</mark>{}{}",
136                    prefix,
137                    html_escape(before),
138                    html_escape(matched),
139                    html_escape(after),
140                    suffix
141                );
142
143                let cfi = Cfi::from_spine_index(section.index, None, char_offset).to_string();
144
145                results.push(SearchResult {
146                    spine_index: section.index,
147                    snippet,
148                    cfi,
149                    char_offset,
150                });
151            }
152        } else {
153            // Non-ASCII case-insensitive matching: match directly on section.plain_text
154            let owned_re;
155            let re = if let Some(r) = precompiled_re {
156                r
157            } else {
158                let pattern = format!("(?i){}", regex::escape(query));
159                if let Ok(r) = regex::Regex::new(&pattern) {
160                    owned_re = Some(r);
161                    owned_re.as_ref().unwrap()
162                } else {
163                    return results;
164                }
165            };
166            for m in re.find_iter(&section.plain_text) {
167                let start_b = m.start();
168                let match_len = m.len();
169                let char_offset = section.plain_text[..start_b].chars().count();
170
171                let (before, matched, after, has_prefix, has_suffix) =
172                    extract_zero_alloc_snippet(&section.plain_text, start_b, match_len);
173
174                let prefix = if has_prefix { "..." } else { "" };
175                let suffix = if has_suffix { "..." } else { "" };
176
177                let snippet = format!(
178                    "{}{}<mark>{}</mark>{}{}",
179                    prefix,
180                    html_escape(before),
181                    html_escape(matched),
182                    html_escape(after),
183                    suffix
184                );
185
186                let cfi = Cfi::from_spine_index(section.index, None, char_offset).to_string();
187
188                results.push(SearchResult {
189                    spine_index: section.index,
190                    snippet,
191                    cfi,
192                    char_offset,
193                });
194            }
195        }
196
197        results
198    }
199
200    /// Perform full-text regex pattern search across chapter sections.
201    pub fn search_regex(sections: &[Section], pattern: &str) -> Result<Vec<SearchResult>, String> {
202        let re = regex::Regex::new(pattern)
203            .map_err(|e| format!("Invalid regex pattern '{}': {}", pattern, e))?;
204        let mut results = Vec::new();
205
206        for section in sections {
207            for m in re.find_iter(&section.plain_text) {
208                let start_b = m.start();
209                let match_len = m.len();
210                let char_idx = section.plain_text[..start_b.min(section.plain_text.len())]
211                    .chars()
212                    .count();
213
214                let (before, matched, after, has_prefix, has_suffix) =
215                    extract_zero_alloc_snippet(&section.plain_text, start_b, match_len);
216
217                let prefix = if has_prefix { "..." } else { "" };
218                let suffix = if has_suffix { "..." } else { "" };
219
220                let snippet = format!(
221                    "{}{}<mark>{}</mark>{}{}",
222                    prefix,
223                    html_escape(before),
224                    html_escape(matched),
225                    html_escape(after),
226                    suffix
227                );
228                let cfi = Cfi::from_spine_index(section.index, None, char_idx).to_string();
229
230                results.push(SearchResult {
231                    spine_index: section.index,
232                    snippet,
233                    cfi,
234                    char_offset: char_idx,
235                });
236            }
237        }
238
239        Ok(results)
240    }
241
242    /// Format search results into Readium Standard Search Collection JSON (application/vnd.readium.search+json).
243    pub fn to_readium_search_json(results: &[SearchResult], query: &str) -> Result<String, String> {
244        let locators: Vec<serde_json::Value> = results
245            .iter()
246            .map(|r| {
247                serde_json::json!({
248                    "href": format!("section_{}.html", r.spine_index),
249                    "type": "application/xhtml+xml",
250                    "locations": {
251                        "cfi": r.cfi,
252                        "position": r.char_offset / 1000 + 1
253                    },
254                    "text": {
255                        "snippet": r.snippet
256                    }
257                })
258            })
259            .collect();
260
261        let collection = serde_json::json!({
262            "@context": "http://readium.org/webpub-manifest/context.jsonld",
263            "metadata": {
264                "numberOfResults": results.len(),
265                "query": query
266            },
267            "locators": locators
268        });
269
270        serde_json::to_string_pretty(&collection)
271            .map_err(|e| format!("Failed to serialize Readium Search JSON: {}", e))
272    }
273}
274
275/// Extract snippet window surrounding a match without converting the entire string to a Vec<char>.
276fn extract_zero_alloc_snippet(
277    text: &str,
278    byte_offset: usize,
279    match_bytes_len: usize,
280) -> (&str, &str, &str, bool, bool) {
281    if text.is_ascii() {
282        let start_b = byte_offset.saturating_sub(40);
283        let end_match_b = (byte_offset + match_bytes_len).min(text.len());
284        let end_b = (end_match_b + 40).min(text.len());
285
286        let prefix = start_b > 0;
287        let suffix = end_b < text.len();
288
289        let before = &text[start_b..byte_offset];
290        let matched = &text[byte_offset..end_match_b];
291        let after = &text[end_match_b..end_b];
292
293        (before, matched, after, prefix, suffix)
294    } else {
295        let safe_byte_offset = byte_offset.min(text.len());
296        let before_slice = &text[..safe_byte_offset];
297        let mut start_b = 0;
298        let mut count = 0;
299        for (idx, _) in before_slice.char_indices().rev() {
300            count += 1;
301            if count == 40 {
302                start_b = idx;
303                break;
304            }
305        }
306
307        let end_match_b = (safe_byte_offset + match_bytes_len).min(text.len());
308        let after_slice = &text[end_match_b..];
309        let mut end_b = text.len();
310        let mut count = 0;
311        for (idx, c) in after_slice.char_indices() {
312            count += 1;
313            if count == 40 {
314                end_b = end_match_b + idx + c.len_utf8();
315                break;
316            }
317        }
318
319        let prefix = start_b > 0;
320        let suffix = end_b < text.len();
321
322        let before = &text[start_b..safe_byte_offset];
323        let matched = &text[safe_byte_offset..end_match_b];
324        let after = &text[end_match_b..end_b];
325
326        (before, matched, after, prefix, suffix)
327    }
328}
329
330/// Helper function to HTML-escape arbitrary text strings to prevent stored XSS attacks.
331pub fn html_escape(s: &str) -> String {
332    s.replace('&', "&amp;")
333        .replace('<', "&lt;")
334        .replace('>', "&gt;")
335        .replace('"', "&quot;")
336        .replace('\'', "&#39;")
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_search() {
345        let sec = Section {
346            index: 0,
347            idref: "ch1".to_string(),
348            href: "ch1.xhtml".to_string(),
349            full_path: "OEBPS/ch1.xhtml".to_string(),
350            raw_html: "<p>Hello Rust Reader</p>".to_string(),
351            processed_html: "<p>Hello Rust Reader</p>".to_string(),
352            plain_text: "Hello Rust Reader".to_string(),
353            plain_text_lower: "hello rust reader".to_string(),
354            char_count: 17,
355            viewport_width: None,
356            viewport_height: None,
357        };
358
359        let results = SearchEngine::search(&[sec], "Rust", false);
360        assert_eq!(results.len(), 1);
361        assert_eq!(results[0].spine_index, 0);
362        assert!(results[0].snippet.contains("Rust"));
363    }
364
365    #[test]
366    fn test_search_unicode_boundary_safety() {
367        let sec = Section {
368            index: 0,
369            idref: "ch1".to_string(),
370            href: "ch1.xhtml".to_string(),
371            full_path: "OEBPS/ch1.xhtml".to_string(),
372            raw_html: "<p>ẞfoo bar</p>".to_string(),
373            processed_html: "<p>ẞfoo bar</p>".to_string(),
374            plain_text: "ẞfoo bar".to_string(),
375            plain_text_lower: "ssfoo bar".to_string(),
376            char_count: 8,
377            viewport_width: None,
378            viewport_height: None,
379        };
380
381        let results = SearchEngine::search(&[sec], "foo", false);
382        assert_eq!(results.len(), 1);
383        assert!(results[0].snippet.contains("foo"));
384    }
385}