Files
vectorcraft/crates/engine/src/tests_eyedropper.rs
dotCooCoo 3adf5141e7 Fix #2276: the Eyedropper and RGB input give color-managed CMYK values in a process document
In a CMYK document, an RGB color from the Eyedropper, a hex value or
paint.setFill was stored as CMYK by the profile-free formula
(k = 1 - max(r, g, b)), not through the color settings. RGB 83/170/223
came in as C63 M24 Y0 K13. A CMYK image was sampled through the RGB its
decoder shows, so its inks came back only when the formula happened to
round-trip them.

paint_from now converts an RGB color into a CMYK document with
Cms::convert and the settings' intent, as Document Color Mode and Swatch
Options already do. This covers paint.setFill and paint.setStroke,
gradient stops, swatch.new {color} and the Eyedropper. With Generic CMYK,
83/170/223 now separates to C59 M12 Y3 K0, and #ff0000 now separates to
C0 M97 Y100 K0 instead of C0 M100 Y100 K0. keepModel: true still keeps
RGB. paint.sampleColor with a stop stores the color the same way.

The Eyedropper samples a CMYK image's ink amounts (a four-component
JPEG, or an 8- or 16-bit chunky CMYK TIFF) and returns them unchanged
(ImageBlob::sample_cmyk_object), also in an RGB document. It takes an
RGB image's sRGB pixel into the working RGB space, as the screen color
sampler does, so the picked color shows as the pixel when the working
space is not sRGB.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00

287 lines
15 KiB
Rust

//! Eyedropper Options and modes (M3.52): the Picks Up / Applies trees, type attributes, Alt-click
//! (reverse) and Shift+Alt-click (append), image pixels, colour models and the preference.
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ImageBlob, ImageObject, Justify, LineCap, Node, NodeKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64) -> NodeId {
id_of(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn click(s: &mut Session, x: f64, y: f64, mods: Mods) {
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, x, y).with_mods(mods), ViewInfo::default()).unwrap();
}
/// A red-filled source with a 5 pt round-capped blue stroke at 50 % opacity, and a selected
/// target with the default white fill and a 2 pt black stroke.
fn source_and_target(s: &mut Session) -> (NodeId, NodeId) {
let src = rect(s, 300.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
s.execute("stroke.set", &json!({"weight": 5, "cap": "round"})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let dst = rect(s, 10.0);
s.execute("paint.default", &json!({})).unwrap();
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
(src, dst)
}
#[test]
fn picking_up_without_weight_keeps_the_target_weight() {
let mut s = session();
let (src, dst) = source_and_target(&mut s);
s.execute("eyedropper.setOptions", &json!({"pickUp": {"appearance": {"stroke": {"weight": false}}}})).unwrap();
let weight = s.paint.stroke_width;
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let n = node(&s, dst);
let st = n.appearance.stroke().unwrap();
assert_eq!((st.paint.color().unwrap().to_hex(), st.width, st.cap), ("#0000ff".into(), 2.0, LineCap::Round));
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
assert_eq!(n.opacity, 0.5);
assert_eq!(s.paint.stroke_width, weight, "new art keeps its weight too");
// Applying no transparency (one call) keeps the target's opacity; undo is one step per copy.
s.execute("transparency.set", &json!({"opacity": 100, "ids": [dst.0]})).unwrap();
// A green fill, which the copy turns red again.
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [dst.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap();
assert_eq!((node(&s, dst).opacity, s.doc().unwrap().history.undo.len()), (1.0, undo + 1));
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"nope": true}})).is_err());
}
#[test]
fn every_appearance_attribute_copies_the_whole_stack() {
let mut s = session();
let (src, dst) = source_and_target(&mut s);
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
assert_eq!(node(&s, dst).appearance, node(&s, src).appearance);
// Without the fill's overprint only the focal attributes go: the target keeps its two items.
let dst2 = rect(&mut s, 150.0);
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"fill": {"overprint": false}}}})).unwrap();
assert_eq!(node(&s, dst2).appearance.items.len(), 2);
}
#[test]
fn type_picks_up_font_and_size() {
let mut s = session();
let src = id_of(s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Source"})).unwrap());
s.execute("text.setStyle", &json!({"font": "Inter", "size": 30, "tracking": 50, "justify": "center", "fill": "#00ff00"})).unwrap();
let dst = id_of(s.execute("text.create", &json!({"x": 10, "y": 400, "text": "Target"})).unwrap());
let style = |s: &Session, id| match &node(s, id).kind {
NodeKind::Text(t) => (t.first_style(), t.para.justify),
_ => panic!("not type"),
};
let width = |s: &Session, id| node(s, id).geometric_bounds().unwrap().width();
let before = width(&s, dst);
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let (st, justify) = style(&s, dst);
assert_eq!((st.font_family.as_str(), st.size, st.tracking, justify), ("Inter", 30.0, 50.0, Justify::Center));
assert_eq!(st.fill.color().unwrap().to_hex(), "#00ff00");
assert!(width(&s, dst) > before, "laid out again at the new size");
// Without character or paragraph attributes only the paints go.
let dst2 = id_of(s.execute("text.create", &json!({"x": 10, "y": 500, "text": "Other"})).unwrap());
let preserved_alignment = style(&s, dst2).1;
s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"character": false, "paragraph": false}})).unwrap();
let (st, justify) = style(&s, dst2);
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into()));
}
#[test]
fn alt_click_applies_the_selection_to_the_clicked_object() {
let mut s = session();
let (src, dst) = source_and_target(&mut s);
// `dst` is selected; Alt-click `src`: it takes the selection's white fill and 2 pt stroke.
let defaults = s.paint.clone();
click(&mut s, 325.0, 35.0, Mods { alt: true, ..Default::default() });
let n = node(&s, src);
assert_eq!((n.appearance.fill_paint(), n.appearance.stroke_width()), (Paint::solid(Color::WHITE), 2.0));
assert_eq!(node(&s, dst).appearance.fill_paint(), Paint::solid(Color::WHITE), "the selection is unchanged");
assert_eq!(s.paint, defaults, "the defaults stay");
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "reverse": true})).is_err(), "nothing to copy from");
}
#[test]
fn shift_alt_click_appends_the_appearance() {
let mut s = session();
let (_, dst) = source_and_target(&mut s);
click(&mut s, 325.0, 35.0, Mods { alt: true, shift: true, ..Default::default() });
let items = node(&s, dst).appearance.items.clone();
assert_eq!(items.len(), 4, "the source's fill and stroke on top of the target's");
assert_eq!((items[0].paint().color().unwrap().to_hex(), items[2].paint().color().unwrap().to_hex()), ("#ffffff".into(), "#ff0000".into()));
}
#[test]
fn cmyk_stays_cmyk() {
let mut s = session();
let src = rect(&mut s, 300.0);
let cmyk = Color::cmyk(0.1, 0.5, 0.9, 0.2);
s.execute("paint.setFill", &json!({"color": {"c": 0.1, "m": 0.5, "y": 0.9, "k": 0.2}})).unwrap();
let dst = rect(&mut s, 10.0);
click(&mut s, 325.0, 35.0, Mods { shift: true, ..Default::default() });
assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(cmyk), "a sampled colour keeps its model");
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
click(&mut s, 325.0, 35.0, Mods::default());
assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(cmyk));
let _ = src;
}
#[test]
fn clicking_an_image_samples_its_pixel() {
let mut s = session();
// Two pixels, orange and teal, 100 pt each, at (300, 0).
let img = image::RgbaImage::from_raw(2, 1, [[255, 128, 0, 255], [0, 128, 128, 255]].concat()).unwrap();
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.edit("Place", |d, _| {
d.images.insert("px".into(), ImageBlob::png(png));
let l = d.layers[0].id;
let id = d.alloc_id();
let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0);
let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() };
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string()))
})
.unwrap();
let dst = rect(&mut s, 10.0);
click(&mut s, 450.0, 50.0, Mods::default());
// Compared as stored (not as displayed through the working RGB space).
assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(Color::rgb8(0, 128, 128)));
s.execute("eyedropper.setOptions", &json!({"sampleSize": 5})).unwrap();
click(&mut s, 350.0, 50.0, Mods::default());
assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(Color::rgb8(128, 128, 64)), "5 x 5 average of both pixels");
}
/// A CMYK document with one image of `px` (one row, 100 pt a pixel) at (300, 0).
fn cmyk_doc_with_image(px: &[[u8; 4]]) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600, "colorMode": "cmyk"})).unwrap();
let w = px.len() as u32;
let img = image::RgbaImage::from_raw(w, 1, px.concat()).unwrap();
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.edit("Place", |d, _| {
d.images.insert("px".into(), ImageBlob::png(png));
let l = d.layers[0].id;
let id = d.alloc_id();
let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0);
let im = ImageObject { key: "px".into(), width: w, height: 1, xf, link: None, placement: Default::default() };
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string()))
})
.unwrap();
s
}
/// The CMYK color the color settings separate `rgb` into (with their intent).
fn managed_cmyk(rgb: Color) -> Color {
let cms = vectorcraft_color::cms::active();
cms.convert(&rgb, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent)
}
/// #2276: an RGB image's pixel sampled into a CMYK document separates through the color
/// settings, as Document Color Mode converts it, not by the profile-free formula
/// (83/170/223 came in as C63 M24 Y0 K13).
#[test]
fn an_rgb_image_sampled_in_a_cmyk_document_separates_through_the_color_settings() {
let mut s = cmyk_doc_with_image(&[[83, 170, 223, 255], [224, 238, 255, 255]]);
let dst = rect(&mut s, 10.0);
click(&mut s, 350.0, 50.0, Mods::default());
let got = node(&s, dst).appearance.fill_paint().color().unwrap();
assert_eq!(got, managed_cmyk(Color::rgb8(83, 170, 223)));
let Color::Cmyk { c, m, y, k } = got else { panic!("{got:?}") };
// Generic CMYK: about C59 M12 Y3 K0, with no black, where the formula gave K13.
assert!((0.5..0.75).contains(&c) && (0.05..0.2).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}");
// A light blue shows close to the color it was picked from; the formula's C12 M7 showed duller.
click(&mut s, 450.0, 50.0, Mods::default());
let got = node(&s, dst).appearance.fill_paint().color().unwrap();
assert!(matches!(got, Color::Cmyk { .. }), "{got:?}");
let light = Color::rgb8(224, 238, 255);
let off = |c: Color| vectorcraft_color::cms::delta_e2000(c.to_lab(), light.to_lab());
let naive = light.in_model(vectorcraft_color::cms::Model::Cmyk);
assert!(off(got) < 2.0, "{} is ΔE {} from #e0eeff ({} at ΔE {} before)", got.to_hex(), off(got), naive.to_hex(), off(naive));
}
/// #2276: colors typed or sent in RGB to a CMYK document separate through the color settings:
/// fills and strokes, gradient stops, new swatches and a sampled gradient stop.
#[test]
fn rgb_colors_given_to_a_cmyk_document_separate_through_the_color_settings() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600, "colorMode": "cmyk"})).unwrap();
let a = rect(&mut s, 10.0);
let blue = Color::rgb8(83, 170, 223);
s.execute("paint.setFill", &json!({"color": "#53aadf"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#53AADF"})).unwrap();
let n = node(&s, a);
assert_eq!(n.appearance.fill_paint().color(), Some(managed_cmyk(blue)));
assert_eq!(n.appearance.stroke_paint().color(), Some(managed_cmyk(blue)));
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#53aadf"}]);
s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap();
let Paint::Gradient(g) = node(&s, a).appearance.fill_paint() else { panic!("a gradient") };
assert_eq!(g.gradient.stops[1].color, managed_cmyk(blue));
// The Gradient panel's eyedropper samples into the selected stop.
s.execute("paint.sampleColor", &json!({"color": "#53aadf", "stop": 0})).unwrap();
let Paint::Gradient(g) = node(&s, a).appearance.fill_paint() else { panic!("a gradient") };
assert_eq!(g.gradient.stops[0].color, managed_cmyk(blue));
let name = s.execute("swatch.new", &json!({"color": "#53aadf"})).unwrap()["name"].as_str().unwrap().to_string();
assert_eq!(s.doc().unwrap().doc.swatch(&name).and_then(|w| w.paint.color()), Some(managed_cmyk(blue)));
// keepModel still keeps RGB.
s.execute("paint.setFill", &json!({"color": "#53aadf", "keepModel": true})).unwrap();
assert_eq!(node(&s, a).appearance.fill_paint().color(), Some(blue));
}
#[test]
fn the_options_are_a_preference() {
let mut s = Session::new();
assert!(s.execute("eyedropper.setOptions", &json!({"sampleSize": 4})).is_err());
assert!(s.execute("eyedropper.setOptions", &json!({"apply": {"character": 1}})).is_err());
s.execute("eyedropper.setOptions", &json!({"sampleSize": 3, "apply": {"paragraph": false}})).unwrap();
let got = s.execute("prefs.get", &json!({"key": "eyedropper"})).unwrap();
assert_eq!((&got["sampleSize"], &got["apply"]["paragraph"], &got["pickUp"]["paragraph"]), (&json!(3), &json!(false), &json!(true)));
s.execute("prefs.set", &json!({"key": "eyedropper", "value": {"pickUp": {"appearance": false}}})).unwrap();
assert!(!s.prefs.eyedropper.pick_up.appearance.fill.color && s.prefs.eyedropper.sample_size == 3);
// Saved with the preferences; older preferences (no options) load the defaults.
let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
assert_eq!(back, s.prefs);
let mut old = Prefs::default().to_json();
old.as_object_mut().unwrap().remove("eyedropper");
assert_eq!(serde_json::from_value::<Prefs>(old).unwrap().eyedropper, EyedropperOptions::default());
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.eyedropper, EyedropperOptions::default());
}
#[test]
fn type_takes_the_stroke_options_it_has() {
let mut s = session();
let (src, _) = source_and_target(&mut s);
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
s.execute("stroke.set", &json!({"dash": [4, 2]})).unwrap();
let dst = id_of(s.execute("text.create", &json!({"x": 10, "y": 400, "text": "Target"})).unwrap());
s.execute("eyedropper.setOptions", &json!({"apply": {"appearance": {"stroke": {"dash": false}}}})).unwrap();
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
let NodeKind::Text(t) = &node(&s, dst).kind else { panic!("not type") };
let st = t.first_style();
assert_eq!((st.stroke.color().unwrap().to_hex(), st.stroke_width, st.stroke_cap), ("#0000ff".into(), 5.0, LineCap::Round));
assert!(st.stroke_dash.is_none(), "the dash pattern isn't applied");
}