Merge remote-tracking branch 'upstream/main' into docking-trial

This commit is contained in:
aiongg
2026-10-25 00:38:08 +00:00
3 changed files with 139 additions and 16 deletions
+1
View File
@@ -359,6 +359,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-24** · Panels dock through the suite's shared docking (craft-libs), and on desktop a floating panel opens in its own window that can leave the main window: keys pressed there run the main window's shortcuts, clipboard and tool keys, and the canvas paints no tool cursor while another of the app's windows has the pointer. The web build keeps floating panels in the page. No readiness percentage change.
- **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.
+107 -11
View File
@@ -258,7 +258,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
{
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, &current))?;
@@ -971,24 +979,35 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// 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<Value> {
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<Value> {
.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<Option<String>> = match p.get("basedOn") {
None => None,
Some(Value::Null) => Some(None),
@@ -1247,10 +1268,13 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
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)]
+31 -5
View File
@@ -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());