diff --git a/ROADMAP.md b/ROADMAP.md index b5759c5..c893b8b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,6 +43,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign. - Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. - File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB). +- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup). - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index f7cd35a..c79541e 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -126,6 +126,7 @@ impl Document { stories: BTreeMap::new(), styles: Arc::new(Styles::default()), swatches: designcraft_color::default_swatches(), + color_groups: vec![], sections: vec![Section { start: 0, start_number: Some(nd.start_page.max(1)), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 90e3bde..881a6aa 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -131,6 +131,14 @@ impl Default for NumberedList { } } +/// A Swatches panel colour group: a named folder of swatches (by name). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ColorGroup { + pub name: String, + pub swatches: Vec, +} + /// Preferences › Advanced Type (stored with the document, like InDesign). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -293,6 +301,10 @@ pub struct Document { pub stories: BTreeMap>, pub styles: Arc, pub swatches: Vec, + /// Swatches panel colour groups (folders), in panel order; swatches not in a group sit at + /// the top level. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub color_groups: Vec, pub sections: Vec
, #[serde(default)] pub assets: BTreeMap>, diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 6440dd5..29a9ed8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -126,6 +126,91 @@ pub fn specs() -> Vec { return Err(bad("swatch.delete", "special swatches can't be deleted")); } d.swatches.retain(|w| w.name != name); + for g in &mut d.color_groups { + g.swatches.retain(|w| *w != name); + } + ok() + }) + }), + cmd!( + "swatch.newColorGroup", + "New Color Group", + [], + None, + "{name?, swatches?: [names]} — a Swatches panel folder (the swatches move into it) → {name}", + has_doc, + |s, p| { + let names: Vec = p + .get("swatches") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + let want = str_param(p, "name").map(str::to_string); + s.edit(|d, _| { + for n in &names { + if d.swatch(n).is_none_or(|w| w.locked) { + return Err(bad("swatch.newColorGroup", format!("`{n}` can't go in a group"))); + } + } + let taken = |n: &str| d.color_groups.iter().any(|g| g.name == n); + let name = match want { + Some(n) if taken(&n) => return Err(bad("swatch.newColorGroup", format!("a group named `{n}` exists"))), + Some(n) => n, + None => (1..).map(|i| format!("Color Group {i}")).find(|n| !taken(n)).expect("free name"), + }; + for g in &mut d.color_groups { + g.swatches.retain(|w| !names.contains(w)); + } + d.color_groups.push(designcraft_doc::ColorGroup { name: name.clone(), swatches: names }); + Ok(json!({"name": name})) + }) + } + ), + cmd!("swatch.moveToGroup", "Move to Color Group", [], None, "{swatches: [names], group: name | null (top level)}", has_doc, |s, p| { + let names: Vec = p + .get("swatches") + .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").map(str::to_string); + s.edit(|d, _| { + if let Some(g) = &group + && !d.color_groups.iter().any(|x| x.name == *g) + { + return Err(bad("swatch.moveToGroup", format!("no color group `{g}`"))); + } + for g in &mut d.color_groups { + g.swatches.retain(|w| !names.contains(w)); + } + if let Some(g) = group.and_then(|g| d.color_groups.iter_mut().find(|x| x.name == g)) { + g.swatches.extend(names.iter().filter(|n| d.swatches.iter().any(|w| w.name == **n && !w.locked)).cloned()); + } + ok() + }) + }), + cmd!("swatch.ungroupColorGroup", "Ungroup Color Group", [], None, "{name} — its swatches go back to the top level", has_doc, |s, p| { + let name = str_param(p, "name").unwrap_or("").to_string(); + s.edit(|d, _| { + let n = d.color_groups.len(); + d.color_groups.retain(|g| g.name != name); + if d.color_groups.len() == n { + return Err(bad("swatch.ungroupColorGroup", format!("no color group `{name}`"))); + } + ok() + }) + }), + cmd!("swatch.renameColorGroup", "Color Group Options", [], None, "{name, to}", has_doc, |s, p| { + let (name, to) = (str_param(p, "name").unwrap_or("").to_string(), str_param(p, "to").unwrap_or("").trim().to_string()); + s.edit(|d, _| { + if to.is_empty() || d.color_groups.iter().any(|g| g.name == to) { + return Err(bad("swatch.renameColorGroup", format!("can't rename to `{to}`"))); + } + let g = d + .color_groups + .iter_mut() + .find(|g| g.name == name) + .ok_or_else(|| bad("swatch.renameColorGroup", format!("no color group `{name}`")))?; + g.name = to; ok() }) }), @@ -696,6 +781,13 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result { let w = d.swatches.iter_mut().find(|w| w.name == name).expect("found above"); w.name = new.clone(); w.named = true; + for g in &mut d.color_groups { + for w in &mut g.swatches { + if *w == name { + *w = new.clone(); + } + } + } // Everything using the colour follows the rename. for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { let sp = std::sync::Arc::make_mut(sp); @@ -808,3 +900,39 @@ mod group_sample_tests { assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}"); } } + +#[cfg(test)] +mod color_group_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn color_groups_hold_swatches_and_round_trip() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("swatch.create", &json!({"name": "Brand Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let b = s.execute("swatch.create", &json!({"name": "Brand Blue", "color": {"c": 100, "m": 60, "y": 0, "k": 0}})).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let g = s.execute("swatch.newColorGroup", &json!({"name": "Brand", "swatches": [a]})).unwrap(); + assert_eq!(g["name"], "Brand"); + assert!(s.execute("swatch.newColorGroup", &json!({"swatches": ["[Black]"]})).is_err(), "special swatches stay at the top"); + s.execute("swatch.moveToGroup", &json!({"swatches": [b], "group": "Brand"})).unwrap(); + let groups = |s: &Session| s.doc().unwrap().doc.color_groups.clone(); + assert_eq!(groups(&s)[0].swatches, [a.clone(), b.clone()]); + let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + assert_eq!(back.color_groups, groups(&s), "IDML ColorGroup"); + s.execute("swatch.delete", &json!({"name": b})).unwrap(); + assert_eq!(groups(&s)[0].swatches, [a.as_str()]); + s.execute("swatch.renameColorGroup", &json!({"name": "Brand", "to": "Identity"})).unwrap(); + s.execute("swatch.ungroupColorGroup", &json!({"name": "Identity"})).unwrap(); + assert!(groups(&s).is_empty()); + assert!(s.doc().unwrap().doc.swatch(&a).is_some(), "ungrouping keeps the swatches"); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 57db4c1..b86d13d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -560,6 +560,18 @@ impl<'a> Ex<'a> { for g in gradients { root.push(g); } + for (gi, g) in d.color_groups.iter().enumerate() { + let mut el = El::new("ColorGroup") + .attr("Self", format!("ColorGroup/{}", escape_id(&g.name))) + .attr("Name", &g.name) + .attr("IsRootColorGroup", "false"); + for (k, n) in g.swatches.iter().enumerate() { + if let Some(id) = self.swatch_ids.get(n) { + el.push(El::new("ColorGroupSwatch").attr("Self", format!("u{gi}ColorGroupSwatch{k}")).attr("SwatchItemRef", id)); + } + } + root.push(el); + } for s in STROKE_STYLES { root.push(El::new("StrokeStyle").attr("Self", format!("StrokeStyle/$ID/{s}")).attr("Name", format!("$ID/{s}"))); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 2f78e7f..7c59af2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -97,6 +97,7 @@ struct Importer<'r> { hidden_colors: HashMap, /// Unnamed swatches (gradients) added on first use. hidden_swatches: HashMap, + color_groups: Vec, styles: Styles, para_names: HashMap, char_names: HashMap, @@ -175,6 +176,7 @@ impl<'r> Importer<'r> { swatch_names, hidden_colors: HashMap::new(), hidden_swatches: HashMap::new(), + color_groups: Vec::new(), styles, para_names: HashMap::new(), char_names: HashMap::new(), @@ -545,6 +547,12 @@ impl<'r> Importer<'r> { names.insert(s.name.clone()); self.swatches.push(s); } + // Colour groups other than the root one become Swatches panel folders. + for g in top.iter().filter(|e| e.local() == "ColorGroup" && e.get("IsRootColorGroup") != Some("true")) { + let swatches: Vec = + g.find_all("ColorGroupSwatch").filter_map(|s| s.get("SwatchItemRef")).filter_map(|r| self.swatch_names.get(r).cloned()).collect(); + self.color_groups.push(designcraft_doc::ColorGroup { name: g.get("Name").unwrap_or("Color Group").to_string(), swatches }); + } } fn color_for_ref(&self, r: &str, pending: &[(String, Swatch)]) -> Color { @@ -1947,6 +1955,7 @@ impl<'r> Importer<'r> { stories: std::mem::take(&mut self.stories).into_iter().map(|(k, v)| (k, Arc::new(v))).collect(), styles: Arc::new(std::mem::take(&mut self.styles)), swatches: std::mem::take(&mut self.swatches), + color_groups: std::mem::take(&mut self.color_groups), sections: std::mem::take(&mut self.sections), assets: std::mem::take(&mut self.assets), hyperlinks: vec![], diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 454bb50..b34d6ac 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -16,57 +16,60 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(egui::RichText::new(if text { "Applies to text" } else { "Applies to fill" }).size(11.0).color(t.text_dim)); }); - // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches. - for sw in doc.swatches.iter().filter(|w| !w.hidden) { + // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in + // colour groups are listed under their folder. + let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); + for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) { + swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref(), text); + } + for g in &doc.color_groups { + let open_id = egui::Id::new(("color_group_open", &g.name)); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - if fill_cur.as_deref() == Some(sw.name.as_str()) { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if resp.hovered() { + if resp.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } - let (c, g) = crate::widgets::swatch_colors(&doc, &sw.name, 1.0); - let chip = egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(16.0, 16.0)); - crate::widgets::paint_chip(ui.painter(), chip, c, g); - ui.painter().text(row.min + vec2(28.0, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); - // Type indicators: spot dot / process square, colour mode. - let kind = match &sw.value { - SwatchValue::Color { color, color_type } => { - let m = match color { - designcraft_color::Color::Cmyk { .. } => "CMYK", - designcraft_color::Color::Rgb { .. } => "RGB", - designcraft_color::Color::Gray { .. } => "Gray", - }; - format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m) - } - SwatchValue::Gradient { .. } => "Gradient".into(), - SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0), - _ => String::new(), - }; - if sw.locked { - icons::paint( - ui.painter(), - egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), - "lock", - t.text_dim, - ); - } - ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); + icons::paint( + ui.painter(), + egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)), + if open { "chevron-down" } else { "chevron-right" }, + t.text_dim, + ); + // A folder. + let f = egui::Rect::from_min_size(row.min + vec2(20.0, 5.0), vec2(16.0, 12.0)); + ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.text_dim), egui::StrokeKind::Inside); + ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.text_dim)); + ui.painter().text(row.min + vec2(42.0, 11.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.5), t.text); if resp.clicked() { - let r = if text { app.run("type.char", json!({"attrs": {"fill": sw.name}})) } else { app.run("object.fill", json!({"swatch": sw.name})) }; - let _ = r; + open = !open; + ui.data_mut(|d| d.insert_temp(open_id, open)); } - if resp.secondary_clicked() { - let _ = app.run("object.stroke", json!({"swatch": sw.name})); + resp.context_menu(|ui| { + if ui.button("Ungroup Color Group").clicked() { + let _ = app.run("swatch.ungroupColorGroup", json!({"name": g.name})); + ui.close(); + } + }); + if open { + for n in &g.swatches { + if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) { + swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref(), text); + } + } } } ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Right-click applies to stroke").size(10.5).color(t.text_disabled)); + ui.label(egui::RichText::new("Right-click: stroke, groups").size(10.5).color(t.text_disabled)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, "plus", 20.0, false, "New Swatch").clicked() { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } ui.menu_button("☰", |ui| { + if ui.button("New Color Group").clicked() { + let _ = app.run("swatch.newColorGroup", json!({})); + ui.close(); + } if ui.button("Load Swatches…").clicked() { let _ = app.run("app.loadSwatches", json!({})); ui.close(); @@ -80,6 +83,81 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// One Swatches panel row: chip, name, type; click applies to fill (or text), the context menu +/// to stroke, colour groups and delete. +fn swatch_row( + app: &mut DesignApp, + ui: &mut egui::Ui, + doc: &designcraft_doc::Document, + sw: &designcraft_color::swatch::Swatch, + indent: f32, + fill_cur: Option<&str>, + text: bool, +) { + let t = Tokens::get(ui.ctx()); + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + if fill_cur == Some(sw.name.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); + } + let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); + let chip = egui::Rect::from_min_size(row.min + vec2(4.0 + indent, 3.0), vec2(16.0, 16.0)); + crate::widgets::paint_chip(ui.painter(), chip, c, g); + ui.painter().text(row.min + vec2(28.0 + indent, 11.0), egui::Align2::LEFT_CENTER, &sw.name, egui::FontId::proportional(12.5), t.text); + // Type indicators: spot dot / process square, colour mode. + let kind = match &sw.value { + SwatchValue::Color { color, color_type } => { + let m = match color { + designcraft_color::Color::Cmyk { .. } => "CMYK", + designcraft_color::Color::Rgb { .. } => "RGB", + designcraft_color::Color::Gray { .. } => "Gray", + }; + format!("{}{}", if *color_type == ColorType::Spot { "● " } else { "" }, m) + } + SwatchValue::Gradient { .. } => "Gradient".into(), + SwatchValue::Tint { tint, .. } => format!("{:.0}%", tint * 100.0), + _ => String::new(), + }; + if sw.locked { + icons::paint(ui.painter(), egui::Rect::from_min_size(egui::pos2(row.max.x - 64.0, row.min.y + 4.0), vec2(13.0, 13.0)), "lock", t.text_dim); + } + ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); + if resp.clicked() { + let r = if text { app.run("type.char", json!({"attrs": {"fill": sw.name}})) } else { app.run("object.fill", json!({"swatch": sw.name})) }; + let _ = r; + } + resp.context_menu(|ui| { + if ui.button("Apply to Stroke").clicked() { + let _ = app.run("object.stroke", json!({"swatch": sw.name})); + ui.close(); + } + if !sw.locked { + ui.menu_button("Move to Color Group", |ui| { + if ui.button("(Top Level)").clicked() { + let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": null})); + ui.close(); + } + for g in &doc.color_groups { + if ui.button(&g.name).clicked() { + let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": g.name})); + ui.close(); + } + } + }); + if ui.button("New Color Group with Swatch").clicked() { + let _ = app.run("swatch.newColorGroup", json!({"swatches": [sw.name]})); + ui.close(); + } + ui.separator(); + if ui.button("Delete Swatch").clicked() { + let _ = app.run("swatch.delete", json!({"name": sw.name})); + ui.close(); + } + } + }); +} + /// Colour models of the Color panel (its panel menu: Lab, CMYK, RGB). #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ColorMode { diff --git a/docs/parity.md b/docs/parity.md index 8901879..9800db2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 74 | 21 | 2 | 87% | -| P2 | 76 | 13 | 13 | 50 | 26% | +| P2 | 76 | 14 | 13 | 49 | 27% | | Area | Parity | |---|---| @@ -20,7 +20,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Layers | 95% | | Frames, shapes & paths | 91% | | Transform | 100% | -| Fill, stroke, colour | 86% | +| Fill, stroke, colour | 88% | | Effects & transparency | 62% | | Placing & links | 82% | | Type & text frames | 88% | @@ -149,7 +149,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Fill, stroke, colour | Spot colours | P1 | D | | Fill, stroke, colour | Ink Manager | P2 | M | | Fill, stroke, colour | Load/Save Swatches (.ase) | P1 | D | -| Fill, stroke, colour | Color groups | P2 | M | +| Fill, stroke, colour | Color groups | P2 | D | | Fill, stroke, colour | Unnamed colour to swatch | P1 | D | | Fill, stroke, colour | Stroke panel | P0 | D | | Fill, stroke, colour | Stroke styles | P1 | P |