Files
photocraft/crates/engine/src/fill_layer_mode_tests.rs
T
4062d40070 Color: Dot Gain 20%-compatible default working gray, as in Photoshop (#4610)
* feat(color): Dot Gain 20%-compatible default working gray, as in Photoshop

Photoshop's default working gray is Dot Gain 20%, and its RGB -> Grayscale
measured in Photoshop 27.11 (default Color Settings) is D50 luminance
encoded with gamma 1.732: sRGB 64 / 128 / 192 grey and pure red become
46 / 105 / 177 / 107. PhotoCraft defaulted to sGray, which kept the sRGB
value (64 / 128 / 192 / 130), up to 24 levels off.

Adds the built-in "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"
gray profile (id dot-gain-20-compat; our own fitted curve, not Adobe's
profile, the same gamma PhotoCraft already uses for gray adjustments) and
makes it the default working gray and the fallback for untagged gray
documents. sGray and Gray Gamma 2.2 stay selectable.

This changes a default: untagged gray documents now convert and display
as Photoshop shows them (midtones lighter than under sGray); documents with
an embedded profile are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Status bar test: the default working gray is now Dot Gain 20%-compatible

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* .atn test: #202020 in the Dot Gain 20%-compatible working gray

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:45:09 -04:00

137 lines
5.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Fill layers made in a Grayscale, CMYK or Lab document keep their colours in the document's
//! mode, so they composite as the same colour painted on a pixel layer does (#1905).
use crate::Session;
use photocraft_doc::{ColorMode, Fill, LayerContent};
use serde_json::{Value, json};
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
fn session(mode: &str, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 32, "mode": mode, "depth": depth, "background": "white"})).unwrap();
s.tools.foreground = BLUE;
s.tools.background = GREEN;
s
}
fn pixel(s: &mut Session, x: i32, y: i32) -> [f32; 4] {
let v = s.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
let a: Vec<f32> = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap() as f32).collect();
[a[0], a[1], a[2], a[3]]
}
/// The composite of `color` painted with Edit › Fill on a new pixel layer: what a fill layer of
/// that colour must look like.
fn painted(mode: &str, depth: u32, color: [f32; 4]) -> [f32; 4] {
let mut s = session(mode, depth);
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap();
pixel(&mut s, 10, 10)
}
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
}
fn active_fill(s: &Session) -> Fill {
let st = s.active().unwrap();
match &st.doc.layer(st.active_layer.unwrap()).unwrap().content {
LayerContent::Fill(f) => f.clone(),
other => panic!("not a fill layer: {other:?}"),
}
}
fn fill_colors(f: &Fill) -> Vec<photocraft_doc::Color> {
match f {
Fill::Solid(c) => vec![*c],
Fill::Gradient { stops, .. } => stops.iter().map(|s| s.1).collect(),
Fill::Pattern { .. } => Vec::new(),
}
}
fn assert_in_mode(s: &Session, mode: ColorMode, what: &str) {
let f = active_fill(s);
for c in fill_colors(&f) {
assert_eq!(c.mode, mode, "{what}: {f:?}");
}
}
#[test]
fn solid_color_fill_matches_edit_fill() {
for (mode, cm) in [("gray", ColorMode::Grayscale), ("cmyk", ColorMode::Cmyk), ("lab", ColorMode::Lab)] {
for depth in [8, 16, 32] {
let mut s = session(mode, depth);
s.execute("layer.newFillLayer.solidColor", json!({"color": "#0000ff"})).unwrap();
assert_in_mode(&s, cm, mode);
let got = pixel(&mut s, 10, 10);
let want = painted(mode, depth, BLUE);
assert!(close(got, want, 0.01), "{mode}/{depth}: fill layer {got:?}, painted {want:?}");
if cm == ColorMode::Grayscale {
assert!((got[0] - 0.114).abs() < 0.01 && got[0] == got[1] && got[1] == got[2], "{mode}/{depth}: {got:?}");
}
}
}
}
#[test]
fn gradient_fill_layers_are_gray_in_a_grayscale_document() {
let grey = |p: [f32; 4]| (p[0] - p[1]).abs() < 1e-4 && (p[1] - p[2]).abs() < 1e-4;
let ways: [(&str, Value); 3] = [
("layer.newFillLayer.gradient", json!({"from": "#0000ff", "to": "#00ff00", "angle": 0})),
("gradient.fill.create", json!({"from": [0, 16], "to": [64, 16]})),
("gradient.presets.apply", json!({"preset": "Blue 01", "angle": 0})),
];
for depth in [8, 16, 32] {
for (cmd, p) in &ways {
let mut s = session("gray", depth);
s.execute(cmd, p.clone()).unwrap();
assert_in_mode(&s, ColorMode::Grayscale, cmd);
for x in [1, 32, 62] {
let px = pixel(&mut s, x, 16);
assert!(grey(px), "{cmd} at 8/16/32 = {depth}, x {x}: {px:?}");
}
}
}
}
#[test]
fn gradient_edits_keep_colours_in_the_document_mode() {
let mut s = session("cmyk", 8);
s.execute("gradient.fill.create", json!({"from": [0, 16], "to": [64, 16]})).unwrap();
s.execute("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]]})).unwrap();
assert_in_mode(&s, ColorMode::Cmyk, "gradient.fill.set");
let start = pixel(&mut s, 0, 16);
let red = painted("cmyk", 8, [1.0, 0.0, 0.0, 1.0]);
assert!(close(start, red, 0.02), "{start:?} vs {red:?}");
s.execute("gradient.presets.select", json!({"preset": "Green 01"})).unwrap();
assert_in_mode(&s, ColorMode::Cmyk, "gradient.presets.select");
}
#[test]
fn rgb_documents_keep_rgb_fill_colours() {
let mut s = session("rgb", 8);
s.execute("layer.newFillLayer.solidColor", json!({"color": "#0000ff"})).unwrap();
assert_eq!(active_fill(&s), Fill::Solid(photocraft_doc::Color::rgb(0.0, 0.0, 1.0)));
assert!(close(pixel(&mut s, 10, 10), BLUE, 1e-6));
}
/// A grayscale fill layer converts like any other colour when the mode changes, and a fill made
/// in Gray survives undo/redo unchanged.
#[test]
fn gray_fill_converts_with_the_document() {
let mut s = session("gray", 16);
// Tagged sGray, where a gray value reads the same as RGB, so the before/after pixels compare
// directly (an untagged gray document takes the Dot Gain 20 %-compatible default).
s.execute("edit.assignProfile", json!({"profile": "sgray"})).unwrap();
s.execute("layer.newFillLayer.solidColor", json!({"color": "#0000ff"})).unwrap();
let before = pixel(&mut s, 10, 10);
s.execute("image.mode.rgb", json!({})).unwrap();
assert_in_mode(&s, ColorMode::Rgb, "after Image › Mode › RGB");
let after = pixel(&mut s, 10, 10);
assert!(close(before, after, 0.01), "{before:?} -> {after:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_in_mode(&s, ColorMode::Grayscale, "undo");
}