diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 528e33e3e..5caada836 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -7,6 +7,7 @@ //! those links (and colours) wherever art and swatches hold them, copying only the nodes that //! change. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use vectorcraft_color::swatch::REGISTRATION; @@ -85,6 +86,12 @@ impl Document { fn numbered_swatch_name(&self, base: &str, from: usize) -> String { (from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) } + /// Names for several new swatches: [`FreeSwatchNames::take`] gives what + /// [`Document::free_swatch_name`] gives once the swatches named before it are added. + pub fn free_swatch_names(&self) -> FreeSwatchNames { + let taken = self.swatches_iter().map(|s| s.name.clone()).chain(self.swatch_groups.iter().map(|g| g.name.clone())); + FreeSwatchNames { taken: taken.chain([REGISTRATION.to_string()]).collect(), next: HashMap::new() } + } /// The name a new swatch of `paint` gets by default, unused in the document: a colour's values /// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1", /// "New Pattern Swatch 1"… @@ -200,6 +207,52 @@ pub fn color_name(c: Color) -> String { } } +/// The swatch and color group names of a document ([`Document::free_swatch_names`]) and the names +/// handed out since, for naming many new swatches without searching the document for each name. +#[derive(Clone, Debug)] +pub struct FreeSwatchNames { + taken: HashSet, + /// For each base and first number already numbered, the number its next numbered name is + /// looked for from: names are only ever added, so the numbers below it stay taken. + next: HashMap<(String, usize), usize>, +} + +impl FreeSwatchNames { + /// `base`, or the first of "base 2", "base 3"… not taken, as [`Document::free_swatch_name`] + /// gives it; the name is taken from then on. + pub fn take(&mut self, base: &str) -> String { + let name = if self.taken.contains(base) { self.numbered(base, 2) } else { base.to_string() }; + self.taken.insert(name.clone()); + name + } + /// The name [`Document::new_swatch_name`] gives a new swatch of `paint`; the name is taken + /// from then on. + pub fn take_new(&mut self, paint: &Paint) -> String { + let base = match paint { + Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.take(&format!("{n} {}%", tint_percent(*tint))), + Paint::Solid { color, .. } => return self.take(&color_name(*color)), + Paint::Gradient(_) => "New Gradient Swatch", + _ => "New Pattern Swatch", + }; + let name = self.numbered(base, 1); + self.taken.insert(name.clone()); + name + } + /// Count `name` as taken (a swatch or color group added another way). + pub fn insert(&mut self, name: &str) { + self.taken.insert(name.to_string()); + } + /// The first of "base `from`", "base `from + 1`"… not taken. + fn numbered(&mut self, base: &str, from: usize) -> String { + let key = (base.to_string(), from); + let start = self.next.get(&key).copied().unwrap_or(from); + let (i, name) = + (start..).map(|i| (i, format!("{base} {i}"))).find(|(_, n)| !self.taken.contains(n)).unwrap_or_else(|| (start, base.to_string())); + self.next.insert(key, i + 1); + name + } +} + /// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`, /// true when it changed them. pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; @@ -419,6 +472,56 @@ mod tests { assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0"); } + #[test] + fn free_swatch_names_match_naming_each_swatch_as_it_is_added() { + let (mut d, _, _) = doc(); + let mut names = d.free_swatch_names(); + for base in ["Brand", "Brand", "Mine", "Fresh", "Fresh", "Brand 2", "Brand", REGISTRATION, "Fresh 3", "Fresh", "Mine 2"] { + let want = d.free_swatch_name(base); + assert_eq!(names.take(base), want, "{base}"); + d.swatches.push(Swatch { name: want, paint: Paint::solid(Color::BLACK), global: false, spot: false }); + } + let added: Vec<&str> = d.swatches.iter().rev().take(11).rev().map(|w| w.name.as_str()).collect(); + assert_eq!( + added, + ["Brand 2", "Brand 3", "Mine 2", "Fresh", "Fresh 2", "Brand 2 2", "Brand 4", "[Registration] 2", "Fresh 3", "Fresh 4", "Mine 2 2"] + ); + } + + #[test] + fn free_swatch_names_match_new_swatch_names_as_swatches_are_added() { + let (mut d, _, _) = doc(); + let red = Color::rgb(1.0, 0.0, 0.0); + d.swatches.push(Swatch { name: "New Gradient Swatch 2".into(), paint: Paint::solid(red), global: false, spot: false }); + let mut names = d.free_swatch_names(); + // A name taken another way (a group made alongside) counts for both. + names.insert("R=255 G=0 B=0 3"); + d.swatch_groups.push(SwatchGroup { name: "R=255 G=0 B=0 3".into(), swatches: vec![] }); + let tint = Paint::Solid { color: red, swatch: Some("Brand".into()), tint: 0.4 }; + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); + let paints = [Paint::solid(red), Paint::solid(red), gradient.clone(), tint.clone(), Paint::solid(red), gradient.clone(), tint, gradient]; + let mut added = vec![]; + for paint in paints { + let want = d.new_swatch_name(&paint); + assert_eq!(names.take_new(&paint), want); + d.swatches.push(Swatch { name: want.clone(), paint, global: false, spot: false }); + added.push(want); + } + assert_eq!( + added, + [ + "R=255 G=0 B=0", + "R=255 G=0 B=0 2", + "New Gradient Swatch 1", + "Brand 40%", + "R=255 G=0 B=0 4", + "New Gradient Swatch 3", + "Brand 40% 2", + "New Gradient Swatch 4" + ] + ); + } + #[test] fn mapping_copies_only_changed_nodes() { let (mut d, a, b) = doc(); diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 7944e59a0..f19861685 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -1,16 +1,15 @@ //! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting. +use std::collections::{HashMap, HashSet}; + use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent}; use vectorcraft_doc::pattern::pattern_paint; use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; -use super::{ - CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or, - unique_name, -}; +use super::{CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or}; use crate::EngineError; pub fn specs() -> Vec { @@ -195,10 +194,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { let requested = name_param(p, "name").filter(|_| paints.len() == 1); let names = s.edit("New Swatch", |d, _| { let mut names = vec![]; + let mut free = d.free_swatch_names(); for paint in paints { let name = match &requested { - Some(n) => d.free_swatch_name(n), - None => d.new_swatch_name(&paint), + Some(n) => free.take(n), + None => free.take_new(&paint), }; // A tint swatch's kind is its base's. let tint = matches!(paint, Paint::Solid { swatch: Some(_), .. }); @@ -271,36 +271,116 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { /// each). A tint (a linked colour that differs from its swatch's) brings its swatch, and with /// `tints` its own colour too. Links to missing swatches count as unlinked. fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec, colors: &mut Vec) { + let by_name = swatches_by_name(d); + let mut seen_swatches: HashSet = swatches.iter().cloned().collect(); + let mut seen_colors: HashSet = colors.iter().filter_map(color_key).collect(); for n in ids.iter().filter_map(|id| d.node(*id)) { node_colors(n, &mut |c, link| { - let sw = link.and_then(|l| d.swatch(l)).filter(|w| w.paint.color().is_some()); + let sw = link.and_then(|l| by_name.get(l).copied()).filter(|w| w.paint.color().is_some()); if let Some(w) = sw - && !swatches.contains(&w.name) + && !seen_swatches.contains(&w.name) { + seen_swatches.insert(w.name.clone()); swatches.push(w.name.clone()); } let own = sw.is_none_or(|w| tints && w.paint.color() != Some(*c)); - if own && !colors.contains(c) { + // A color with a NaN component equals no color, so it is never already listed. + if own && color_key(c).is_none_or(|k| seen_colors.insert(k)) { colors.push(*c); } }); } } -/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch, -/// unless they already link to an existing swatch. Returns the number of paints linked. +/// Every swatch by name, as [`Document::swatch`] finds it: the first swatch of each name, and the +/// built-in Registration swatch when no swatch has its name. +fn swatches_by_name(d: &Document) -> HashMap<&str, &Swatch> { + let mut by_name = HashMap::new(); + for w in d.swatches_iter().chain([vectorcraft_color::swatch::registration()]) { + by_name.entry(w.name.as_str()).or_insert(w); + } + by_name +} + +/// A color's model and the bits of its components, with -0.0 read as 0.0: two keys are equal +/// exactly when the colors are equal (`==`). `None` for a color with a NaN component, which +/// equals no color. +type ColorKey = (u8, [u32; 4]); + +fn color_key(c: &Color) -> Option { + let (model, v) = match *c { + Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]), + Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]), + Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]), + Color::Lab { l, a, b } => (3, [l, a, b, 0.0]), + }; + Some((model, [float_key(v[0])?, float_key(v[1])?, float_key(v[2])?, float_key(v[3])?])) +} + +/// The bits of `v`, with -0.0 read as 0.0, so keys are equal exactly when the values are; `None` +/// for NaN. +fn float_key(v: f32) -> Option { + if v.is_nan() { + None + } else if v == 0.0 { + Some(0) + } else { + Some(v.to_bits()) + } +} + +/// A gradient stop's offset, color, opacity, midpoint, swatch link, tint and process plates, as +/// keys. +type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>); + +/// A gradient's kind, method, dither and stops, as keys. +type GradientKey<'a> = (u8, u8, bool, Vec>); + +/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when +/// any of its numbers is NaN, so the gradient equals no gradient. +fn gradient_key(g: &Gradient) -> Option> { + let kind = match g.kind { + GradientKind::Linear => 0, + GradientKind::Radial => 1, + GradientKind::Freeform => 2, + }; + let method = match g.method { + GradientMethod::Classic => 0, + GradientMethod::Perceptual => 1, + }; + Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::>()?)) +} + +/// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. +fn stop_key(s: &GradientStop) -> Option> { + let plates = match s.plates { + Some(p) => Some([float_key(p[0])?, float_key(p[1])?, float_key(p[2])?, float_key(p[3])?]), + None => None, + }; + Some((float_key(s.offset)?, color_key(&s.color)?, float_key(s.opacity)?, float_key(s.midpoint)?, s.swatch.as_deref(), float_key(s.tint)?, plates)) +} + +/// Link the solid paints in the subtrees of `ids` whose color is one of `links`' to that swatch +/// (the first one listed for the color), unless they already link to an existing swatch or to the +/// built-in Registration swatch. Returns the number of paints linked. fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize { if links.is_empty() { return 0; } - let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); + let live: HashSet = d.swatches_iter().chain([vectorcraft_color::swatch::registration()]).map(|w| w.name.clone()).collect(); + let mut by_color: HashMap = HashMap::new(); + for (c, n) in links { + if let Some(k) = color_key(c) { + by_color.entry(k).or_insert(n); + } + } d.map_solid_paints_in(ids, &mut |c, link, _| { if link.as_ref().is_some_and(|l| live.contains(l)) { return false; } - match links.iter().find(|(lc, _)| lc == c) { - Some((_, n)) => { - *link = Some(n.clone()); + match color_key(c).and_then(|k| by_color.get(&k)) { + Some(n) => { + *link = Some((*n).clone()); true } None => false, @@ -346,8 +426,11 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { } // The global swatches made from the art's colours, which its unlinked paints link to. let mut links: Vec<(Color, String)> = vec![]; + let mut free = d.free_swatch_names(); + free.insert(&name); + group.swatches.iter().for_each(|sw| free.insert(&sw.name)); for (c, global) in new { - let nm = unique_name(&color_name(c), |n| n == name || d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n)); + let nm = free.take(&color_name(c)); if global { links.push((c, nm.clone())); } @@ -798,10 +881,12 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result { let (mut linked_to, mut colors) = (vec![], vec![]); artwork_colors(d, &ids, false, &mut linked_to, &mut colors); // Colours a solid swatch already has are skipped. - colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c))); + let have: HashSet = d.swatches_iter().filter_map(|w| w.paint.color()).filter_map(|c| color_key(&c)).collect(); + colors.retain(|c| color_key(c).is_none_or(|k| !have.contains(&k))); + let mut free = d.free_swatch_names(); let mut links = vec![]; for c in colors { - let name = d.free_swatch_name(&color_name(c)); + let name = free.take(&color_name(c)); d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false }); links.push((c, name)); } @@ -813,10 +898,11 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result { fn swatch_unused(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; + let by_name = swatches_by_name(d); // Links with their tints, so a tint swatch is used where its tint of its base is. let (mut links, mut colors, mut gradients, mut patterns, mut gradient_links) = (vec![], vec![], vec![], vec![], vec![]); let mut color = |c: &Color, link: Option<&String>, tint: f32| match link { - Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)), + Some(l) if by_name.contains_key(l.as_str()) => links.push((l.clone(), tint)), _ => colors.push(*c), }; let mut paint = |p: &Paint| match p { @@ -838,16 +924,22 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result { } } } + let gradient_links: HashSet<&String> = gradient_links.iter().collect(); + let link_names: HashSet<&str> = links.iter().map(|(l, _)| l.as_str()).collect(); + let link_tints: HashSet<(&str, u32)> = links.iter().filter_map(|(l, t)| Some((l.as_str(), float_key(*t)?))).collect(); + let colors: HashSet = colors.iter().filter_map(color_key).collect(); + let patterns: HashSet<&String> = patterns.iter().collect(); + let gradients: HashSet = gradients.iter().filter_map(gradient_key).collect(); let used = |w: &Swatch| { gradient_links.contains(&w.name) - || links.iter().any(|(l, t)| match w.tint_of() { - Some((base, tint)) => l == base && *t == tint, - None => *l == w.name, - }) + || match w.tint_of() { + Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))), + None => link_names.contains(w.name.as_str()), + } || match &w.paint { Paint::None => true, - Paint::Solid { color, .. } => colors.contains(color), - Paint::Gradient(g) => gradients.contains(&g.gradient), + Paint::Solid { color, .. } => color_key(color).is_some_and(|k| colors.contains(&k)), + Paint::Gradient(g) => gradient_key(&g.gradient).is_some_and(|k| gradients.contains(&k)), Paint::Pattern { pattern, .. } => patterns.contains(pattern), } }; @@ -1017,8 +1109,11 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren } } } + let mut free = d.free_swatch_names(); + free.insert(&name); + swatches.iter().for_each(|w| free.insert(&w.name)); for c in colors.iter().skip(swatches.len()) { - let nm = unique_name(&color_name(*c), |n| n == name || d.swatch_name_taken(n) || swatches.iter().any(|w| w.name == n)); + let nm = free.take(&color_name(*c)); swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false }); } let g = &mut d.swatch_groups[gi]; diff --git a/crates/engine/src/tests/swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs index b0d0edf86..53876a218 100644 --- a/crates/engine/src/tests/swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -3,7 +3,7 @@ //! document's colour mode. use serde_json::{Value, json}; -use vectorcraft_color::Paint; +use vectorcraft_color::{Color, Paint}; use crate::*; @@ -125,6 +125,275 @@ fn add_used_colors_skips_colours_that_have_swatches() { assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global); } +/// Give `id` the fill `p` exactly as written (a command would round or convert some colors). +fn set_fill(s: &mut Session, id: NodeId, p: Paint) { + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.set_fill(p); +} + +/// Rectangles filled with repeated colors, near-equal colors that get the same name, a color and +/// its twin with a -0.0 component, a color whose name a swatch already has, and colors two +/// swatches already have (one of them through -0.0). Returns the rectangles in paint order. +fn used_colors_doc(s: &mut Session) -> Vec { + run(s, "swatch.new", json!({"name": "Gray K=33", "color": "#00ff00"})); + run(s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"})); + let red = doc(s).swatch("Red").unwrap().paint.color().unwrap(); + let colors = [ + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6001), + Color::rgb(0.0, 0.4, 0.6), + Color::rgb(-0.0, 0.4, 0.6), + Color::gray(0.33), + Color::gray(0.3301), + red, + Color::rgb(-0.0, 128.0 / 255.0, 1.0), + Color::rgb(0.2, 0.4, 0.6001), + ]; + colors + .into_iter() + .map(|c| { + let id = rect_filled(s, json!({"color": "#000000"})); + set_fill(s, id, Paint::solid(c)); + id + }) + .collect() +} + +const USED_NAMES: [&str; 5] = ["R=51 G=102 B=153", "R=51 G=102 B=153 2", "R=0 G=102 B=153", "Gray K=33 2", "Gray K=33 3"]; + +#[test] +fn add_used_colors_adds_each_new_color_once_with_free_names() { + let mut s = session(); + used_colors_doc(&mut s); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.addUsedColors", json!({})); + assert_eq!(r, json!({"added": USED_NAMES, "linked": 0})); + assert_eq!(names(&s, None)[names(&s, None).len() - 5..], USED_NAMES); + let color = |n: &str| doc(&s).swatch(n).unwrap().paint.color().unwrap(); + assert_eq!(color("R=51 G=102 B=153 2"), Color::rgb(0.2, 0.4, 0.6001)); + assert_eq!(color("Gray K=33 3"), Color::gray(0.3301)); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + +#[test] +fn add_used_colors_global_links_every_paint_of_a_new_color() { + let mut s = session(); + let ids = used_colors_doc(&mut s); + let r = run(&mut s, "swatch.addUsedColors", json!({"global": true})); + assert_eq!(r, json!({"added": USED_NAMES, "linked": 8})); + let link = |s: &Session, id: NodeId| match fill(s, id) { + Paint::Solid { swatch, .. } => swatch, + p => panic!("{p:?}"), + }; + // The index in USED_NAMES of the swatch each rectangle links to. + let want = [Some(0), Some(0), Some(1), Some(2), Some(2), Some(3), Some(4), None, None, Some(1)]; + let got: Vec> = ids.iter().map(|id| link(&s, *id)).collect(); + assert_eq!(got, want.map(|i: Option| i.map(|i| USED_NAMES[i].to_string()))); + assert!(USED_NAMES.iter().all(|n| doc(&s).swatch(n).unwrap().global)); +} + +/// A paint linked to Registration keeps its link when a new global swatch has the same color. +#[test] +fn add_used_colors_global_leaves_registration_links_alone() { + let mut s = session(); + let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + let registration = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, registration, reg_paint.clone()); + let plain = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, plain, Paint::solid(Color::cmyk(1.0, 1.0, 1.0, 1.0))); + let r = run(&mut s, "swatch.addUsedColors", json!({"global": true})); + assert_eq!(r["linked"], json!(1), "{r}"); + let name = r["added"][0].as_str().unwrap().to_string(); + assert_eq!(fill(&s, registration), reg_paint); + assert_eq!(fill(&s, plain), Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(name), tint: 1.0 }); +} + +#[test] +fn unused_matches_links_tints_colors_gradients_and_patterns_exactly() { + let mut s = session(); + run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true})); + run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 40})); + run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 70})); + run(&mut s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"})); + let teal = doc(&s).swatch("Teal").unwrap().paint.color().unwrap(); + let tinted = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, tinted, Paint::Solid { color: teal.tinted(0.4), swatch: Some("Teal".into()), tint: 0.4 }); + let zero = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, zero, Paint::solid(Color::rgb(-0.0, 128.0 / 255.0, 1.0))); + // Sunset's gradient without a link to it, beside gradients with Sunset's stop colors and a + // different midpoint or opacity; Ocean's with one stop moved, and Ocean's with another kind, + // method or dither. + let gradient = |s: &Session, n: &str| match &doc(s).swatch(n).unwrap().paint { + Paint::Gradient(g) => g.gradient.clone(), + p => panic!("{p:?}"), + }; + let sunset = gradient(&s, "Sunset"); + let (mut midpoint, mut opacity) = (sunset.clone(), sunset.clone()); + midpoint.stops[0].midpoint = 0.3; + opacity.stops[1].opacity = 0.5; + let ocean = gradient(&s, "Ocean"); + let (mut moved, mut radial, mut perceptual, mut dither) = (ocean.clone(), ocean.clone(), ocean.clone(), ocean); + moved.stops[0].offset += 0.25; + radial.kind = vectorcraft_color::GradientKind::Radial; + perceptual.method = vectorcraft_color::GradientMethod::Perceptual; + dither.dither = true; + for g in [midpoint, sunset.clone(), opacity, sunset, moved, radial, perceptual, dither] { + let id = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, id, Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g)))); + } + let r = rect_filled(&mut s, json!({"color": "#ff0000"})); + run(&mut s, "select.set", json!({"ids": [r.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + rect_filled(&mut s, json!({"swatch": "Dots"})); + let names = run(&mut s, "swatch.unused", json!({}))["names"].clone(); + let names: Vec<&str> = names.as_array().unwrap().iter().filter_map(Value::as_str).collect(); + for used in ["Teal", "Teal 40%", "Zeroed", "Sunset", "Dots"] { + assert!(!names.contains(&used), "{used} is used: {names:?}"); + } + for unused in ["Teal 70%", "Ocean", "Orange", "Red"] { + assert!(names.contains(&unused), "{unused} is unused: {names:?}"); + } +} + +#[test] +fn new_swatches_from_colors_are_numbered_after_names_already_taken() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"})); + run(&mut s, "swatch.newGroup", json!({"name": "Mine"})); + let before = doc(&s).clone(); + let undo = undo_len(&s); + // Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component. + let colors = json!([ + "#ff0000", + "#ff0000", + "#ff0000", + "#00ff00", + "#00ff00", + [0.2, 0.4, 0.6], + [0.2, 0.4, 0.6001], + {"l": 50, "a": 0.0, "b": 0}, + {"l": 50, "a": -0.0, "b": 0} + ]); + let r = run(&mut s, "swatch.new", json!({ "colors": colors })); + let want = [ + "R=255 G=0 B=0", + "R=255 G=0 B=0 3", + "R=255 G=0 B=0 4", + "R=0 G=255 B=0", + "R=0 G=255 B=0 2", + "R=51 G=102 B=153", + "R=51 G=102 B=153 2", + "L=50 a=0 b=0", + "L=50 a=0 b=0 2", + ]; + assert_eq!(r, json!({"name": want[0], "names": want})); + assert_eq!(names(&s, None)[names(&s, None).len() - want.len()..], want); + assert!(matches!(doc(&s).swatch("L=50 a=0 b=0 2").unwrap().paint.color(), Some(Color::Lab { a, .. }) if a.is_sign_negative())); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); + let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000", "#ff0000"]})); + assert_eq!(r["names"], json!(["R=255 G=0 B=0", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4"])); + let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000"], "group": "Mine"})); + assert_eq!(r["names"], json!(["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"])); + assert_eq!(names(&s, Some("Mine")), ["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"]); +} + +#[test] +fn new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"})); + let r = run( + &mut s, + "swatch.newGroup", + json!({"name": "R=255 G=0 B=0", "swatches": ["R=255 G=0 B=0 2"], "colors": ["#ff0000", "#ff0000", "#00ff00", "#ff0000"]}), + ); + let want = ["R=255 G=0 B=0 2", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4", "R=0 G=255 B=0", "R=255 G=0 B=0 5"]; + assert_eq!(r, json!({"name": "R=255 G=0 B=0", "swatches": want, "linked": 0})); +} + +/// New Color Group from artwork lists each color once in paint order: a repeat and a twin with a +/// -0.0 component are the same color, near-equal colors with the same name are numbered after the +/// names already taken, and a paint linked to Registration brings no color. One undo step. +#[test] +fn new_color_group_from_artwork_lists_each_color_once_with_free_names() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=0 G=102 B=153", "color": "#00ff00"})); + let colors = [ + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.0, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6001), + Color::rgb(-0.0, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6), + Color::lab(50.0, 0.0, 0.0), + Color::lab(50.0, -0.0, 0.0), + Color::lab(50.0, 0.0001, 0.0), + ]; + let mut ids: Vec = vec![]; + for c in colors { + let id = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, id, Paint::solid(c)); + ids.push(id.0); + } + let registration = rect_filled(&mut s, json!({"color": "#000000"})); + let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + set_fill(&mut s, registration, reg_paint); + ids.push(registration.0); + run(&mut s, "select.set", json!({ "ids": ids })); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.newGroup", json!({"name": "R=51 G=102 B=153 2", "fromArtwork": true})); + let want = ["R=51 G=102 B=153", "R=0 G=102 B=153 2", "R=51 G=102 B=153 3", "L=50 a=0 b=0", "L=50 a=0 b=0 2"]; + assert_eq!(r, json!({"name": "R=51 G=102 B=153 2", "swatches": want, "linked": 8})); + assert_eq!(names(&s, Some("R=51 G=102 B=153 2")), want); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + +#[test] +fn edit_color_group_numbers_extra_colors_after_the_names_it_keeps() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Mine", "colors": ["#ff0000", "#00ff00", "#0000ff"]})); + let before = doc(&s).clone(); + let undo = undo_len(&s); + // Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component. + let colors = json!([ + "#0000ff", + "#ff0000", + "#ff0000", + "#ff0000", + "#ff0000", + "#00ff00", + [0.2, 0.4, 0.6], + [0.2, 0.4, 0.6001], + {"l": 50, "a": 0.0, "b": 0}, + {"l": 50, "a": -0.0, "b": 0} + ]); + let r = run(&mut s, "swatch.editGroup", json!({"group": "Mine", "colors": colors, "rename": "R=255 G=0 B=0 2"})); + let want = [ + "R=255 G=0 B=0", + "R=0 G=255 B=0", + "R=0 G=0 B=255", + "R=255 G=0 B=0 3", + "R=255 G=0 B=0 4", + "R=0 G=255 B=0 2", + "R=51 G=102 B=153", + "R=51 G=102 B=153 2", + "L=50 a=0 b=0", + "L=50 a=0 b=0 2", + ]; + assert_eq!(r, json!({"name": "R=255 G=0 B=0 2", "swatches": want, "relinked": 0})); + assert_eq!(names(&s, Some("R=255 G=0 B=0 2")), want); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + #[test] fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 10e547daf..016b14fd6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1119,6 +1119,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. + Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels.