mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
M3.60: gradient swatches and stops linked to global and spot swatches
Applying a gradient swatch records it as the gradient's swatch (the Swatches panel highlights it and the Gradient panel's swatch menu marks it). A stop given a global or spot swatch (and a tint) in paint.editGradient / a gradient paint's stops, dropped from the Swatches panel or picked in the stop popover stays linked: editing the swatch recolours it, in art and in gradient swatches, deleting it unlinks it. Spot stops separate on their plate, and a gradient whose stops are all tints of one spot ink (or paper white) exports to PDF as a Separation shading; gradients mixing models are written in one colour space (the PDF writer requires it; mixed models used to abort the export). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b2ececc7b4
commit
7227e295ea
@@ -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<CommandSpec> {
|
||||
"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> {
|
||||
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<Value> {
|
||||
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<Vec<GradientStop>> {
|
||||
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<Vec<GradientStop>> {
|
||||
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::<Parsed<Vec<_>>>()?;
|
||||
@@ -301,6 +338,8 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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))?;
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
"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<Option<Paint>> {
|
||||
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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>) {
|
||||
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"));
|
||||
}
|
||||
Reference in New Issue
Block a user