mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 21:50:16 +00:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user