diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 2ca605db0..008770bdc 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -201,6 +201,53 @@ impl ImageBlob { let bytes = self.bytes.as_slice(); if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) } } + + /// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image + /// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered + /// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images + /// [`Self::cmyk`] doesn't read. The inks of the last CMYK image are kept for the next sample + /// (the Eyedropper samples one image click after click), as [`Self::sample_object`] keeps its + /// pixels. + pub fn sample_cmyk_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> { + thread_local! { + static LAST: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; + } + if !self.is_cmyk() { + return None; + } + let key = self.content_key(); + let inks = LAST.with_borrow_mut(|last| match last { + Some((k, inks)) if *k == key => Some(inks.clone()), + _ => { + let inks = Arc::new(self.cmyk()?); + *last = Some((key, inks.clone())); + Some(inks) + } + })?; + let k = |n: u32, of: u32| n as f64 / of.max(1) as f64; + sample_inks(&inks, x * k(inks.width, object.0), y * k(inks.height, object.1), size) + } +} + +/// [`ImageBlob::sample_cmyk_object`] of `inks` at (`x`, `y`) in their own pixel space. +fn sample_inks(inks: &Inks, x: f64, y: f64, size: u32) -> Option<[u8; 4]> { + let (w, h) = (i64::from(inks.width), i64::from(inks.height)); + let (cx, cy) = (x.floor() as i64, y.floor() as i64); + if !(0..w).contains(&cx) || !(0..h).contains(&cy) { + return None; + } + let r = i64::from(size.max(1)) / 2; + let (mut sum, mut n) = ([0u64; 4], 0u64); + for py in (cy - r).max(0)..=(cy + r).min(h - 1) { + for px in (cx - r).max(0)..=(cx + r).min(w - 1) { + let i = usize::try_from((py * w + px) * 4).ok()?; + let p = inks.data.get(i..i + 4)?; + sum.iter_mut().zip(p).for_each(|(s, v)| *s += u64::from(*v)); + n += 1; + } + } + let c = |v: u64| ((v + n / 2) / n) as u8; + Some(sum.map(c)) } /// The ink amounts of a four-component JPEG (its samples inverted back; YCCK converted to CMYK @@ -430,6 +477,20 @@ mod tests { assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]); } + #[test] + fn a_cmyk_image_samples_its_ink_amounts() { + let b = ImageBlob::cmyk_tiff(&inks()).unwrap(); + assert_eq!(b.sample_cmyk_object(0.5, 0.5, (3, 2), 1), Some([0, 40, 100, 0])); + // An object of 6 × 4 pixels showing these 3 × 2: (5, 3) is the last pixel. + assert_eq!(b.sample_cmyk_object(5.0, 3.0, (6, 4), 1), Some([12, 34, 56, 78])); + // 3 × 3 around (1, 0), clipped to the image: all six pixels averaged. + assert_eq!(b.sample_cmyk_object(1.5, 0.5, (3, 2), 3), Some([45, 55, 69, 56])); + assert_eq!(b.sample_cmyk_object(3.5, 0.5, (3, 2), 1), None, "outside"); + let mut png = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert_eq!(ImageBlob::png(png).sample_cmyk_object(0.5, 0.5, (2, 2), 1), None, "an RGB image has no inks"); + } + #[test] fn a_cmyk_jpeg_reads_back_as_ink_amounts() { // A flat CMYK JPEG: Adobe's inverted samples with its marker. diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 2eeadf8dc..d16c5883b 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -298,14 +298,31 @@ fn new_color_model(s: &Session, p: &Value) -> Option, c: Color) -> Color { + match (c, model) { + (Color::Rgb { .. }, Some(m)) => { + let cms = vectorcraft_color::cms::active(); + cms.convert(&c, m, cms.settings().intent) + } + _ => c, + } +} + +/// A color given in `p` as the document stores it: an RGB color in a CMYK document (without +/// `keepModel`) becomes CMYK through the color settings ([`stored_color`]). +pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color { + stored_color(new_color_model(s, p), c) +} + /// 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`]). +/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]), +/// converted through the color settings. pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { let model = new_color_model(s, p); - let new_color = |c: Color| match (c, model) { - (Color::Rgb { .. }, Some(m)) => c.in_model(m), - _ => c, - }; + let new_color = |c: Color| stored_color(model, c); if bool_or(p, "none", false) { return Ok(Some(Paint::None)); } diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index f5a05c55e..c2a48a62e 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Sample Color", [], None, - "{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient)", + "{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it", has_doc, sample_color ), @@ -346,7 +346,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })), Some(v) => { let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize; - let color = color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?; + let color = super::paint::new_color(s, p, color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?); let (fill, stroke_paint) = s.proxy_paints(); let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else { return Err(bad(C, "the paint behind the proxy is not a gradient")); diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index 9b30500ae..7be5f4327 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -173,6 +173,83 @@ fn clicking_an_image_samples_its_pixel() { 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(); diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs index 85289bb47..d11ed9b2a 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests_swatchcmds.rs @@ -203,8 +203,11 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { assert_eq!(doc(&s).color_mode, vectorcraft_doc::ColorMode::Cmyk); assert!(doc(&s).swatches_iter().filter_map(|w| w.paint.color()).all(|c| !matches!(c, Color::Rgb { .. }))); assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + // RGB colors separate through the color settings (Generic CMYK: red is C0 M97 Y100 K0). + let cms = vectorcraft_color::cms::active(); + let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent); let a = rect_filled(&mut s, json!({"color": "#ff0000"})); - assert_eq!(fill(&s, a), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))); + assert_eq!(fill(&s, a), Paint::solid(managed(Color::rgb(1.0, 0.0, 0.0)))); // Gray stays Gray; keepModel keeps RGB; gradient stops follow the document too. run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": {"gray": 0.4}})); assert_eq!(fill(&s, a), Paint::solid(Color::gray(0.4))); @@ -218,8 +221,9 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { let Paint::Gradient(g) = fill(&s, a) else { panic!("a gradient") }; assert!(g.gradient.stops.iter().all(|st| matches!(st.color, Color::Cmyk { .. }))); // A swatch made from a hex colour is CMYK as well; the document round-trips natively. - let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].clone(); - assert_eq!(name, "C=100 M=0 Y=100 K=0"); + let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string(); + assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0)))); + assert_eq!(name, "C=62 M=0 Y=100 K=0"); let json = serde_json::to_string(doc(&s)).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); } diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index bd512abe3..7a5dcab68 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -226,3 +226,38 @@ fn live_objects_draw_through_new_color_settings() { assert!(warm == fresh, "{what}: drawn as a new renderer draws it"); } } + +/// #2276: with a working RGB space other than sRGB, the Eyedropper takes an image's sRGB pixel +/// into the working space, so the sampled fill shows as the pixel it was picked from. +#[test] +fn the_eyedropper_takes_an_image_pixel_into_the_working_rgb_space() { + use vectorcraft_color::Color; + use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind}; + use vectorcraft_engine::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let _g = settings(); + let old = cms::active_settings(); + let mut s = session(); + let img = image::RgbaImage::from_raw(1, 1, vec![83, 170, 223, 255]).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 = Affine::translate((300.0, 0.0)) * Affine::scale(100.0); + let im = ImageObject { key: "px".into(), width: 1, height: 1, xf, link: None, placement: Default::default() }; + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| vectorcraft_engine::EngineError::Other(e.to_string())) + }) + .unwrap(); + let dst = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + s.execute("edit.colorSettings", &json!({"rgb": cms::WIDE_GAMUT_RGB})).unwrap(); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0), ViewInfo::default()).unwrap(); + let got = s.doc().unwrap().doc.node(dst).unwrap().appearance.fill_paint().color(); + let shown = got.map(|c| c.to_rgba8(1.0)); + cms::set_active(&old).unwrap(); + assert!(matches!(got, Some(Color::Rgb { .. })), "{got:?}"); + let [r, g, b, _] = shown.unwrap_or_default(); + assert!(r.abs_diff(83) <= 1 && g.abs_diff(170) <= 1 && b.abs_diff(223) <= 1, "{shown:?} for 83/170/223"); +} diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 0cdfbaa89..20248ad6e 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -4,8 +4,9 @@ //! selection and the paint defaults (`appearance.copyFrom`); Alt-click: the selection's attributes //! onto the clicked object (`reverse`); Shift+Alt-click: add the clicked object's appearance to the //! selection's (`append`). Shift-click: sample only the colour under the cursor into the active -//! fill/stroke (`paint.sampleColor`, keeping the colour's model). Clicking an image samples its -//! pixels (averaged over the options' raster sample size). +//! fill/stroke (`paint.sampleColor`; in a CMYK document an RGB color is stored as CMYK). Clicking +//! an image samples its pixels (averaged over the options' raster sample size; a CMYK image gives +//! its ink amounts). //! //! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click //! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor @@ -50,15 +51,24 @@ fn paint_sample(n: &Node, on_stroke: bool, p: Point) -> Option { paint_color_at(paint, Some(bounds), p) } -/// The colour of image `n`'s pixels at `p`, averaged over the raster sample size. +/// The color of image `n`'s pixels at `p`, averaged over the raster sample size: a CMYK image's +/// ink amounts as they are, other images' sRGB pixels in the working RGB space (the color that +/// shows as that pixel). fn image_color_at(cx: &ToolContext, n: &Node, p: Point) -> Option { let NodeKind::Image(im) = &n.kind else { return None }; if im.xf.determinant().abs() < 1e-12 { return None; } let px = im.xf.inverse() * p; - let [r, g, b, _] = cx.doc.images.get(&im.key)?.sample_object(px.x, px.y, (im.width, im.height), cx.raster_sample)?; - Some(Color::rgb8(r, g, b)) + let blob = cx.doc.images.get(&im.key)?; + let object = (im.width, im.height); + if let Some(inks) = blob.sample_cmyk_object(px.x, px.y, object, cx.raster_sample) { + let [c, m, y, k] = inks.map(|v| f32::from(v) / 255.0); + return Some(Color::cmyk(c, m, y, k)); + } + let [r, g, b, _] = blob.sample_object(px.x, px.y, object, cx.raster_sample)?; + let [r, g, b] = vectorcraft_color::cms::active().srgb_to_rgb([r, g, b].map(|v| f32::from(v) / 255.0)); + Some(Color::rgb(r, g, b)) } impl Tool for EyedropperTool { @@ -230,6 +240,42 @@ mod tests { assert!(EyedropperTool::default().pointer(&crate::testutil::cx(&d, &s, &p), &click).is_empty()); } + /// #2276: a CMYK image gives its ink amounts as they are, not the RGB its decoder shows. + #[test] + fn clicking_a_cmyk_image_samples_its_inks() { + use vectorcraft_doc::cmyk::Inks; + use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + // Two pixels, 10 pt each: C10 M20 Y30 K40 (in bytes), then K100. + let inks = Inks::new(2, 1, vec![26, 51, 77, 102, 0, 0, 0, 255]).unwrap(); + d.images.insert("ink".into(), ImageBlob::cmyk_tiff(&inks).unwrap()); + let im = ImageObject { + key: "ink".into(), + width: 2, + height: 1, + xf: vectorcraft_geom::Affine::scale(10.0), + link: None, + placement: Default::default(), + }; + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let (s, p) = (Selection::default(), paint()); + let mut cx = cx(&d, &s, &p); + let ink = |v: [u8; 4]| { + let [c, m, y, k] = v.map(|b| f32::from(b) / 255.0); + json!({ "color": color_json(&Color::cmyk(c, m, y, k)) }) + }; + let click = PointerEvent::new(PointerKind::Down, 5.0, 5.0); + assert_eq!(EyedropperTool::default().pointer(&cx, &click), vec![Action::Exec("paint.sampleColor".into(), ink([26, 51, 77, 102]))]); + cx.raster_sample = 3; + assert_eq!( + EyedropperTool::default().pointer(&cx, &click), + vec![Action::Exec("paint.sampleColor".into(), ink([13, 26, 39, 179]))], + "3 x 3 average of both pixels" + ); + } + #[test] fn gradient_sampled_along_vector() { let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()); diff --git a/docs/mcp.md b/docs/mcp.md index a089c96ed..158a48770 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -426,7 +426,9 @@ 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, `swatch.new`) are stored as CMYK; +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 +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 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 @@ -1498,7 +1500,10 @@ the source's fills and strokes on top of each target's stack (Shift+Alt-click). `eyedropper.setOptions {sampleSize?, pickUp?, apply?}` (or `prefs.get`/`prefs.set` with key `eyedropper`) reads and sets them; a tree names flags under `appearance` (`transparency`, `fill` `{color, transparency, overprint}`, `stroke` `{color, transparency, overprint, weight, cap, join, miter, dash}`), `character` and `paragraph`, and a bool for a -branch sets all of it. `paint.sampleColor {color}` puts a sampled colour (in its own model) into the active proxy. +branch sets all of it. `paint.sampleColor {color}` puts a sampled color into the active proxy (in a CMYK document an +RGB color is stored as CMYK, as `paint.setFill` stores it). Clicking an image samples its pixels: 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 sRGB +pixel is taken into the working RGB space. ```json {"name":"run_command","arguments":{"command":"eyedropper.setOptions","params":{"pickUp":{"appearance":{"stroke":{"weight":false}}}}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 23fd60f9e..caf680311 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -612,6 +612,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). 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. - **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.