diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 57ba9fb0..9eac6fca 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -944,6 +944,16 @@ fn batch_rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) } +/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. +/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. +fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { + if needle.is_empty() { + return None; + } + (from..hay.len()) + .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) +} + fn find_replace(s: &mut Session, p: &Value) -> Result { let find = req_string("edit.findReplace", p, "find")?; if find.is_empty() { @@ -954,11 +964,11 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { let page = s.page()?; let doc = s.doc_mut()?; let mut hits = Vec::new(); - let lower = find.to_lowercase(); + let needle: Vec = find.chars().collect(); for i in doc.subtree(page) { let Some(n) = doc.node(i) else { continue }; - let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower)); - let name_hit = names && n.name.to_lowercase().contains(&lower); + let text_hit = matches!(&n.kind, NodeKind::Text(t) if find_ci(&t.characters.chars().collect::>(), &needle, 0).is_some()); + let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); if !(text_hit || name_hit) || n.is_page() { continue; } @@ -966,17 +976,25 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if let Some(r) = &replace { let n = doc.get_mut(i)?; if names && name_hit { - n.name = n.name.replace(find.as_str(), r); + let chars: Vec = n.name.chars().collect(); + let mut out = String::new(); + let mut at = 0; + while let Some(pos) = find_ci(&chars, &needle, at) { + out.extend(chars.get(at..pos).unwrap_or_default()); + out.push_str(r); + at = pos + needle.len(); + } + out.extend(chars.get(at..).unwrap_or_default()); + n.name = out; } if let NodeKind::Text(t) = &mut n.kind { // Replace occurrence by occurrence so styles shift correctly. + let r_chars: Vec = r.chars().collect(); let mut guard = 0; - while let Some(pos) = t.characters.find(find.as_str()) { - let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0); - let len = find.chars().count(); - t.replace_range(start, start + len, r); + while let Some(start) = find_ci(&t.characters.chars().collect::>(), &needle, 0) { + t.replace_range(start, start + needle.len(), r); guard += 1; - if guard > 10_000 || r.contains(find.as_str()) { + if guard > 10_000 || find_ci(&r_chars, &needle, 0).is_some() { break; } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 40da2f1d..ff1ad723 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -532,3 +532,17 @@ fn guides_add_move_remove() { s.execute("guide.clear", &json!({})).unwrap(); assert!(guides(&s).is_empty()); } + +#[test] +fn find_replace_matches_and_replaces_case_insensitively() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()); + let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap()); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap()); + let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone(); + let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap(); + assert_eq!(r["matches"], json!([t, u, f])); + assert_eq!(chars(&s, t), "bye"); + assert_eq!(chars(&s, u), "İ bye bye"); + assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box"); +}