1use crate::cfi::Cfi;
2use crate::section::Section;
3use serde::{Deserialize, Serialize};
4
5#[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
14pub struct SearchEngine;
16
17impl SearchEngine {
18 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 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<®ex::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 §ion.plain_text
108 } else {
109 §ion.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(§ion.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 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(§ion.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(§ion.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 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(§ion.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(§ion.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 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
275fn 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
330pub fn html_escape(s: &str) -> String {
332 s.replace('&', "&")
333 .replace('<', "<")
334 .replace('>', ">")
335 .replace('"', """)
336 .replace('\'', "'")
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}