From 08727dfd7e156c878308320ff01e1390869e05bc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 16:36:29 -0400 Subject: [PATCH] S: style groups (style.group), renames reach table cells and footnotes; Package report preflight fix Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/table.rs | 13 +++ crates/engine/src/cmd/package.rs | 3 +- crates/engine/src/cmd/style.rs | 138 ++++++++++++++++++++++++++-- crates/ui-egui/src/panels/styles.rs | 94 ++++++++++++++----- docs/parity.md | 8 +- 6 files changed, 219 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bc702bd..0ac66b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. - Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase are still to come. diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 38caa5f..d203aae 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -713,6 +713,19 @@ impl crate::Document { } impl Story { + /// Visit this story and every text inside it (table cells, footnotes), mutably. + pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) { + f(self); + for t in self.tables.values_mut() { + for c in &mut std::sync::Arc::make_mut(t).cells { + c.text.for_each_text_mut(f); + } + } + for n in &mut self.notes { + std::sync::Arc::make_mut(n).text.for_each_text_mut(f); + } + } + /// The table whose anchor paragraph is `pi`. pub fn para_table(&self, pi: usize) -> Option<&Arc> { self.tables.get(&self.paras.get(pi)?.table?) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 989c19d..a0b3b46 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -111,7 +111,7 @@ fn package(s: &mut Session, p: &Value) -> Result { for l in links.as_array().into_iter().flatten() { r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or("")); } - if let Some(issues) = pre.as_array() { + if let Some(issues) = pre.get("issues").and_then(Value::as_array) { r += &format!("\nPreflight: {} issue(s)\n", issues.len()); for i in issues { r += &format!(" {}\n", i.get("message").and_then(Value::as_str).unwrap_or("")); @@ -159,6 +159,7 @@ mod tests { assert!(out.join("Links").join("logo.svg").exists()); let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); + assert!(report.contains("Preflight:"), "{report}"); // The packaged document links to its Links folder. let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index f22a5f5..be98950 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -60,6 +60,15 @@ pub fn specs() -> Vec { super::has_selection, apply_gradient ), + cmd!( + "style.group", + "Move to Group", + [], + None, + "{kind: paragraph|character, names: [style names], group: \"Heads\" | \"Heads/Display\" | \"\" (out of any group)} — styles are named Group/Name; every use is renamed → {renamed: {old: new}}", + has_doc, + group_styles + ), cmd!( "swatch.load", "Load Swatches…", @@ -238,18 +247,90 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { _ => {} } if let Some(n) = rename.clone() { - st.name = n.clone(); - 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 = n.clone(); - } - } + // Every use: other styles, stories, table cells, footnotes, object styles. + rename_style(d, true, &name, &n); + } + ok() + }) +} + +/// 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 { + if s.name == from { + s.name = to.to_string(); + } + for r in [&mut s.based_on, &mut s.next_style] { + if r.as_deref() == Some(from) { + *r = Some(to.to_string()); } } } - ok() + for o in &mut st.object { + if o.paragraph_style.as_deref() == Some(from) { + o.paragraph_style = Some(to.to_string()); + } + } + } 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()); + } + } + } + for sid in d.stories.keys().copied().collect::>() { + let Some(story) = d.story_mut(sid) else { continue }; + story.for_each_text_mut(&mut |st| { + if para { + for f in st.paras.iter_mut().filter(|f| f.style == from) { + f.style = to.to_string(); + } + } else { + for r in st.chars.iter_mut().filter(|r| r.format.style == from) { + r.format.style = to.to_string(); + } + } + st.rev += 1; + }); + } +} + +fn group_styles(s: &mut Session, p: &Value) -> Result { + let para = match str_param(p, "kind").unwrap_or("paragraph") { + "paragraph" => true, + "character" => false, + k => return Err(bad("style.group", format!("unknown kind `{k}`"))), + }; + let names: Vec = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let group = str_param(p, "group").unwrap_or("").trim_matches('/').to_string(); + s.edit(|d, _| { + let mut renamed = serde_json::Map::new(); + for n in &names { + if n.starts_with('[') { + return Err(bad("style.group", format!("`{n}` is built in"))); + } + let exists = if para { d.styles.para(n).is_some() } else { d.styles.character.iter().any(|c| c.name == *n) }; + if !exists { + return Err(bad("style.group", format!("no style `{n}`"))); + } + let base = n.rsplit('/').next().unwrap_or(n); + let mut to = if group.is_empty() { base.to_string() } else { format!("{group}/{base}") }; + let taken = |d: &designcraft_doc::Document, x: &str| { + if para { d.styles.para(x).is_some() } else { d.styles.character.iter().any(|c| c.name == x) } + }; + if to != *n { + to = Styles::unique_name(|x| taken(d, x), &to); + rename_style(d, para, n, &to); + renamed.insert(n.clone(), json!(to)); + } + } + Ok(json!({"renamed": renamed})) }) } @@ -643,6 +724,27 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { mod color_tests { use super::*; + #[test] + fn style_groups_rename_every_use() { + 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": "Sub", "basedOn": "Head"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + let r = s.execute("style.group", &json!({"kind": "paragraph", "names": ["Head", "Sub"], "group": "Titles"})).unwrap(); + assert_eq!(r["renamed"]["Head"], "Titles/Head"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].style, "Titles/Head"); + assert_eq!(d.styles.para("Titles/Sub").unwrap().based_on.as_deref(), Some("Titles/Head")); + // Out of the group again. + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Titles/Head"], "group": ""})).unwrap(); + assert!(s.doc().unwrap().doc.styles.para("Head").is_some()); + assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err()); + } + #[test] fn swatches_save_and_load_ase() { let mut s = Session::new(); @@ -686,3 +788,21 @@ mod color_tests { assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); } } + +#[cfg(test)] +mod group_sample_tests { + use serde_json::json; + + use crate::Session; + + /// Grouping the sample's table styles keeps its table cells styled (no missing styles). + #[test] + fn grouping_reaches_table_cells() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let before = s.execute("preflight.run", &json!({})).unwrap()["errors"].as_u64().unwrap(); + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Table Head", "Table Body"], "group": "Tables"})).unwrap(); + let after = s.execute("preflight.run", &json!({})).unwrap(); + assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}"); + } +} diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index d6f7473..4da4a32 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -25,33 +25,23 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) { .size(11.0) .color(t.text_dim), ); - for n in &names { + // Groups (`Group/Name`) as folders, ungrouped styles first. + let groups: Vec = { + let mut g: Vec = names.iter().filter_map(|n| n.rsplit_once('/').map(|(g, _)| g.to_string())).collect(); + g.sort(); + g.dedup(); + g + }; + for n in names.iter().filter(|n| !n.contains('/')) { if para && n == designcraft_doc::NO_PARA_STYLE { continue; } - let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - if current.as_deref() == Some(n.as_str()) { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); - } - ui.painter().text(row.min + vec2(8.0, 11.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text); - if resp.double_clicked() && para { - app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n}))); - } else if resp.clicked() { - let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; - let clear = ui.input(|i| i.modifiers.alt); - let _ = app.run(cmd, json!({"name": n, "clearOverrides": clear})); - } - resp.context_menu(|ui| { - if ui.button(format!("Apply \"{n}\"")).clicked() { - let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; - let _ = app.run(cmd, json!({"name": n})); - ui.close(); - } - if current.as_deref() == Some(n.as_str()) && ui.button("Break Link to Style").clicked() { - let _ = app.run("style.breakLink", json!({"kind": if para { "paragraph" } else { "character" }})); - ui.close(); + style_row(app, ui, para, n, current.as_deref(), &groups, &t); + } + for g in &groups { + egui::CollapsingHeader::new(g.as_str()).id_salt(("style_group", para, g)).default_open(true).show(ui, |ui| { + for n in names.iter().filter(|n| n.rsplit_once('/').is_some_and(|(x, _)| x == g)) { + style_row(app, ui, para, n, current.as_deref(), &groups, &t); } }); } @@ -75,3 +65,59 @@ pub fn paragraph(app: &mut DesignApp, ui: &mut egui::Ui) { pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { list(app, ui, false); } + +/// One style in the list: click applies (⌥ clears overrides), double-click edits (paragraph), +/// right-click: apply, break link, move to group. +fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, groups: &[String], t: &Tokens) { + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + if current == Some(n) { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + let shown = n.rsplit('/').next().unwrap_or(n); + ui.painter().text(row.min + vec2(8.0, 11.0), egui::Align2::LEFT_CENTER, shown, egui::FontId::proportional(12.5), t.text); + if resp.double_clicked() && para { + app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n}))); + } else if resp.clicked() { + let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; + let clear = ui.input(|i| i.modifiers.alt); + let _ = app.run(cmd, json!({"name": n, "clearOverrides": clear})); + } + let kind = if para { "paragraph" } else { "character" }; + resp.context_menu(|ui| { + if ui.button(format!("Apply \"{shown}\"")).clicked() { + let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; + let _ = app.run(cmd, json!({"name": n})); + ui.close(); + } + if current == Some(n) && ui.button("Break Link to Style").clicked() { + let _ = app.run("style.breakLink", json!({"kind": kind})); + ui.close(); + } + if !n.starts_with('[') { + ui.menu_button("Move to Group", |ui| { + if n.contains('/') && ui.button("[No Group]").clicked() { + let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": ""})); + ui.close(); + } + for g in groups { + if n.rsplit_once('/').map(|(x, _)| x) != Some(g.as_str()) && ui.button(g).clicked() { + let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": g})); + ui.close(); + } + } + ui.separator(); + let id = egui::Id::new(("new_style_group", para)); + let mut name: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text("New group…").desired_width(140.0)); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !name.trim().is_empty() { + let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": name.trim()})); + name.clear(); + ui.close(); + } + ui.data_mut(|d| d.insert_temp(id, name)); + }); + } + }); +} diff --git a/docs/parity.md b/docs/parity.md index ba2f526..8edf91c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. -**Weighted breadth parity: 77%** over 275 features. +**Weighted breadth parity: 78%** over 275 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 54 | 24 | 19 | 68% | +| P1 | 97 | 55 | 24 | 18 | 69% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -25,7 +25,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Placing & links | 78% | | Type & text frames | 82% | | Typography | 83% | -| Styles | 71% | +| Styles | 82% | | Tables | 73% | | Long documents | 58% | | Interactivity & digital | 30% | @@ -240,7 +240,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Styles | Character styles | P0 | D | | Styles | Object styles | P1 | D | | Styles | Table & cell styles | P1 | M | -| Styles | Style groups | P1 | M | +| Styles | Style groups | P1 | D | | Styles | Quick Apply | P1 | D | | Styles | Break link to style | P1 | D | | Styles | Style mapping on import | P1 | P |