Merge pull request #7473 from dotCooCoo/fix/map-links-stop-plates

Swatches: a gradient stop unlinked from its spot swatch also drops its plates
This commit is contained in:
BFlat
2026-11-14 21:50:25 +07:00
committed by GitHub
5 changed files with 96 additions and 26 deletions
+37 -4
View File
@@ -435,9 +435,10 @@ impl Paint {
}
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
/// left without a link gets tint 1. A freeform gradient's points, which it draws from, go
/// through `f` too, as unlinked colours (a link `f` gives them is dropped). Returns whether
/// anything changed.
/// left without a link gets tint 1, and a stop left without one also loses its process plates
/// ([`GradientStop::plates`], which print with a linked spot tint). A freeform gradient's
/// points, which it draws from, go through `f` too, as unlinked colours (a link `f` gives them
/// is dropped). Returns whether anything changed.
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let changed = f(c, link, tint);
@@ -449,7 +450,11 @@ impl Paint {
match self {
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
Paint::Gradient(g) => {
let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch);
let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| {
let changed = one(&mut s.color, &mut s.swatch, &mut s.tint);
let unplated = s.swatch.is_none() && s.plates.take().is_some();
changed | unplated | ch
});
let points =
g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()).fold(false, |ch, p| one(&mut p.color, &mut None, &mut 1.0) | ch);
stops | points
@@ -593,6 +598,34 @@ mod tests {
assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5));
}
#[test]
fn mapping_links_drops_the_process_plates_of_unlinked_stops() {
let mut gradient = Gradient::default();
let ink = Color::cmyk(0.0, 0.4, 0.0, 0.0);
gradient.stops[0].swatch = Some("Ink".into());
(gradient.stops[0].color, gradient.stops[0].tint, gradient.stops[0].plates) = (ink, 0.4, Some([0.3, 0.0, 0.0, 0.0]));
let mut g = Paint::Gradient(Box::new(GradientPaint::new(gradient)));
// Unlinking the stop takes its plates with its tint and keeps the colour it shows.
assert!(g.map_links(&mut |_, l, _| l.take().is_some()));
let Paint::Gradient(gp) = &g else { panic!("a gradient") };
let st = &gp.gradient.stops[0];
assert_eq!((st.color, st.swatch.as_deref(), st.tint, st.plates), (ink, None, 1.0, None));
// A linked stop keeps its plates.
let mut linked = Gradient::default();
(linked.stops[0].swatch, linked.stops[0].plates) = (Some("Ink".into()), Some([0.3, 0.0, 0.0, 0.0]));
let mut l = Paint::Gradient(Box::new(GradientPaint::new(linked)));
assert!(!l.map_links(&mut |_, _, _| false));
let Paint::Gradient(lp) = &l else { panic!("a gradient") };
assert_eq!(lp.gradient.stops[0].plates, Some([0.3, 0.0, 0.0, 0.0]));
// An unlinked stop that still has plates loses them, and that counts as a change.
let mut stale = Gradient::default();
stale.stops[1].plates = Some([0.0, 0.0, 0.2, 0.0]);
let mut p = Paint::Gradient(Box::new(GradientPaint::new(stale)));
assert!(p.map_links(&mut |_, _, _| false));
let Paint::Gradient(pp) = &p else { panic!("a gradient") };
assert!(pp.gradient.stops.iter().all(|s| s.plates.is_none()));
}
#[test]
fn lab_colours_serialize_with_their_tag_and_keep_their_model() {
let c = Color::lab(52.5, 70.0, -30.0);
+8 -22
View File
@@ -470,8 +470,8 @@ impl Library {
}
}
/// Make `g` a library gradient: no placement, freeform points or swatch links (a linked stop keeps
/// the color it shows), the angle its placement showed (a finite one), and stops in order with
/// Make `g` a library gradient: no placement, freeform points, swatch links or process plates (a
/// linked stop keeps the color it shows), the angle its placement showed (a finite one), and stops in order with
/// offsets, opacities and midpoints in range. False when it has fewer than 2 or more than
/// [`MAX_STOPS`] stops.
fn clean_gradient(g: &mut GradientPaint) -> bool {
@@ -490,8 +490,8 @@ fn clean_gradient(g: &mut GradientPaint) -> bool {
st.offset = within(st.offset, 0.0, 1.0, 0.0);
st.opacity = within(st.opacity, 0.0, 1.0, 1.0);
st.midpoint = within(st.midpoint, 0.13, 0.87, 0.5);
st.swatch = None;
st.tint = 1.0;
let color = st.color;
st.set_color(color, None);
}
g.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
true
@@ -1488,29 +1488,15 @@ fn add_to_char_styles(s: &mut Session, p: &Value) -> Result<Value> {
/// Unlink a library character style's `fill` and `stroke` colors (solid, and each gradient stop)
/// from swatches that are not global or spot swatches of `doc`: an unlinked color keeps the color
/// it shows at full tint, as [`clean_gradient`] keeps a library gradient's stops. A link to a
/// it shows at full tint, and an unlinked stop has no process plates, as [`clean_gradient`] keeps
/// a library gradient's stops. A link to a
/// global or spot swatch `doc` has stays, and takes that swatch's color.
fn unlink_missing_swatches(doc: &vectorcraft_doc::Document, attrs: &mut Map<String, Value>) {
let keep = |name: &str| doc.global_color(name).is_some();
for key in ["fill", "stroke"] {
let Some(mut paint) = attrs.get(key).and_then(|v| serde_json::from_value::<Paint>(v.clone()).ok()) else { continue };
let mut changed = false;
let mut unlink = |swatch: &mut Option<String>, tint: &mut f32| {
if swatch.as_deref().is_some_and(|n| !keep(n)) {
*swatch = None;
*tint = 1.0;
changed = true;
}
};
match &mut paint {
Paint::Solid { swatch, tint, .. } => unlink(swatch, tint),
Paint::Gradient(g) => {
for st in &mut g.gradient.stops {
unlink(&mut st.swatch, &mut st.tint);
}
}
_ => {}
}
// `map_links` gives an unlinked color full tint, and an unlinked stop no process plates.
let changed = paint.map_links(&mut |_, swatch, _| swatch.take_if(|n| !keep(n.as_str())).is_some());
if changed && let Ok(v) = serde_json::to_value(&paint) {
attrs.insert(key.to_string(), v);
}
+32
View File
@@ -733,6 +733,38 @@ fn a_library_character_style_linked_to_a_missing_swatch_is_added_unlinked() {
assert_eq!((r["style"].as_str(), r["existing"].as_bool()), (Some("Accent"), Some(true)));
}
/// A gradient stop linked to a spot swatch with process plates, as a library file can hold one.
fn plated_stops() -> Value {
let ink = vectorcraft_color::Color::cmyk(0.0, 0.5, 0.0, 0.0);
json!([
{"offset": 0, "color": vectorcraft_color::Color::WHITE},
{"offset": 1, "color": ink, "swatch": "Brand", "tint": 0.5, "plates": [0.3, 0, 0, 0]}
])
}
/// Library gradients and the gradient stops of library character styles lose their links to
/// swatches the document does not have together with the process plates those links print with.
#[test]
fn library_stops_unlinked_from_their_swatch_keep_no_process_plates() {
let data = json!({
"name": "Plated",
"gradients": [{"name": "Glow", "gradient": {"gradient": {"kind": "Linear", "stops": plated_stops()}, "angle": 0}}],
"charStyles": [{"name": "Accent", "attrs": {"fill": {"type": "gradient", "gradient": {"kind": "Linear", "stops": plated_stops()}}}}]
});
let mut s = session();
let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string();
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [a]}));
run(&mut s, "library.use", json!({"library": lib, "kind": "fillGradient", "item": "Glow"}));
let st = gradient(&paint_of(&s, &a, false)).gradient.stops[1].clone();
assert_eq!((st.swatch.as_deref(), st.tint, st.plates), (None, 1.0, None), "{st:?}");
run(&mut s, "library.addToCharacterStyles", json!({"library": lib, "item": "Accent"}));
let style = s.doc().unwrap().doc.char_styles.iter().find(|c| c.name == "Accent").map(|c| c.attrs["fill"].clone()).unwrap();
let fill = serde_json::from_value::<vectorcraft_color::Paint>(style).unwrap();
let st = gradient(&fill).gradient.stops[1].clone();
assert_eq!((st.swatch.as_deref(), st.tint, st.plates), (None, 1.0, None), "{st:?}");
}
/// A library style is compared with the document's in the form the document stores it in, so a
/// style whose file writes a size as a whole number is reused, by Add to Character Styles and by a
/// click alike.
+18
View File
@@ -213,6 +213,24 @@ fn spot_stops_with_process_plates_export_both_in_device_n_shadings() {
assert_eq!(gradient(&s, id).gradient.stops[1].plates, None);
}
#[test]
fn deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks() {
let (mut s, id) = setup();
let plated = json!({"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "swatch": "Ink", "tint": 50, "plates": [0.3, 0, 0, 0]}]});
run(&mut s, "paint.editGradient", plated.clone());
run(&mut s, "swatch.new", json!({"name": "Glow", "gradient": plated}));
let linked = gradient(&s, id).gradient.stops[1].clone();
assert_eq!((linked.swatch.as_deref(), linked.plates), (Some("Ink"), Some([0.3, 0.0, 0.0, 0.0])));
run(&mut s, "swatch.delete", json!({"name": "Ink"}));
// The art's stop and the gradient swatch's stop keep the color they show, unlinked and without plates.
let Paint::Gradient(sw) = doc(&s).swatch("Glow").unwrap().paint.clone() else { panic!("a gradient swatch") };
for st in [&gradient(&s, id).gradient.stops[1], &sw.gradient.stops[1]] {
assert_eq!((st.color, st.swatch.as_deref(), st.tint, st.plates), (linked.color, None, 1.0, None), "{st:?}");
}
run(&mut s, "edit.undo", json!({}));
assert_eq!(gradient(&s, id).gradient.stops[1], linked, "undo brings back the link and the plates");
}
#[test]
fn radial_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() {
let (mut s, _) = setup();
+1
View File
@@ -1543,6 +1543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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 XDG desktop portal's [PickColor](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html) on Linux, and 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. On Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request another way each show a status message and end the wait. Windows, the BSDs 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/it/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, both ends of a vector drawn on the art and the start of a clicked one snap as other drawn points do (path segments, construction guides and View › Snap to Glyph's guides included) 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.
A gradient stop that loses its link to a spot swatch (the swatch deleted, a recolor or adjustment that unlinks it, a library gradient, or a library character style linked to a swatch the document lacks) keeps the color it shows at full tint and drops the process plates it printed with that ink, as a stop given a plain color does; this is VectorCraft's own rule. Evidence: `tests::mapping_links_drops_the_process_plates_of_unlinked_stops` in `crates/color`, `tests::linked_stops::deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks` and `tests::library::library_stops_unlinked_from_their_swatch_keep_no_process_plates` in `crates/engine`.
- The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403).
- Conic gradients, VectorCraft's own gradient type: the stops sweep once around the center from the vector, clockwise on the page (a negative aspect, or a reflection, turns the sweep the other way), with a Conic button in the Gradient panel's Type row and the Gradient tool's extent ellipse and aspect handle; they follow transforms, Expand into wedges or a mesh, open from `.af` files, and export to PDF as a function-based shading that PDF import reads back as a conic gradient; SVG, EPS and legacy `.ai` files get solid wedges, and CSS a `conic-gradient`.
- Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient.