1use crate::text;
12
13#[derive(Debug, Clone)]
15pub struct SearchResult {
16 pub page: u32,
18 pub text: String,
20 pub bounding_boxes: Vec<[f64; 4]>,
22 pub offset: usize,
24}
25
26#[derive(Debug, Clone)]
28pub struct SearchOptions {
29 pub case_insensitive: bool,
31 pub max_results: usize,
33 pub pages: Vec<u32>,
35 pub skip_bounding_boxes: bool,
37}
38
39impl Default for SearchOptions {
40 fn default() -> Self {
41 Self {
42 case_insensitive: true,
43 max_results: 0,
44 pages: Vec::new(),
45 skip_bounding_boxes: false,
46 }
47 }
48}
49
50pub fn search_text(
52 doc: &lopdf::Document,
53 query: &str,
54 options: &SearchOptions,
55) -> Vec<SearchResult> {
56 if query.is_empty() {
57 return Vec::new();
58 }
59
60 let pages = doc.get_pages();
61 let total = pages.len() as u32;
62 let mut results = Vec::new();
63
64 let page_nums: Vec<u32> = if options.pages.is_empty() {
66 (1..=total).collect()
67 } else {
68 options
69 .pages
70 .iter()
71 .copied()
72 .filter(|&p| p >= 1 && p <= total)
73 .collect()
74 };
75
76 let query_normalized = if options.case_insensitive {
77 query.to_lowercase()
78 } else {
79 query.to_string()
80 };
81
82 for page_num in &page_nums {
85 let page_text = text::extract_page_text(doc, *page_num).unwrap_or_default();
86
87 let haystack = if options.case_insensitive {
88 page_text.to_lowercase()
89 } else {
90 page_text.clone()
91 };
92
93 let positioned = if options.skip_bounding_boxes {
95 Vec::new()
96 } else {
97 text::extract_positioned_chars(doc, *page_num).unwrap_or_default()
98 };
99
100 let mut start = 0;
101 while let Some(pos) = haystack[start..].find(&query_normalized) {
102 let offset = start + pos;
103 let end = offset + query_normalized.len();
104
105 let bboxes: Vec<[f64; 4]> = if options.skip_bounding_boxes {
106 Vec::new()
107 } else {
108 positioned
109 .iter()
110 .skip(offset)
111 .take(end - offset)
112 .map(|c| c.bbox)
113 .collect()
114 };
115
116 let matched_text = page_text
117 .chars()
118 .skip(offset)
119 .take(query_normalized.len())
120 .collect::<String>();
121
122 results.push(SearchResult {
123 page: *page_num,
124 text: matched_text,
125 bounding_boxes: bboxes,
126 offset,
127 });
128
129 if options.max_results > 0 && results.len() >= options.max_results {
130 return results;
131 }
132
133 start = offset + 1;
134 }
135 }
136
137 results
138}
139
140pub fn count_occurrences(doc: &lopdf::Document, query: &str) -> usize {
142 count_text_only(doc, query, &SearchOptions::default())
143}
144
145pub fn count_text_only(doc: &lopdf::Document, query: &str, options: &SearchOptions) -> usize {
150 if query.is_empty() {
151 return 0;
152 }
153
154 let pages = doc.get_pages();
155 let total = pages.len() as u32;
156
157 let page_nums: Vec<u32> = if options.pages.is_empty() {
158 (1..=total).collect()
159 } else {
160 options
161 .pages
162 .iter()
163 .copied()
164 .filter(|&p| p >= 1 && p <= total)
165 .collect()
166 };
167
168 let query_normalized = if options.case_insensitive {
169 query.to_lowercase()
170 } else {
171 query.to_string()
172 };
173
174 let mut count = 0usize;
175 for page_num in &page_nums {
176 let page_text = text::extract_page_text(doc, *page_num).unwrap_or_default();
177 let haystack = if options.case_insensitive {
178 page_text.to_lowercase()
179 } else {
180 page_text
181 };
182
183 let mut start = 0;
184 while let Some(pos) = haystack[start..].find(&query_normalized) {
185 count += 1;
186 start += pos + 1;
187 }
188 }
189
190 count
191}
192
193pub fn pages_containing(doc: &lopdf::Document, query: &str) -> Vec<u32> {
195 if query.is_empty() {
196 return Vec::new();
197 }
198
199 let pages = doc.get_pages();
200 let total = pages.len() as u32;
201 let query_lower = query.to_lowercase();
202 let mut result = Vec::new();
203
204 for page_num in 1..=total {
205 let page_text = text::extract_page_text(doc, page_num).unwrap_or_default();
206 if page_text.to_lowercase().contains(&query_lower) {
207 result.push(page_num);
208 }
209 }
210
211 result
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use lopdf::{dictionary, Document, Object, Stream};
218
219 fn make_doc_with_text(content: &[u8]) -> Document {
221 let mut doc = Document::with_version("1.7");
222
223 let content_stream = Stream::new(dictionary! {}, content.to_vec());
224 let content_id = doc.add_object(Object::Stream(content_stream));
225
226 let page_dict = dictionary! {
227 "Type" => "Page",
228 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
229 "Contents" => Object::Reference(content_id),
230 };
231 let page_id = doc.add_object(Object::Dictionary(page_dict));
232
233 let pages_dict = dictionary! {
234 "Type" => "Pages",
235 "Kids" => vec![Object::Reference(page_id)],
236 "Count" => 1_i64,
237 };
238 let pages_id = doc.add_object(Object::Dictionary(pages_dict));
239
240 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
241 d.set("Parent", Object::Reference(pages_id));
242 }
243
244 let catalog = dictionary! {
245 "Type" => "Catalog",
246 "Pages" => Object::Reference(pages_id),
247 };
248 let catalog_id = doc.add_object(Object::Dictionary(catalog));
249 doc.trailer.set("Root", Object::Reference(catalog_id));
250
251 doc
252 }
253
254 fn make_multi_page_doc(contents: &[&[u8]]) -> Document {
256 let mut doc = Document::with_version("1.7");
257 let mut page_ids = Vec::new();
258
259 for content in contents {
260 let content_stream = Stream::new(dictionary! {}, content.to_vec());
261 let content_id = doc.add_object(Object::Stream(content_stream));
262
263 let page_dict = dictionary! {
264 "Type" => "Page",
265 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
266 "Contents" => Object::Reference(content_id),
267 };
268 let page_id = doc.add_object(Object::Dictionary(page_dict));
269 page_ids.push(page_id);
270 }
271
272 let kids: Vec<Object> = page_ids.iter().map(|id| Object::Reference(*id)).collect();
273 let pages_dict = dictionary! {
274 "Type" => "Pages",
275 "Kids" => kids,
276 "Count" => Object::Integer(page_ids.len() as i64),
277 };
278 let pages_id = doc.add_object(Object::Dictionary(pages_dict));
279
280 for &page_id in &page_ids {
281 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
282 d.set("Parent", Object::Reference(pages_id));
283 }
284 }
285
286 let catalog = dictionary! {
287 "Type" => "Catalog",
288 "Pages" => Object::Reference(pages_id),
289 };
290 let catalog_id = doc.add_object(Object::Dictionary(catalog));
291 doc.trailer.set("Root", Object::Reference(catalog_id));
292
293 doc
294 }
295
296 #[test]
297 fn search_single_page() {
298 let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello World) Tj ET");
299 let options = SearchOptions::default();
300 let results = search_text(&doc, "Hello", &options);
301 assert_eq!(results.len(), 1);
302 assert_eq!(results[0].page, 1);
303 assert_eq!(results[0].text, "Hello");
304 }
305
306 #[test]
307 fn search_case_insensitive() {
308 let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello World) Tj ET");
309 let options = SearchOptions {
310 case_insensitive: true,
311 ..Default::default()
312 };
313 let results = search_text(&doc, "hello", &options);
314 assert_eq!(results.len(), 1);
315 assert_eq!(results[0].text, "Hello");
316 }
317
318 #[test]
319 fn search_case_sensitive() {
320 let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello World) Tj ET");
321 let options = SearchOptions {
322 case_insensitive: false,
323 ..Default::default()
324 };
325 let results = search_text(&doc, "hello", &options);
326 assert!(results.is_empty());
327 }
328
329 #[test]
330 fn search_multiple_pages() {
331 let doc = make_multi_page_doc(&[
332 b"BT /F1 12 Tf (Hello) Tj ET",
333 b"BT /F1 12 Tf (Hello again) Tj ET",
334 ]);
335 let options = SearchOptions::default();
336 let results = search_text(&doc, "Hello", &options);
337 assert_eq!(results.len(), 2);
338 }
339
340 #[test]
341 fn search_specific_pages() {
342 let doc = make_multi_page_doc(&[
343 b"BT /F1 12 Tf (Hello) Tj ET",
344 b"BT /F1 12 Tf (Hello again) Tj ET",
345 ]);
346 let options = SearchOptions {
347 pages: vec![1],
348 ..Default::default()
349 };
350 let results = search_text(&doc, "Hello", &options);
351 assert_eq!(results.len(), 1);
352 assert_eq!(results[0].page, 1);
353 }
354
355 #[test]
356 fn search_max_results() {
357 let doc = make_doc_with_text(b"BT /F1 12 Tf (aaa) Tj ET");
358 let options = SearchOptions {
359 max_results: 1,
360 ..Default::default()
361 };
362 let results = search_text(&doc, "a", &options);
363 assert_eq!(results.len(), 1);
364 }
365
366 #[test]
367 fn search_empty_query() {
368 let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
369 let options = SearchOptions::default();
370 let results = search_text(&doc, "", &options);
371 assert!(results.is_empty());
372 }
373
374 #[test]
375 fn search_no_match() {
376 let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
377 let options = SearchOptions::default();
378 let results = search_text(&doc, "xyz", &options);
379 assert!(results.is_empty());
380 }
381
382 #[test]
383 fn count_occurrences_basic() {
384 let doc = make_doc_with_text(b"BT /F1 12 Tf (abcabc) Tj ET");
385 let count = count_occurrences(&doc, "abc");
386 assert_eq!(count, 2);
387 }
388
389 #[test]
390 fn pages_containing_basic() {
391 let doc = make_multi_page_doc(&[
392 b"BT /F1 12 Tf (Hello) Tj ET",
393 b"BT /F1 12 Tf (World) Tj ET",
394 b"BT /F1 12 Tf (Hello World) Tj ET",
395 ]);
396 let pages = pages_containing(&doc, "Hello");
397 assert!(pages.contains(&1));
398 assert!(!pages.contains(&2));
399 assert!(pages.contains(&3));
400 }
401
402 #[test]
403 fn search_results_have_bounding_boxes() {
404 let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
405 let options = SearchOptions::default();
406 let results = search_text(&doc, "Hello", &options);
407 assert_eq!(results.len(), 1);
408 assert_eq!(results[0].bounding_boxes.len(), 5); }
410}