diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index 58b959c3f7..df14c063cc 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -9,6 +9,7 @@ use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShape, PaletteKind}; use photocraft_algo::transform::{Homography, Interp}; +use photocraft_cms::{ColorSpace, Transform}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::adjust::CurvePoint; use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size}; @@ -255,23 +256,47 @@ fn parse_dither(s: &str) -> Option { }) } +/// Convert a tool colour from the working RGB space into the samples used by the +/// flattened document. RGB and Indexed documents keep their own document profile; +/// grayscale output is a scalar sample replicated into the flattened RGB view. +fn working_matte_color(s: &Session, doc: &Document, color: [f32; 4]) -> Result<[f32; 3]> { + let destination = match crate::color_cmds::mode_space(doc.mode) { + ColorSpace::Rgb | ColorSpace::Gray => crate::color_cmds::document_profile(doc), + // CMYK and Lab flatten through the compositor's sRGB view on the pinned main path. + _ => crate::color_cmds::composite_profile(doc), + }; + let source = s.color.working(ColorMode::Rgb); + let transform = Transform::new(&source, &destination, s.color.settings.intent(), s.color.settings.bpc) + .map_err(|e| bad("image.mode.indexedColor", format!("cannot convert matte colour: {e}")))?; + let mut output = [0.0; 16]; + transform.eval(&color[..3], &mut output); + let matte = if destination.color_space == ColorSpace::Gray { + [output[0]; 3] + } else { + [output[0], output[1], output[2]] + }; + Ok(matte) +} + fn indexed_matte(s: &Session, p: &Value) -> Result> { const CMD: &str = "image.mode.indexedColor"; - let rgba = |color: [f32; 4]| [color[0], color[1], color[2]]; + let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let matte = match p.get("matte") { None | Some(Value::Null) => return Ok(None), Some(Value::String(value)) => match value.as_str() { "none" => return Ok(None), "white" => [1.0; 3], "black" => [0.0; 3], - "foreground" => rgba(s.tools.foreground), - "background" => rgba(s.tools.background), + "gray" => [128.0 / 255.0; 3], + "netscapeGray" => [192.0 / 255.0; 3], + "foreground" => working_matte_color(s, &doc, s.tools.foreground)?, + "background" => working_matte_color(s, &doc, s.tools.background)?, value if value.len() == 7 && value.starts_with('#') => p .get("matte") .and_then(parse_color) - .ok_or_else(|| bad(CMD, "matte must be none, foreground, background, white, black or #rrggbb"))? + .ok_or_else(|| bad(CMD, "matte must be none, foreground, background, white, black, gray, netscapeGray or #rrggbb"))? .map(|v| f32::from(v) / 255.0), - _ => return Err(bad(CMD, "matte must be none, foreground, background, white, black or #rrggbb")), + _ => return Err(bad(CMD, "matte must be none, foreground, background, white, black, gray, netscapeGray or #rrggbb")), }, _ => return Err(bad(CMD, "matte must be a colour string")), }; @@ -730,7 +755,7 @@ pub fn specs() -> Vec { "image.mode.indexedColor", "Indexed Color…", ["Image", "Mode"], - r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|#rrggbb)"##, +r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"matte":color,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens; matte: none|foreground|background|white|black|gray|netscapeGray|#rrggbb; gray=50% Gray (#808080), netscapeGray=Netscape Gray (#c0c0c0))"##, |s| mode_is( s, &[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab], diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 0cf1d38bf9..f249f87bca 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -287,6 +287,67 @@ fn indexed_matte_blends_edges_without_replacing_clear_pixels() { } } +#[test] +fn indexed_matte_converts_working_rgb_to_document_profile_at_every_depth() { + use photocraft_cms::Transform; + + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.color.settings.working_rgb = "display-p3".into(); + s.execute("file.new", json!({"width": 2, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#3366cc"})).unwrap(); + paint(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 0.5] } else { [0.0; 4] }); + + let source = s.color.working(ColorMode::Rgb); + let destination = crate::color_cmds::document_profile(doc(&s)); + let transform = Transform::new(&source, &destination, s.color.settings.intent(), s.color.settings.bpc).unwrap(); + let mut converted = [0.0; 16]; + transform.eval(&[0.2, 0.4, 0.8], &mut converted); + let expected = [0.5 + 0.5 * converted[0], 0.5 * converted[1], 0.5 * converted[2]]; + + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "foreground"})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + for (got, want) in edge[..3].iter().zip(expected) { + assert!((got - want).abs() <= 2.0 / 255.0, "{depth}-bit: {edge:?} vs {expected:?}"); + } + assert_eq!(edge[3], 1.0); + } +} + +#[test] +fn indexed_matte_hex_stays_in_document_rgb_coordinates() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1, "height": 1, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + paint(&mut s, |_| [1.0, 0.0, 0.0, 0.5]); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": "#3366cc"})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + let expected = [0.5 + 0.5 * 0.2, 0.5 * 0.4, 0.5 * 0.8]; + for (got, want) in edge[..3].iter().zip(expected) { + assert!((got - want).abs() <= 2.0 / 255.0, "{depth}-bit: {edge:?} vs {expected:?}"); + } + assert_eq!(edge[3], 1.0); + } +} + +#[test] +fn indexed_matte_named_gray_choices_use_their_document_samples() { + for (matte, channel) in [("gray", 128.0 / 255.0), ("netscapeGray", 192.0 / 255.0)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); + paint(&mut s, |_| [1.0, 0.0, 0.0, 0.5]); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "dither": "none", "matte": matte})).unwrap(); + let edge = doc(&s).layers[0].surface().unwrap().rgba(0, 0); + assert!((edge[0] - 0.5).abs() <= 2.0 / 255.0, "{matte}: {edge:?}"); + for value in edge[1..3].iter() { + assert!((*value - channel * 0.5).abs() <= 2.0 / 255.0, "{matte}: {edge:?}"); + } + assert_eq!(edge[3], 1.0); + } +} + #[test] fn indexed_matte_without_transparency_fills_clear_pixels_and_rejects_bad_input_atomically() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 4a91bdf81b..31a7bbc03d 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -6,6 +6,8 @@ use std::sync::Arc; +use photocraft_cms::{ColorSpace, Transform}; +use photocraft_color::ColorMode; use photocraft_doc::Document; use photocraft_engine::color_cmds::ColorSettings; use photocraft_engine::installed_profiles::InstalledProfile; @@ -429,6 +431,9 @@ fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option /// Command-specific open defaults (ruler straighten, warp from layer, …), not remembered prefs. fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map) { + if command == "image.mode.indexedColor" { + indexed_matte_defaults(app, fields); + } if command == "image.rotation.arbitrary" { straighten_defaults(app, fields); } @@ -840,10 +845,58 @@ fn replace_color_result(ui: &mut egui::Ui, f: &mut Map) { } } +const INDEXED_MATTE_FOREGROUND: &str = "__indexedMatteForeground"; +const INDEXED_MATTE_BACKGROUND: &str = "__indexedMatteBackground"; + +/// Resolve a tool colour into the encoded samples used by Indexed Color's matte. +/// +/// The picker and command fields carry document RGB coordinates. Tool swatches are +/// working-RGB coordinates, so only the named tool choices need this conversion here. +fn indexed_matte_document_color(app: &PhotocraftApp, doc: &Document, color: [f32; 4]) -> [f32; 3] { + let destination = match photocraft_engine::color_cmds::mode_space(doc.mode) { + ColorSpace::Rgb | ColorSpace::Gray => photocraft_engine::color_cmds::document_profile(doc), + _ => photocraft_engine::color_cmds::composite_profile(doc), + }; + let source = app.session.color.working(ColorMode::Rgb); + let Ok(transform) = Transform::new(&source, &destination, app.session.color.settings.intent(), app.session.color.settings.bpc) else { + return [color[0], color[1], color[2]]; + }; + let mut output = [0.0; 16]; + transform.eval(&color[..3], &mut output); + if destination.color_space == ColorSpace::Gray { [output[0]; 3] } else { [output[0], output[1], output[2]] } +} + +fn indexed_matte_defaults(app: &PhotocraftApp, fields: &mut Map) { + let Some(doc) = app.session.active().map(|state| state.doc.clone()) else { return }; + let foreground = indexed_matte_document_color(app, &doc, app.session.tools.foreground); + let background = indexed_matte_document_color(app, &doc, app.session.tools.background); + fields.insert(INDEXED_MATTE_FOREGROUND.into(), json!(crate::color_picker_ui::hex(foreground))); + fields.insert(INDEXED_MATTE_BACKGROUND.into(), json!(crate::color_picker_ui::hex(background))); +} + +fn indexed_matte_seed(fields: &Map, value: &str) -> [f32; 3] { + match value { + "foreground" => fields + .get(INDEXED_MATTE_FOREGROUND) + .and_then(Value::as_str) + .and_then(crate::color_picker_ui::parse_hex) + .unwrap_or([0.0; 3]), + "background" => fields + .get(INDEXED_MATTE_BACKGROUND) + .and_then(Value::as_str) + .and_then(crate::color_picker_ui::parse_hex) + .unwrap_or([1.0; 3]), + "black" => [0.0; 3], + "gray" => [128.0 / 255.0; 3], + "netscapeGray" => [192.0 / 255.0; 3], + _ => [1.0; 3], + } +} + fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { let value = fields.get("matte").and_then(Value::as_str).unwrap_or("none"); let mut choice = if value.starts_with('#') { "custom" } else { value }; - let color = crate::color_picker_ui::parse_hex(value).unwrap_or([1.0; 3]); + let color = crate::color_picker_ui::parse_hex(value).unwrap_or_else(|| indexed_matte_seed(fields, value)); let mut changed = false; let mut pick = false; ui.horizontal(|ui| { @@ -859,6 +912,8 @@ fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { ("background", "Background Color"), ("white", "White"), ("black", "Black"), + ("gray", "50% Gray"), + ("netscapeGray", "Netscape Gray"), ("custom", "Other…"), ], 170.0, @@ -872,7 +927,7 @@ fn indexed_matte_row(ui: &mut egui::Ui, fields: &mut Map) { if changed { if custom { // Keep the previous matte until the picker confirms; the picker uses this only as - // its initial colour when switching from a named matte or None. + // its initial document-encoded colour when switching from a named matte or None. fields.insert("__pickColorDefault".into(), json!(crate::color_picker_ui::hex(color))); } else { fields.insert("matte".into(), json!(choice.to_owned())); diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index db0f606677..31786411b7 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -90,6 +90,31 @@ fn display_names_are_not_split_like_camel_case_keys() { assert_eq!(source_label("centerX"), "Center X"); } +#[test] +fn indexed_matte_tool_seeds_use_document_encoded_rgb() { + use photocraft_cms::Transform; + + let mut session = photocraft_engine::Session::new(); + session.color.settings.working_rgb = "display-p3".into(); + session.execute("file.new", json!({"width": 2, "height": 2, "background": "transparent"})).unwrap(); + session.execute("edit.assignProfile", json!({"profile": "adobe-rgb-compat"})).unwrap(); + session.execute("tools.setColors", json!({"foreground": "#3366cc", "background": "#cc6633"})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let mut fields = Map::new(); + indexed_matte_defaults(&app, &mut fields); + + let doc = app.session.active().unwrap().doc.clone(); + let source = app.session.color.working(ColorMode::Rgb); + let destination = photocraft_engine::color_cmds::document_profile(&doc); + let transform = Transform::new(&source, &destination, app.session.color.settings.intent(), app.session.color.settings.bpc).unwrap(); + for (key, input) in [(INDEXED_MATTE_FOREGROUND, [0.2, 0.4, 0.8]), (INDEXED_MATTE_BACKGROUND, [0.8, 0.4, 0.2])] { + let mut output = [0.0; 16]; + transform.eval(&input, &mut output); + let expected = crate::color_picker_ui::hex([output[0], output[1], output[2]]); + assert_eq!(fields.get(key).and_then(Value::as_str), Some(expected.as_str()), "{key}"); + assert_eq!(indexed_matte_seed(&fields, if key == INDEXED_MATTE_FOREGROUND { "foreground" } else { "background" }), crate::color_picker_ui::parse_hex(&expected).unwrap()); + } +} /// Convert to Profile's and Proof Setup's `bpc` read "Bpc": spelled out. #[test] fn black_point_compensation_is_spelled_out() { @@ -1648,7 +1673,12 @@ fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() { use egui::accesskit::Role; use egui_kittest::{Harness, kittest::Queryable}; - for (choice, blue) in [("Black", 0.0), ("Other…", 0.25)] { + for (choice, matte) in [ + ("Black", [0.0, 0.0, 0.0]), + ("50% Gray", [128.0 / 255.0; 3]), + ("Netscape Gray", [192.0 / 255.0; 3]), + ("Other…", [0.0, 0.0, 1.0]), + ] { let mut session = photocraft_engine::Session::new(); session.execute("file.new", json!({"width": 2, "height": 1, "depth": 32, "background": "transparent"})).unwrap(); session @@ -1682,7 +1712,8 @@ fn indexed_matte_dropdown_and_picker_change_the_applied_edge_color() { assert_eq!(doc.mode, photocraft_color::ColorMode::Indexed); let surface = doc.layers[0].surface().unwrap(); let edge = surface.rgba(0, 0); - assert!((edge[0] - 0.75).abs() <= 1.0 / 255.0 && edge[1] == 0.0 && (edge[2] - blue).abs() <= 1.0 / 255.0 && edge[3] == 1.0, "{choice}: {edge:?}"); + let expected = [0.75 + 0.25 * matte[0], 0.25 * matte[1], 0.25 * matte[2]]; + assert!(edge.iter().zip(expected).all(|(got, want)| (got - want).abs() <= 1.0 / 255.0) && edge[3] == 1.0, "{choice}: {edge:?} vs {expected:?}"); assert_eq!(surface.rgba(1, 0)[3], 0.0); } } diff --git a/docs/development.md b/docs/development.md index 836b68f85f..79ca11f377 100644 --- a/docs/development.md +++ b/docs/development.md @@ -662,11 +662,14 @@ except Place, which is a job that `poll_jobs` steps on the UI thread (a *coopera ## Indexed Color timings The stock Indexed Color dialog exposes Matte: None, Foreground Color, Background Color, -White, Black and Other… (the shared Color Picker). Choosing Other… opens the picker without -replacing the current matte; Cancel leaves that choice unchanged, and OK commits the picked -`#rrggbb`. Automation accepts `matte` as `none`, `foreground`, `background`, -`white`, `black` or `#rrggbb`. A selected matte blends all partially transparent -edges; with Transparency on, fully clear pixels stay transparent. +White, Black, 50% Gray, Netscape Gray and Other… (the shared Color Picker). Choosing Other… +opens the picker without replacing the current matte; Cancel leaves that choice unchanged, and +OK commits the picked `#rrggbb` in document RGB coordinates. Automation accepts `matte` as +`none`, `foreground`, `background`, `white`, `black`, `gray` (50% Gray, encoded +`#808080`), `netscapeGray` (Netscape Gray, encoded `#c0c0c0`) or `#rrggbb`. Foreground/background +tool colours are converted from the +working RGB profile into the document profile before blending. A selected matte blends all +partially transparent edges; with Transparency on, fully clear pixels stay transparent. With Transparency off, the selected matte also fills clear pixels. None retains the existing hard-edged transparency/default flattening behavior. Indexed conversion retains the document's ICC profile and encoded RGB sample representation; matte colours are interpreted in the document