Find Whole Word: a change of style inside a word is not a word boundary

Find and Replace matched each style run on its own, and the whole-word
test looked only at the characters inside the run, so a run's end read
as a word boundary: with cat coloured red in catfish, a Whole Word
search for cat replaced it. The test now looks past the run's ends at
the characters next to it in the neighbouring runs. Matches still stay
inside one run, as the command documents.

Fixes #5901
This commit is contained in:
Hamza
2026-11-06 23:04:21 +07:00
committed by bflatastic
parent d316edaad9
commit b474525e4b
2 changed files with 51 additions and 5 deletions
+21 -5
View File
@@ -625,6 +625,22 @@ fn is_word(c: char) -> bool {
/// Byte ranges of `needle` in `hay`.
pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> {
find_all_between(hay, needle, match_case, whole_word, None, None)
}
/// [`find_all`] in run `i` of `runs`: a whole word looks past the run's ends at the characters
/// next to it, so a change of style inside a word is not a word boundary (#5901). Matches stay
/// inside the run.
fn find_in_run(runs: &[TextRun], i: usize, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> {
let Some(run) = runs.get(i) else { return vec![] };
let before = runs.get(..i).and_then(|r| r.iter().rev().find_map(|r| r.text.chars().next_back()));
let after = runs.get(i + 1..).and_then(|r| r.iter().find_map(|r| r.text.chars().next()));
find_all_between(&run.text, needle, match_case, whole_word, before, after)
}
/// [`find_all`], with `before` and `after` the characters on either side of `hay` (none at the
/// text's ends), which a whole word must not touch either.
fn find_all_between(hay: &str, needle: &str, match_case: bool, whole_word: bool, before: Option<char>, after: Option<char>) -> Vec<(usize, usize)> {
let nc: Vec<char> = needle.chars().collect();
if nc.is_empty() {
return vec![];
@@ -635,9 +651,9 @@ pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bo
let mut i = 0;
while i + nc.len() <= hc.len() {
if (0..nc.len()).all(|k| eq(hc[i + k].1, nc[k])) {
let before = i.checked_sub(1).map(|j| hc[j].1);
let after = hc.get(i + nc.len()).map(|x| x.1);
if !whole_word || (!before.is_some_and(is_word) && !after.is_some_and(is_word)) {
let prev = i.checked_sub(1).map_or(before, |j| hc.get(j).map(|x| x.1));
let next = hc.get(i + nc.len()).map_or(after, |x| Some(x.1));
if !whole_word || (!prev.is_some_and(is_word) && !next.is_some_and(is_word)) {
let start = hc[i].0;
let end = hc.get(i + nc.len()).map(|x| x.0).unwrap_or(hay.len());
out.push((start, end));
@@ -685,7 +701,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
let total: usize = ids
.iter()
.filter_map(|id| match &st.doc.node(*id)?.kind {
NodeKind::Text(t) => Some(t.runs.iter().map(|r| find_all(&r.text, &find, mc, ww).len()).sum::<usize>()),
NodeKind::Text(t) => Some((0..t.runs.len()).map(|i| find_in_run(&t.runs, i, &find, mc, ww).len()).sum::<usize>()),
_ => None,
})
.sum();
@@ -702,7 +718,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
let mut base = 0;
for (ri, r) in t.runs.iter().enumerate() {
if r.inline.is_none() {
for (a, b) in find_all(&r.text, &find, mc, ww) {
for (a, b) in find_in_run(&t.runs, ri, &find, mc, ww) {
if r.text.get(a..b) != Some(rep.as_str()) {
hits.push((ri, base + a, base + b, a, b));
}
+30
View File
@@ -1989,6 +1989,36 @@ fn find_and_replace_options() {
assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err());
}
/// Whole Word looks past a change of style: `cat` coloured red inside `catfish`, or at the end of
/// `bobcat`, is part of a word, not a word of its own (#5901). A real word next to a run's end
/// still matches, and without Whole Word the part is replaced as before.
#[test]
fn find_whole_word_is_not_fooled_by_a_style_change_inside_a_word() {
let styled = |s: &mut Session, t: &str, a: usize, b: usize| {
let id = text(s, t);
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": a, "end": b, "fill": "#ff0000"})).unwrap();
let NodeKind::Text(tt) = &node(s, id).kind else { panic!() };
assert!(tt.runs.len() >= 2, "{t}: two runs");
id
};
let mut s = session();
let fish = styled(&mut s, "catfish", 0, 3);
let bob = styled(&mut s, "bobcat", 3, 6);
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG", "wholeWord": true})).unwrap();
assert_eq!((r["count"].as_u64(), plain(&s, fish), plain(&s, bob)), (Some(0), "catfish".to_string(), "bobcat".to_string()));
// A word that ends where its run does, at a space or the text's end, is still a word.
let mut s = session();
let spaced = styled(&mut s, "cat fish", 0, 3);
let tail = styled(&mut s, "a cat", 2, 5);
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG", "wholeWord": true})).unwrap();
assert_eq!((r["count"].as_u64(), plain(&s, spaced), plain(&s, tail)), (Some(2), "DOG fish".to_string(), "a DOG".to_string()));
// Without Whole Word, the part inside the word is found as before.
let mut s = session();
let fish = styled(&mut s, "catfish", 0, 3);
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG"})).unwrap();
assert_eq!((r["count"].as_u64(), plain(&s, fish)), (Some(1), "DOGfish".to_string()));
}
/// Find and Replace with no ids leaves the type on a locked artboard as it is.
#[test]
fn find_and_replace_leaves_type_on_a_locked_artboard() {