From ee6cd6de555355f62c53f9f04ca0b9b92a32700d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:01:34 +0000 Subject: [PATCH] Keep paragraph styles aligned in Find and Replace with line breaks Found while testing crates/engine/src/cmd/typemenu.rs:668 (find_replace / edit_runs) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/typemenu.rs | 41 ++++++++++++++++----- crates/engine/tests/find_replace_paras.rs | 44 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/find_replace_paras.rs diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index b18b97bff..a21d09263 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -665,16 +665,39 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if total == 0 { return Ok(json!({ "count": 0 })); } - edit_runs::<()>(s, "Replace", &ids, |text, _| { - let mut out = String::with_capacity(text.len()); - let mut last = 0; - for (a, b) in find_all(text, &find, mc, ww) { - out.push_str(&text[last..a]); - out.push_str(&rep); - last = b; + super::overprint::edit_counted(s, "Replace", |d| { + let mut changed = 0; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + // Matches as (run, start, end) with offsets inside the run. Inline graphics are art, not + // characters to rewrite. + let mut hits = vec![]; + 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) { + if r.text.get(a..b) != Some(rep.as_str()) { + hits.push((ri, base + a, base + b, a, b)); + } + } + } + base += r.text.len(); + } + if hits.is_empty() { + continue; + } + // Last match first, so the offsets of earlier ones stay valid. The paragraph styles follow + // each edit, as `text.editRange` does. + for (ri, ta, tb, a, b) in hits.into_iter().rev() { + t.splice_paras(ta, tb, &rep); + if let Some(r) = t.runs.get_mut(ri) { + r.text.replace_range(a..b, &rep); + } + } + changed += 1; + refresh_bounds(t); } - out.push_str(&text[last..]); - out + Ok(changed) })?; Ok(json!({ "count": total })) } diff --git a/crates/engine/tests/find_replace_paras.rs b/crates/engine/tests/find_replace_paras.rs new file mode 100644 index 000000000..b511ddf08 --- /dev/null +++ b/crates/engine/tests/find_replace_paras.rs @@ -0,0 +1,44 @@ +//! `edit.findReplace` keeps per-paragraph styles lined up with the text when a find or replace +//! string holds a line break, as `text.editRange` does. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; +use vectorcraft_doc::{Justify, NodeId, NodeKind}; +use vectorcraft_testkit::fixtures::{self, exec, id_of}; + +/// "A\nB\nC" with B centred and C right-aligned. +fn setup() -> (vectorcraft_engine::Session, NodeId) { + let mut s = fixtures::session_with(400.0, 300.0); + let id = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "A\nB\nC", "size": 20}))); + exec(&mut s, "text.setStyle", json!({"id": id.0, "start": 2, "end": 3, "justify": "center"})); + exec(&mut s, "text.setStyle", json!({"id": id.0, "start": 4, "end": 5, "justify": "right"})); + exec(&mut s, "select.none", json!({})); + (s, id) +} + +fn state(s: &vectorcraft_engine::Session, id: NodeId) -> (String, Vec) { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (t.plain_text(), t.paragraph_styles().iter().map(|p| p.justify).collect()), + _ => panic!("not text"), + } +} + +#[test] +fn replacement_adding_a_line_break_keeps_later_paragraph_styles() { + let (mut s, id) = setup(); + assert_eq!(state(&s, id).1, [Justify::Auto, Justify::Center, Justify::Right]); + exec(&mut s, "edit.findReplace", json!({"find": "A", "replace": "A\nA2", "ids": [id.0]})); + let (text, styles) = state(&s, id); + assert_eq!(text, "A\nA2\nB\nC"); + assert_eq!(styles, [Justify::Auto, Justify::Auto, Justify::Center, Justify::Right]); +} + +#[test] +fn find_text_with_a_line_break_merges_paragraphs_like_edit_range() { + let (mut s, id) = setup(); + exec(&mut s, "edit.findReplace", json!({"find": "A\nB", "replace": "AB", "ids": [id.0]})); + let (text, styles) = state(&s, id); + assert_eq!(text, "AB\nC"); + assert_eq!(styles, [Justify::Auto, Justify::Right]); +}