spectre_pdf 1.0.0

Native Rust PDF extraction engine: text, markdown for RAG, AcroForm widgets, image decoding, and encrypted PDFs. Lazy parser, persistent Document handle, no C dependencies.
Documentation
//! Text search returning bounding boxes.
//!
//! Words come from the positional interpreter. A sliding window of
//! `query.len()` tokens walks each (page, block, line) group; matches
//! emit the union of contributing word bboxes — one rect per match,
//! including cross-word matches like "New York".

use crate::geom::Rect;
use crate::positioned::{extract_words_impl, Word};
use crate::ExtractError;

#[derive(Debug, Clone, PartialEq)]
pub struct SearchHit {
    pub page: u32,
    pub bbox: Rect,
    /// Matched text as it appears in the document (preserves case).
    pub text: String,
}

#[derive(Debug, Clone, Copy)]
pub struct SearchOptions {
    pub case_insensitive: bool,
    /// Whitespace in the query matches one or more inter-word gaps.
    pub flexible_whitespace: bool,
}

impl Default for SearchOptions {
    fn default() -> Self {
        Self {
            case_insensitive: true,
            flexible_whitespace: true,
        }
    }
}

pub fn search_impl(
    pdf_bytes: &[u8],
    query: &str,
    page_filter: Option<u32>,
    options: SearchOptions,
) -> Result<Vec<SearchHit>, ExtractError> {
    let query = query.trim();
    if query.is_empty() {
        return Ok(Vec::new());
    }
    let words = extract_words_impl(pdf_bytes, page_filter)?;
    Ok(search_in_words(&words, query, options))
}

/// Search over a pre-extracted word vec. Used by [`crate::Document`]
/// to skip re-parsing when the words are already cached.
pub(crate) fn search_in_words_pub(
    words: &[Word],
    query: &str,
    options: SearchOptions,
) -> Vec<SearchHit> {
    let q = query.trim();
    if q.is_empty() {
        return Vec::new();
    }
    search_in_words(words, q, options)
}

fn search_in_words(words: &[Word], query: &str, opts: SearchOptions) -> Vec<SearchHit> {
    let q_tokens: Vec<String> = if opts.flexible_whitespace {
        query.split_whitespace().map(|s| normalize(s, opts)).collect()
    } else {
        vec![normalize(query, opts)]
    };
    if q_tokens.is_empty() {
        return Vec::new();
    }

    let mut hits = Vec::new();
    // Group by (page, block, line) so cross-word matches stay on the
    // same visual line. Insertion order matches the reading order.
    let mut groups: Vec<Vec<&Word>> = Vec::new();
    let mut cur_key: Option<(u32, u32, u32)> = None;
    for w in words {
        let key = (w.page, w.block_no, w.line_no);
        if Some(key) != cur_key {
            groups.push(Vec::new());
            cur_key = Some(key);
        }
        groups.last_mut().unwrap().push(w);
    }

    for group in &groups {
        if group.is_empty() {
            continue;
        }
        let win = q_tokens.len();
        if group.len() < win {
            // Single-token queries fall back to substring match within
            // a word ("quick" matches "quickly").
            if win == 1 {
                let q = &q_tokens[0];
                for w in group {
                    if normalize(&w.text, opts).contains(q) {
                        hits.push(SearchHit {
                            page: w.page,
                            bbox: w.bbox,
                            text: w.text.clone(),
                        });
                    }
                }
            }
            continue;
        }
        for start in 0..=(group.len() - win) {
            let window = &group[start..start + win];
            let matched = window
                .iter()
                .zip(q_tokens.iter())
                .all(|(w, q)| normalize(&w.text, opts) == *q);
            if matched {
                let bbox = window
                    .iter()
                    .map(|w| w.bbox)
                    .reduce(|a, b| a.union(b))
                    .unwrap_or(Rect::ZERO);
                let text = window
                    .iter()
                    .map(|w| w.text.as_str())
                    .collect::<Vec<_>>()
                    .join(" ");
                hits.push(SearchHit {
                    page: window[0].page,
                    bbox,
                    text,
                });
            }
        }
    }
    hits
}

fn normalize(s: &str, opts: SearchOptions) -> String {
    if opts.case_insensitive {
        s.to_lowercase()
    } else {
        s.to_string()
    }
}