fix: preserve document colours for indexed mattes

This commit is contained in:
Firstbober committed 2026-11-06 19:14:59 +01:00
1 parent b57ec9f729
commit 2fbec8b533
5 files changed
+190 -15

No files matched your search

+31 -6
View File
@@ -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<Dither> {
})
}
/// 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<Option<[f32; 3]>> {
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<CommandSpec> {
"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],
+61
View File
@@ -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();
+57 -2
View File
@@ -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<Value>
/// Command-specific open defaults (ruler straighten, warp from layer, …), not remembered prefs.
fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map<String, Value>) {
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<String, Value>) {
}
}
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<String, Value>) {
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<String, Value>, 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<String, Value>) {
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<String, Value>) {
("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<String, Value>) {
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()));
@@ -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);
}
}
+8 -5
View File
@@ -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