mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:47:22 -06:00
Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
113 lines
4.5 KiB
Rust
113 lines
4.5 KiB
Rust
use pdfcraft_render::{PageText, TextGlyph, text::fold_case};
|
||
use std::ops::Range;
|
||
|
||
fn page(parts: &[&str]) -> PageText {
|
||
PageText {
|
||
glyphs: parts.iter().map(|text| TextGlyph { text: (*text).into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(),
|
||
line_of: vec![0; parts.len()],
|
||
space_before: vec![false; parts.len()],
|
||
}
|
||
}
|
||
|
||
// The original straightforward search is an independent oracle for valid page metadata.
|
||
fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec<Range<usize>> {
|
||
let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) };
|
||
let needle: Vec<char> = fold(query).split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
|
||
if needle.is_empty() || limit == 0 {
|
||
return Vec::new();
|
||
}
|
||
let mut chars = Vec::new();
|
||
for (i, glyph) in page.glyphs.iter().enumerate() {
|
||
if i > 0 && (page.space_before[i] || page.line_of[i] != page.line_of[i - 1]) && chars.last().is_some_and(|&(c, _)| c != ' ') {
|
||
chars.push((' ', i));
|
||
}
|
||
for c in fold(&glyph.text).chars() {
|
||
let c = if c.is_whitespace() { ' ' } else { c };
|
||
if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') {
|
||
chars.push((c, i));
|
||
}
|
||
}
|
||
}
|
||
let word = |i: Option<usize>| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric());
|
||
let mut hits = Vec::new();
|
||
let mut start = 0;
|
||
while start + needle.len() <= chars.len() {
|
||
let end = start + needle.len();
|
||
if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) {
|
||
hits.push(chars[start].1..chars[end - 1].1 + 1);
|
||
if hits.len() == limit {
|
||
break;
|
||
}
|
||
start = end;
|
||
} else {
|
||
start += 1;
|
||
}
|
||
}
|
||
hits
|
||
}
|
||
|
||
#[test]
|
||
fn search_tolerates_missing_layout_metadata() {
|
||
let mut text = page(&["a", "b", "c"]);
|
||
text.line_of.clear();
|
||
text.space_before.clear();
|
||
assert_eq!(text.find("abc"), vec![0..3]);
|
||
text.space_before = vec![false, true];
|
||
assert_eq!(text.find("a bc"), vec![0..3]);
|
||
text.line_of = vec![0, 1];
|
||
assert_eq!(text.find("a bc"), vec![0..3]);
|
||
}
|
||
|
||
#[test]
|
||
fn whole_word_rejection_keeps_overlapping_candidates() {
|
||
let text = page(&["xaa-aa-aa"]);
|
||
assert_eq!(text.find_opts("aa-aa", true, true), vec![0..1]);
|
||
// Accepted matches still do not overlap, even within one multi-character glyph.
|
||
assert_eq!(page(&["aaaaa"]).find("aaa"), vec![0..1]);
|
||
assert_eq!(page(&["aaaaa"]).find("aa"), vec![0..1, 0..1]);
|
||
}
|
||
|
||
#[test]
|
||
fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() {
|
||
let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية"];
|
||
let mut seed = 1234567u64;
|
||
let mut next = || {
|
||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||
(seed >> 32) as usize
|
||
};
|
||
for case in 0..2000 {
|
||
let parts: Vec<_> = (0..10 + case % 70).map(|_| alphabet[next() % alphabet.len()]).collect();
|
||
let query = if case % 2 == 0 {
|
||
parts.iter().skip(case % 8).take(1 + case % 9).copied().collect::<String>()
|
||
} else {
|
||
(0..1 + case % 9).map(|_| alphabet[next() % alphabet.len()]).collect::<String>()
|
||
};
|
||
let mut text = page(&parts);
|
||
for i in 1..parts.len() {
|
||
text.space_before[i] = next() % 11 == 0;
|
||
text.line_of[i] = text.line_of[i - 1] + u32::from(next() % 17 == 0);
|
||
}
|
||
for sensitive in [false, true] {
|
||
for whole in [false, true] {
|
||
for limit in [0, 1, 2, usize::MAX] {
|
||
assert_eq!(
|
||
text.find_opts_limited(&query, sensitive, whole, limit),
|
||
reference(&text, &query, sensitive, whole, limit),
|
||
"case {case}: {query:?}, sensitive={sensitive}, whole={whole}, limit={limit}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn long_repeated_prefix_search_preserves_the_last_match_and_limit() {
|
||
let mut parts = vec!["a"; 131072];
|
||
parts.push("b");
|
||
let text = page(&parts);
|
||
let query = format!("{}b", "a".repeat(4095));
|
||
assert_eq!(text.find_opts_limited(&query, false, false, 1), vec![126977..131073]);
|
||
assert!(text.find(&format!("{}c", "a".repeat(4095))).is_empty());
|
||
}
|