mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 14:22:25 +00:00
Find and replace text in Edit text mode: a Replace row in the Find box, Replace all as one undo step (#2467)
This commit is contained in:
1 parent
2b46f8bf8c
commit
b7cfbd3980
32 files changed
+1569
-39
No files matched your search
@@ -93,6 +93,15 @@ pub fn fold_case(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Byte ranges in `text` where `needle` occurs, with the find bar's rules ([`PageText::find_opts`]):
|
||||
/// case folded unless `case_sensitive`, any run of whitespace matching one space, and with
|
||||
/// `whole_words` only where no letter or digit adjoins the match. At most `limit` ranges, in order
|
||||
/// and not overlapping. Find and replace matches a paragraph's text with it, so what the find bar
|
||||
/// highlights is what gets replaced.
|
||||
pub fn find_in_text(text: &str, needle: &str, case_sensitive: bool, whole_words: bool, limit: usize) -> Vec<std::ops::Range<usize>> {
|
||||
search::find_in_text(text, needle, case_sensitive, whole_words, limit)
|
||||
}
|
||||
|
||||
/// The text of one page, in content order, with line structure.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PageText {
|
||||
|
||||
@@ -50,16 +50,7 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
|
||||
let texts: Vec<String> = page.glyphs.iter().map(|g| fold(&g.text)).collect();
|
||||
let divided: Vec<bool> = (0..texts.len()).map(|i| divides(page, &texts, i)).collect();
|
||||
let readings: &[Hyphen] = if divided.contains(&true) { &[Hyphen::Written, Hyphen::Joined, Hyphen::Kept] } else { &[Hyphen::Written] };
|
||||
// KMP reuses a matched prefix instead of comparing a long query again at every position.
|
||||
// The table is bounded by the page's already materialized character count.
|
||||
let mut prefix = vec![0; needle.len()];
|
||||
let mut matched = 0;
|
||||
for (i, &c) in needle.iter().enumerate().skip(1) {
|
||||
matched = advance(&needle, &prefix, matched, c);
|
||||
if let Some(slot) = prefix.get_mut(i) {
|
||||
*slot = matched;
|
||||
}
|
||||
}
|
||||
let prefix = prefix_table(&needle);
|
||||
let mut hits: Vec<(Range<usize>, usize)> = Vec::new();
|
||||
for (r, &reading) in readings.iter().enumerate() {
|
||||
let (chars, zero_width_space) = characters(page, &texts, ÷d, reading);
|
||||
@@ -92,6 +83,58 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b
|
||||
kept
|
||||
}
|
||||
|
||||
/// [`super::find_in_text`]: the same search over a string (find and replace matches a paragraph's
|
||||
/// text with it), as the byte ranges of the characters each hit covers. Hyphenated line ends do
|
||||
/// not arise in a string, so there is the one reading.
|
||||
pub(super) fn find_in_text(text: &str, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec<Range<usize>> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let fold = |s: &str| if sensitive { ligature_letters(s.to_owned()) } else { fold_case(&ligature_letters(s.to_owned())) };
|
||||
let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect();
|
||||
let needle: Vec<char> = query.split_whitespace().collect::<Vec<_>>().join(" ").chars().collect();
|
||||
if needle.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Each character is owned by its index in the string, turned back into bytes at the end.
|
||||
let mut chars: Vec<(char, usize)> = Vec::new();
|
||||
let mut zero_width_space: Vec<bool> = Vec::new();
|
||||
let mut pending_zero_width_space = false;
|
||||
for (index, (_, c)) in text.char_indices().enumerate() {
|
||||
for c in fold(c.encode_utf8(&mut [0; 4])).chars() {
|
||||
if is_invisible(c) {
|
||||
pending_zero_width_space |= c == '\u{200B}';
|
||||
continue;
|
||||
}
|
||||
let c = if c.is_whitespace() { ' ' } else { c };
|
||||
if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') {
|
||||
chars.push((c, index));
|
||||
zero_width_space.push(pending_zero_width_space && c != ' ');
|
||||
pending_zero_width_space = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let starts: Vec<usize> = text.char_indices().map(|(at, _)| at).chain(std::iter::once(text.len())).collect();
|
||||
search(&chars, &zero_width_space, &needle, &prefix_table(&needle), whole_words, limit)
|
||||
.into_iter()
|
||||
.filter_map(|hit| Some(*starts.get(hit.start)?..*starts.get(hit.end)?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// KMP reuses a matched prefix instead of comparing a long query again at every position. The
|
||||
/// table is bounded by the page's already materialized character count.
|
||||
fn prefix_table(needle: &[char]) -> Vec<usize> {
|
||||
let mut prefix = vec![0; needle.len()];
|
||||
let mut matched = 0;
|
||||
for (i, &c) in needle.iter().enumerate().skip(1) {
|
||||
matched = advance(needle, &prefix, matched, c);
|
||||
if let Some(slot) = prefix.get_mut(i) {
|
||||
*slot = matched;
|
||||
}
|
||||
}
|
||||
prefix
|
||||
}
|
||||
|
||||
/// Whether glyph `i` ends its line with a hyphen that follows a letter of its word, and the next
|
||||
/// line starts with a letter. `texts` are the glyphs' folded texts; the invisible characters find
|
||||
/// skips are passed over on both sides.
|
||||
|
||||
@@ -1123,6 +1123,33 @@ fn turkish_dotted_and_dotless_i_match_in_any_case() {
|
||||
assert_eq!(lower.find("KIŞ"), vec![13..16]);
|
||||
}
|
||||
|
||||
/// Find and replace matches a paragraph's text with the find bar's own rules: the same
|
||||
/// matches as the page search, as byte ranges of the text (multi-byte letters included).
|
||||
#[test]
|
||||
fn text_search_agrees_with_the_page_search() {
|
||||
let mut v = Vec::new();
|
||||
word(&mut v, "İSTANBUL", 10.0, 10.0, 7.0);
|
||||
word(&mut v, "cat", 80.0, 10.0, 7.0);
|
||||
word(&mut v, "Cats", 110.0, 10.0, 7.0);
|
||||
word(&mut v, "cat.", 150.0, 10.0, 7.0);
|
||||
let page = layout(v);
|
||||
let text = page.plain_text();
|
||||
assert_eq!(text, "İSTANBUL cat Cats cat.");
|
||||
for (needle, case, whole) in
|
||||
[("cat", false, false), ("cat", true, false), ("cat", false, true), ("istanbul cat", false, false), ("CATS", true, true)]
|
||||
{
|
||||
let glyphs: Vec<String> = page.find_opts(needle, case, whole).into_iter().map(|r| page.text_of(r)).collect();
|
||||
let bytes: Vec<&str> = find_in_text(&text, needle, case, whole, usize::MAX).into_iter().filter_map(|r| text.get(r)).collect();
|
||||
assert_eq!(glyphs, bytes, "{needle:?} case {case} whole {whole}");
|
||||
}
|
||||
assert_eq!(find_in_text(&text, "cat", false, true, usize::MAX), vec![10..13, 19..22]);
|
||||
assert_eq!(find_in_text(&text, "istanbul", false, false, usize::MAX), vec![0..9], "İ is two bytes");
|
||||
assert_eq!(find_in_text("a\n b", "a b", false, false, usize::MAX), vec![0..5], "any whitespace run is one space");
|
||||
assert_eq!(find_in_text("cat cat", "cat", false, false, 1), vec![0..3]);
|
||||
assert!(find_in_text("cat", " ", false, false, usize::MAX).is_empty());
|
||||
assert!(find_in_text("cat", "cat", false, false, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greek_sigma_forms_match_without_changing_exact_search_or_glyph_ranges() {
|
||||
let mut v = Vec::new();
|
||||
|
||||
Reference in new issue
Block a user