diff --git a/ROADMAP.md b/ROADMAP.md index a1028cc4c..db6c5a868 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -371,6 +371,8 @@ 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. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. 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 e299dcb3b..39bacf3bd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3888,9 +3888,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); } @@ -3902,11 +3902,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. @@ -8022,6 +8028,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()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3f6e4c7c6..74fe860ea 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3372,13 +3372,53 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str, cjk out } +/// UI commands search leaves out: they only run with a parameter search can't supply and have no +/// dialog to ask for it, or without one they reset a setting. Each stays in the menu, panel or +/// dialog that gives it the value (the workspace menus, Keyboard Shortcuts, Preferences…). +const NOT_SEARCHED: &[&str] = &[ + // The workspace to switch to or delete. + "window.workspace", + "window.deleteWorkspace", + // A command and its shortcut, a menu item, a shortcut set; Reset Shortcuts clears every + // custom shortcut, and the Keyboard Shortcuts dialog offers it. + "window.setShortcut", + "window.resetShortcuts", + "window.importShortcuts", + "window.hideMenuItem", + // Without a value these reset the interface to 100% and the dark theme. + "window.uiScale", + "window.brightness", + // A panel, and where it goes. + "window.floatPanel", + "window.dockPanel", + "window.panel.layout", + "window.panel.move", + "window.panel.float", + "window.panel.dock", + "window.panel.activate", + "window.panel.close", + "window.panel.closeGroup", + // A window edge, an app page, a recent file, a swatch or color group. + "window.resize", + "help.app", + "app.openRecent", + "app.swatchOptionsDialog", + "app.colorGroupOptionsDialog", + // Without a value these clear the flattener preset, go to Normal screen mode or 100% zoom. + "app.flattener", + "view.screenMode", + "view.zoom", + // Where to pin the Contextual Task Bar. + "window.taskBarPin", +]; + /// The commands Quick Apply and the application bar's search find: (id, label, default shortcut). fn quick_commands() -> impl Iterator)> { designcraft_engine::command_specs() .iter() .filter(|c| !c.menu.is_empty() || c.shortcut.is_some()) .map(|c| (c.id, c.label, c.shortcut)) - .chain(UI_COMMANDS.iter().map(|c| (c.0, c.1, c.2))) + .chain(UI_COMMANDS.iter().filter(|c| !NOT_SEARCHED.contains(&c.0)).map(|c| (c.0, c.1, c.2))) } /// Whether any of `texts` contains the lowercased query (an empty query matches everything). @@ -5246,6 +5286,104 @@ mod tests { assert!(quick_apply_items(&s, "preferences", false).iter().any(|i| i.id == "app.preferences")); } + #[test] + fn searched_ui_commands_run_without_parameters_and_keep_preferences() { + // Without a value these reset a setting or need a place the test host always has. + let searched: Vec<&str> = quick_commands().map(|c| c.0).collect(); + for id in ["app.flattener", "view.screenMode", "view.zoom", "window.taskBarPin"] { + assert!(!searched.contains(&id), "{id} is searched"); + } + // A placed PDF: a graphic with layers. + let mut scratch = designcraft_engine::Session::new(); + scratch.execute("file.new", &json!({})).unwrap(); + let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); + let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"]; + // A two-page document, a saved workspace and non-default preferences, a host with window + // controls, and file dialogs that are dismissed. + let setup = |context: usize| { + let services = crate::Services { + pick_open: Some(Box::new(|_| None)), + pick_save: Some(Box::new(|_| None)), + clipboard_text: Some(Box::new(|| None)), + ..Default::default() + }; + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.custom_titlebar = true; + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))); + app.run("file.new", json!({"pages": 2})).unwrap(); + app.session.prefs.recent_files.push("/nowhere/recent.dcraft".into()); + app.ui.shortcuts.insert("view.rulers".into(), "Ctrl+Alt+R".into()); + app.ui.hidden_menu_items.push("view.guides".into()); + app.ui.ui_scale = 1.25; + app.ui.brightness = crate::theme::Brightness::Light; + app.ui.task_bar_at = Some([200.0, 200.0]); + run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); + match context { + 1 => { + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One", "caret": true})).unwrap(); + } + 2 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + } + 3 => { + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + } + 4 => { + let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400]})).unwrap()["id"].clone(); + app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + } + _ => {} + } + app.ui.dialog = None; + app + }; + let prefs = |app: &DesignApp| { + let u = &app.ui; + json!({ + "shortcuts": u.shortcuts, "hiddenMenuItems": u.hidden_menu_items, "uiScale": u.ui_scale, + "brightness": u.brightness, "workspace": u.workspace, "customWorkspaces": u.custom_workspaces, + "docking": u.docking, "dockingHidden": u.docking_hidden, "floating": u.floating, + }) + }; + let quick = quick_apply_items(&setup(0).session, "workspace", false); + assert!(quick.iter().any(|i| i.id == "window.newWorkspace")); + // Deleting needs a workspace name: the application bar's workspace menu lists them. + assert!(!quick.iter().any(|i| i.id == "window.deleteWorkspace")); + // Each command runs as Quick Apply runs an entry. One that needs a selection may fail + // without it, but runs in one of the contexts; none changes a preference it wasn't asked to. + // Opening a panel changes where the panels are, as it should. + let opens_a_panel = ["app.tabsPanel", "app.characterStylesPanel", "app.paragraphStylesPanel"]; + let arrangement = ["docking", "dockingHidden", "floating"]; + let mut failed = Vec::new(); + for (id, ..) in quick_commands().filter(|(id, ..)| UI_COMMANDS.iter().any(|c| c.0 == *id)) { + let mut errors = Vec::new(); + for (i, context) in contexts.iter().enumerate() { + let mut app = setup(i); + let before = prefs(&app); + match app.run(id, json!({})) { + Err(e) => errors.push(format!("{context}: {e}")), + Ok(_) => { + let after = prefs(&app); + let changed: Vec<&String> = + before.as_object().unwrap().iter().filter(|(k, v)| after.get(k.as_str()) != Some(*v)).map(|(k, _)| k).collect(); + let changed: Vec<&String> = + changed.into_iter().filter(|k| !(opens_a_panel.contains(&id) && arrangement.contains(&k.as_str()))).collect(); + if !changed.is_empty() { + failed.push(format!("{id} ({context}): changed {changed:?}")); + } + } + } + } + if errors.len() == contexts.len() { + failed.push(format!("{id}: {errors:?}")); + } + } + assert!(failed.is_empty(), "{failed:#?}"); + } + #[test] fn distribute_rows_menu_dispatches_an_undoable_edit() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());