From 8a4c8dc2cea697670c13f3fdbe68c07bf6da3b10 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 23:02:52 -0700 Subject: [PATCH] Swatches: Load Swatches numbers repeated .ase names and names unnamed colors by value load_swatches matched each name in a swatch exchange file against the document, which by then held the file's earlier colors. A color whose name the file had already used was dropped, or with replace it overwrote the earlier color. A color with no name was added with an empty name, and every later unnamed color was treated as a repeat of it. A file of seven colors with a repeated name and three unnamed colors added three of its six new colors. load_swatches now names an unnamed color by its value, as object.color names an unnamed color ("C=0 M=0 Y=0 K=55"), and sets named to false. A name the file has already given gets the first free number ("Red 2", "Red 3"), as Styles::unique_name numbers names. Names already in the document follow the same rule as before: they are kept unless replace is set, and a locked swatch is left unchanged. The swatch.load description now states both rules. --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 91 ++++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..2cc44c4b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -448,6 +448,7 @@ Newest first. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. - **2026-10-13** · Dropping an `.ase` file on the window loads its swatches, as Load Swatches does (the drop failed with "Can't Place the File"). No readiness percentage change. +- **2026-10-14** · Load Swatches adds the colors of an `.ase` file that repeat a name or have none: a repeated name gets a number (`Red 2`), and a color without a name is named by its value (`C=0 M=0 Y=0 K=55`). Before, a repeated name kept one of its colors, and only the first unnamed color was added, with an empty name. No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 1cccc7fd1..6a7b8947b 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -163,7 +163,7 @@ pub fn specs() -> Vec { "Load Swatches…", [], None, - "{path?|base64?, replace?: bool} — colour swatches from a swatch exchange (.ase) file; names already in the document are kept unless `replace` → {added, replaced}", + "{path?|base64?, replace?: bool} — colour swatches from a swatch exchange (.ase) file; a name the file repeats gets a number (\"Red 2\") and an unnamed color is named by its value; names already in the document are kept unless `replace` → {added, replaced}", has_doc, load_swatches ), @@ -903,8 +903,17 @@ fn load_swatches(s: &mut Session, p: &Value) -> Result { for (i, x) in d.swatches.iter().enumerate() { at.entry(x.name.clone()).or_insert(i); } + let mut names = FileNames::default(); let (mut added, mut replaced) = (0, 0); - for w in incoming { + for mut w in incoming { + // An unnamed color is named by its value, and a name the file already gave gets a number. + if w.name.is_empty() + && let SwatchValue::Color { color, .. } = w.value + { + w.name = value_name(color); + w.named = false; + } + w.name = names.unique(std::mem::take(&mut w.name)); match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) { Some(x) if x.locked => {} Some(x) => { @@ -925,6 +934,32 @@ fn load_swatches(s: &mut Session, p: &Value) -> Result { }) } +/// The swatch names one `.ase` file has given. A name already given gets the first free number +/// ("Red 2"), as [`Styles::unique_name`] numbers names. +#[derive(Default)] +struct FileNames { + taken: std::collections::HashSet, + /// The last number tried for each repeated name. + last: std::collections::HashMap, +} + +impl FileNames { + fn unique(&mut self, base: String) -> String { + if self.taken.insert(base.clone()) { + return base; + } + // Each pass tries the next number, and only finitely many names are taken. + let n = self.last.entry(base.clone()).or_insert(1); + loop { + *n += 1; + let name = format!("{base} {n}"); + if self.taken.insert(name.clone()) { + return name; + } + } + } +} + fn save_swatches(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let names: Option> = @@ -1705,11 +1740,12 @@ mod color_tests { let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect() }; - // With `replace`, the file's second "New" replaces its first, only the first "Twin" changes, the hidden + // With `replace`, the file's second "New" is added as "New 2", only the first "Twin" changes, the hidden // swatch becomes visible, and the locked [Black] stays. let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(1), Some(3))); - assert_eq!(named(&s, "New"), [(ink(0.5), false)]); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(2), Some(2))); + assert_eq!(named(&s, "New"), [(ink(0.4), false)]); + assert_eq!(named(&s, "New 2"), [(ink(0.5), false)]); assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]); assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]); assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]); @@ -1720,6 +1756,51 @@ mod color_tests { assert_eq!(s.doc().unwrap().doc.swatches, before); } + #[test] + fn loading_swatches_numbers_repeated_names_and_names_unnamed_colors() { + use designcraft_color::{Color, ColorType}; + let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; + let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.edit(|d, _| { + d.swatches.push(sw("Kept", 0.1)); + Ok(()) + }) + .unwrap(); + // "Red 2" comes before the second "Red", which takes "Red 3". Two unnamed colors share a value. + let file = designcraft_color::ase::write(&[ + sw("Red", 0.2), + sw("Red 2", 0.3), + sw("Red", 0.4), + sw("", 0.55), + sw("", 0.55), + sw("", 0.65), + sw("Kept", 0.7), + ]); + let b64 = crate::cmd::base64_encode(&file); + let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { + s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.named)).collect() + }; + // Names already in the document are kept. + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(6), Some(0))); + assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); + assert_eq!(named(&s, "Red 2"), [(ink(0.3), true)]); + assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=55"), [(ink(0.55), false)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=55 2"), [(ink(0.55), false)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=65"), [(ink(0.65), false)]); + assert_eq!(named(&s, "Kept"), [(ink(0.1), true)]); + assert!(named(&s, "").is_empty()); + // With `replace`, the same file again replaces each of its colors once. + let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(7))); + assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); + assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); + assert_eq!(named(&s, "Kept"), [(ink(0.7), true)]); + } + #[test] fn unnamed_colors_then_add_to_swatches() { let mut s = Session::new();