Merge pull request #2812 from dotCooCoo/fix/cmyk-document-converts-rgb-everywhere

Color: type, gradient, mesh and swatch commands store RGB input as color-managed CMYK in a CMYK document
This commit is contained in:
BFlat
2026-10-18 13:21:51 +07:00
committed by GitHub
26 changed files with 500 additions and 63 deletions
+2 -2
View File
@@ -210,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Symbolism Tool",
[],
None,
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, style?: name} adjust instances near the points (selection, else all) → {count}",
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}",
has_doc,
symbol_adjust
),
@@ -1110,7 +1110,7 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
let alt = bool_or(p, "alt", false);
let st = s.doc()?;
let color = match p.get("color").and_then(color_value) {
Some(c) => c,
Some(c) => super::paint::new_color(s, p, c),
None => s.paint.fill.color().unwrap_or(Color::BLACK),
};
let style_paint = match str_param(p, "style") {
+1 -1
View File
@@ -232,7 +232,7 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
Some(v @ Value::Object(o)) if o.contains_key("color") || o.contains_key("swatch") || o.contains_key("gradient") || o.contains_key("none") => {
paint_from(s, v)
}
Some(v) => color_value(v).map(|c| Some(Paint::solid(c))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
Some(v) => color_value(v).map(|c| Some(Paint::solid(super::paint::new_color(s, p, c)))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
}
}
+2 -1
View File
@@ -142,7 +142,8 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "colorTheme.addToSwatches";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let t = &s.prefs.color_themes[find(s, name, C)?];
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>() });
// The theme's colors keep their model, in a CMYK document too.
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>(), "keepModel": true });
let r = s.execute("swatch.newGroup", &params)?;
Ok(json!({ "name": r["name"], "swatches": r["swatches"] }))
}
+5 -4
View File
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -573,8 +573,9 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraf
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
}
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
/// defaults, filled with the current fill unless that is None or white (black then).
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
/// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults,
/// filled with the current fill unless that is None or white (black then).
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
let mut style = CharStyle::default();
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
@@ -587,7 +588,7 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
style.font_style = f.to_string();
}
style.fill = match p.get("color").and_then(color_value) {
Some(c) => vectorcraft_color::Paint::solid(c),
Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)),
None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(),
None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
};
+18 -3
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
has_doc,
add_point
),
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Freeform Point",
[],
None,
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
has_doc,
set_point
),
@@ -134,6 +134,12 @@ fn point_fields(v: &Value, pt: &mut FreeformPoint, to_paint: Affine) -> std::res
Ok(())
}
/// The `color` of `p` as the document stores it: an RGB color in a CMYK document becomes CMYK
/// through the color settings ([`super::paint::new_color`]). `None` without a valid one.
fn given_color(s: &Session, p: &Value) -> Option<vectorcraft_color::Color> {
p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c))
}
/// Parse the `freeform` param of a gradient paint (`{points: [{at, color, opacity?, spread?}],
/// lines?: [[index, …]], mode?}`; points in the paint's space). Lossless for
/// `vectorcraft_tools::params::freeform_json`.
@@ -238,11 +244,15 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Bool(true)) => s.selected_freeform_point(),
Some(_) => return Err(bad(C, "`line` must be true or false")),
};
let given = given_color(s, p);
let index = edit(s, p, C, |f, sp| {
let q = sp.to_paint * at;
let (color, opacity) = f.sample(q, sp.scale);
let mut pt = FreeformPoint { opacity, ..FreeformPoint::new(q, color) };
point_fields(p, &mut pt, sp.to_paint)?;
if let Some(c) = given {
pt.color = c;
}
let i = f.add_point(pt);
if let Some(from) = join {
f.connect(from, i);
@@ -256,9 +266,14 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.setPoint";
let i = point_index(s, p, C)?;
let given = given_color(s, p);
edit(s, p, C, |f, sp| {
check_index(f, i)?;
point_fields(p, &mut f.points[i], sp.to_paint)
point_fields(p, &mut f.points[i], sp.to_paint)?;
if let Some(c) = given {
f.points[i].color = c;
}
Ok(())
})?;
Ok(json!({ "index": i }))
}
+48 -4
View File
@@ -20,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? (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?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} 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), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), 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?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
@@ -137,6 +137,47 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
Ok(v)
}
/// Stop colors to store, one per stop of `g` (params holding `stops`, before [`link_stops`]): the
/// color a stop gives and the color the document stores it as when it is new to the gradient, an
/// RGB color in a CMYK document becoming CMYK through the color settings
/// ([`super::paint::new_color`], with `p`'s `keepModel`). `None` for a stop that names a swatch,
/// which gives its color in the swatch's model, and for a color stored as given.
pub(crate) type StopColors = Vec<Option<(vectorcraft_color::Color, vectorcraft_color::Color)>>;
/// The [`StopColors`] of `g`'s stops.
pub(crate) fn stop_colors(s: &Session, p: &Value, g: &Value) -> StopColors {
let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let stored = |st: &Value| {
let c = st.get("color").and_then(color_value).filter(|_| str_param(st, "swatch").is_none())?;
let to = super::paint::new_color(s, p, c);
(to != c).then_some((c, to))
};
stops.iter().map(stored).collect()
}
/// `g` (params holding `stops`, resolved by [`link_stops`] in their order) for an edit of paint
/// `old`, with each stop's color replaced by the one `stored` ([`stop_colors`]) gives for it,
/// unless `old`'s stops already have that color. A freeform gradient's points are colored from the
/// stored colors, and a stop list sent back with only its positions changed keeps its colors.
pub(crate) fn with_stored_stops<'a>(g: &'a Value, old: &Paint, stored: &StopColors) -> std::borrow::Cow<'a, Value> {
use std::borrow::Cow;
if stored.iter().all(Option::is_none) {
return Cow::Borrowed(g);
}
let had = |c: &vectorcraft_color::Color| matches!(old, Paint::Gradient(o) if o.gradient.stops.iter().any(|st| st.color == *c));
let mut v = g.clone();
let Some(stops) = v.get_mut("stops").and_then(Value::as_array_mut) else { return Cow::Borrowed(g) };
for (st, sc) in stops.iter_mut().zip(stored) {
let Some((from, to)) = sc.filter(|(from, _)| !had(from)) else { continue };
if st.get("color").and_then(color_value) == Some(from)
&& let Some(o) = st.as_object_mut()
{
o.insert("color".into(), color_json(&to));
}
}
Cow::Owned(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; `swatch` and `tint` (%) keep a stop's link as given (resolve them first with
@@ -340,11 +381,13 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
};
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
let stored = stop_colors(s, p, 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))?;
let new_default = apply_gradient_edit(&default_paint, &with_stored_stops(p, &default_paint, &stored), None).map_err(|e| bad(C, e))?;
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
if index.is_none()
@@ -353,7 +396,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
let lb = t.local_bounds();
for r in &mut t.runs {
let (cur, b) = run_paint_mut(r, stroke, lb);
*cur = apply_gradient_edit(cur, p, Some(b)).map_err(|e| bad(C, e))?;
*cur = apply_gradient_edit(cur, &with_stored_stops(p, cur, &stored), Some(b)).map_err(|e| bad(C, e))?;
}
return Ok(());
}
@@ -364,7 +407,8 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None => &|_| true,
};
let np = apply_gradient_edit_in(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b, inside).map_err(|e| bad(C, e))?;
let old = n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None);
let np = apply_gradient_edit_in(old, &with_stored_stops(p, old, &stored), b, inside).map_err(|e| bad(C, e))?;
n.appearance.set_paint_at(index, !stroke, np);
if let Some(m) = mode
&& let Some(st) = n.appearance.stroke_at_mut(index)
+4 -4
View File
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Mesh Point Color",
[],
None,
"{id, index, color, opacity?: 0..1} colour of one mesh point",
"{id, index, color, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour of one mesh point",
has_doc,
mesh_set_color
),
@@ -274,7 +274,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Mesh Line",
[],
None,
"{id, x, y, color?} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}",
"{id, x, y, color?, keepModel?: bool (as paint.setFill)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}",
has_doc,
mesh_add_line
),
@@ -1522,7 +1522,7 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.setPointColor";
let index = index_param(p, "index", C)?;
let color = p.get("color").and_then(color_value);
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let opacity = p.get("opacity").and_then(Value::as_f64);
if color.is_none() && opacity.is_none() {
return Err(bad(C, "missing `color` or `opacity`"));
@@ -1559,7 +1559,7 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.addLine";
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let color = p.get("color").and_then(color_value);
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
if let Some(c) = color {
+12 -4
View File
@@ -318,8 +318,8 @@ pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
}
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]),
/// converted through the color settings.
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
/// model ([`new_color_model`]), converted through the color settings.
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
let model = new_color_model(s, p);
let new_color = |c: Color| stored_color(model, c);
@@ -342,9 +342,17 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
};
}
if let Some(g) = p.get("gradient") {
// A stop naming a process swatch keeps the swatch's model.
let stored = super::gradient::stop_colors(s, p, g);
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));
let mut gp = super::gradient::parse_gradient(&super::gradient::with_stored_stops(&g, &Paint::None, &stored)).map_err(|e| bad("paint", e))?;
if g.get("stops").is_none() {
// The default stops, white to black.
gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color));
}
if let Some(f) = &mut gp.freeform {
f.points.iter_mut().for_each(|pt| pt.color = new_color(pt.color));
}
return Ok(Some(Paint::Gradient(Box::new(gp))));
}
if let Some(c) = p.get("color") {
+41 -10
View File
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Color Group",
["Window", "Swatches"],
None,
"{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text, gradient stops and mesh points; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}",
"{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), keepModel?: bool (as paint.setFill), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text, gradient stops and mesh points; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}",
has_doc,
swatch_new_group
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Options",
["Window", "Swatches"],
None,
"{name, newName?, color? (e.g. {l, a, b} for a Lab colour), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour; gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
"{name, newName?, color? (e.g. {l, a, b} for a Lab colour), keepModel?: bool (as paint.setFill; the swatch's own color and a color given with mode keep their model), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour; gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
has_doc,
swatch_edit
),
@@ -127,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Color Group",
["Window", "Swatches"],
None,
"{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}",
"{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change; a color the swatch already has keeps its model), keepModel?: bool (as paint.setFill), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}",
has_doc,
swatch_edit_group
),
@@ -136,10 +136,15 @@ pub fn specs() -> Vec<CommandSpec> {
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.new";
let mode = str_param(p, "mode");
let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) {
(Some(cs), _) => {
let cs: Vec<Paint> =
cs.iter().map(|c| color_value(c).map(Paint::solid)).collect::<Option<_>>().ok_or_else(|| bad(C, "bad colour in `colors`"))?;
let stored = |c: Color| if mode.is_some() { c } else { super::paint::new_color(s, p, c) };
let cs: Vec<Paint> = cs
.iter()
.map(|c| color_value(c).map(|c| Paint::solid(stored(c))))
.collect::<Option<_>>()
.ok_or_else(|| bad(C, "bad colour in `colors`"))?;
if cs.is_empty() {
return Err(bad(C, "`colors` is empty"));
}
@@ -148,7 +153,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
(None, Some(name)) if s.doc()?.doc.pattern(name).is_some() => vec![pattern_paint(name)],
(None, Some(name)) => return Err(bad(C, format!("no pattern `{name}`"))),
(None, None) => {
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let paint = match mode {
Some(_) => paint_from(s, &keeping_model(p))?,
None => paint_from(s, p)?,
}
.unwrap_or_else(|| s.paint.fill.clone());
// `tint` alone: that tint of the global colour the current fill links to.
match (super::paint::tint_param(p, C)?, &paint) {
(Some(t), Paint::Solid { swatch: Some(l), .. }) if p.get("swatch").is_none() => {
@@ -161,7 +170,6 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
};
let spot = bool_or(p, "spot", false);
let global = spot || bool_or(p, "global", false);
let mode = str_param(p, "mode");
let group = str_param(p, "group");
let paints = paints
.into_iter()
@@ -294,7 +302,11 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.newGroup";
let mut names = str_list(p, "swatches");
let colors: Vec<Color> = p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default();
let colors: Vec<Color> = p
.get("colors")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(color_value).map(|c| super::paint::new_color(s, p, c)).collect())
.unwrap_or_default();
let from_art = bool_or(p, "fromArtwork", false);
let to_global = from_art && bool_or(p, "toGlobal", true);
let tints = bool_or(p, "includeTints", false);
@@ -448,6 +460,16 @@ fn convert_to_mode(c: Color, mode: &str, cmd: &str) -> Result<Color> {
})
}
/// Paint params `p` with `keepModel: true`, for a color given with a `mode`: [`convert_to_mode`]
/// converts it, and the document's color mode does not convert it first.
fn keeping_model(p: &Value) -> Value {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("keepModel".into(), Value::Bool(true));
}
q
}
/// How a swatch edit reaches the solid paints linked to swatch `from`.
struct Relink {
from: String,
@@ -518,7 +540,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
}
// A replacement paint of the swatch's kind (a dropped colour or edited gradient).
let paint = match p.get("paint").filter(|v| !v.is_null()) {
Some(v) => match (paint_from(s, v)?, &sw.paint) {
Some(v) => match (if given("mode") { paint_from(s, &keeping_model(v))? } else { paint_from(s, v)? }, &sw.paint) {
(Some(Paint::Solid { color, .. }), Paint::Solid { .. }) => Some(Paint::solid(color)),
(Some(Paint::Gradient(g)), Paint::Gradient(_)) => {
Some(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { geom: None, swatch: None, ..*g })))
@@ -529,7 +551,11 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let mut color = match p.get("color").filter(|v| !v.is_null()) {
Some(v) => Some(color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?),
Some(v) => {
let c = color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?;
// The swatch's own color, and a color given with a mode, keep their model.
Some(if given("mode") || solid == Some(c) { c } else { super::paint::new_color(s, p, c) })
}
None => paint.as_ref().and_then(Paint::color).or(solid),
};
if let (Some(c), Some(m)) = (color, str_param(p, "mode")) {
@@ -849,6 +875,11 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}"))))
.collect::<Result<_>>()?;
// A color its swatch already has keeps its model. The others take the document's.
let old: Vec<Option<Color>> =
s.doc()?.doc.swatch_groups.iter().find(|g| g.name == group).map(|g| g.swatches.iter().map(|w| w.paint.color()).collect()).unwrap_or_default();
let colors: Vec<Color> =
colors.into_iter().enumerate().map(|(i, c)| if old.get(i) == Some(&Some(c)) { c } else { super::paint::new_color(s, p, c) }).collect();
let rename = str_param(p, "rename");
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
e.defaults(s);
+13 -2
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
has_doc,
create_in_path
),
@@ -421,6 +421,16 @@ impl CharChange {
Ok(c)
}
/// The `fill` and `stroke` colors as the document stores them: an RGB color in a CMYK
/// document becomes CMYK through the color settings ([`super::paint::new_color`]).
fn store_colors(&mut self, s: &Session, p: &Value) {
for paint in [&mut self.fill, &mut self.stroke].into_iter().flatten() {
if let Paint::Solid { color, .. } = paint {
*color = super::paint::new_color(s, p, *color);
}
}
}
pub(crate) fn is_empty(&self) -> bool {
self.font.is_none()
&& self.style.is_none()
@@ -600,6 +610,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
},
};
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
change.store_colors(s, p);
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
+2 -2
View File
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
has_doc,
set_style
),
@@ -547,7 +547,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
let fill = match p.get("fill") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(vectorcraft_color::Paint::None),
Some(v) => Some(vectorcraft_color::Paint::solid(color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?)),
Some(v) => Some(vectorcraft_color::Paint::solid(super::paint::new_color(s, p, color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?))),
};
let features = super::textedit::features_param(p, C)?;
if font.is_none()
+14
View File
@@ -655,6 +655,20 @@ fn symbolism_adjustments() {
assert!(s.execute("symbol.adjust", &json!({"tool": "nope", "points": at})).is_err());
}
/// In a CMYK document the Symbol Stainer's RGB `color` separates through the color settings, as
/// `paint.setFill` stores it.
#[test]
fn symbol_stainer_rgb_color_in_a_cmyk_document_separates_through_the_color_settings() {
use vectorcraft_color::{Color, Paint, cms};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
let (inst, _, b) = make_symbol(&mut s);
let c = b.center();
s.execute("symbol.adjust", &json!({"tool": "stain", "points": [[c.x, c.y]], "color": "#ff0000", "intensity": 10})).unwrap();
let red = cms::active().convert(&Color::rgb(1.0, 0.0, 0.0), cms::Model::Cmyk, cms::active().settings().intent);
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
}
#[test]
fn symbol_sprayer_tool_gesture_is_one_undo_step() {
let mut s = session();
+5
View File
@@ -103,4 +103,9 @@ fn add_to_swatches_makes_a_colour_group_in_one_step() {
// Again: a second group with a free name.
assert_eq!(run(&mut s, "colorTheme.addToSwatches", json!({"name": "Tide"}))["name"], "Tide 2");
assert!(s.execute("colorTheme.addToSwatches", &json!({"name": "Nope"})).is_err());
// In a CMYK document the theme's colors keep their model.
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
run(&mut s, "colorTheme.addToSwatches", json!({"name": "Tide"}));
let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Tide").expect("the group");
assert_eq!(g.swatches.iter().filter_map(|w| w.paint.color()).collect::<Vec<_>>(), s.prefs.color_themes[0].colors);
}
+124
View File
@@ -250,6 +250,130 @@ fn rgb_colors_given_to_a_cmyk_document_separate_through_the_color_settings() {
assert_eq!(node(&s, a).appearance.fill_paint().color(), Some(blue));
}
/// A new CMYK document.
fn cmyk_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600, "colorMode": "cmyk"})).unwrap();
s
}
/// RGB colors given to the type commands in a CMYK document separate through the color settings,
/// as `paint.setFill` stores them: `text.create {color}`, `text.setStyle {fill}` and
/// `text.setRangeStyle {fill, stroke}`. CMYK colors are stored as given.
#[test]
fn rgb_type_colors_given_to_a_cmyk_document_separate_through_the_color_settings() {
let mut s = cmyk_session();
let blue = managed_cmyk(Color::rgb8(83, 170, 223));
let t = id_of(s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hello", "color": "#53aadf"})).unwrap());
let runs = |s: &Session| match node(s, t).kind {
NodeKind::Text(o) => o.runs.iter().map(|r| (r.style.fill.color(), r.style.stroke.color())).collect::<Vec<_>>(),
k => panic!("not type: {k:?}"),
};
assert_eq!(runs(&s)[0].0, Some(blue));
s.execute("text.setStyle", &json!({"ids": [t.0], "fill": "#ff0000"})).unwrap();
assert_eq!(runs(&s)[0].0, Some(managed_cmyk(Color::rgb8(255, 0, 0))));
s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 0, "end": 2, "fill": "#53aadf", "stroke": "#53aadf"})).unwrap();
assert_eq!(runs(&s)[0], (Some(blue), Some(blue)));
s.execute("text.setStyle", &json!({"ids": [t.0], "fill": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap();
assert!(runs(&s).iter().all(|r| r.0 == Some(Color::cmyk(0.1, 0.2, 0.3, 0.4))), "{:?}", runs(&s));
s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 0, "end": 2, "stroke": "#53aadf", "keepModel": true})).unwrap();
assert_eq!(runs(&s)[0].1, Some(Color::rgb8(83, 170, 223)), "keepModel keeps RGB");
}
/// `shapeBuilder.merge {fill: "#rrggbb"}` in a CMYK document fills the merged shape as
/// `fill: {color}` does.
#[test]
fn a_shape_builder_hex_fill_in_a_cmyk_document_separates_through_the_color_settings() {
let mut s = cmyk_session();
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0));
let r = s.execute("shapeBuilder.merge", &json!({"ids": [a.0, b.0], "points": [[20, 30], [80, 30]], "fill": "#53aadf"})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
assert_eq!(node(&s, merged).appearance.fill_paint().color(), Some(managed_cmyk(Color::rgb8(83, 170, 223))));
// The paint form takes keepModel.
let (a, b) = (rect(&mut s, 200.0), rect(&mut s, 230.0));
let fill = json!({"color": "#53aadf", "keepModel": true});
let r = s.execute("shapeBuilder.merge", &json!({"ids": [a.0, b.0], "points": [[210, 30], [270, 30]], "fill": fill})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
assert_eq!(node(&s, merged).appearance.fill_paint().color(), Some(Color::rgb8(83, 170, 223)));
}
/// RGB colors given to gradient stops, freeform gradient points and gradient mesh points in a CMYK
/// document separate through the color settings: `paint.editGradient {stops}`, a freeform
/// `gradient` given to `paint.setFill`, `paint.freeform.addPoint` and `setPoint`, and
/// `object.mesh.setPointColor` and `addLine`. The Mesh tool adds a line in the fill's own model.
#[test]
fn rgb_gradient_and_mesh_colors_given_to_a_cmyk_document_separate_through_the_color_settings() {
let mut s = cmyk_session();
let (blue, red) = (managed_cmyk(Color::rgb8(83, 170, 223)), managed_cmyk(Color::rgb8(255, 0, 0)));
let a = rect(&mut s, 10.0);
let gradient = |s: &Session| match node(s, a).appearance.fill_paint() {
Paint::Gradient(g) => *g,
p => panic!("not a gradient: {p:?}"),
};
let stops = |s: &Session| gradient(s).gradient.stops.iter().map(|st| st.color).collect::<Vec<_>>();
s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": {"gray": 0}}, {"offset": 1, "color": "#53aadf"}]})).unwrap();
assert_eq!(stops(&s), vec![Color::gray(0.0), blue]);
// keepModel keeps RGB, and stops sent back with only their offsets changed keep their colors.
let rgb = [Color::rgb8(255, 0, 0), Color::rgb8(83, 170, 223)];
s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#53aadf"}], "keepModel": true}))
.unwrap();
assert_eq!(stops(&s), rgb);
s.execute("paint.editGradient", &json!({"stops": [{"offset": 0.2, "color": "#ff0000"}, {"offset": 1, "color": "#53aadf"}]})).unwrap();
assert_eq!(stops(&s), rgb);
// A stop naming an RGB process swatch takes the swatch's color as it is.
let orange = Color::rgb8(255, 128, 0);
s.execute("swatch.new", &json!({"name": "Screen", "color": "#ff8000", "keepModel": true})).unwrap();
let named = json!([{"offset": 0, "swatch": "Screen"}, {"offset": 1, "color": "#53aadf"}]);
s.execute("paint.editGradient", &json!({"stops": named})).unwrap();
assert_eq!(stops(&s), [orange, rgb[1]], "the RGB blue the gradient has stays RGB");
s.execute("paint.setFill", &json!({"gradient": {"stops": named}})).unwrap();
assert_eq!(stops(&s), [orange, blue]);
let magenta = Color::cmyk(0.0, 1.0, 0.0, 0.0);
let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": {"c": 0, "m": 100, "y": 0, "k": 0}}]);
s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
let colors = |s: &Session| gradient(s).freeform.map(|f| f.points.iter().map(|p| p.color).collect::<Vec<_>>()).unwrap_or_default();
assert_eq!(colors(&s), vec![blue, magenta]);
let i = s.execute("paint.freeform.addPoint", &json!({"at": [40, 20], "color": "#ff0000"})).unwrap()["index"].as_u64().unwrap() as usize;
assert_eq!(colors(&s)[i], red);
s.execute("paint.freeform.setPoint", &json!({"index": 1, "color": "#53aadf"})).unwrap();
assert_eq!(colors(&s)[1], blue);
// New stops recolor the three points from their CMYK values.
s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]})).unwrap();
let stored = vectorcraft_color::Gradient {
kind: Default::default(),
stops: vec![vectorcraft_color::GradientStop::new(0.0, managed_cmyk(Color::WHITE)), vectorcraft_color::GradientStop::new(1.0, red)],
};
assert_eq!(colors(&s), [0.0, 0.5, 1.0].map(|t| stored.sample(t).0), "{:?}", colors(&s));
let m = rect(&mut s, 300.0);
s.execute("object.mesh.create", &json!({"ids": [m.0], "rows": 1, "cols": 1})).unwrap();
let mesh = |s: &Session| match node(s, m).kind {
NodeKind::Mesh(g) => g.points.iter().map(|p| p.color).collect::<Vec<_>>(),
k => panic!("not a mesh: {k:?}"),
};
s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 1, "color": "#53aadf", "keepModel": true})).unwrap();
assert_eq!(mesh(&s)[1], Color::rgb8(83, 170, 223), "keepModel keeps RGB");
s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 0, "color": "#53aadf"})).unwrap();
assert_eq!(mesh(&s)[0], blue);
let i = s.execute("object.mesh.addLine", &json!({"id": m.0, "x": 320, "y": 30, "color": "#ff0000"})).unwrap()["index"].as_u64().unwrap() as usize;
assert_eq!(mesh(&s)[i], red);
let cyan = Color::cmyk(0.5, 0.2, 0.0, 0.0);
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 50, "m": 20, "y": 0, "k": 0}})).unwrap();
let before = mesh(&s).len();
s.select_tool("mesh", ViewInfo::default()).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 335.0, 45.0), ViewInfo::default()).unwrap();
let after = mesh(&s);
assert!(after.len() > before, "the click adds a line");
assert!(after.contains(&cyan), "{after:?}");
// An RGB fill kept with keepModel stays RGB on the new point.
let orange = Color::rgb8(255, 128, 0);
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff8000", "keepModel": true})).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 20.0), ViewInfo::default()).unwrap();
assert!(mesh(&s).contains(&orange), "{:?}", mesh(&s));
}
#[test]
fn the_options_are_a_preference() {
let mut s = Session::new();
+39
View File
@@ -228,6 +228,45 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() {
assert_eq!(serde_json::from_str::<vectorcraft_doc::Document>(&json).unwrap(), *doc(&s));
}
/// The other swatch commands store RGB colors in a CMYK document as `swatch.new {color}` does:
/// `swatch.new {colors}`, `swatch.newGroup {colors}`, `swatch.editGroup {colors}` and
/// `swatch.edit {color}`. A color a swatch already has, and a color given with `mode`, keep their
/// model.
#[test]
fn a_cmyk_document_stores_rgb_colors_given_to_the_swatch_commands_as_cmyk() {
use vectorcraft_color::Color;
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
let cms = vectorcraft_color::cms::active();
let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent);
let (red, green) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 1.0, 0.0));
let color = |s: &Session, n: &Value| doc(s).swatch(n.as_str().unwrap()).and_then(|w| w.paint.color());
let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", {"c": 0, "m": 0, "y": 100, "k": 0}]}));
assert_eq!((color(&s, &r["names"][0]), color(&s, &r["names"][1])), (Some(managed(red)), Some(Color::cmyk(0.0, 0.0, 1.0, 0.0))));
let r = run(&mut s, "swatch.newGroup", json!({"name": "G", "colors": ["#00ff00"]}));
assert_eq!(color(&s, &r["swatches"][0]), Some(managed(green)));
let kept = run(&mut s, "swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "keepModel": true}));
assert_eq!(color(&s, &kept["swatches"][0]), Some(green), "keepModel keeps RGB");
let r = run(&mut s, "swatch.editGroup", json!({"group": "G", "colors": ["#ff0000", "#00ff00"]}));
assert_eq!((color(&s, &r["swatches"][0]), color(&s, &r["swatches"][1])), (Some(managed(red)), Some(managed(green))));
let name = run(&mut s, "swatch.new", json!({"color": "#000000", "keepModel": true}))["name"].clone();
assert_eq!(color(&s, &name), Some(Color::BLACK));
run(&mut s, "swatch.edit", json!({"name": name, "color": "#ff0000"}));
assert_eq!(color(&s, &name), Some(managed(red)));
// An RGB swatch sent back unchanged stays RGB.
let rgb = run(&mut s, "swatch.new", json!({"color": "#00ff00", "keepModel": true}))["name"].clone();
run(&mut s, "swatch.edit", json!({"name": rgb, "color": "#00ff00", "global": true}));
assert_eq!(color(&s, &rgb), Some(green));
run(&mut s, "swatch.newGroup", json!({"name": "H", "swatches": [rgb]}));
let r = run(&mut s, "swatch.editGroup", json!({"group": "H", "colors": ["#00ff00", "#ff0000"]}));
assert_eq!((color(&s, &r["swatches"][0]), color(&s, &r["swatches"][1])), (Some(green), Some(managed(red))));
// With `mode`, the color is converted once, into that mode.
let r = run(&mut s, "swatch.new", json!({"color": "#00ff00", "mode": "rgb"}));
assert_eq!(color(&s, &r["name"]), Some(green));
let r = run(&mut s, "swatch.new", json!({"colors": ["#00ff00"], "mode": "rgb"}));
assert_eq!(color(&s, &r["names"][0]), Some(green));
}
#[test]
fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() {
use vectorcraft_color::Color;
+7 -6
View File
@@ -6,10 +6,10 @@
//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over.
//! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the
//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point →
//! `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the next
//! solid fill colour recolours just that point.
//! new point takes the current fill color in its own model, unless Shift); drag a mesh point,
//! or a handle of the point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]);
//! Alt-click a mesh point → `object.mesh.deletePoint`. Clicking a gradient mesh's point selects
//! the mesh, and the next solid fill color recolors just that point.
use serde_json::json;
use vectorcraft_color::Paint;
@@ -196,7 +196,7 @@ impl Tool for MeshTool {
self.edit.unfocus();
let Some((top, leaf)) = hit_top(cx, p) else { return vec![] };
let color = match (&cx.paint.fill, ev.mods.shift) {
(Paint::Solid { color, .. }, false) => Some(color.to_hex()),
(Paint::Solid { color, .. }, false) => Some(crate::params::color_json(color)),
_ => None,
};
for id in [leaf, top] {
@@ -204,7 +204,8 @@ impl Tool for MeshTool {
Some(NodeKind::Mesh(_)) => {
let mut params = json!({"id": id.0, "x": p.x, "y": p.y});
if let Some(c) = &color {
params["color"] = json!(c);
params["color"] = c.clone();
params["keepModel"] = json!(true);
}
return vec![Action::Exec("object.mesh.addLine".into(), params)];
}
+5
View File
@@ -94,6 +94,7 @@ impl SymbolismTool {
&& let Some(c) = cx.paint.fill.color()
{
v["color"] = serde_json::to_value(c).unwrap_or(Value::Null);
v["keepModel"] = json!(true);
}
if let Some(s) = &self.style {
v["style"] = json!(s);
@@ -301,6 +302,10 @@ mod tests {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 5.0, 5.0).with_mods(Mods { alt: true, ..Default::default() }));
assert!(matches!(&a[1], Action::Preview(c, v) if c == "symbol.adjust" && v["tool"] == tool && v["alt"] == true), "{a:?}");
}
// The Symbol Stainer sends the fill color in its own model.
let fill = serde_json::to_value(p.fill.color().unwrap()).unwrap();
let a = create("symbolStainer").unwrap().pointer(&cx, &PointerEvent::new(PointerKind::Down, 5.0, 5.0));
assert!(matches!(&a[1], Action::Preview(_, v) if v["color"] == fill && v["keepModel"] == true), "{a:?}");
}
#[test]
+4 -1
View File
@@ -298,8 +298,10 @@ impl GradientTool {
fn geom_params(cx: &ToolContext, start: Point, end: Point) -> Value {
json!({ "start": [start.x, start.y], "end": [end.x, end.y], "stroke": !cx.fill_active })
}
/// The stops the tool sends carry the gradient's own colors or samples of it, so they keep
/// their models (`keepModel`), in a CMYK document too.
fn stops_params(cx: &ToolContext, stops: &[GradientStop]) -> Value {
json!({ "stops": crate::params::stops_json(stops), "stroke": !cx.fill_active })
json!({ "stops": crate::params::stops_json(stops), "stroke": !cx.fill_active, "keepModel": true })
}
fn select_stop(i: usize) -> Action {
Action::Exec("gradient.selectStop".into(), json!({ "index": i }))
@@ -763,6 +765,7 @@ mod tests {
let a = t.pointer(&cx, &ev(PointerKind::Up, Point::new(130.0, 150.0)));
let (c, v) = exec(&a[0]);
assert_eq!((c, offsets(v)), ("paint.editGradient", vec![0.0, 30.0, 100.0]));
assert_eq!(v["keepModel"], json!(true), "the sampled color keeps its model");
assert_eq!(a[1], GradientTool::select_stop(1));
}
+4 -3
View File
@@ -206,12 +206,13 @@ fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, target: &Target) {
});
let Some((name, c)) = pick else { return };
match target {
// A global or spot colour (or a tint of one) stays linked.
// A global or spot color (or a tint of one) stays linked, and the swatch's color keeps
// its model.
Target::Stop(stops, i) => {
let link = crate::panels::gradient::dropped_link(app, &json!({ "swatch": name }));
set_stops(app, &recolor(stops.clone(), *i, c, link), Some(*i), Live::Released);
crate::panels::gradient::set_stops_keeping_models(app, &recolor(stops.clone(), *i, c, link), Some(*i), Live::Released);
}
Target::Point(_, i) => edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": color_json(&c) })),
Target::Point(_, i) => edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": color_json(&c), "keepModel": true })),
}
}
+13 -1
View File
@@ -255,7 +255,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
sync(app, &mut d);
if d.bool("__newGroup") {
let colors: Vec<Value> = rows(&d).into_iter().filter(|r| !excluded(r)).map(|r| r["to"].clone()).collect();
return form::commit_preview(app, "swatch.newGroup", json!({"name": d.str("groupName"), "colors": colors}));
return form::commit_preview(app, "swatch.newGroup", json!({"name": d.str("groupName"), "colors": colors, "keepModel": true}));
}
form::commit_preview(app, CMD, apply_params(&d))
}
@@ -668,4 +668,16 @@ mod tests {
apply_rule(&mut r, Harmony::Triad, 0, Color::rgb(1.0, 0.0, 0.0), &[0, 1, 2]);
assert_eq!([&r[0]["to"], &r[1]["to"], &r[2]["to"]], ["rgb 255 0 0", "rgb 0 255 0", "rgb 0 0 255"]);
}
/// In a CMYK document colors opened alone are saved as a new color group in their own model.
#[test]
fn a_new_group_from_colors_keeps_their_model_in_a_cmyk_document() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"})).unwrap();
app.run("ui.recolorDialog", json!({"colors": ["#ff8000"]})).unwrap();
let d = app.ui.dialog.clone().expect("Recolor Artwork opens");
confirm(&mut app, &d).unwrap();
let g = app.session.doc().unwrap().doc.swatch_groups.last().expect("a new color group");
assert_eq!(g.swatches.iter().filter_map(|w| w.paint.color()).collect::<Vec<_>>(), [Color::rgb8(255, 128, 0)]);
}
}
+25 -2
View File
@@ -416,10 +416,10 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
if let Some(s) = stops.get_mut(*index) {
s.set_color(c, None);
}
super::gradient::set_stops(app, &stops, None, phase);
super::gradient::set_stops_keeping_models(app, &stops, None, phase);
}
Target::Point { index, .. } if !behind => {
let p = json!({"index": index, "color": color_json(&c), "stroke": !app.session.fill_active});
let p = json!({"index": index, "color": color_json(&c), "stroke": !app.session.fill_active, "keepModel": true});
live_run(app, "Color", "paint.freeform.setPoint", p, phase);
}
_ => {
@@ -816,6 +816,29 @@ mod tests {
assert_eq!(hex, ["#ffffff", "#ff0000"]);
}
/// In a CMYK document the sliders keep the model picked in the panel for a gradient stop and a
/// freeform point, as they do for a solid fill.
#[test]
fn a_gradient_stop_and_a_freeform_point_keep_the_panels_model_in_a_cmyk_document() {
let mut app = app();
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let stops = json!([{"offset": 0, "color": {"c": 0, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 100, "y": 100, "k": 0}}]);
app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap();
app.select_tool("gradient");
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
let orange = Color::rgb8(255, 128, 0);
let tgt = target_with(&app, false);
apply(&mut app, &tgt, orange, Live::Released, false);
let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") };
assert_eq!(g.gradient.stops[1].color, orange);
app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap();
let tgt = target_with(&app, false);
assert!(matches!(tgt, Target::Point { index: 0, .. }), "a freeform point is the target");
apply(&mut app, &tgt, orange, Live::Released, false);
let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") };
assert_eq!(g.freeform.map(|f| f.points[0].color), Some(orange));
}
fn paints_hex(app: &VectorcraftApp) -> (String, String) {
let (f, s) = crate::panels::current_paints(app);
(f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default())
+20 -3
View File
@@ -120,12 +120,12 @@ fn edit_or_apply(app: &mut VectorcraftApp, g: &Guide) {
app.run("ui.recolorDialog", p).ok();
}
/// Save the selected cells' colours as swatches (one undo step).
/// Save the selected cells' colors as swatches (one undo step), each in its own model.
fn save_selected(app: &mut VectorcraftApp, ctx: &egui::Context) {
let g = guide(app, ctx);
let colors: Vec<Value> = selected(ctx).into_iter().filter_map(|(r, c)| g.grid.get(r)?.get(c)).map(color_json).collect();
if !colors.is_empty() {
app.run("swatch.new", json!({ "colors": colors })).ok();
app.run("swatch.new", json!({ "colors": colors, "keepModel": true })).ok();
}
}
@@ -232,7 +232,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
if widgets::icon_button(ui, "dc-folder", tl!("Save color group to Swatch panel"), false, 24.0).clicked() {
let cs: Vec<_> = g.colors.iter().map(color_json).collect();
app.run("swatch.newGroup", json!({"name": h.label(), "colors": cs})).ok();
app.run("swatch.newGroup", json!({"name": h.label(), "colors": cs, "keepModel": true})).ok();
}
});
if let Some(c) = chosen {
@@ -328,6 +328,23 @@ mod tests {
assert_eq!(st.doc.swatches.last().unwrap().paint.color().map(|c| c.to_hex()), Some(g.grid[0][8].to_hex()));
}
/// In a CMYK document the cells of an RGB guide are saved in RGB, their own model.
#[test]
fn saved_cells_keep_their_model_in_a_cmyk_document() {
let mut app = app();
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
app.run("paint.setFill", json!({"color": "#3366cc", "keepModel": true})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx);
set_pstate(&ctx, "cg-sel", vec![(0usize, 0usize), (0, 6)]);
save_selected(&mut app, &ctx);
let g = guide(&app, &ctx);
assert!(matches!(g.grid[0][6], Color::Rgb { .. }), "{:?}", g.grid[0][6]);
let sw = &app.session.doc().unwrap().doc.swatches;
let saved: Vec<Color> = sw[sw.len() - 2..].iter().filter_map(|w| w.paint.color()).collect();
assert_eq!(saved, [g.grid[0][0], g.grid[0][6]]);
}
/// Every colour of the panel's guide.
fn shown(app: &VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
let g = guide(app, ctx);
+19 -1
View File
@@ -123,7 +123,7 @@ fn edit(ctx: &egui::Context, t: &ColorTheme) {
fn add_draft_to_swatches(app: &mut VectorcraftApp, ctx: &egui::Context) {
let d = draft(app, ctx);
let name = if d.name.trim().is_empty() { "Color Theme" } else { d.name.trim() };
app.run("swatch.newGroup", json!({ "name": name, "colors": colors_json(&d.colors) })).ok();
app.run("swatch.newGroup", json!({ "name": name, "colors": colors_json(&d.colors), "keepModel": true })).ok();
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -453,4 +453,22 @@ mod tests {
assert_eq!(picked(&app, &ctx), None);
frame(&mut app, &ctx);
}
/// In a CMYK document the theme being made goes to Swatches with its colors in their own model.
#[test]
fn the_theme_being_made_keeps_its_model_in_a_cmyk_document() {
let mut app = app();
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx);
app.run("colorTheme.save", json!({"color": "#2266aa", "rule": "complementary", "name": "Harbor"})).unwrap();
frame(&mut app, &ctx);
edit_saved(&app, &ctx, "Harbor");
let colors = draft(&app, &ctx).colors;
assert!(colors.iter().all(|c| matches!(c, Color::Rgb { .. })), "{colors:?}");
add_draft_to_swatches(&mut app, &ctx);
let g = app.session.doc().unwrap().doc.swatch_groups.last().expect("the group");
assert_eq!(g.swatches.iter().filter_map(|w| w.paint.color()).collect::<Vec<_>>(), colors);
}
}
+62 -6
View File
@@ -84,18 +84,19 @@ pub(crate) fn dropped_link(app: &VectorcraftApp, params: &Value) -> Option<(Stri
/// Recolor the selected stop of the gradient behind the active proxy with `c`, as dropping the
/// paint of `params` on the stop does (fully opaque, linked by [`dropped_link`]), or the selected
/// point of a freeform gradient; the stop or point stays selected. Returns false, and changes
/// nothing, when the active paint is not a gradient or no stop or point is selected.
/// point of a freeform gradient; the stop or point stays selected and keeps the color's model.
/// Returns false, and changes nothing, when the active paint is not a gradient or no stop or point
/// is selected.
pub(crate) fn recolor_selected(app: &mut VectorcraftApp, c: Color, params: &Value) -> bool {
let Paint::Gradient(g) = active_paint(app) else { return false };
if g.gradient.kind == GradientKind::Freeform {
let Some(i) = app.session.selected_freeform_point().filter(|i| *i < shown_points(&g).points.len()) else { return false };
edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": super::color_json(&c) }));
edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": super::color_json(&c), "keepModel": true }));
return true;
}
let Some(i) = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len()) else { return false };
let (v, i) = drop_color(&g.gradient, Some(i), 0.0, c, dropped_link(app, params));
set_stops(app, &v, Some(i), Live::Released);
set_stops_keeping_models(app, &v, Some(i), Live::Released);
true
}
@@ -167,7 +168,19 @@ fn edit(app: &mut VectorcraftApp, params: Value, phase: Live) {
/// Write `stops` to the gradient behind the active proxy (live while dragging) and, once
/// released, select stop `select` (the panel, the stop popover and the Color panel share this).
pub(crate) fn set_stops(app: &mut VectorcraftApp, stops: &[GradientStop], select: Option<usize>, phase: Live) {
edit(app, json!({ "stops": stops_json(stops) }), phase);
write_stops(app, json!({ "stops": stops_json(stops) }), select, phase);
}
/// [`set_stops`] with `keepModel`, for colors copied from existing paint (a swatch, a dropped
/// color, a sample of the gradient) and for the Color panel's sliders, which work in the model
/// picked there: the stops keep the color models given, in a CMYK document too.
pub(crate) fn set_stops_keeping_models(app: &mut VectorcraftApp, stops: &[GradientStop], select: Option<usize>, phase: Live) {
write_stops(app, json!({ "stops": stops_json(stops), "keepModel": true }), select, phase);
}
/// Edit the stops with `params` (live while dragging) and, once released, select stop `select`.
fn write_stops(app: &mut VectorcraftApp, params: Value, select: Option<usize>, phase: Live) {
edit(app, params, phase);
if phase == Live::Released
&& let Some(i) = select
&& app.session.selected_stop() != Some(i)
@@ -696,7 +709,9 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
set_pstate(ui.ctx(), MID_SELECTED, None::<(usize, Option<NodeId>)>);
}
match changed {
Some((v, phase)) => set_stops(app, &v, select, phase),
// Stops added, moved or dropped on the ramp carry colors the gradient or a swatch has, or
// sample the gradient: they keep their models.
Some((v, phase)) => set_stops_keeping_models(app, &v, select, phase),
None => {
if let Some(i) = select.filter(|i| sel != Some(*i)) {
select_stop(app, Some(i));
@@ -756,6 +771,26 @@ mod tests {
assert_eq!((v.len(), i, v[1].color, v[1].offset, v[1].opacity), (3, 1, red, 0.3, 1.0));
}
/// In a CMYK document an RGB swatch's color applied to the selected stop or freeform point
/// keeps its model, as the swatch applied to a fill does.
#[test]
fn a_swatch_color_on_a_stop_or_a_point_keeps_its_model_in_a_cmyk_document() {
let stops = json!([{"offset": 0, "color": {"c": 0, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 1}}]);
let mut app = app(json!({"gradient": {"stops": stops}}));
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let orange = Color::rgb8(255, 128, 0);
app.run("swatch.new", json!({"name": "Screen", "color": "#ff8000", "keepModel": true})).unwrap();
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
assert!(recolor_selected(&mut app, orange, &json!({"swatch": "Screen"})));
let Paint::Gradient(g) = active_paint(&app) else { panic!("a gradient fill") };
assert_eq!(g.gradient.stops[1].color, orange);
app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap();
app.run("paint.freeform.selectPoint", json!({"index": 1})).unwrap();
assert!(recolor_selected(&mut app, orange, &json!({"swatch": "Screen"})));
let Paint::Gradient(g) = active_paint(&app) else { panic!("a gradient fill") };
assert_eq!(g.freeform.map(|f| f.points[1].color), Some(orange));
}
#[test]
fn dragged_stops_move_copy_or_swap() {
let g = Gradient::default();
@@ -1022,6 +1057,27 @@ mod tests {
assert_eq!(hexes(&app)[0], "#0066cc");
}
/// In a CMYK document a color dropped on the ramp of an RGB gradient keeps its model, as the
/// gradient's other stops do.
#[test]
fn a_color_dropped_on_the_ramp_keeps_its_model_in_a_cmyk_document() {
let mut app = app(json!({"gradient": {}}));
app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap();
let rgb = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
app.run("paint.setFill", json!({"gradient": {"stops": rgb}, "keepModel": true})).unwrap();
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
let at = pos2((a.x + b.x) / 2.0, a.y - 20.0);
let red = Color::rgb(1.0, 0.0, 0.0);
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(red)));
p.frame(&mut app, vec![Event::PointerMoved(at)], Modifiers::NONE);
let up = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE };
p.frame(&mut app, vec![up], Modifiers::NONE);
let stops: Vec<Color> = fill(&app).gradient.stops.iter().map(|s| s.color).collect();
assert_eq!(stops, [Color::WHITE, red, Color::BLACK]);
}
#[test]
fn double_clicking_a_stop_opens_the_shared_popover_beside_it() {
let mut app = app(json!({"gradient": {}}));
+10 -3
View File
@@ -426,10 +426,17 @@ The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`
names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and
`swatch.sortByKind`.
Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with CMYK default swatches, and RGB
colours applied to it (`paint.setFill`/`setStroke` colours and gradient stops, `paint.sampleColor`,
`swatch.new {color}`) are stored as CMYK, converted through the color settings (`edit.colorSettings`: the CMYK
colors applied to it (`paint.setFill`/`setStroke` colors, gradient stops and freeform points, `paint.editGradient`
stops, `paint.freeform.addPoint`/`setPoint`, `paint.sampleColor`, `object.mesh.setPointColor`/`addLine`, the
`text.create` and `text.createInPath` color, the `text.setStyle` fill, `text.setRangeStyle` fills and strokes, a
`shapeBuilder.merge` hex fill (its `fill: {color, keepModel: true}` form keeps RGB), the
`symbol.adjust` stain color, `swatch.new {color}` and `{colors}`, `swatch.newGroup`, `swatch.editGroup` and
`swatch.edit`) are stored as CMYK, converted through the color settings (`edit.colorSettings`: the CMYK
working space and intent, as Document Color Mode converts them);
Gray stays Gray, and `keepModel: true` keeps a colour as given. RGB documents keep colours as given. Harmonies, Edit
Gray stays Gray, and `keepModel: true` keeps a color as given. A color that `swatch.edit` or `swatch.editGroup` is
given and the swatch already has keeps its model, as does a stop color that `paint.editGradient` is given and the
gradient already has; a stop that names a swatch takes the swatch's color as it is, and `swatch.new` or `swatch.edit`
with `mode` converts a color into that mode only. RGB documents keep colors as given. Harmonies, Edit
Colors blends, inversions and Recolor Artwork keep each colour's model (a blend between models takes the document's).
`swatch.new {colors: [...]}` saves several colours as swatches in one undo step. `color.harmony {color, rule, steps?, variation?, amount?}` answers what the Color
Guide panel shows: the harmony rule's colours, base first, and per colour its row of `2·steps+1` variations
+1
View File
@@ -652,6 +652,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value.
- **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement.
- **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13.
- **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C59 M12 Y3 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only.
- **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there.
- **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/stroke_visibility.rs`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.