diff --git a/ROADMAP.md b/ROADMAP.md index 541a56e5d..963bc5960 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 9568d842e..5d440a5d4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -258,7 +258,7 @@ pub fn specs() -> Vec { if !exists { return Err(bad("style.exportTag", format!("no style `{name}`"))); } - let key = format!("{}:{name}", if character { "c" } else { "p" }); + let key = export_tag_key(!character, &name); let (tag, class) = (str_param(p, "tag").map(str::to_string), str_param(p, "class").map(str::to_string)); let split = p.get("split").and_then(Value::as_bool); if character && split == Some(true) { @@ -941,7 +941,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; - let rename = str_param(p, "rename").map(str::to_string); + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); let based = p.get("basedOn").cloned(); s.edit(|d, _| { if let Some(Value::String(b)) = &based @@ -949,6 +949,14 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.paragraph.edit", format!("can't rename to `{n}`"))); + } + if d.styles.para(n).is_some() { + return Err(bad("style.paragraph.edit", format!("a paragraph style named `{n}` already exists"))); + } + } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; @@ -971,24 +979,35 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { }) } -/// Rename a paragraph or character style everywhere it's used. +/// Rename a paragraph or character style everywhere it's used; its Export Tagging follows. 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 d.styles_mut().paragraph { + for s in &mut st.paragraph { if s.name == from { s.name = to.to_string(); } } } else { - for s in &mut d.styles_mut().character { + for s in &mut st.character { if s.name == from { s.name = to.to_string(); } } } + // An entry already under `to` belongs to no style (names are unique), so it never survives. + st.export_tags.remove(&export_tag_key(para, to)); + if let Some(tag) = st.export_tags.remove(&export_tag_key(para, from)) { + st.export_tags.insert(export_tag_key(para, to), tag); + } redirect_style_refs(d, para, from, to); } +/// The `Styles::export_tags` key of a paragraph (`para`) or character style. +fn export_tag_key(para: bool, name: &str) -> String { + format!("{}:{name}", if para { "p" } else { "c" }) +} + /// 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). @@ -1175,8 +1194,10 @@ fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) { *b = parent.clone(); } + // The replacement keeps its own Export Tagging. + st.export_tags.remove(&export_tag_key(para, &name)); // Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults. - rename_style(d, para, &name, &repl); + redirect_style_refs(d, para, &name, &repl); ok() }) } @@ -1230,7 +1251,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_object) .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) .unwrap_or_default(); - let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string()); + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); let based: Option> = match p.get("basedOn") { None => None, Some(Value::Null) => Some(None), @@ -1247,10 +1268,13 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { return Err(bad("style.character.edit", "based-on would create a cycle")); } } - if let Some(n) = &rename - && (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some()) - { - return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + } + if d.styles.char_style(n).is_some() { + return Err(bad("style.character.edit", format!("a character style named `{n}` already exists"))); + } } let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; st.chars.merge(&chars); @@ -2843,6 +2867,78 @@ mod export_tag_tests { assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements"); assert!(designcraft_render::pdf_page_count(&pdf) == Some(1)); } + + #[test] + fn export_tagging_follows_a_renamed_style_and_goes_with_a_deleted_one() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Term"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Head", "tag": "h2", "class": "head"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Body", "tag": "p", "class": "body"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Term", "character": true, "tag": "em", "class": "term"})).unwrap(); + let tag = |s: &Session, name: &str, character: bool| { + s.doc().unwrap().doc.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone())) + }; + let pair = |t: &str, c: &str| Some((t.to_string(), c.to_string())); + // A rename onto another style's name is refused, so neither tagging is lost. + assert!(s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Body"})).is_err()); + assert_eq!((tag(&s, "Head", false), tag(&s, "Body", false)), (pair("h2", "head"), pair("p", "body"))); + + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Title"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Term", "rename": "Word"})).unwrap(); + assert_eq!(tag(&s, "Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Word", true), pair("em", "term")); + assert_eq!(tag(&s, "Term", true), None); + // Moving a style into a group renames it to Group/Name. + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Title"], "group": "Heads"})).unwrap(); + assert_eq!(tag(&s, "Heads/Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(tag(&s, "Head", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + assert_eq!(tag(&s, "Term", true), pair("em", "term")); + assert_eq!(tag(&s, "Word", true), None); + + // Deleting drops the deleted style's tagging; the replacement keeps its own. + s.execute("style.paragraph.delete", &json!({"name": "Head", "replaceWith": "Body"})).unwrap(); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Body", false), pair("p", "body")); + s.execute("style.character.delete", &json!({"name": "Term"})).unwrap(); + assert_eq!(tag(&s, "Term", true), None); + assert!(s.doc().unwrap().doc.styles.export_tags.keys().all(|k| k == "p:Body")); + } + + #[test] + fn a_rename_onto_a_taken_name_says_so_and_a_trimmed_rename_drops_a_stale_tag() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (kind, a, b) in [("paragraph", "Head", "Body"), ("character", "Term", "Word")] { + s.execute(&format!("style.{kind}.create"), &json!({"name": a})).unwrap(); + s.execute(&format!("style.{kind}.create"), &json!({"name": b})).unwrap(); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": b})).unwrap_err().to_string(); + assert!(e.contains(&format!("a {kind} style named `{b}` already exists")), "{e}"); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": "[None]"})).unwrap_err().to_string(); + assert!(e.contains("can't rename to `[None]`"), "{e}"); + } + // An entry left under a name no style has doesn't attach to a style renamed onto it. + s.edit(|d, _| { + d.styles_mut() + .export_tags + .insert("p:Title".into(), designcraft_doc::ExportTag { tag: "h6".into(), class: "stale".into(), ..Default::default() }); + Ok(()) + }) + .unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": " Title "})).unwrap(); + let st = &s.doc().unwrap().doc.styles; + assert!(st.para("Title").is_some(), "the new name is trimmed"); + assert_eq!(st.export_tag("Title", false), None); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9e44501f0..20a0c58c5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3876,9 +3876,9 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul } let (para, chars) = style_option_maps(d); let based = d.s("basedOn"); - let rename = d.s("rename"); + let rename = d.s("rename").trim().to_string(); let (r, style) = if d.b("new") { - let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars}); + let mut params = json!({"name": if rename.is_empty() { "Paragraph Style 1" } else { rename.as_str() }, "para": para, "chars": chars}); if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { params["basedOn"] = json!(based); } @@ -3890,11 +3890,17 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul if !based.is_empty() { params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; } - if !rename.is_empty() && rename != name { + let renaming = !rename.is_empty() && rename != name; + if renaming { params["rename"] = json!(rename); } - let style = if !rename.is_empty() && rename != name { rename } else { name }; - (app.run("style.paragraph.edit", params)?, style) + let r = app.run("style.paragraph.edit", params).inspect_err(|_| { + // A refused name is shown next to the name field. + if renaming { + d.fields.insert("section".into(), json!("general")); + } + })?; + (r, if renaming { rename } else { name }) }; // The style now exists under `style`: if a later step fails, the dialog stays open on it and // a second OK edits it rather than creating another one. @@ -7987,6 +7993,26 @@ mod tests { draw(&mut app, &ctx); } + #[test] + fn paragraph_style_options_show_a_refused_rename_on_general_and_trim_a_new_name() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled", "section": "rules"}))); + draw(&mut app, &ctx); + app.ui.dialog.as_mut().unwrap().fields.insert("rename".into(), json!("Ruled Child")); + let e = confirm(&mut app).unwrap_err(); + let d = app.ui.dialog.clone().expect("the dialog stays open"); + assert_eq!(d.s("section"), "general", "the name field is on General"); + assert!(d.s("status").contains("already exists"), "{e}"); + + app.ui.dialog.as_mut().unwrap().fields.insert("rename".into(), json!(" Fresh ")); + app.ui.dialog.as_mut().unwrap().fields.insert("x.tag".into(), json!("h3")); + confirm(&mut app).unwrap(); + let st = &app.session.doc().unwrap().doc.styles; + assert!(st.para("Fresh").is_some()); + assert_eq!(st.export_tag("Fresh", false).map(|t| t.tag.as_str()), Some("h3")); + } + /// A frame with two paragraphs of [Basic Paragraph]; the first has a local Word Spacing minimum. fn two_paragraphs() -> (DesignApp, Value) { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());