Skip to main content

pdfluent_extract/
search.rs

1//! Full-text search across PDF documents.
2//!
3//! Provides search functionality using extracted text and positioned characters.
4
5// Copyright (c) 2026 Innovation Trigger B.V.
6//
7// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
8// the PDFluent Commercial Licence. See the LICENSE file in this repository --
9// that file travels with the copy you received, which a URL does not.
10
11use crate::text;
12
13/// A search result with location information.
14#[derive(Debug, Clone)]
15pub struct SearchResult {
16    /// The page number (1-based) where the match was found.
17    pub page: u32,
18    /// The matched text.
19    pub text: String,
20    /// Bounding boxes of the matched characters.
21    pub bounding_boxes: Vec<[f64; 4]>,
22    /// Character offset within the page text.
23    pub offset: usize,
24}
25
26/// Options for text search.
27#[derive(Debug, Clone)]
28pub struct SearchOptions {
29    /// Whether to perform case-insensitive search.
30    pub case_insensitive: bool,
31    /// Maximum number of results to return (0 = unlimited).
32    pub max_results: usize,
33    /// Specific pages to search (empty = all pages).
34    pub pages: Vec<u32>,
35    /// Skip bounding box extraction for faster search when only text matches are needed.
36    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
50/// Search for text across all pages of a document.
51pub 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    // Determine which pages to search.
65    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    // Process pages lazily — extract text (and optionally positioned chars)
83    // one page at a time to avoid upfront cost on large documents.
84    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        // Only extract positioned chars when bounding boxes are actually needed.
94        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
140/// Count the total number of occurrences of a query across all pages.
141pub fn count_occurrences(doc: &lopdf::Document, query: &str) -> usize {
142    count_text_only(doc, query, &SearchOptions::default())
143}
144
145/// Fast text-only occurrence count — skips all bounding box extraction.
146///
147/// Accepts `SearchOptions` for page filtering and case sensitivity,
148/// but ignores `max_results` (always counts all occurrences).
149pub 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
193/// Return a list of page numbers that contain the query text.
194pub 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    /// Helper: create a doc with text content.
220    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    /// Helper: create a multi-page doc.
255    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); // 5 chars in "Hello"
409    }
410}