diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 38eb20d23..7559a8179 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -511,12 +511,29 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { /// Rename a paragraph or character style everywhere it's used. fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { - let st = d.styles_mut(); if para { - for s in &mut st.paragraph { + for s in &mut d.styles_mut().paragraph { if s.name == from { s.name = to.to_string(); } + } + } else { + for s in &mut d.styles_mut().character { + if s.name == from { + s.name = to.to_string(); + } + } + } + redirect_style_refs(d, para, from, to); +} + +/// Point every reference to style `from` at `to`, except the style's own registry entry (its +/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which +/// instead removes the entry, after its former uses point at the replacement). +fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { + let st = d.styles_mut(); + if para { + for s in &mut st.paragraph { for r in [&mut s.based_on, &mut s.next_style] { if r.as_deref() == Some(from) { *r = Some(to.to_string()); @@ -530,9 +547,6 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: & } } else { for s in &mut st.character { - if s.name == from { - s.name = to.to_string(); - } if s.based_on.as_deref() == Some(from) { s.based_on = Some(to.to_string()); } @@ -564,7 +578,8 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: & } } -/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style. +/// Point the nested, nested line and GREP styles, and the drop cap style, of paragraph +/// attributes at a renamed (or deleted, pointed at its replacement) character style. fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style); let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style)); @@ -573,6 +588,9 @@ fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { *n = to.to_string(); } } + if p.drop_cap_style.as_deref() == Some(from) { + p.drop_cap_style = Some(to.to_string()); + } } fn group_styles(s: &mut Session, p: &Value) -> Result { @@ -610,22 +628,23 @@ fn group_styles(s: &mut Session, p: &Value) -> Result { } fn delete_para(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").unwrap_or("").to_string(); + let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad("style.paragraph.delete", "missing name"))?.to_string(); let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::BASIC_PARAGRAPH).to_string(); if name.starts_with('[') { return Err(bad("style.paragraph.delete", "built-in styles can't be deleted")); } + if repl == name { + return Err(bad("style.paragraph.delete", "`replaceWith` can't be the deleted style")); + } s.edit(|d, _| { - d.styles_mut().paragraph.retain(|x| x.name != name); - for sid in d.stories.keys().copied().collect::>() { - if let Some(story) = d.story_mut(sid) { - for f in &mut story.paras { - if f.style == name { - f.style = repl.clone(); - } - } - } + if d.styles.para(&name).is_none() { + return Err(bad("style.paragraph.delete", format!("no paragraph style `{name}`"))); } + if d.styles.para(&repl).is_none() { + return Err(bad("style.paragraph.delete", format!("no paragraph style `{repl}`"))); + } + redirect_style_refs(d, true, &name, &repl); + d.styles_mut().paragraph.retain(|x| x.name != name); ok() }) } @@ -718,22 +737,23 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { } fn delete_char(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").unwrap_or("").to_string(); + let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad("style.character.delete", "missing name"))?.to_string(); let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); if name.starts_with('[') { return Err(bad("style.character.delete", "built-in styles can't be deleted")); } + if repl == name { + return Err(bad("style.character.delete", "`replaceWith` can't be the deleted style")); + } s.edit(|d, _| { - d.styles_mut().character.retain(|x| x.name != name); - for sid in d.stories.keys().copied().collect::>() { - if let Some(story) = d.story_mut(sid) { - story.for_each_text_mut(&mut |st| { - for r in st.chars.iter_mut().filter(|r| r.format.style == name) { - r.format.style = repl.clone(); - } - }); - } + if d.styles.char_style(&name).is_none() { + return Err(bad("style.character.delete", format!("no character style `{name}`"))); } + if d.styles.char_style(&repl).is_none() { + return Err(bad("style.character.delete", format!("no character style `{repl}`"))); + } + redirect_style_refs(d, false, &name, &repl); + d.styles_mut().character.retain(|x| x.name != name); ok() }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e47542518..d7dc80b68 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -681,6 +681,56 @@ fn character_style_delete_rejects_builtin_styles() { assert!(r.is_err(), "built-in styles can't be deleted"); } +#[test] +fn character_style_delete_rejects_missing_unknown_and_self_replacement() { + let mut s = session(); + let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + assert!(s.execute("style.character.delete", &json!({"name": ""})).is_err(), "empty name is rejected"); + assert!(s.execute("style.character.delete", &json!({"name": "No Such Style"})).is_err(), "unknown name is rejected"); + assert!(s.execute("style.character.delete", &json!({"name": name, "replaceWith": "No Such Style"})).is_err(), "unknown replaceWith is rejected"); + assert!(s.execute("style.character.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style"); +} + +#[test] +fn paragraph_style_delete_rejects_missing_unknown_and_self_replacement() { + let mut s = session(); + let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); + assert!(s.execute("style.paragraph.delete", &json!({"name": ""})).is_err(), "empty name is rejected"); + assert!(s.execute("style.paragraph.delete", &json!({"name": "No Such Style"})).is_err(), "unknown name is rejected"); + assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": "No Such Style"})).is_err(), "unknown replaceWith is rejected"); + assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style"); +} + +#[test] +fn character_style_delete_reassigns_every_kind_of_reference() { + let mut s = session(); + let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + let repl = s.execute("style.character.create", &json!({"name": "Emphasis"})).unwrap()["name"].as_str().unwrap().to_string(); + // Another character style's basedOn. + let based = s.execute("style.character.create", &json!({"name": "Strong", "basedOn": name})).unwrap()["name"].as_str().unwrap().to_string(); + // A paragraph style's nested, nested line, GREP and drop cap styles, and the footnote reference style. + s.execute( + "style.paragraph.create", + &json!({"name": "Body", "para": { + "nestedStyles": [{"style": name, "through": true, "count": 1, "until": {"kind": "words"}}], + "grepStyles": [{"style": name, "pattern": "x"}], + "nestedLineStyles": [{"style": name, "lines": 1}], + "dropCapStyle": name}}), + ) + .unwrap(); + s.execute("footnote.options", &json!({"refCharStyle": name})).unwrap(); + s.execute("style.character.delete", &json!({"name": name, "replaceWith": repl})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone"); + assert_eq!(st.doc.styles.char_style(&based).unwrap().based_on.as_deref(), Some(repl.as_str()), "basedOn redirected"); + let body = st.doc.styles.para("Body").unwrap(); + assert_eq!(body.para.nested_styles.as_ref().unwrap()[0].style, repl, "nested style redirected"); + assert_eq!(body.para.nested_line_styles.as_ref().unwrap()[0].style, repl, "nested line style redirected"); + assert_eq!(body.para.grep_styles.as_ref().unwrap()[0].style, repl, "grep style redirected"); + assert_eq!(body.para.drop_cap_style.as_deref(), Some(repl.as_str()), "drop cap style redirected"); + assert_eq!(st.doc.footnote_options.ref_char_style, repl, "footnote reference style redirected"); +} + #[test] fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() { use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind};