diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 329acde8..2dd4f4db 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -3,11 +3,13 @@ use serde_json::{Value, json}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_tools::params::color_json; use super::appearance::{ItemTarget, edit_items, edits_stroke, item_target}; use super::edit::selected_roots; +use super::paint::swatch_solid; use super::*; use crate::EngineError; @@ -18,7 +20,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -103,9 +105,42 @@ fn parse_kind(k: &str) -> Parsed { GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)")) } +/// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d` +/// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and +/// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps +/// the colour it gives. +pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed { + let mut v = v.clone(); + let Some(stops) = v.get_mut("stops").and_then(Value::as_array_mut) else { return Ok(v) }; + for (i, st) in stops.iter_mut().enumerate() { + let Some(name) = st.get("swatch").and_then(Value::as_str).map(str::to_string) else { continue }; + if d.swatch(&name).is_none() && st.get("color").is_some() { + continue; + } + let tint = st.get("tint").and_then(Value::as_f64).map(|t| (t / 100.0).clamp(0.0, 1.0) as f32); + let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else { + continue; + }; + let Some(o) = st.as_object_mut() else { continue }; + o.insert("color".into(), color_json(&color)); + match swatch { + Some(n) => { + o.insert("swatch".into(), json!(n)); + o.insert("tint".into(), json!(tint * 100.0)); + } + None => { + o.remove("swatch"); + o.remove("tint"); + } + } + } + Ok(v) +} + /// Parse `stops` params: at least two, each with a numeric `offset` (clamped to 0..1) and a valid /// `color`; `opacity` is 0..1 (values above 1 are percentages) and `midpoint` is clamped to the -/// diamond's range. The result is sorted by offset. +/// diamond's range; `swatch` and `tint` (%) keep a stop's link as given (resolve them first with +/// [`link_stops`]). The result is sorted by offset. pub(crate) fn parse_stops(v: &Value) -> Parsed> { let arr = v.as_array().ok_or("`stops` must be an array")?; if arr.len() < 2 { @@ -119,13 +154,15 @@ pub(crate) fn parse_stops(v: &Value) -> Parsed> { let offset = num("offset")?.ok_or_else(|| format!("stop {i} needs `offset`"))?; let color = st.get("color").and_then(color_value).ok_or_else(|| format!("stop {i} needs a valid `color`"))?; let opacity = num("opacity")?.map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0); + let swatch = str_param(st, "swatch").map(str::to_string); + let tint = num("tint")?.filter(|_| swatch.is_some()).map_or(1.0, |t| (t / 100.0).clamp(0.0, 1.0) as f32); Ok(GradientStop { offset: offset.clamp(0.0, 1.0) as f32, color, opacity: opacity.clamp(0.0, 1.0) as f32, midpoint: num("midpoint")?.unwrap_or(0.5).clamp(0.13, 0.87) as f32, - swatch: None, - tint: 1.0, + swatch, + tint, }) }) .collect::>>()?; @@ -301,6 +338,8 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { let stroke = edits_stroke(s, p, item, !s.fill_active)?; let item = item.of_kind(s, !stroke); let ids = item.targets(s, p)?; + // Stops naming swatches take their colours from them. + let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?; // Validate against the defaults first so bad params fail without touching the document. let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }; let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index d1a3fde7..edda0328 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,7 +1,7 @@ //! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. use serde_json::{Value, json}; -use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target}; @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch fits each object, keeping its aspect), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), @@ -314,12 +314,14 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { return match &sw.paint { Paint::Solid { .. } => swatch_solid(d, name, tint).map(Some).map_err(|e| bad("paint", e)), _ if tint.is_some() => Err(bad("paint", "`tint` applies to global and spot colours")), + Paint::Gradient(g) => Ok(Some(Paint::Gradient(Box::new(GradientPaint { swatch: Some(name.to_string()), ..(**g).clone() })))), other => Ok(Some(other.clone())), }; } if let Some(g) = p.get("gradient") { - let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; - gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color)); + let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?; + let mut gp = super::gradient::parse_gradient(&g).map_err(|e| bad("paint", e))?; + gp.gradient.stops.iter_mut().filter(|st| st.swatch.is_none()).for_each(|st| st.color = new_color(st.color)); return Ok(Some(Paint::Gradient(Box::new(gp)))); } if let Some(c) = p.get("color") { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 41e5b1f3..c9467541 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -805,6 +805,8 @@ mod tests_knockout; #[cfg(test)] mod tests_layerclip; #[cfg(test)] +mod tests_linked_stops; +#[cfg(test)] mod tests_live; #[cfg(test)] mod tests_menucmds; diff --git a/crates/engine/src/tests_linked_stops.rs b/crates/engine/src/tests_linked_stops.rs new file mode 100644 index 00000000..edd392fa --- /dev/null +++ b/crates/engine/src/tests_linked_stops.rs @@ -0,0 +1,154 @@ +//! Gradient swatches and gradient stops linked to global and spot swatches: the recorded links, +//! stops following swatch edits, spot stops on their plate and as Separation shadings in PDF. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, GradientPaint, GradientStop, Paint}; +use vectorcraft_render::proof; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +const INK: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.0 }; + +/// A document with spot swatch "Ink" and a selected rectangle. +fn setup() -> (Session, NodeId) { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Ink", "color": INK, "spot": true})); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [id.0]})); + (s, id) +} + +fn gradient(s: &Session, id: NodeId) -> GradientPaint { + match doc(s).node(id).unwrap().appearance.fill_paint() { + Paint::Gradient(g) => *g, + p => panic!("not a gradient: {p:?}"), + } +} + +/// Ink at `tint` % → white. +fn ink_to_white(tint: f64) -> Value { + json!({"stops": [{"offset": 0, "swatch": "Ink", "tint": tint}, {"offset": 1, "color": "#ffffff"}]}) +} + +#[test] +fn applying_a_gradient_swatch_records_the_link_until_the_stops_change() { + let (mut s, id) = setup(); + run(&mut s, "paint.setFill", json!({"swatch": "Sunset"})); + assert_eq!(gradient(&s, id).swatch.as_deref(), Some("Sunset")); + let used = run(&mut s, "swatch.unused", json!({})); + assert!(!used["names"].as_array().unwrap().contains(&json!("Sunset"))); + run(&mut s, "paint.editGradient", json!({"angle": 45})); + assert_eq!(gradient(&s, id).swatch.as_deref(), Some("Sunset"), "moving the vector keeps it"); + run(&mut s, "paint.editGradient", ink_to_white(100.0)); + assert_eq!(gradient(&s, id).swatch, None, "new stops are another gradient"); +} + +#[test] +fn stops_link_to_swatches_and_follow_their_edits() { + let (mut s, id) = setup(); + run(&mut s, "paint.editGradient", ink_to_white(50.0)); + let st = gradient(&s, id).gradient.stops[0].clone(); + assert_eq!((st.swatch.as_deref(), st.tint, st.color), (Some("Ink"), 0.5, INK.tinted(0.5)), "the colour comes from the swatch"); + assert_eq!(gradient(&s, id).gradient.stops[1].swatch, None); + // A tint swatch links the stop to its base at its tint; a process swatch just gives its colour. + run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40})); + run(&mut s, "swatch.new", json!({})); + run(&mut s, "paint.setFill", json!({"gradient": {"stops": [{"offset": 0, "swatch": "Ink 40%"}, {"offset": 1, "swatch": "Black"}]}})); + let stops = gradient(&s, id).gradient.stops; + assert_eq!((stops[0].swatch.as_deref(), stops[0].tint), (Some("Ink"), 0.4)); + assert_eq!((stops[1].swatch.as_deref(), stops[1].tint), (None, 1.0)); + assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "swatch": "Nope"}, {"offset": 1, "color": "#fff"}]})).is_err()); + // Editing the swatch recolours the stops (and the tint swatch) as one undo step. + let undo = s.doc().unwrap().history.undo.len(); + run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})); + let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0); + assert_eq!(gradient(&s, id).gradient.stops[0].color, cyan.tinted(0.4)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // Global mode of Adjust Color Balance shifts stop tints too. + run(&mut s, "edit.colors.adjustBalance", json!({"tint": 60})); + assert_eq!(gradient(&s, id).gradient.stops[0].color, cyan); + // Recolouring a stop unlinks it; deleting the swatch unlinks the rest, which keep their colour. + run(&mut s, "paint.editGradient", ink_to_white(25.0)); + run(&mut s, "swatch.delete", json!({"name": "Ink"})); + let st = gradient(&s, id).gradient.stops[0].clone(); + assert_eq!((st.swatch, st.tint, st.color), (None, 1.0, cyan.tinted(0.25))); +} + +#[test] +fn gradient_swatches_keep_their_stop_links_and_follow_edits() { + let (mut s, id) = setup(); + run(&mut s, "swatch.new", json!({"name": "Glow", "gradient": ink_to_white(100.0)})); + run(&mut s, "paint.setFill", json!({"swatch": "Glow"})); + run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"c": 0, "m": 0, "y": 1, "k": 0}})); + let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0); + let Paint::Gradient(sw) = doc(&s).swatch("Glow").unwrap().paint.clone() else { panic!() }; + assert_eq!(sw.gradient.stops[0].color, yellow, "the gradient swatch's stop follows"); + let g = gradient(&s, id); + assert_eq!((g.gradient.stops[0].color, g.swatch.as_deref()), (yellow, Some("Glow")), "so does the art, still linked to its swatch"); + assert_eq!(g.gradient, sw.gradient); +} + +#[test] +fn spot_stops_separate_on_their_plate() { + let (mut s, id) = setup(); + run(&mut s, "paint.editGradient", ink_to_white(60.0)); + let mut node = doc(&s).node(id).unwrap().clone(); + let mut links = vec![]; + proof::map_node_colors(&mut node, &mut |c, link| { + links.push(link.map(|(n, t)| (n.to_string(), t))); + *c + }); + assert!(links.contains(&Some(("Ink".to_string(), 0.6))), "{links:?}"); + let c = vectorcraft_color::cms::active(); + let st = gradient(&s, id).gradient.stops[0].clone(); + let ink = proof::inks(doc(&s), &c, &st.color, Some(("Ink", st.tint)), proof::Intent::RelativeColorimetric); + assert_eq!((ink.cmyk, ink.spot), ([0.0; 4], Some(("Ink".to_string(), 0.6)))); +} + +fn pdf_of(s: &Session) -> (String, Vec) { + let opts = vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }; + let r = vectorcraft_pdf::export_with_report(doc(s), &opts).unwrap(); + (String::from_utf8_lossy(&r.bytes).into_owned(), r.warnings) +} + +#[test] +fn spot_gradients_export_as_separation_shadings() { + let (mut s, _) = setup(); + run(&mut s, "paint.editGradient", ink_to_white(80.0)); + let (pdf, _) = pdf_of(&s); + assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "an Ink → paper gradient is a Separation shading"); + // Its function runs over the tints: 80 % (204 / 255) to 0 %. + assert!(pdf.contains("/C0[0.8]/C1[0]"), "the shading's tints"); + // Mixing the spot ink with another colour falls back to process colours, with a warning. + run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]})); + let (pdf, warnings) = pdf_of(&s); + assert!(!pdf.contains("/Separation"), "no Separation for a mixed gradient"); + assert!(warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); +} + +#[test] +fn linked_stops_round_trip() { + let (mut s, id) = setup(); + run(&mut s, "paint.editGradient", ink_to_white(30.0)); + let d = doc(&s).clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert_eq!(back.node(id).unwrap().appearance.fill_paint(), d.node(id).unwrap().appearance.fill_paint()); + // Stops from before links read as unlinked. + let old: GradientStop = serde_json::from_value(json!({"offset": 0.5, "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}})).unwrap(); + assert_eq!(old, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0))); + assert!(!serde_json::to_string(&old).unwrap().contains("tint")); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 46715647..4571f970 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -261,6 +261,41 @@ impl Exporter<'_> { } } + /// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of + /// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the + /// tint and opacity there are halfway). `None` for other gradients; one that mixes a spot ink + /// with other colours is written in process colours (the PDF writer has no DeviceN). + fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { + let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; + let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; + let tints: Option> = g + .stops + .iter() + .map(|s| match s.swatch.as_deref() { + Some(n) if n == ink => Some(s.tint), + None if paper(&s.color) => Some(0.0), + _ => None, + }) + .collect(); + let Some(tints) = tints else { + self.warn("gradients mixing a spot color with other colors are exported in process colors"); + return None; + }; + let space = self.separation(ink)?; + let tint = |t: f32| -> krilla::color::Color { SepColor::new(q(t), space.clone()).into() }; + let mut out = vec![]; + for (i, s) in g.stops.iter().enumerate() { + out.push((s.offset, tint(tints[i]), s.opacity)); + if let Some(n) = g.stops.get(i + 1) + && (s.midpoint - 0.5).abs() > 1e-3 + { + let at = s.offset + (n.offset - s.offset) * s.midpoint; + out.push((at, tint((tints[i] + tints[i + 1]) / 2.0), (s.opacity + n.opacity) / 2.0)); + } + } + Some(out) + } + /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect. fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect]) { if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { @@ -282,12 +317,30 @@ impl Exporter<'_> { Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), Paint::Gradient(g) => { let geom = g.resolve(bounds); + let spot = (g.gradient.kind != GradientKind::Freeform).then(|| self.spot_stops(&g.gradient)).flatten(); + let colored = match spot { + Some(v) => v, + None => { + let stops = g.gradient.expanded_stops(); + // The PDF writer needs every stop in one colour space: stops of mixed + // models (midpoints sample in RGB) go through RGB, or stay CMYK in a CMYK + // document, where `col` separates the rest. + let mixed = stops.windows(2).any(|w| w[0].1.model() != w[1].1.model()); + let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; + let one = |c: Color| match c { + Color::Cmyk { .. } if cmyk_doc => c, + _ if mixed => c.in_model(vectorcraft_color::cms::Model::Rgb), + _ => c, + }; + stops.into_iter().map(|(o, c, a)| (o, self.col(&one(c)), a)).collect() + } + }; let mut stops: Vec = Vec::new(); let mut last = 0.0f32; - for (o, c, a) in g.gradient.expanded_stops() { + for (o, color, a) in colored { let o = o.clamp(last, 1.0); last = o; - stops.push(Stop { offset: norm(o), color: self.col(&c), opacity: norm(a) }); + stops.push(Stop { offset: norm(o), color, opacity: norm(a) }); } if stops.is_empty() { return None; diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index 2403d1fd..694b4211 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -13,10 +13,21 @@ pub fn color_json(c: &Color) -> Value { } } -/// Gradient stops as the `stops` param of `paint.editGradient` / a `gradient` paint. +/// Gradient stops as the `stops` param of `paint.editGradient` / a `gradient` paint (a linked +/// stop also carries its `swatch` and `tint` %). pub fn stops_json(stops: &[GradientStop]) -> Value { Value::Array( - stops.iter().map(|s| json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint})).collect(), + stops + .iter() + .map(|s| { + let mut v = json!({"offset": s.offset, "color": color_json(&s.color), "opacity": s.opacity, "midpoint": s.midpoint}); + if let Some(n) = &s.swatch { + v["swatch"] = json!(n); + v["tint"] = json!(s.tint * 100.0); + } + v + }) + .collect(), ) } @@ -75,6 +86,11 @@ mod tests { let v = gradient_params(&g); assert_eq!((v["start"].clone(), v["end"].clone(), v["aspect"].clone()), (json!([1.0, 2.0]), json!([3.0, 4.0]), json!(50.0))); assert_eq!(v["swatch"], "Sky"); + assert!(v["stops"][0].get("swatch").is_none(), "unlinked stops carry no link"); + g.gradient.stops[1].swatch = Some("Ink".into()); + g.gradient.stops[1].tint = 0.25; + let v = gradient_params(&g); + assert_eq!((v["stops"][1]["swatch"].clone(), v["stops"][1]["tint"].clone()), (json!("Ink"), json!(25.0))); assert_eq!(color_json(&Color::gray(0.25)), json!({"gray": 0.25})); } } diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 2026a1a5..a4088f23 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -1,7 +1,8 @@ //! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator (or the //! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls, -//! or a document swatch), opacity and location. It edits the selected stop -//! (`gradient.selectStop`) through `paint.editGradient`, and closes on Escape or a click elsewhere. +//! or a document swatch, which stays linked when it is a global or spot colour), opacity and +//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and +//! closes on Escape or a click elsewhere. //! //! Fields: `index`, `x`, `y` (the chip, document coordinates) or `screen` ([x, y], screen points, //! from the panel), and `tab` (`color` or `swatches`). @@ -31,9 +32,9 @@ fn selected(app: &VectorcraftApp) -> Option<(Vec, usize)> { Some((g.gradient.stops, i)) } -/// `stops` with stop `i` given `color`. -fn recolor(mut stops: Vec, i: usize, color: Color) -> Vec { - stops[i].color = color; +/// `stops` with stop `i` given `color`, linked to `link` (a global swatch and tint) or unlinked. +fn recolor(mut stops: Vec, i: usize, color: Color, link: Option<(String, f32)>) -> Vec { + stops[i].set_color(color, link); stops } @@ -124,12 +125,14 @@ fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientSto let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); widgets::swatch_tile(ui, r, &Paint::solid(*c), stops[i].color == *c, resp.hovered()); if resp.on_hover_text(name).clicked() { - pick = Some(*c); + pick = Some((name.clone(), *c)); } } }); - if let Some(c) = pick { - set_stops(app, &recolor(stops.to_vec(), i, c), Some(i), Live::Released); + // A global or spot colour (or a tint of one) stays linked. + if let Some((name, c)) = pick { + let link = crate::panels::gradient::dropped_link(app, &json!({ "swatch": name })); + set_stops(app, &recolor(stops.to_vec(), i, c, link), Some(i), Live::Released); } } @@ -138,7 +141,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { let (mut stops, mut i) = selected(app).ok_or("no gradient stop is selected")?; if let Some(hex) = d.fields.get("color").and_then(Value::as_str) { let c = crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?; - stops = recolor(stops, i, c); + stops = recolor(stops, i, c, None); } if d.fields.contains_key("opacity") { stops[i].opacity = (d.f64("opacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32; diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 9e4ba766..0e2da2d1 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -6,7 +6,9 @@ //! Alt-dropping it on another stop swaps their colours), drag it off to remove it, double-click it //! for the stop popover, and drag a diamond to move a midpoint (a selected diamond's midpoint //! shows in Location). Dropping a colour swatch on the ramp adds a stop of that colour, or -//! recolours the stop it lands on. Hide Options leaves the thumbnail, the proxy and the slider. +//! recolours the stop it lands on; a global or spot colour (or a tint of one) stays linked, so +//! editing the swatch recolours the stop. The thumbnail menu marks the gradient swatch the +//! gradient was applied from. Hide Options leaves the thumbnail, the proxy and the slider. //! //! A freeform gradient shows its section instead of the angle, aspect and slider: the Draw //! toggle (Points or Lines: how the Gradient tool adds points) and the selected point's colour @@ -54,17 +56,28 @@ pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 { pub use vectorcraft_tools::params::stops_json; /// A colour dropped on the ramp: recolours stop `on` (fully opaque), or adds an opaque stop of -/// that colour at `offset`. Returns the stops and the index of the stop that took the colour. -fn drop_color(g: &Gradient, on: Option, offset: f32, c: Color) -> (Vec, usize) { +/// that colour at `offset`, linked to `link` (a global swatch and tint) when given. Returns the +/// stops and the index of the stop that took the colour. +fn drop_color(g: &Gradient, on: Option, offset: f32, c: Color, link: Option<(String, f32)>) -> (Vec, usize) { let (mut v, i) = match on.filter(|i| *i < g.stops.len()) { Some(i) => (g.stops.clone(), i), None => insert_stop(g, offset), }; - v[i].color = c; + v[i].set_color(c, link); v[i].opacity = 1.0; (v, i) } +/// The swatch link a dropped paint's params give a stop: `{swatch, tint?}` of a global or spot +/// colour or a tint swatch (a proxy's tint carries its percentage); none for other colours. +pub(crate) fn dropped_link(app: &VectorcraftApp, params: &Value) -> Option<(String, f32)> { + let name = params.get("swatch")?.as_str()?; + let d = &app.session.active()?.doc; + let (base, own) = d.swatch_link(name)?; + let tint = params.get("tint").and_then(Value::as_f64).map_or(own, |t| (t / 100.0).clamp(0.0, 1.0) as f32); + Some((base, tint)) +} + /// The stops after dragging stop `i` of `origin` to `offset`: moved, or with `copy` (Alt) a copy /// left there, or the colours swapped with stop `over` when the copy is dropped on it. Returns /// the stops and the stop to select. @@ -337,7 +350,10 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: } for (name, grad) in &swatches { let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - if resp.hovered() { + // The gradient swatch the gradient was applied from. + if g.swatch.as_ref() == Some(name) { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if resp.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } let chip = Rect::from_min_size(row.left_center() + vec2(4.0, -8.0), vec2(16.0, 16.0)); @@ -371,7 +387,12 @@ fn stop_fields(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: boo widgets::dim_label(ui, "Color:"); let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); widgets::swatch_tile(ui, r, &Paint::solid(s.color), false, resp.hovered()); - widgets::dim_label(ui, &s.color.to_hex().to_uppercase()); + // A linked stop names its swatch and tint. + let label = match &s.swatch { + Some(n) => format!("{n} {}%", vectorcraft_color::tint_percent(s.tint)), + None => s.color.to_hex().to_uppercase(), + }; + widgets::dim_label(ui, &label); if resp.on_hover_text("Edit the stop").clicked() { open_popover(app, i, r.left_bottom() + vec2(0.0, 4.0)); } @@ -598,7 +619,11 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { && let Some(c) = d.color() && let Some(p) = ui.input(|i| i.pointer.latest_pos()) { - let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c); + let link = match &*d { + PanelDrag::Paint { params, .. } => dropped_link(app, params), + _ => None, + }; + let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c, link); select = Some(i); changed = Some((v, Live::Released)); } @@ -666,9 +691,9 @@ mod tests { let mut g = Gradient::default(); g.stops[1].opacity = 0.5; let red = Color::rgb(1.0, 0.0, 0.0); - let (v, i) = drop_color(&g, Some(1), 0.3, red); + let (v, i) = drop_color(&g, Some(1), 0.3, red, None); assert_eq!((v.len(), i, v[1].color, v[1].opacity), (2, 1, red, 1.0)); - let (v, i) = drop_color(&g, None, 0.3, red); + let (v, i) = drop_color(&g, None, 0.3, red, None); assert_eq!((v.len(), i, v[1].color, v[1].offset, v[1].opacity), (3, 1, red, 0.3, 1.0)); } @@ -879,6 +904,20 @@ mod tests { let last = p.widget(("grad-stop", 2)).center(); drop(&mut p, &mut app, last); assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]); + // A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop. + app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true, "focus": false})).unwrap(); + let ink = app.session.doc().unwrap().doc.swatch("Ink").unwrap().paint.clone(); + egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::Paint { paint: ink, params: json!({"swatch": "Ink"}), rows: None }); + p.frame(&mut app, vec![Event::PointerMoved(a)], Modifiers::NONE); + p.frame( + &mut app, + vec![Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }], + Modifiers::NONE, + ); + let st = fill(&app).gradient.stops[0].clone(); + assert_eq!((st.swatch.as_deref(), st.tint, st.color.to_hex()), (Some("Ink"), 1.0, "#cc0066".to_string())); + app.run("swatch.edit", json!({"name": "Ink", "color": "#0066cc"})).unwrap(); + assert_eq!(hexes(&app)[0], "#0066cc"); } #[test] diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 31e1b5be..8deaab36 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -693,7 +693,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { } /// The swatch the active paint comes from: a linked colour's global swatch (a tint's tint swatch -/// when there is one), or None. +/// when there is one), a gradient's gradient swatch, or None. fn active_swatch(app: &VectorcraftApp) -> Option { match active_paint(app) { Paint::Solid { swatch: Some(n), tint, .. } => { @@ -701,6 +701,7 @@ fn active_swatch(app: &VectorcraftApp) -> Option { let tint_swatch = d.swatches_iter().find(|w| w.tint_of() == Some((n.as_str(), tint))); Some(tint_swatch.map_or(n.clone(), |w| w.name.clone())) } + Paint::Gradient(g) => g.swatch, Paint::None => Some("[None]".to_string()), _ => None, } @@ -1482,7 +1483,7 @@ mod tests { } #[test] - fn the_active_tint_swatch_is_highlighted_and_a_tint_edits_its_base() { + fn the_active_tint_or_gradient_swatch_is_highlighted_and_a_tint_edits_its_base() { let mut app = app(); app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap(); app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap(); @@ -1491,6 +1492,8 @@ mod tests { assert_eq!(active_swatch(&app).as_deref(), Some("Ink 40%")); let tint = entries(&app, Kind::Color, "Ink 40").into_iter().find(|e| e.name() == "Ink 40%").unwrap(); assert!(matches!(tint, Entry::Swatch { global: true, spot: true, .. }), "a tint swatch shows its base's kind"); + app.run("paint.setFill", json!({"swatch": "Sunset"})).unwrap(); + assert_eq!(active_swatch(&app).as_deref(), Some("Sunset"), "the applied gradient swatch"); app.run("ui.swatchOptions", json!({"name": "Ink 40%"})).unwrap(); let d = app.ui.dialog.as_ref().unwrap(); assert_eq!((d.kind.as_str(), d.str("__swatch").as_str()), (KIND, "Ink"), "a tint swatch opens its base's options"); diff --git a/docs/mcp.md b/docs/mcp.md index 7aaeeb21..78d7205b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -444,3 +444,17 @@ colours and leaves the rest alone. {"name":"run_command","arguments":{"command":"paint.setFill","params":{"swatch":"Ink","tint":40}}} {"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"global","tint":-20}}} ``` + +## Linked gradient stops + +Applying a gradient swatch (`paint.setFill {swatch}`) records it as the gradient's `swatch` (the Swatches panel +highlights it; new stops drop the link). A gradient stop can link to a global or spot swatch like a solid colour: +give it `swatch` (and `tint` %, default 100 or a tint swatch's own) instead of `color` in `paint.editGradient +{stops}` or a `gradient` paint's stops. Editing or deleting the swatch then recolours or unlinks the stop, in art and +in gradient swatches. A spot stop separates on its plate; a gradient whose stops are all tints of one spot ink (or +paper white, 0 %) exports to PDF as a Separation shading (the writer has no DeviceN, so a gradient mixing a spot +ink with other colours is written in process colours, with a warning). + +```json +{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"stops":[{"offset":0,"swatch":"Ink"},{"offset":1,"swatch":"Ink","tint":20}]}}} +```