diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index 9376102ef..c32f28c64 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -63,6 +63,7 @@ fn composite_area( coverage: impl Fn(i32, i32) -> f32 + Sync, color: impl Fn(i32, i32) -> [f32; 4] + Sync, lock_transparency: bool, + inks: Option<[f32; 4]>, ) { // Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched, // so their copy-on-write storage (and GPU residency) survives. @@ -93,6 +94,14 @@ fn composite_area( o[3] = d[3]; } from_rgba_into(&fmt, o, &mut enc); + // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. + if let Some(i) = inks + && fmt.mode == photocraft_color::ColorMode::Cmyk + && blend_mode == BlendMode::Normal + && k >= 1.0 + { + enc[..4].copy_from_slice(&i); + } px.copy_from_slice(&enc[..n]); } } @@ -144,6 +153,7 @@ pub fn paint_gradient( c }, lock_transparency, + None, ); } @@ -179,6 +189,25 @@ pub fn bucket_fill_src( selection: Option<&Surface>, lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, +) -> bool { + bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None) +} + +/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target +/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK). +#[allow(clippy::too_many_arguments)] +pub fn bucket_fill_inks( + s: &mut Surface, + area: Rect, + seed: (i32, i32), + tolerance: f32, + contiguous: bool, + anti_alias: bool, + opacity: f32, + selection: Option<&Surface>, + lock_transparency: bool, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, + inks: Option<[f32; 4]>, ) -> bool { if !area.contains(seed.0, seed.1) { return false; @@ -186,7 +215,7 @@ pub fn bucket_fill_src( let img = crate::selection::rgba8_image(s, area); let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false }; drop(img); - bucket_fill_region_src(s, ®ion, opacity, selection, lock_transparency, src); + bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks); true } @@ -199,10 +228,31 @@ pub fn bucket_fill_region_src( selection: Option<&Surface>, lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, +) { + bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None); +} + +/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`]. +pub fn bucket_fill_region_inks( + s: &mut Surface, + region: &crate::selection::Region, + opacity: f32, + selection: Option<&Surface>, + lock_transparency: bool, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, + inks: Option<[f32; 4]>, ) { // Composite only over the filled region's box. let b = region.bbox; - composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency); + composite_area( + s, + b, + BlendMode::Normal, + |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), + src, + lock_transparency, + inks, + ); } #[cfg(test)] diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 1399784b1..c4fdebf21 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -136,6 +136,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result { // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; let color = color_param(p, "color", s.tools.foreground); + // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). + let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { None | Some(Value::Null) => photocraft_color::BlendMode::Normal, Some(Value::String(m)) => crate::commands::blend_from_str(m) @@ -214,7 +216,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result { let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; let area = band.content_bounds().intersect(&canvas); if !area.is_empty() { - crate::fill_cmds::blend_color_mask(surf, area, color, &band, mode, opacity, preserve || lock)?; + crate::fill_cmds::blend_color_mask(surf, area, color, picked, &band, mode, opacity, preserve || lock)?; surf.prune(); } Ok(()) diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index d99ebd9a5..30c3ea1dd 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -215,16 +215,22 @@ pub(crate) fn fill_color(surf: &mut Surface, area: Rect, color: [f32; 4], sel: O /// Reuse Edit › Fill's compositing for Edit › Stroke. The stroke's rasterized band /// is already a grayscale coverage mask; opacity, blend modes and alpha locking /// must behave identically for both commands at 8/16/32-bit depth. +#[allow(clippy::too_many_arguments)] pub(crate) fn blend_color_mask( surf: &mut Surface, area: Rect, color: [f32; 4], + picked: Option<[f32; 4]>, coverage: &Surface, mode: BlendMode, opacity: f32, preserve_transparency: bool, ) -> Result<()> { - blend_into(surf, area, &Source::Color(color), Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) + let source = match picked { + Some(cmyk) if color[3] >= 1.0 => Source::Cmyk { rgba: color, cmyk }, + _ => Source::Color(color), + }; + blend_into(surf, area, &source, Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) } /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index ad16cd85d..d97ca5b31 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -1,6 +1,6 @@ //! Paint helpers: Paint Bucket and Gradient tool. -use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_region_src, bucket_fill_src, paint_gradient}; +use photocraft_algo::paint::{GradientShape, bucket_fill_inks, bucket_fill_region_inks, bucket_fill_region_src, bucket_fill_src, paint_gradient}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -61,6 +61,8 @@ fn bucket(s: &mut Session, p: &Value) -> Result { } else { None }; + // A foreground picked as CMYK fills a CMYK document with the picked inks (#4196). + let inks = s.tools.picked_cmyk([c[0], c[1], c[2]]).filter(|_| c[3] >= 1.0 && pattern.is_none()); let filled = s.edit("Paint Bucket", |doc, active| { let area = doc.bounds(); let sel = doc.selection.clone(); @@ -80,10 +82,10 @@ fn bucket(s: &mut Session, p: &Value) -> Result { bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src) } } else if let Some(region) = ®ion { - bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, |_, _| c); + bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks); true } else { - bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref(), lock) + bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks) }; surf.prune(); Ok(ok) @@ -330,6 +332,30 @@ mod tests { assert_eq!(px(&s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]); } + /// #4196: the Paint Bucket and Edit > Stroke write a CMYK pick as its inks into a CMYK document, + /// not as its sRGB stand-in converted back (100/0/0/0 cyan would come out as about 100/28/0/17). + #[test] + fn bucket_and_stroke_write_a_cmyk_pick_as_its_inks() { + let cyan = [1.0f32, 0.0, 0.0, 0.0]; + let mut s = session(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap(); + let inks_at = |s: &Session, x: i32, y: i32| -> Vec { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + surf.read_region(Rect::new(x, y, x + 1, y + 1)).into_iter().take(4).collect() + }; + let is_cyan = |got: &[f32]| got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02); + s.execute("paint.bucket", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap(); + let got = inks_at(&s, 5, 5); + assert!(is_cyan(&got), "bucket: {got:?}"); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.stroke", json!({"width": 2, "location": "inside", "color": rgb})).unwrap(); + let got = inks_at(&s, 0, 5); + assert!(is_cyan(&got), "stroke: {got:?}"); + } + #[test] fn bucket_fills_with_a_pattern() { // Paint Bucket with contents="pattern" flood-fills the region with a pattern, not a colour.