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,
pub text: String,
}
#[derive(Debug, Clone, Copy)]
pub struct SearchOptions {
pub case_insensitive: bool,
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))
}
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();
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 {
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()
}
}