Style delete: redirect every reference, validate name and replacement

delete_para and delete_char previously reassigned only the story text
runs/paragraphs using the deleted style, leaving other styles' Based
On, paragraph styles' nested/nested-line/GREP style refs, the drop cap
style, and the footnote reference style pointed at a name that no
longer exists.

Extract redirect_style_refs() from rename_style() (everything it does
except updating the registry entry's own name) and reuse it from both
delete commands, so deleting a style now redirects every reference
type to the replacement style before removing the registry entry.
Also teach rename_char_refs() about drop_cap_style, which it missed
even for renames.

Add validation: name must be non-empty and refer to an existing
style, and replaceWith (if given) must refer to an existing style and
can't be the style being deleted.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
zydvc26s67-web
2026-10-14 11:33:35 -07:00
co-authored by Copilot App
parent 7c5b248dfc
commit bfcd6ef71f
2 changed files with 96 additions and 26 deletions
+46 -26
View File
@@ -511,12 +511,29 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<Value> {
@@ -610,22 +628,23 @@ fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>() {
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<Value> {
}
fn delete_char(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>() {
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()
})
}
+50
View File
@@ -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};