mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 12:46:47 +00:00
fix: preserve document colours for indexed mattes
This commit is contained in:
5 files changed
+190
-15
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user