Files
photocraft/crates/compose/src/tests.rs
T
aiongg 5ecfa852f4 Merge PR #5771: Lab: adjustment results are whole samples, as the reference app stores them (lab8 curves passes)
crates/io/tests/corpus.rs: kept main's pass_floor 188 (TAKE-FIX: drop the PR's 186 hunk)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
2026-10-22 15:37:35 +00:00

3769 lines
181 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.
use super::*;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::{CurvePoint, LevelsChannel, ToneSpace};
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerMask};
use photocraft_geom::Size;
const E: f32 = 2.0 / 255.0;
fn close4(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E)
}
fn doc_white(w: u32, h: u32) -> Document {
Document::with_background("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE)
}
#[test]
fn prepared_tones_match_fresh_tables_and_stay_bounded() {
use photocraft_doc::adjust::ToneSpace;
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for space in [ToneSpace::Rgb, ToneSpace::Cmyk, ToneSpace::Lab] {
for (mut adj, edited) in [
(Adjustment::identity_levels(), false),
(Adjustment::identity_levels(), true),
(Adjustment::identity_curves(), false),
(Adjustment::identity_curves(), true),
] {
match &mut adj {
Adjustment::Levels { master, space: s, .. } => {
if edited {
master.gamma = 1.4;
}
*s = space;
}
Adjustment::Curves { master, space: s, .. } => {
if edited {
master[1].output = 0.8;
}
*s = space;
}
_ => {}
}
let mut doc = Document::new("tones", Size::new(3, 1), ColorMode::Rgb, depth);
doc.layers.push(Layer::group("group", vec![Layer::new("tone", LayerContent::Adjustment(adj.clone()))]));
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&doc, &patterns);
let cached_adj = cx.tone_luts[0].0;
let input = Buffer { rect: doc.bounds(), px: vec![[0.2, 0.4, 0.7, 0.6], [2.0, -0.2, 0.5, 0.5], [2.0, -0.2, 0.5, 0.0]] };
let mut expected = input.clone();
adjust::apply_depth(&adj, &mut expected, cx.transfer, Some(depth));
for a in [cached_adj, cached_adj, &adj] {
let mut actual = input.clone();
cx.apply_adjustment(a, &mut actual);
assert_eq!(actual.px, expected.px);
}
let direct = depth == SampleType::F32 && matches!(adj, Adjustment::Levels { space: ToneSpace::Rgb, .. });
let identity = adjust::float_tone_identity(&adj, Some(depth)) == [true; 3];
assert_eq!(cx.tone_luts[0].1.get().is_none(), direct || identity);
}
}
}
let mut layers: Vec<_> = (0..20).map(|_| Layer::new("tone", LayerContent::Adjustment(Adjustment::identity_levels()))).collect();
layers[0].visible = false;
let tables = prepare_tones(&layers, 0);
assert_eq!(tables.len(), 16);
assert!(tables.iter().all(|(_, cell)| cell.get().is_none()));
assert!(matches!(&layers[1].content, LayerContent::Adjustment(a) if std::ptr::eq(tables[0].0, a)));
assert!(prepare_tones(&[Layer::group("deep", layers)], PREPARE_DEPTH).is_empty());
}
#[test]
fn prepared_adjustments_keep_document_channel_semantics() {
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let doc = Document::new("channels", Size::new(1, 1), mode, depth);
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&doc, &patterns);
for adj in [Adjustment::Invert, Adjustment::BrightnessContrast { brightness: 20.0, contrast: 30.0, legacy: false }] {
let mut actual = Buffer::filled(doc.bounds(), [0.2, 0.4, 0.7, 0.6]);
let mut expected = actual.clone();
adjust::apply_in(&adj, &mut expected, cx.transfer, Some(depth), mode);
cx.apply_adjustment(&adj, &mut actual);
assert_eq!(actual.px, expected.px);
}
}
}
}
#[test]
fn identity_float_curves_match_previous_mapping_bits() {
let adj = Adjustment::identity_curves();
let input = Buffer {
rect: Rect::new(0, 0, 4, 1),
px: vec![[2.0, -0.0, -0.2, 1.0], [f32::from_bits(0x7fc01234), f32::INFINITY, f32::NEG_INFINITY, 0.6], [0.7, -0.0, 1.5, 0.0], [0.1; 4]],
};
let mut previous = input.clone();
adjust::apply_tone(&mut previous, ToneSpace::Rgb, &adjust::tone_luts_depth(&adj, Some(SampleType::F32)), [true; 3]);
let mut actual = input.clone();
adjust::apply_depth(&adj, &mut actual, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
assert_buffer_bits(&actual, &previous);
assert_buffer_bits(&actual, &input);
}
fn solid_layer(name: &str, rect: Rect, rgba: [f32; 4]) -> Layer {
let mut l = Layer::raster(name, PixelFormat::RGBA8);
l.surface_mut().unwrap().fill_rect(rect, &rgba);
l
}
fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] {
render(doc, Rect::from_xywh(x, y, 1, 1)).px[0]
}
#[test]
fn background_only() {
let d = doc_white(8, 8);
assert!(close4(px(&d, 3, 3), [1.0; 4]));
// outside canvas the background layer has no pixels
assert!(close4(px(&d, 20, 3), [0.0; 4]));
}
#[test]
fn normal_layer_over_background() {
let mut d = doc_white(8, 8);
d.layers.push(solid_layer("red", Rect::new(0, 0, 4, 8), [1.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 5, 1), [1.0; 4]));
}
// The pre-optimisation Normal formula, independent of the production helper. Its operation
// order matters: an algebraically equivalent premultiplied mix can round differently.
fn original_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
let ab = backdrop[3];
let as_ = source[3] * opacity;
if as_ <= 0.0 {
return backdrop;
}
let ao = as_ + ab * (1.0 - as_);
if ao <= 0.0 {
return [0.0; 4];
}
let kb = ab * (1.0 - as_);
let inv = 1.0 / ao;
[(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao]
}
#[test]
fn normal_rgb_kernel_preserves_scalar_bits() {
let subnormal = f32::from_bits(1);
let backdrops = [[0.0, -0.0, 2.0, 0.0], [0.2, 0.4, 0.6, 0.5], [2.0, -0.25, 4.0, 1.0], [-1.0, 0.01, 1.0, -0.5], [1.0, -0.0, f32::MIN_POSITIVE, subnormal]];
let sources = [
[0.9, 0.1, 0.3, 1.0],
[0.25, 2.0, -1.0, 0.5],
[subnormal, 1e-20, 1.0, subnormal],
[0.5, 0.2, 0.7, -0.0],
[0.5, 0.2, 0.7, -0.5],
[f32::NAN, 0.2, 0.7, 0.0],
];
for b in backdrops {
for s in sources {
for opacity in [-1.0, -0.0, 0.0, 0.5, 1.0, 2.0] {
let want = original_normal_rgb(b, s, opacity);
let got = psblend::composite_normal_rgb(b, s, opacity);
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{b:?} / {s:?} / {opacity}");
}
}
}
}
/// The vectorized row kernel against the scalar loop it replaced, over row lengths that cover
/// whole blocks, tails and both, with samples full of NaN, signed zeros, infinities, subnormals
/// and values outside 0..1: the same bits, except that a NaN only has to stay NaN.
#[test]
fn normal_rgb_span_preserves_scalar_bits() {
let specials = [0.0, -0.0, 1.0, 0.5, 0.03, -0.5, 2.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::from_bits(1), f32::MIN_POSITIVE, 1e-30];
let mut seed = 0x9e37_79b9u32;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
let mut value = || {
let r = next();
if r % 3 == 0 { specials[(r / 3) as usize % specials.len()] } else { (r % 1000) as f32 / 999.0 }
};
for len in 0..41 {
for _ in 0..40 {
let backdrop: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect();
let source: Vec<[f32; 4]> = (0..len).map(|_| [value(), value(), value(), value()]).collect();
let opacity = value();
let mut want = backdrop.clone();
for (b, s) in want.iter_mut().zip(&source) {
if s[3] <= 0.0 {
continue;
}
*b = original_normal_rgb(*b, *s, opacity);
}
let mut got = backdrop;
psblend::composite_normal_rgb_span(&mut got, &source, opacity);
// IEEE 754 leaves open which operand's NaN an operation passes on (x86 and the
// vector units differ in its sign), so any NaN counts as the same NaN.
let bits = |p: &[f32; 4]| p.map(|v| if v.is_nan() { u32::MAX } else { v.to_bits() });
for (i, (g, w)) in got.iter().zip(&want).enumerate() {
assert_eq!(bits(g), bits(w), "len {len}, pixel {i}, opacity {opacity}: {g:?} vs {w:?}");
}
}
}
}
/// A pixel layer read and blended a row at a time gives exactly what blending a tile-sized copy
/// of it gave, across depths, modes (Dissolve's per-pixel noise and the fill-special modes
/// included), fill, masks (feathered too) and a rect that is not tile-aligned and runs off the
/// layer's pixels.
#[test]
fn pixel_layer_rows_match_tile_copy() {
let rect = Rect::new(-3, 2, 37, 21);
let modes = [
BlendMode::Normal,
BlendMode::Multiply,
BlendMode::Dissolve,
BlendMode::Overlay,
BlendMode::ColorBurn,
BlendMode::LinearBurn,
BlendMode::VividLight,
BlendMode::Difference,
BlendMode::Hue,
];
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let mut l = Layer::raster("p", fmt);
for y in 0..20 {
for x in 0..30 {
let t = (x * 7 + y * 13) as f32 / 400.0;
let a = [0.0, 0.03, 0.5, 1.0][(x + y) as usize % 4];
l.surface_mut().unwrap().write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, [t.fract(), 1.0 - t.fract(), 0.3, a]));
}
}
let mut sharp = LayerMask::reveal_all();
sharp.surface.fill_rect(Rect::new(5, 0, 12, 30), &[0.25]);
let mut feathered = sharp.clone();
feathered.feather = 2.5;
for mask in [None, Some(sharp), Some(feathered)] {
l.mask = mask;
for mode in modes {
for fill in [1.0, 0.4] {
l.blend = mode;
l.opacity = 0.8;
l.fill_opacity = fill;
let mut doc = doc_white(40, 30);
doc.layers.push(l.clone());
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
let cx = Ctx::for_doc(&doc, &patterns);
let layer = &doc.layers[1];
let backdrop = Buffer {
rect,
px: (0..rect.width() * rect.height())
.map(|i| [(i % 11) as f32 / 10.0, (i % 5) as f32 / 4.0, 0.6, [1.0, 0.5, 0.0][i as usize % 3]])
.collect(),
};
let mut want = backdrop.clone();
let content = render_content(layer, rect, &cx).unwrap();
blend_into_fill(&mut want, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer));
let mut got = backdrop;
composite_pixel_layer(layer, pixel_surface(layer).unwrap(), &mut got, &cx);
assert_buffer_bits(&got, &want);
}
}
}
}
}
fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) {
let rect = Rect::new(-3, -2, 5, 0);
let mut bottom = Surface::new(fmt);
let mut top = Surface::new(fmt);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let i = ((y - rect.y0) * rect.width() as i32 + x - rect.x0) as usize;
let t = i as f32 / 16.0;
let b = [2.0 * t, 0.4, -0.25 + t, [0.0, 0.25, 0.75, 1.0][i % 4]];
let s = [0.9 - t, 0.1 + t, 0.3, [0.0, -0.0, 0.5, 1.0][i % 4]];
bottom.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, b));
top.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, s));
}
}
(surface_to_buffer(&bottom, rect), surface_to_buffer(&top, rect))
}
// Keep the old buffer dispatch as an oracle for the specialised loop and all of its fallbacks.
fn original_blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
let rect = backdrop.rect;
let w = rect.width() as i32;
for (i, b) in backdrop.px.iter_mut().enumerate() {
let mut s = src.px[i];
if s[3] <= 0.0 {
continue;
}
let mut mode = mode;
if mode == BlendMode::Dissolve {
let x = rect.x0 + (i as i32 % w);
let y = rect.y0 + (i as i32 / w);
s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 };
mode = BlendMode::Normal;
*b = blend::composite(mode, *b, s, 1.0);
continue;
}
*b = blend::composite_gamma(mode, *b, s, opacity, gamma);
}
}
fn assert_buffer_bits(got: &Buffer, want: &Buffer) {
assert_eq!(got.rect, want.rect);
assert_eq!(got.px.len(), want.px.len());
for (i, (got, want)) in got.px.iter().zip(&want.px).enumerate() {
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "pixel {i}: {got:?} vs {want:?}");
}
}
#[test]
fn normal_rgb_buffer_dispatch_and_fallbacks_preserve_bits() {
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let (bottom, top) = normal_rgb_test_buffers(fmt);
for lab in [false, true] {
psblend::with_lab_mix(lab, || {
psblend::with_hdr(fmt.sample == SampleType::F32, || {
for mode in BlendMode::LAYER_MODES {
for gamma in [1.0, psblend::TEXT_GAMMA] {
for opacity in [0.0, 0.5, 1.0] {
let mut got = bottom.clone();
let mut want = bottom.clone();
original_blend_into_g(&mut want, &top, mode, opacity, gamma);
blend_into_g(&mut got, &top, mode, opacity, gamma);
assert_buffer_bits(&got, &want);
}
}
}
});
});
}
}
}
#[test]
fn normal_rgb_buffer_preserves_alpha_skip_and_nested_scopes() {
let rect = Rect::new(-1, -1, 2, 0);
let bottom = Buffer::filled(rect, [-0.0, 0.4, 2.0, 0.75]);
let top = Buffer { rect, px: vec![[f32::NAN, 0.3, 0.1, -0.5], [0.2, 0.3, 0.1, -0.0], [0.2, 0.3, 0.1, f32::NAN]] };
psblend::with_lab_mix(true, || {
psblend::with_hdr(true, || {
psblend::with_lab_mix(false, || {
psblend::with_hdr(false, || {
let mut got = bottom.clone();
let mut want = bottom.clone();
original_blend_into_g(&mut want, &top, BlendMode::Normal, -1.0, 1.0);
blend_into_g(&mut got, &top, BlendMode::Normal, -1.0, 1.0);
assert_buffer_bits(&got, &want);
assert_eq!(got.px[0].map(f32::to_bits), bottom.px[0].map(f32::to_bits));
assert_eq!(got.px[1].map(f32::to_bits), bottom.px[1].map(f32::to_bits));
});
assert!(psblend::HDR.with(|h| h.get()));
});
assert!(psblend::LAB_MIX.with(|l| l.get()));
assert!(psblend::HDR.with(|h| h.get()));
let mut got = bottom.clone();
let mut want = bottom.clone();
original_blend_into_g(&mut want, &top, BlendMode::Normal, 0.5, 1.0);
blend_into_g(&mut got, &top, BlendMode::Normal, 0.5, 1.0);
assert_buffer_bits(&got, &want);
});
});
}
#[test]
fn opacity_and_fill_multiply() {
let mut d = doc_white(4, 4);
let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]);
l.opacity = 0.5;
l.fill_opacity = 0.5;
d.layers.push(l);
let p = px(&d, 0, 0);
assert!((p[0] - 0.75).abs() <= E, "{p:?}");
}
#[test]
fn hidden_layers_skipped() {
let mut d = doc_white(4, 4);
let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]);
l.visible = false;
d.layers.push(l);
assert!(close4(px(&d, 0, 0), [1.0; 4]));
}
#[test]
fn every_blend_mode_matches_reference_on_opaque_pixels() {
let backdrop = [0.6, 0.3, 0.2, 1.0];
let source = [0.2, 0.7, 0.5, 1.0];
for mode in BlendMode::LAYER_MODES {
if mode == BlendMode::Dissolve {
continue;
}
let mut d = Document::new("b", Size::new(2, 2), ColorMode::Rgb, SampleType::F32);
let mut bottom = Layer::raster("b", PixelFormat::RGBA32F);
bottom.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &backdrop);
let mut top = Layer::raster("t", PixelFormat::RGBA32F);
top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &source);
top.blend = mode;
d.layers = vec![bottom, top];
let got = px(&d, 0, 0);
// A 32-bit document: Add / Divide don't clip at 1 (Divide gives 0.6 / 0.2 = 3 in red).
psblend::HDR.with(|h| h.set(true));
let want = blend::blend_rgb(mode, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
psblend::HDR.with(|h| h.set(false));
for i in 0..3 {
assert!((got[i] - want[i]).abs() < 1e-5, "{mode:?}: {got:?} vs {want:?}");
}
assert!((got[3] - 1.0).abs() < 1e-6);
}
}
/// One opaque pixel: `top` in `mode` over `bottom`, in a document of `depth`.
fn blend_px(depth: SampleType, mode: BlendMode, bottom: [f32; 3], top: [f32; 3]) -> [f32; 4] {
let mut d = Document::new("b", Size::new(1, 1), ColorMode::Rgb, depth);
let fmt = d.pixel_format();
let mut b = Layer::raster("b", fmt);
b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &photocraft_raster::from_rgba(&fmt, [bottom[0], bottom[1], bottom[2], 1.0]));
let mut t = Layer::raster("t", fmt);
t.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &photocraft_raster::from_rgba(&fmt, [top[0], top[1], top[2], 1.0]));
t.blend = mode;
d.layers = vec![b, t];
px(&d, 0, 0)
}
#[test]
fn non_separable_modes_keep_values_above_one_in_32_bit() {
use photocraft_color::blend::{blend_rgb_within, lum};
let modes = [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity];
// Hue over a grey of 2: the grey (the backdrop has no saturation), not ClipColor dividing
// by the rounding noise of `max − lum` (Hue over (4, 3.9999, 4) gave 27.7 in blue).
for bg in [[2.0, 2.0, 2.0], [2.0, 2.0, 1.999_99], [4.0, 3.999_9, 4.0]] {
for m in modes {
for top in [[3.0, 0.25, 0.125], [2.0, 2.0, 2.0], [4.0, 3.999_9, 4.0]] {
let got = blend_px(SampleType::F32, m, bg, top);
let want = blend_rgb_within(m, bg, top, f32::MAX);
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-4 * w.abs().max(1.0)), "{m:?} {bg:?} {top:?}: {got:?} vs {want:?}");
assert!(got.iter().all(|v| v.is_finite() && *v >= -1e-5), "{m:?} {bg:?} {top:?}: {got:?}");
}
}
}
// A saturated colour keeps its brightness above 1 in 32-bit, and is clipped into 0..1
// (exactly as before) in 8/16-bit.
let (bg, top) = ([0.5, 0.5, 0.5], [1.0, 0.0, 0.0]);
let hdr = blend_px(SampleType::F32, BlendMode::Color, bg, top);
assert!((hdr[0] - 1.2).abs() < 1e-4 && (lum([hdr[0], hdr[1], hdr[2]]) - 0.5).abs() < 1e-4, "{hdr:?}");
for depth in [SampleType::U8, SampleType::U16] {
for m in modes {
let got = blend_px(depth, m, bg, top);
let want = photocraft_color::blend::blend_rgb(m, bg, top);
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0 / 255.0), "{m:?} {depth:?}: {got:?} vs {want:?}");
}
let got = blend_px(depth, BlendMode::Color, bg, top);
assert!(got[0] <= 1.0 && got[0] > 0.9, "{depth:?}: {got:?}");
}
// A 32-bit document's in-range colours that stay in range are unchanged.
for m in modes {
let got = blend_px(SampleType::F32, m, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
let want = photocraft_color::blend::blend_rgb(m, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-5), "{m:?}: {got:?} vs {want:?}");
}
}
#[test]
fn layer_mask_hides_pixels() {
let mut d = doc_white(4, 4);
let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]);
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(0, 0, 2, 4), &[0.0]);
l.mask = Some(m);
d.layers.push(l);
assert!(close4(px(&d, 0, 0), [1.0; 4]));
assert!(close4(px(&d, 3, 0), [0.0, 0.0, 0.0, 1.0]));
}
#[test]
fn disabled_mask_is_ignored() {
let mut d = doc_white(2, 2);
let mut l = solid_layer("k", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]);
let mut m = LayerMask::hide_all();
m.enabled = false;
l.mask = Some(m);
d.layers.push(l);
// CMYK displays through the built-in CMYK profile: 100 % K alone is a dark neutral
// (as in any real CMYK profile), not pure black.
let p = px(&d, 0, 0);
assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}");
}
#[test]
fn clipping_mask_restricts_to_base_alpha() {
let mut d = doc_white(8, 8);
d.layers.push(solid_layer("base", Rect::new(0, 0, 4, 8), [0.0, 0.0, 1.0, 1.0]));
let mut clip = solid_layer("clip", Rect::new(0, 0, 8, 8), [1.0, 0.0, 0.0, 1.0]);
clip.clipped = true;
d.layers.push(clip);
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]), "inside base: clipped layer visible");
assert!(close4(px(&d, 6, 1), [1.0; 4]), "outside base: clipped layer hidden");
}
#[test]
fn hidden_base_hides_clipping_group() {
let mut d = doc_white(4, 4);
let mut base = solid_layer("base", Rect::new(0, 0, 4, 4), [0.0, 0.0, 1.0, 1.0]);
base.visible = false;
d.layers.push(base);
let mut clip = solid_layer("clip", Rect::new(0, 0, 4, 4), [1.0, 0.0, 0.0, 1.0]);
clip.clipped = true;
d.layers.push(clip);
assert!(close4(px(&d, 1, 1), [1.0; 4]));
}
#[test]
fn isolated_group_differs_from_pass_through() {
// A Multiply layer inside a group: pass-through multiplies with the background,
// an isolated (Normal) group multiplies only with its own (empty) contents.
let make = |blend: BlendMode| {
let mut d = doc_white(2, 2);
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.5, 0.5, 0.5, 1.0]);
let mut m = solid_layer("m", Rect::new(0, 0, 2, 2), [0.5, 0.5, 0.5, 1.0]);
m.blend = BlendMode::Multiply;
let mut g = Layer::group("g", vec![m]);
g.blend = blend;
d.layers.push(g);
px(&d, 0, 0)
};
let pass = make(BlendMode::PassThrough);
let iso = make(BlendMode::Normal);
assert!((pass[0] - 0.25).abs() <= E, "{pass:?}");
assert!((iso[0] - 0.5).abs() <= E, "{iso:?}");
}
/// psd-tools passthrough_fill_adjustment.psd: below 100% fill a pass-through group is isolated,
/// so an adjustment inside it no longer changes the layers beneath.
#[test]
fn pass_through_group_below_full_fill_is_isolated() {
let make = |fill: f32| {
let mut d = doc_white(2, 2);
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
g.fill_opacity = fill;
d.layers.push(g);
px(&d, 0, 0)
};
assert!(close4(make(1.0), [0.0, 0.0, 0.0, 1.0]), "full fill: the invert reaches the background");
assert!(close4(make(0.4), [1.0; 4]), "isolated: nothing beneath to invert");
}
/// psd-tools layer_mask_data.psd: a mask feather blurs the mask edge (CPU and the GPU's
/// combined mask share `masks::combined_mask`).
#[test]
fn mask_feather_blurs_the_edge() {
let mut d = doc_white(64, 8);
let mut l = solid_layer("k", Rect::new(0, 0, 64, 8), [0.0, 0.0, 0.0, 1.0]);
let mut m = LayerMask::hide_all();
m.surface.fill_rect(Rect::new(0, 0, 32, 8), &[1.0]);
l.mask = Some(m);
d.layers.push(l.clone());
assert!(px(&d, 31, 4)[0] < 0.01 && px(&d, 32, 4)[0] > 0.99, "sharp without feather");
if let Some(m) = &mut d.layers[1].mask {
m.feather = 8.0;
}
let (a, b) = (px(&d, 31, 4)[0], px(&d, 32, 4)[0]);
assert!(a > 0.3 && a < 0.5 && b > 0.5 && b < 0.7, "soft edge around 50%: {a} {b}");
assert!(px(&d, 4, 4)[0] < 0.01 && px(&d, 60, 4)[0] > 0.99, "far from the edge unchanged");
}
#[test]
fn group_opacity_applies_once() {
let mut d = doc_white(2, 2);
let a = solid_layer("a", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]);
let b = solid_layer("b", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]);
let mut g = Layer::group("g", vec![a, b]);
g.blend = BlendMode::Normal;
g.opacity = 0.5;
d.layers.push(g);
assert!((px(&d, 0, 0)[0] - 0.5).abs() <= E);
}
#[test]
fn pass_through_group_opacity_mixes() {
let mut d = doc_white(2, 2);
let a = solid_layer("a", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]);
let mut g = Layer::group("g", vec![a]);
g.opacity = 0.25;
d.layers.push(g);
assert!((px(&d, 0, 0)[0] - 0.75).abs() <= E);
// Opaque backdrops hide straight-alpha interpolation errors.
let mut transparent = Document::new("coverage", Size::new(2, 2), ColorMode::Rgb, SampleType::F32);
let mut red = Layer::raster("HDR red", transparent.pixel_format());
red.surface_mut().unwrap().fill_rect(transparent.bounds(), &[2.0, 0.0, 0.0, 1.0]);
let mut group = Layer::group("half", vec![red]);
group.opacity = 0.5;
transparent.layers.push(group);
let actual = px(&transparent, 0, 0);
assert!(close4(actual, [2.0, 0.0, 0.0, 0.5]), "{actual:?}");
}
#[test]
fn invert_adjustment_layer() {
let mut d = doc_white(2, 2);
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.2, 0.4, 0.6, 1.0]);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(close4(px(&d, 0, 0), [0.8, 0.6, 0.4, 1.0]));
}
#[test]
fn adjustment_opacity_and_mask() {
let mut d = doc_white(4, 1);
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 1), &[0.0, 0.0, 0.0, 1.0]);
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
adj.opacity = 0.5;
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(2, 0, 4, 1), &[0.0]);
adj.mask = Some(m);
d.layers.push(adj);
assert!((px(&d, 0, 0)[0] - 0.5).abs() <= E);
assert!(px(&d, 3, 0)[0].abs() <= E);
}
#[test]
fn clipped_adjustment_only_affects_base() {
let mut d = doc_white(4, 1);
d.layers.push(solid_layer("base", Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0]));
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
adj.clipped = true;
d.layers.push(adj);
assert!(close4(px(&d, 0, 0), [1.0; 4]), "base inverted to white");
assert!(close4(px(&d, 3, 0), [1.0; 4]), "background untouched (white)");
}
/// #6535: showing or hiding a clipped adjustment changes only its base's pixels, so its change
/// bounds are the base's; it still reaches everywhere without a base, or clipped to an
/// adjustment, or to a pass-through group whose adjustment it isolates.
#[test]
fn clipped_adjustment_changes_only_its_base() {
let canvas = Rect::new(0, 0, 8, 6);
let base_rect = Rect::new(2, 1, 5, 4);
let mut d = doc_white(8, 6);
d.layers.push(solid_layer("base", base_rect, [0.2, 0.4, 0.6, 1.0]));
let mut over = solid_layer("over", Rect::new(3, 2, 4, 3), [0.9, 0.1, 0.1, 1.0]);
over.clipped = true;
d.layers.push(over);
let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
adj.clipped = true;
d.layers.push(adj);
assert_eq!(change_bounds(&d.layers[3], canvas), None);
assert_eq!(change_bounds_in(&d.layers, 3, None, canvas), Some(base_rect));
// Every pixel the toggle changes lies inside those bounds.
let shown = render(&d, canvas);
d.layers[3].visible = false;
let hidden = render(&d, canvas);
let mut changed = Rect::EMPTY;
for (i, (a, b)) in shown.px.iter().zip(&hidden.px).enumerate() {
if !close4(*a, *b) {
let (x, y) = ((i % 8) as i32, (i / 8) as i32);
assert!(base_rect.contains(x, y), "pixel {x},{y} changed outside the base");
let p = Rect::from_xywh(x, y, 1, 1);
changed = if changed.is_empty() { p } else { changed.union(&p) };
}
}
assert_eq!(changed, base_rect);
// Other layers keep their own bounds.
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(3, 2, 4, 3)));
assert_eq!(change_bounds_in(&d.layers, 9, None, canvas), None);
// Unclipped, or with no base beneath it, it reaches everything.
let mut free = d.layers.clone();
free[3].clipped = false;
assert_eq!(change_bounds_in(&free, 3, None, canvas), None);
let orphan = vec![d.layers[3].clone()];
assert_eq!(change_bounds_in(&orphan, 0, None, canvas), None);
// Clipped layers on an adjustment composite atop the adjusted backdrop.
let on_adj = vec![d.layers[0].clone(), Layer::new("lv", LayerContent::Adjustment(Adjustment::Invert)), d.layers[3].clone()];
assert_eq!(change_bounds_in(&on_adj, 2, None, canvas), None);
// A pass-through group holding an adjustment is isolated only while it has visible clipped
// layers, so they change whether its adjustment reaches the layers beneath.
let mut g = Layer::group("g", vec![solid_layer("in", base_rect, [0.2, 0.4, 0.6, 1.0]), Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
let on_group = vec![d.layers[0].clone(), g, d.layers[3].clone()];
assert_eq!(change_bounds_in(&on_group, 2, None, canvas), None);
}
/// #6535: a clipped adjustment with no base beneath it, inside a pass-through group, composites
/// as an ordinary adjustment, so hiding the group or the layer clipped to it (which ends its
/// isolation) changes the whole canvas, not just the group's pixels.
#[test]
fn orphan_clipped_adjustment_in_pass_through_base_reaches_everything() {
let canvas = Rect::new(0, 0, 8, 6);
let mut d = doc_white(8, 6);
let mut orphan = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert));
orphan.clipped = true;
let mut g = Layer::group("G", vec![orphan, solid_layer("P", Rect::new(2, 1, 4, 3), [0.2, 0.4, 0.6, 1.0])]);
g.blend = BlendMode::PassThrough;
d.layers.push(g);
let mut x = Layer::new("X", LayerContent::Adjustment(Adjustment::Invert));
x.clipped = true;
d.layers.push(x);
let shown = px(&d, 0, 0);
d.layers[2].visible = false;
assert!(!close4(shown, px(&d, 0, 0)), "the toggle changes pixels outside the group");
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), None);
assert_eq!(change_bounds(&d.layers[1], canvas), None);
// With a base beneath it the clipped adjustment stays inside the group.
let LayerContent::Group(g) = &mut d.layers[1].content else { panic!("group") };
g.children.swap(0, 1);
assert_eq!(change_bounds_in(&d.layers, 2, None, canvas), Some(Rect::new(2, 1, 4, 3)));
}
/// A pass-through group that is a clip base renders isolated while it holds a visible
/// adjustment, so hiding a clipped adjustment inside it changes the group's other children too:
/// its damage is the group's, not just its base's.
#[test]
fn clipped_adjustment_in_pass_through_clip_base_reaches_its_group() {
let canvas = Rect::new(0, 0, 10, 8);
let mut d = doc_white(10, 8);
d.layers.push(solid_layer("bg", canvas, [0.5, 0.5, 0.5, 1.0]));
let mut a = Layer::new("A", LayerContent::Adjustment(Adjustment::Invert));
a.clipped = true;
let mut q = solid_layer("Q", Rect::new(6, 4, 8, 6), [0.5, 0.5, 0.5, 1.0]);
q.blend = BlendMode::Multiply;
let mut g = Layer::group("G", vec![solid_layer("P", Rect::new(2, 1, 4, 3), [0.2, 0.4, 0.6, 1.0]), a, q]);
g.blend = BlendMode::PassThrough;
d.layers.push(g);
let mut x = solid_layer("X", Rect::new(2, 1, 3, 2), [1.0, 0.0, 0.0, 1.0]);
x.clipped = true;
d.layers.push(x);
let shown = px(&d, 7, 5);
let LayerContent::Group(g) = &mut d.layers[2].content else { panic!("group") };
g.children[1].visible = false;
assert!(!close4(shown, px(&d, 7, 5)), "hiding A changes Q, outside A's base");
let group = Rect::new(2, 1, 8, 6);
let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") };
assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(group));
// Not a clip base: the group stays pass-through either way, and A changes only its base.
d.layers.truncate(3);
let LayerContent::Group(g) = &d.layers[2].content else { panic!("group") };
assert_eq!(change_bounds_in(&g.children, 1, Some((&d.layers, 2)), canvas), Some(Rect::new(2, 1, 4, 3)));
}
#[test]
fn threshold_posterize_levels_curves() {
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.3, 0.3, 1.0]);
adjust::apply(&Adjustment::Threshold { level: 0.5 }, &mut buf);
assert!(close4(buf.px[0], [0.0, 0.0, 0.0, 1.0]));
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.6, 0.9, 1.0]);
adjust::apply(&Adjustment::Posterize { levels: 2 }, &mut buf);
assert!(close4(buf.px[0], [0.0, 1.0, 1.0, 1.0]));
let lv = LevelsChannel { in_black: 0.2, in_white: 0.8, ..Default::default() };
assert!((adjust::levels(&lv, 0.5) - 0.5).abs() < 1e-6);
assert!((adjust::levels(&lv, 0.2)).abs() < 1e-6);
assert!((adjust::levels(&lv, 0.9) - 1.0).abs() < 1e-6);
let identity = adjust::curve_lut(&[CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]);
for (i, v) in identity.iter().enumerate().step_by(97) {
assert!((v - i as f32 / (identity.len() - 1) as f32).abs() < 1e-4);
}
let s = adjust::curve_lut(&[
CurvePoint { input: 0.0, output: 0.0 },
CurvePoint { input: 0.25, output: 0.15 },
CurvePoint { input: 0.75, output: 0.85 },
CurvePoint { input: 1.0, output: 1.0 },
]);
assert!(s.windows(2).all(|w| w[1] >= w[0] - 1e-6), "monotone");
assert!(s[s.len() / 4] < 0.25 && s[3 * s.len() / 4] > 0.75);
}
#[test]
fn hue_saturation_roundtrips_and_desaturates() {
let c = [0.8, 0.3, 0.1];
let (h, s, l) = adjust::rgb_to_hsl(c);
let back = adjust::hsl_to_rgb(h, s, l);
for i in 0..3 {
assert!((back[i] - c[i]).abs() < 1e-5);
}
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.8, 0.3, 0.1, 1.0]);
adjust::apply(
&Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
&mut buf,
);
let p = buf.px[0];
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]);
adjust::apply(
&Adjustment::HueSaturation { hue: 120.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
&mut buf,
);
assert!(close4(buf.px[0], [0.0, 1.0, 0.0, 1.0]));
}
#[test]
fn solid_and_gradient_fill_layers() {
let mut d = doc_white(10, 1);
d.layers.push(Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.0, 0.0, 1.0)))));
assert!(close4(px(&d, 5, 0), [0.0, 0.0, 1.0, 1.0]));
let g = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None);
// tile independence: a 1px render of the right edge equals the full render
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None);
assert_eq!(one.px[0], buf.px[9]);
assert!(buf.px[0][0] < buf.px[9][0], "left dark, right light");
}
fn dithered_gradient(stops: Vec<(f32, Color)>, angle: f32) -> Fill {
let mut f = Fill::gradient(stops, angle, 1.0, photocraft_doc::GradientStyle::Linear, false);
if let Fill::Gradient { dither, .. } = &mut f {
*dither = true;
}
f
}
/// #2755: a dithered gradient fill under a bright Color Dodge gradient. Dodge divides by
/// `1 − cs`, so the dither's fraction of a level, left in float, came out as speckle several
/// levels high (a black channel lifted to 4/255: coloured fringes). On an 8-bit document the
/// dithered pixels sit on levels, as a painted gradient's do, so a channel at 0 stays 0.
#[test]
fn dithered_gradient_fills_under_color_dodge_keep_black_channels() {
let mut d = doc_white(128, 32);
// Red to blue: green is 0 everywhere.
let bottom = dithered_gradient(vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], 0.0);
d.layers.push(Layer::new("bottom", LayerContent::Fill(bottom)));
let top = dithered_gradient(vec![(0.0, Color::rgb(0.88, 0.88, 0.88)), (1.0, Color::rgb(0.97, 0.97, 0.97))], 90.0);
let mut l = Layer::new("top", LayerContent::Fill(top));
l.blend = BlendMode::ColorDodge;
d.layers.push(l);
for (i, p) in flatten(&d).px.iter().enumerate() {
assert_eq!(p[1], 0.0, "pixel {i}: green lifted off black to {} levels", p[1] * 255.0);
}
}
/// Dithered gradient pixels land on the depth's levels; float documents and undithered
/// gradients keep their float values.
#[test]
fn dithered_gradient_fill_is_rounded_to_the_document_depth() {
let stops = vec![(0.0, Color::rgb(0.1, 0.4, 0.0)), (1.0, Color::rgb(0.9, 0.2, 1.0))];
let rect = Rect::new(0, 0, 64, 8);
let f = dithered_gradient(stops.clone(), 0.0);
for depth in [SampleType::U8, SampleType::U16] {
let q = adjustment_quantum(depth).unwrap();
for p in gradient_fill::render_quantized(&f, rect, rect, Some(q)) {
for v in &p[..3] {
assert_eq!((v * q).round() / q, *v, "{depth:?}: {v} is between levels");
}
}
}
assert_eq!(gradient_fill::render_quantized(&f, rect, rect, None), gradient_fill::render(&f, rect, rect));
let plain = Fill::gradient(stops, 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
assert_eq!(gradient_fill::render_quantized(&plain, rect, rect, Some(255.0)), gradient_fill::render(&plain, rect, rect));
}
#[test]
fn dissolve_coverage_matches_opacity() {
let mut d = Document::new("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
let mut l = solid_layer("k", Rect::new(0, 0, 64, 64), [0.0, 0.0, 0.0, 1.0]);
l.blend = BlendMode::Dissolve;
l.opacity = 0.3;
d.layers.push(l);
let b = flatten(&d);
let covered = b.px.iter().filter(|p| p[3] > 0.5).count() as f32 / b.px.len() as f32;
assert!((covered - 0.3).abs() < 0.05, "{covered}");
// deterministic
assert_eq!(flatten(&d), b);
}
#[test]
fn render_is_tile_independent() {
// Rendering a sub-rect must equal the same region of a full render.
let mut d = doc_white(300, 300);
let mut l = solid_layer("a", Rect::new(20, 20, 280, 280), [0.1, 0.5, 0.9, 0.7]);
l.blend = BlendMode::Overlay;
d.layers.push(l);
let full = flatten(&d);
let sub = render(&d, Rect::new(250, 250, 270, 262));
for y in 250..262 {
for x in 250..270 {
assert_eq!(full.get(x, y), sub.get(x, y));
}
}
}
/// A Lab Invert layer in a 16-bit document keeps a grey neutral (the compositor passes the
/// document's depth to `adjust::apply_in`).
#[test]
fn a_16_bit_lab_invert_layer_keeps_greys_neutral() {
let mut d = Document::new("lab", Size::new(2, 2), ColorMode::Lab, SampleType::U16);
let mut bg = Layer::raster("bg", d.pixel_format());
bg.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
d.layers.push(bg);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
let p = px(&d, 1, 1);
let lab = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]]);
assert!((lab[0] - 75.0).abs() < 0.1 && lab[1].abs() < 0.05 && lab[2].abs() < 0.05, "{lab:?}");
}
/// A CMYK document of one opaque raster layer holding `ink` everywhere, at `depth`.
fn cmyk_doc(ink: [f32; 4], depth: SampleType) -> Document {
let mut d = Document::new("c", Size::new(4, 4), ColorMode::Cmyk, depth);
let mut l = Layer::raster("bg", d.pixel_format());
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[ink[0], ink[1], ink[2], ink[3], 1.0]);
d.layers.push(l);
d
}
/// Adjustment layers Photoshop evaluates on the inks composite on the document's own inks, not on
/// a separation of the RGB composite: exact for Invert, Posterize, Threshold, Brightness/Contrast
/// and CMYK Levels / Curves (photoshop corpus cmyk8 and cmyk16 oracles), through opacity and an
/// (unfeathered) mask as linear ink mixes.
#[test]
fn cmyk_ink_adjustments_composite_on_the_documents_inks() {
use photocraft_color::convert::cmyk_to_rgb;
let ink = [0.2, 0.4, 0.6, 0.1];
let rgb = |i: [f32; 4]| {
let c = cmyk_to_rgb(i);
[c[0], c[1], c[2], 1.0]
};
for depth in [SampleType::U8, SampleType::U16] {
let mut d = cmyk_doc(ink, depth);
// Stored inks, as the document's surface rounds them.
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| 1.0 - v))), "{depth:?} invert");
// Half opacity and a half mask mix the inks linearly: a quarter of the way.
d.layers[1].opacity = 0.5;
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.5]);
d.layers[1].mask = Some(m);
let k = 0.5 * d.layers[1].mask.as_ref().unwrap().value(1, 1);
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| v + (1.0 - 2.0 * v) * k))), "{depth:?} mixed");
// Brightness/Contrast curves each ink's stored value.
let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, true);
d.layers[1] = Layer::new("bc", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy: true }));
assert!(close4(px(&d, 1, 1), rgb(stored.map(|v| adjust::ink_through(&table, v)))), "{depth:?} brightness/contrast");
// Flattening keeps the adjusted inks exactly, with no trip through RGB.
let flat = flatten_to_surface(&d, d.pixel_format(), None);
let got = flat.pixel(2, 2);
for c in 0..4 {
let want = adjust::ink_through(&table, stored[c]);
let q = if depth == SampleType::U8 { 255.0 } else { 65535.0 };
assert!((got[c] - want).abs() <= 0.5 / q + 1e-6, "{depth:?} flattened ink {c}: {} vs {want}", got[c]);
}
}
}
/// Only stacks with ink adjustments take the ink compositor for display: plain CMYK layers keep
/// the RGB compositor (which the GPU matches), and so does any stack with an adjustment the inks
/// don't carry or a layer they can't composite.
#[test]
fn only_cmyk_stacks_with_ink_adjustments_render_on_their_inks() {
let r = Rect::new(0, 0, 4, 4);
let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8);
assert!(native::render_cmyk(&d, r, 64).is_none(), "plain layers");
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(native::render_cmyk(&d, r, 64).is_some());
let exposure = Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 };
d.layers.push(Layer::new("exposure", LayerContent::Adjustment(exposure)));
assert!(native::render_cmyk(&d, r, 64).is_none(), "an adjustment the inks don't carry");
d.layers.pop();
d.layers[1].blend = BlendMode::Multiply;
assert!(native::render_cmyk(&d, r, 64).is_none(), "a blend mode");
d.layers[1].blend = BlendMode::Normal;
let mut rgb = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U8);
rgb.mode = ColorMode::Rgb;
rgb.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(native::render_cmyk(&rgb, r, 64).is_none(), "not a CMYK document");
// Tiles render alike whatever their size.
let whole = render_tiled(&d, d.bounds(), 64);
assert_eq!(render_tiled(&d, d.bounds(), 1).px, whole.px);
}
/// Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with the
/// black ink kept (photoshop corpus cmyk16 hue-saturation.psd: every ink within half a level).
#[test]
fn cmyk_hue_saturation_and_color_balance_keep_the_black_ink() {
use photocraft_color::convert::cmyk_to_rgb;
let ranges = photocraft_doc::adjust::HueRange::defaults();
let hue = Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: ranges.clone() };
let balance =
Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true };
for adj in [hue, balance] {
assert!(adjust::cmy_adjustment(&adj) && adjust::cmyk_channel_adjustment(&adj));
let mut d = cmyk_doc([0.2, 0.7, 0.1, 0.3], SampleType::U16);
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone())));
// The RGB adjustment on the inks' complement, black untouched.
let mut rgb = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0 - stored[0], 1.0 - stored[1], 1.0 - stored[2], 1.0]);
adjust::apply(&adj, &mut rgb);
let want = [1.0 - rgb.px[0][0], 1.0 - rgb.px[0][1], 1.0 - rgb.px[0][2], stored[3]];
assert!(native::render_cmyk(&d, d.bounds(), 64).is_some(), "{}", adj.label());
let c = cmyk_to_rgb(want);
assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "{}", adj.label());
let flat = flatten_to_surface(&d, d.pixel_format(), None);
let got = flat.pixel(1, 1);
assert!((got[3] - stored[3]).abs() < 1e-4, "{}: black kept", adj.label());
assert!((0..3).all(|i| (got[i] - want[i]).abs() < 1e-3), "{}: {got:?} vs {want:?}", adj.label());
}
// Colorize separates its colour again (black included), so it stays on the screen colour.
let colorize = Adjustment::HueSaturation { hue: -160.0, saturation: 40.0, lightness: 5.0, colorize: true, ranges };
assert!(!adjust::cmy_adjustment(&colorize) && !adjust::cmyk_channel_adjustment(&colorize));
}
/// Groups at full opacity without a mask are inlined: a pass-through group's adjustment reaches
/// the layers below it, and an isolated group of raster layers composites as its children would.
/// An isolated group's adjustment (which only sees the group) or a translucent group keeps the
/// RGB compositor.
#[test]
fn cmyk_ink_compositing_inlines_open_groups() {
use photocraft_color::convert::cmyk_to_rgb;
let r = Rect::new(0, 0, 4, 4);
let mut d = cmyk_doc([0.2, 0.4, 0.6, 0.1], SampleType::U16);
let stored: [f32; 4] = std::array::from_fn(|c| d.layers[0].surface().unwrap().pixel(0, 0)[c]);
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
d.layers.push(g);
let c = cmyk_to_rgb(stored.map(|v| 1.0 - v));
assert!(close4(px(&d, 1, 1), [c[0], c[1], c[2], 1.0]), "a pass-through group's Invert reaches the inks below");
d.layers[1].blend = BlendMode::Normal;
assert!(native::render_cmyk(&d, r, 64).is_none(), "an isolated group's adjustment");
d.layers[1].blend = BlendMode::PassThrough;
d.layers[1].opacity = 0.5;
assert!(native::render_cmyk(&d, r, 64).is_none(), "a translucent group");
// An isolated group of raster layers, with an adjustment above it, still composites on the inks.
let mut top = Layer::raster("top", d.pixel_format());
top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 4), &[0.9, 0.0, 0.0, 0.0, 1.0]);
d.layers[1] = Layer::group("raster", vec![top]);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
assert!(native::render_cmyk(&d, r, 64).is_some());
let c = cmyk_to_rgb(stored.map(|v| 1.0 - v));
assert!(close4(px(&d, 3, 1), [c[0], c[1], c[2], 1.0]), "outside the group's layer");
}
#[test]
fn cmyk_document_renders_via_rgb() {
let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8);
let mut l = Layer::raster("k", d.pixel_format());
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 0.0, 0.0, 1.0, 1.0]);
d.layers.push(l);
// CMYK displays through the built-in CMYK profile: 100 % K alone is a dark neutral
// (as in any real CMYK profile), not pure black.
let p = px(&d, 0, 0);
assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}");
}
/// Transparent pixels convert to colour in CMYK (K = 100%) and Lab (a/b = 0.5). A flattened
/// surface must still treat them as empty, or its content spans the whole canvas.
#[test]
fn flatten_to_surface_leaves_transparent_pixels_empty_in_every_mode() {
let modes = [
(ColorMode::Rgb, [SampleType::U8, SampleType::U16, SampleType::F32].as_slice()),
(ColorMode::Grayscale, &[SampleType::U8, SampleType::U16, SampleType::F32]),
(ColorMode::Cmyk, &[SampleType::U8, SampleType::U16]),
(ColorMode::Lab, &[SampleType::U8, SampleType::U16]),
];
for (mode, samples) in modes {
for &sample in samples {
let mut d = Document::new("t", Size::new(300, 200), mode, sample);
let fmt = PixelFormat::new(mode, sample, true);
let mut l = Layer::raster("dot", fmt);
let opaque: Vec<f32> = photocraft_raster::from_rgba(&fmt, [0.9, 0.4, 0.1, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(120, 80, 140, 90), &opaque);
d.layers.push(l);
let s = flatten_to_surface(&d, fmt, None);
assert_eq!(s.content_bounds(), Rect::new(120, 80, 140, 90), "{mode:?} {sample:?}");
// Over a background everything is content, as before.
let fmt_opaque = PixelFormat::new(mode, sample, false);
let s = flatten_to_surface(&d, fmt_opaque, Some([1.0, 1.0, 1.0]));
assert_eq!(s.rgba(5, 5)[3], 1.0, "{mode:?} {sample:?}: background stays opaque");
}
}
}
#[test]
fn sixteen_bit_and_float_layers_composite() {
for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let mut d = Document::new("x", Size::new(2, 2), ColorMode::Rgb, fmt.sample);
let mut l = Layer::raster("a", fmt);
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.25, 0.5, 0.75, 1.0]);
d.layers.push(l);
assert!(close4(px(&d, 1, 1), [0.25, 0.5, 0.75, 1.0]), "{fmt:?}");
}
}
/// `top` blended with `mode` over an opaque `bg`, both solid, in a 4×4 RGB document of `depth`.
fn two_solid_layers(depth: SampleType, mode: BlendMode, bg: [f32; 3], top: [f32; 3]) -> Document {
let mut d = Document::new("hdr", Size::new(4, 4), ColorMode::Rgb, depth);
let fmt = d.pixel_format();
let mut b = Layer::raster("bg", fmt);
b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [bg[0], bg[1], bg[2], 1.0]));
let mut t = Layer::raster("top", fmt);
t.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [top[0], top[1], top[2], 1.0]));
t.blend = mode;
d.layers.push(b);
d.layers.push(t);
d
}
#[test]
fn float_documents_add_and_divide_past_white() {
// 32-bit: Linear Dodge (Add) and Divide keep HDR values (adding light never darkens).
let (bg, top) = ([2.0, 0.5, 4.0], [0.5, 0.25, 0.5]);
for (mode, want) in [(BlendMode::LinearDodge, [2.5, 0.75, 4.5]), (BlendMode::Divide, [4.0, 2.0, 8.0]), (BlendMode::Multiply, [1.0, 0.125, 2.0])] {
let d = two_solid_layers(SampleType::F32, mode, bg, top);
for buf in [flatten(&d), render_tiled(&d, d.bounds(), 2)] {
for p in &buf.px {
assert!(close4(*p, [want[0], want[1], want[2], 1.0]), "{mode:?}: {p:?}");
}
}
}
// The flag doesn't leak into later renders on this thread.
let d = two_solid_layers(SampleType::U16, BlendMode::LinearDodge, [0.8, 0.5, 0.6], top);
assert!(close4(flatten(&d).px[0], [1.0, 0.75, 1.0, 1.0]));
}
#[test]
fn integer_documents_clip_add_and_divide_at_white() {
let (bg, top) = ([0.8, 0.5, 0.6], [0.5, 0.25, 0.5]);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let f32_doc = depth == SampleType::F32;
for (mode, clipped, hdr) in [(BlendMode::LinearDodge, [1.0, 0.75, 1.0], [1.3, 0.75, 1.1]), (BlendMode::Divide, [1.0, 1.0, 1.0], [1.6, 2.0, 1.2])] {
let want = if f32_doc { hdr } else { clipped };
let p = flatten(&two_solid_layers(depth, mode, bg, top)).px[5];
assert!(close4(p, [want[0], want[1], want[2], 1.0]), "{mode:?} {depth:?}: {p:?}");
}
}
}
#[test]
fn thumbnail_dimensions() {
let d = doc_white(400, 200);
let t = thumbnail(&d, 100);
assert_eq!((t.width, t.height), (100, 50));
assert_eq!(&t.pixels[0..4], &[255, 255, 255, 255]);
let small = doc_white(10, 5);
let t = thumbnail(&small, 100);
assert_eq!((t.width, t.height), (10, 5));
}
#[test]
fn buffer_over_background() {
let b = Buffer::filled(Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.5]);
let o = b.over_background([1.0, 1.0, 1.0]);
assert!(close4(o.px[0], [0.5, 0.5, 0.5, 1.0]));
assert_eq!(b.to_rgba8().pixels, vec![0, 0, 0, 128]);
}
// ---------- layer effects ----------
use photocraft_doc::{Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, StrokePosition};
fn fx_doc(effects: Vec<Effect>) -> Document {
let mut d = doc_white(40, 40);
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
l.effects.items = effects;
d.layers.push(l);
d
}
fn stroke(size: f32, position: StrokePosition) -> Effect {
Effect::Stroke(StrokeFx {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
size,
position,
paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)),
overprint: false,
})
}
#[test]
fn outside_stroke_width() {
let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]));
assert!(close4(px(&d, 7, 20), [0.0, 0.0, 1.0, 1.0]));
assert!(close4(px(&d, 5, 20), [1.0; 4]), "{:?}", px(&d, 5, 20));
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "interior untouched");
}
#[test]
fn outside_stroke_at_zero_fill_leaves_the_interior_clear() {
// Fill 0 % + an Outside stroke is the classic "outline only" look: the stroke lies outside
// the layer's pixels, so the interior shows the backdrop, not the stroke colour.
for (depth, fmt) in [(SampleType::U8, PixelFormat::RGBA8), (SampleType::U16, PixelFormat::RGBA16), (SampleType::F32, PixelFormat::RGBA32F)] {
let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, depth, Color::WHITE);
let mut l = Layer::raster("sq", fmt);
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
l.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
l.fill_opacity = 0.0;
d.layers.push(l);
assert!(close4(px(&d, 20, 20), [1.0; 4]), "{depth:?}: interior shows the white backdrop: {:?}", px(&d, 20, 20));
assert!(close4(px(&d, 11, 20), [1.0; 4]), "{depth:?}: just inside the edge too: {:?}", px(&d, 11, 20));
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]), "{depth:?}: the stroke itself stays: {:?}", px(&d, 8, 20));
// Partial fill: the interior is the layer at that fill over the backdrop, still no stroke.
d.layers[1].fill_opacity = 0.5;
assert!(close4(px(&d, 20, 20), [1.0, 0.5, 0.5, 1.0]), "{depth:?}: {:?}", px(&d, 20, 20));
// 100 % fill is unchanged.
d.layers[1].fill_opacity = 1.0;
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "{depth:?}: {:?}", px(&d, 20, 20));
}
}
#[test]
fn inside_and_center_strokes() {
let d = fx_doc(vec![stroke(2.0, StrokePosition::Inside)]);
assert!(close4(px(&d, 10, 20), [0.0, 0.0, 1.0, 1.0]));
assert!(close4(px(&d, 11, 20), [0.0, 0.0, 1.0, 1.0]));
assert!(close4(px(&d, 13, 20), [1.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 9, 20), [1.0; 4]));
let d = fx_doc(vec![stroke(4.0, StrokePosition::Center)]);
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]));
assert!(close4(px(&d, 11, 20), [0.0, 0.0, 1.0, 1.0]));
assert!(close4(px(&d, 13, 20), [1.0, 0.0, 0.0, 1.0]));
}
#[test]
fn master_switch_and_per_effect_enable() {
let mut d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
d.layers[1].effects.enabled = false;
assert!(close4(px(&d, 8, 20), [1.0; 4]));
let mut d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
if let Effect::Stroke(s) = &mut d.layers[1].effects.items[0] {
s.common.enabled = false;
}
assert!(close4(px(&d, 8, 20), [1.0; 4]));
}
#[test]
fn color_overlay_ignores_fill_opacity_but_not_opacity() {
let mut d = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::rgb(0.0, 1.0, 0.0) }]);
d.layers[1].fill_opacity = 0.0;
assert!(close4(px(&d, 20, 20), [0.0, 1.0, 0.0, 1.0]), "overlay shows at fill 0");
d.layers[1].opacity = 0.5;
assert!(close4(px(&d, 20, 20), [0.5, 1.0, 0.5, 1.0]), "{:?}", px(&d, 20, 20));
}
fn shadow(distance: f32, angle: f32) -> Shadow {
Shadow {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
color: Color::BLACK,
angle,
use_global_light: false,
distance,
spread: 1.0,
size: 0.0,
contour: photocraft_doc::Contour::Linear,
anti_alias: false,
noise: 0.0,
knocks_out: true,
}
}
#[test]
fn drop_shadow_falls_away_from_light() {
// Light from the top (90°): shadow below the square.
let d = fx_doc(vec![Effect::DropShadow(shadow(5.0, 90.0))]);
assert!(close4(px(&d, 20, 32), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 20, 32));
assert!(close4(px(&d, 20, 8), [1.0; 4]));
// Light from the right (0°, 3 o'clock): shadow to the left.
let d = fx_doc(vec![Effect::DropShadow(shadow(5.0, 0.0))]);
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 32, 20), [1.0; 4]));
}
#[test]
fn group_effects_reach_past_children_effects() {
// A child's outside stroke reaches further than its group's drop shadow: the group's shadow
// follows the stroke, not the child's pixels alone (#3274).
let mut d = doc_white(48, 48);
let mut sq = solid_layer("sq", Rect::new(14, 14, 34, 34), [1.0, 0.0, 0.0, 1.0]);
sq.effects.items = vec![stroke(6.0, StrokePosition::Outside)];
let mut grp = Layer::group("g", vec![sq]);
grp.effects.items = vec![Effect::DropShadow(shadow(2.0, 90.0))];
d.layers.push(grp);
assert!(close4(px(&d, 9, 24), [0.0, 0.0, 1.0, 1.0]), "stroke left of the pixels: {:?}", px(&d, 9, 24));
assert!(close4(px(&d, 24, 38), [0.0, 0.0, 1.0, 1.0]), "stroke below the pixels: {:?}", px(&d, 24, 38));
assert!(close4(px(&d, 24, 41), [0.0, 0.0, 0.0, 1.0]), "shadow below the stroke: {:?}", px(&d, 24, 41));
assert!(close4(px(&d, 24, 43), [1.0; 4]), "nothing past the shadow: {:?}", px(&d, 24, 43));
}
#[test]
fn drop_shadow_uses_global_light_and_knockout() {
let mut d = fx_doc(vec![Effect::DropShadow(Shadow { use_global_light: true, ..shadow(5.0, 0.0) })]);
d.global_light.angle = 90.0;
assert!(close4(px(&d, 20, 32), [0.0, 0.0, 0.0, 1.0]));
// Knock-out: with fill 0 the shadow does not show through the layer.
d.layers[1].fill_opacity = 0.0;
assert!(close4(px(&d, 20, 28), [1.0; 4]), "{:?}", px(&d, 20, 28));
if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] {
s.knocks_out = false;
}
assert!(close4(px(&d, 20, 28), [0.0, 0.0, 0.0, 1.0]));
}
#[test]
fn soft_shadow_is_blurred_and_bounded() {
let d = fx_doc(vec![Effect::DropShadow(Shadow { spread: 0.0, size: 6.0, ..shadow(0.0, 90.0) })]);
let edge = px(&d, 30, 20); // just outside the right edge
let far = px(&d, 39, 20);
assert!(edge[0] < 0.8 && edge[0] > 0.2, "{edge:?}");
assert!(far[0] > 0.99, "{far:?}");
}
#[test]
fn inner_shadow_only_inside() {
let d = fx_doc(vec![Effect::InnerShadow(Shadow { knocks_out: false, ..shadow(4.0, 90.0) })]);
// Top rows of the square are shadowed (light from the top pushes the
// outside's shadow down into the shape).
assert!(close4(px(&d, 20, 11), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 20, 11));
assert!(close4(px(&d, 20, 25), [1.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 20, 5), [1.0; 4]));
}
fn glow(technique: GlowTechnique, source: GlowSource) -> Glow {
Glow {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)),
technique,
spread: 0.0,
size: 4.0,
contour: photocraft_doc::Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source,
}
}
#[test]
fn outer_and_inner_glow_regions() {
let d = fx_doc(vec![Effect::OuterGlow(glow(GlowTechnique::Precise, GlowSource::Edge))]);
let near = px(&d, 9, 20);
assert!(near[1] > 0.7 && near[0] < 0.3, "{near:?}");
assert!(close4(px(&d, 3, 20), [1.0; 4]));
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]));
let d = fx_doc(vec![Effect::InnerGlow(glow(GlowTechnique::Softer, GlowSource::Edge))]);
let edge = px(&d, 10, 20);
assert!(edge[1] > 0.3, "{edge:?}");
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 5, 20), [1.0; 4]));
}
// A Precise inner glow from the centre is brightest in the middle and fades towards the edge,
// like the Softer one; it used to paint nothing at all (#966).
#[test]
fn precise_inner_glow_from_center_paints_the_middle() {
let d = fx_doc(vec![Effect::InnerGlow(glow(GlowTechnique::Precise, GlowSource::Center))]);
let (center, edge) = (px(&d, 20, 20), px(&d, 10, 20));
assert!(center[1] > 0.9 && center[0] < 0.1, "green in the middle: {center:?}");
assert!(edge[0] > center[0] && edge[1] < center[1], "fades towards the edge: {edge:?} vs {center:?}");
assert!(close4(px(&d, 5, 20), [1.0; 4]));
}
// A glow gradient runs along the glow, not across the canvas: the first stop hugs the shape on
// every side and later stops lie further out, opaque until the glow fades (#443).
#[test]
fn glow_gradient_follows_the_shape() {
let red_to_blue = Gradient { stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], ..Gradient::default() };
let g = Glow { paint: FxPaint::Gradient(red_to_blue), technique: GlowTechnique::Softer, size: 8.0, ..glow(GlowTechnique::Softer, GlowSource::Edge) };
let d = fx_doc(vec![Effect::OuterGlow(g.clone())]);
let near = px(&d, 9, 20);
for p in [px(&d, 30, 20), px(&d, 20, 9), px(&d, 20, 30)] {
assert!((0..4).all(|c| (p[c] - near[c]).abs() < 1.0 / 255.0), "{p:?} vs {near:?}");
}
assert!(near[0] > 0.8 && near[2] < 0.2 && near[1] < 0.1, "red at the edge: {near:?}");
let out = px(&d, 5, 20);
assert!(out[2] > out[0] && out[1] < 0.9, "bluer and fading further out: {out:?}");
// An inner glow's gradient also starts at the edge.
let d = fx_doc(vec![Effect::InnerGlow(g)]);
let (edge, top) = (px(&d, 10, 20), px(&d, 20, 10));
assert!((0..4).all(|c| (edge[c] - top[c]).abs() < 1.0 / 255.0), "{edge:?} vs {top:?}");
assert!(edge[0] > 0.8 && edge[2] < 0.2, "{edge:?}");
}
// A Gradient Overlay spans the layer's frame: growing the layer repaints all of it, not just the
// new pixels (#4053). Effects that don't depend on the frame repaint only what changed.
#[test]
fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() {
let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false };
let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| {
let mut b = a.clone();
let old = b.layers.last().unwrap().clone();
let mut l = solid_layer("sq", rect, rgba);
(l.id, l.effects) = (old.id, old.effects);
*b.layers.last_mut().unwrap() = l;
b
};
let a = fx_doc(vec![overlay.clone()]);
let b = grown(&a, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0]);
let d = crate::relayout_damage(&a, &b);
assert!(d.contains_rect(&Rect::new(10, 10, 36, 34)), "{d:?}");
// Same bounds, other pixels: the frame stays, the pixels' own damage is enough.
assert_eq!(crate::relayout_damage(&a, &grown(&a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0])), Rect::EMPTY);
// A Color Overlay doesn't depend on the frame.
let c = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK }]);
assert_eq!(crate::relayout_damage(&c, &grown(&c, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY);
// Nor does a disabled overlay.
let mut off = a.clone();
off.layers.last_mut().unwrap().effects.enabled = false;
assert_eq!(crate::relayout_damage(&off, &grown(&off, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY);
}
#[test]
fn gradient_overlay_follows_angle_and_reverse() {
let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() };
let d = fx_doc(vec![Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: g.clone(), dither: false }]);
let (l, r) = (px(&d, 10, 20), px(&d, 29, 20));
assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}");
let d = fx_doc(vec![Effect::GradientOverlay {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
gradient: Gradient { reverse: true, style: GradientStyle::Linear, ..g },
dither: false,
}]);
assert!(px(&d, 10, 20)[0] > 0.9);
}
#[test]
fn satin_and_bevel_stay_inside_shape() {
let satin = Effect::Satin(Satin {
common: FxCommon::new(photocraft_color::BlendMode::Multiply, 1.0),
color: Color::BLACK,
angle: 19.0,
distance: 4.0,
size: 4.0,
contour: photocraft_doc::Contour::Linear,
anti_alias: true,
invert: false,
});
let d = fx_doc(vec![
satin,
Effect::BevelEmboss(photocraft_doc::Bevel {
enabled: true,
style: photocraft_doc::BevelStyle::InnerBevel,
technique: photocraft_doc::BevelTechnique::Smooth,
depth: 1.0,
up: true,
size: 4.0,
soften: 0.0,
angle: 90.0,
altitude: 30.0,
use_global_light: false,
gloss_contour: photocraft_doc::Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
shadow_color: Color::BLACK,
contour: None,
texture: None,
}),
]);
for (x, y) in [(5, 5), (35, 20), (20, 35)] {
assert!(close4(px(&d, x, y), [1.0; 4]), "({x},{y}) {:?}", px(&d, x, y));
}
// Light from the top: the top bevel edge is brighter than the bottom one.
let (top, bot) = (px(&d, 20, 11), px(&d, 20, 28));
assert!(top[1] > bot[1], "{top:?} {bot:?}");
}
#[test]
fn clipped_layer_effects_are_clipped_to_base() {
let mut d = doc_white(40, 40);
d.layers.push(solid_layer("base", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]));
let mut c = solid_layer("clip", Rect::new(20, 10, 40, 30), [0.0, 1.0, 0.0, 1.0]);
c.clipped = true;
c.effects.items.push(stroke(3.0, StrokePosition::Outside));
d.layers.push(c);
assert!(close4(px(&d, 18, 20), [0.0, 0.0, 1.0, 1.0]), "stroke inside base");
assert!(close4(px(&d, 20, 8), [1.0; 4]), "stroke clipped outside base");
}
#[test]
fn group_effects_apply_to_group_shape() {
let mut d = doc_white(40, 40);
let mut g = Layer::group(
"g",
vec![solid_layer("a", Rect::new(10, 10, 20, 30), [1.0, 0.0, 0.0, 1.0]), solid_layer("b", Rect::new(20, 10, 30, 30), [1.0, 0.0, 0.0, 1.0])],
);
g.effects.items.push(stroke(2.0, StrokePosition::Outside));
d.layers.push(g);
assert!(close4(px(&d, 9, 20), [0.0, 0.0, 1.0, 1.0]));
assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "no stroke at the seam between children");
}
#[test]
fn effects_render_identically_in_tiles() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = fx_doc(vec![Effect::DropShadow(Shadow { spread: 0.0, size: 5.0, ..shadow(4.0, 120.0) }), stroke(2.0, StrokePosition::Outside)]);
d.depth = depth;
for layer in &mut d.layers {
if let Some(surface) = layer.surface_mut() {
*surface = surface.convert(photocraft_color::PixelFormat { sample: depth, ..surface.format() });
}
}
let full = flatten(&d);
// Compare every boundary pixel: a few sampled points miss application-halo regressions.
for tile in [1, 7, 33] {
assert_eq!(render_tiled(&d, d.bounds(), tile), full, "{depth:?}, tile {tile}");
}
}
}
#[test]
fn parallel_tiles_match_single_pass() {
// A document exercising masks, clipping, groups, adjustments, fills and effects.
let mut d = doc_white(97, 61);
let mut a = solid_layer("a", Rect::new(5, 5, 70, 50), [1.0, 0.2, 0.1, 0.8]);
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(0, 0, 40, 61), &[0.25]);
m.density = 0.8;
a.mask = Some(m);
a.effects.items.push(stroke(3.0, StrokePosition::Outside));
d.layers.push(a);
let mut clip = solid_layer("clip", Rect::new(30, 0, 97, 61), [0.0, 1.0, 0.0, 1.0]);
clip.clipped = true;
clip.blend = photocraft_color::BlendMode::Multiply;
d.layers.push(clip);
let mut g = Layer::group("g", vec![solid_layer("in", Rect::new(50, 20, 90, 60), [0.2, 0.3, 0.9, 1.0])]);
g.opacity = 0.6;
d.layers.push(g);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
let mut f = Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.5, 0.5, 0.0))));
f.opacity = 0.3;
d.layers.push(f);
let r = d.bounds();
let whole = render_tiled(&d, r, 10_000);
for tile in [7, 16, 33] {
assert_eq!(render_tiled(&d, r, tile), whole, "tile {tile}");
}
assert_eq!(render(&d, r), whole);
}
#[test]
fn vector_mask_combines_with_pixel_mask() {
use photocraft_doc::{Path, Subpath, VectorMask};
let mut d = doc_white(8, 8);
let mut l = solid_layer("k", Rect::new(0, 0, 8, 8), [0.0, 0.0, 0.0, 1.0]);
// Vector mask reveals x in 0..4 (and half of column 4); the pixel mask hides rows 0..2.
l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (4.5, 0.0), (4.5, 8.0), (0.0, 8.0)])])));
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(0, 0, 8, 2), &[0.0]);
l.mask = Some(m);
d.layers.push(l);
assert!(close4(px(&d, 1, 4), [0.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 6, 4), [1.0; 4]));
assert!(close4(px(&d, 4, 4), [0.5, 0.5, 0.5, 1.0]));
assert!(close4(px(&d, 1, 1), [1.0; 4]));
// Tiled rendering gives the same result.
let whole = render_tiled(&d, d.bounds(), 3);
assert_eq!(whole.px, render_tiled(&d, d.bounds(), 256).px);
// Density and disabling.
let top = d.layers.len() - 1;
let vm = d.layers[top].vector_mask.as_mut().unwrap();
vm.density = 0.5;
assert!(close4(px(&d, 6, 4), [0.5, 0.5, 0.5, 1.0]));
d.layers[top].vector_mask.as_mut().unwrap().enabled = false;
assert!(close4(px(&d, 6, 4), [0.0, 0.0, 0.0, 1.0]));
// Without a pixel mask the vector mask alone applies.
d.layers[top].mask = None;
d.layers[top].vector_mask.as_mut().unwrap().enabled = true;
d.layers[top].vector_mask.as_mut().unwrap().density = 1.0;
assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 6, 1), [1.0; 4]));
}
#[test]
fn effect_maps_are_cached_and_invalidated_by_pixel_changes() {
let mut doc = doc_white(64, 64);
let mut l = solid_layer("fx", Rect::new(16, 16, 48, 48), [1.0, 0.0, 0.0, 1.0]);
l.effects.items.push(photocraft_doc::Effect::default_drop_shadow());
doc.layers.push(l);
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&doc, &patterns);
let a = effect_maps(&doc.layers[1], doc.bounds(), &cx);
let b = effect_maps(&doc.layers[1], doc.bounds(), &cx);
assert!(std::sync::Arc::ptr_eq(&a, &b), "second request hits the cache");
let first = flatten(&doc);
// Editing the layer's pixels changes its tiles, so the maps are rebuilt.
doc.layers[1].surface_mut().unwrap().fill_rect(Rect::new(8, 8, 20, 20), &[0.0, 0.0, 1.0, 1.0]);
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&doc, &patterns);
let c = effect_maps(&doc.layers[1], doc.bounds(), &cx);
assert!(!std::sync::Arc::ptr_eq(&a, &c), "pixel edit invalidates");
// Cached rendering equals a fresh build (tiled and full renders agree too).
let again = flatten(&doc);
let region = doc.bounds();
let tiled = render_tiled(&doc, region, 16);
for (p, q) in again.px.iter().zip(&tiled.px) {
assert!(close4(*p, *q));
}
assert_ne!(first.px, again.px);
// Tileless masks differ only in their defaults; reusing their effect maps leaves a ghost shadow.
let mut masked = fx_doc(vec![Effect::DropShadow(shadow(5.0, 90.0))]);
masked.layers[1].mask = Some(photocraft_doc::LayerMask::reveal_all());
assert!(close4(px(&masked, 20, 32), [0.0, 0.0, 0.0, 1.0]));
masked.layers[1].mask = Some(photocraft_doc::LayerMask::hide_all());
let warm = flatten(&masked);
let mut cold = masked.clone();
cold.layers[1].id = Layer::raster("uncached", cold.pixel_format()).id;
assert!(warm.px == flatten(&cold).px, "default-only mask changes must invalidate cached effects");
assert!(close4(px(&masked, 20, 32), [1.0; 4]));
}
// ---------- Transparency Shapes Layer off on tall documents (#1909) ----------
fn bevel_fx(style: photocraft_doc::BevelStyle, soften: f32) -> Effect {
Effect::BevelEmboss(photocraft_doc::Bevel {
enabled: true,
style,
technique: photocraft_doc::BevelTechnique::Smooth,
depth: 1.0,
up: true,
size: 5.0,
soften,
angle: 120.0,
altitude: 30.0,
use_global_light: false,
gloss_contour: photocraft_doc::Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
shadow_color: Color::BLACK,
contour: None,
texture: None,
})
}
/// A tall page of layers with Transparency Shapes Layer off, one effect stack each (every local
/// kind), most masked to a soft-edged block so their effect shapes vary down the page.
fn shapeless_fx_doc(depth: SampleType) -> Document {
use photocraft_doc::BevelStyle;
let mut d = Document::with_background("t", Size::new(48, 640), ColorMode::Rgb, depth, Color::WHITE);
let satin = Effect::Satin(Satin {
common: FxCommon::new(photocraft_color::BlendMode::Multiply, 1.0),
color: Color::BLACK,
angle: 19.0,
distance: 4.0,
size: 5.0,
contour: photocraft_doc::Contour::Linear,
anti_alias: true,
invert: false,
});
let stacks = vec![
vec![stroke(3.0, StrokePosition::Outside)],
vec![stroke(2.0, StrokePosition::Inside), Effect::DropShadow(Shadow { size: 6.0, spread: 0.3, noise: 0.2, ..shadow(5.0, 120.0) })],
vec![stroke(4.0, StrokePosition::Center)],
vec![Effect::InnerShadow(Shadow { size: 4.0, spread: 0.0, ..shadow(3.0, 30.0) })],
vec![Effect::OuterGlow(Glow { size: 6.0, spread: 0.2, noise: 0.1, ..glow(GlowTechnique::Softer, GlowSource::Edge) })],
vec![Effect::InnerGlow(glow(GlowTechnique::Softer, GlowSource::Center)), satin],
vec![bevel_fx(BevelStyle::Emboss, 2.0)],
vec![bevel_fx(BevelStyle::PillowEmboss, 0.0), bevel_fx(BevelStyle::OuterBevel, 1.0)],
vec![stroke(3.0, StrokePosition::Outside), Effect::InnerGlow(glow(GlowTechnique::Softer, GlowSource::Edge))],
];
let mask_fmt = PixelFormat::new(ColorMode::Grayscale, depth, false);
for (i, fx) in stacks.into_iter().enumerate() {
let y = 24 + i as i32 * 66;
let mut l = Layer::raster(format!("fx{i}"), d.pixel_format());
let c = [0.2 + 0.08 * i as f32, 0.5, 1.0 - 0.1 * i as f32, 1.0];
l.surface_mut().unwrap().fill_rect(Rect::new(10, y, 38, y + 18), &photocraft_raster::from_rgba(&d.pixel_format(), c));
l.effects.items = fx;
l.advanced.transparency_shapes = false;
l.opacity = 0.8;
// The last layer stays unmasked: its shape is the whole canvas, edged only at its sides.
if i < 8 {
let mut m = photocraft_raster::Surface::new(mask_fmt);
m.fill_rect(Rect::new(4 + i as i32, y - 12, 44, y + 30), &[1.0]);
m.fill_rect(Rect::new(4 + i as i32, y + 30, 30, y + 34), &[0.5]);
l.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 });
}
d.layers.push(l);
}
// A tsly-off stroked layer inside a group whose own effects reach much further: the group's
// effect shape composites its children over the group's whole region, far past any band.
let mut inner = Layer::raster("inner", d.pixel_format());
inner.surface_mut().unwrap().fill_rect(Rect::new(12, 300, 30, 310), &photocraft_raster::from_rgba(&d.pixel_format(), [0.1, 0.9, 0.3, 1.0]));
inner.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
inner.advanced.transparency_shapes = false;
let mut m = photocraft_raster::Surface::new(mask_fmt);
m.fill_rect(Rect::new(8, 240, 36, 380), &[1.0]);
inner.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 });
let mut g = Layer::group("g", vec![inner]);
g.effects.items = vec![Effect::DropShadow(Shadow { size: 40.0, spread: 0.1, ..shadow(90.0, 70.0) }), stroke(120.0, StrokePosition::Outside)];
g.opacity = 0.6;
d.layers.push(g);
// A clipping mask: a tsly-off stroked layer clipped to a base with large effects of its own.
let mut base = Layer::raster("base", d.pixel_format());
base.surface_mut().unwrap().fill_rect(Rect::new(6, 420, 42, 470), &photocraft_raster::from_rgba(&d.pixel_format(), [0.9, 0.9, 0.2, 1.0]));
base.effects.items = vec![Effect::OuterGlow(Glow { size: 150.0, ..glow(GlowTechnique::Softer, GlowSource::Edge) })];
let mut clip = Layer::raster("clip", d.pixel_format());
clip.surface_mut().unwrap().fill_rect(Rect::new(14, 430, 34, 450), &photocraft_raster::from_rgba(&d.pixel_format(), [0.6, 0.1, 0.6, 1.0]));
clip.effects.items = vec![stroke(2.0, StrokePosition::Center), Effect::InnerShadow(Shadow { size: 3.0, spread: 0.0, ..shadow(2.0, 45.0) })];
clip.advanced.transparency_shapes = false;
clip.clipped = true;
d.layers.push(base);
d.layers.push(clip);
d
}
#[test]
fn shapeless_effects_render_the_same_in_bands_and_parts() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let d = shapeless_fx_doc(depth);
// Bands and parts first, from an empty cache: maps a band builds (a group's, say) must not
// borrow from a whole render's.
purge_effect_cache();
let mut banded = Vec::new();
render_bands(&d, d.bounds(), 64, |b| -> Result<(), ()> {
banded.extend_from_slice(&b.px);
Ok(())
})
.unwrap();
purge_effect_cache();
let parts: Vec<(Rect, Buffer)> =
[Rect::new(3, 290, 41, 333), Rect::new(0, 0, 48, 20), Rect::new(20, 600, 48, 640), Rect::new(0, 296, 20, 312), Rect::new(10, 425, 40, 445)]
.into_iter()
.map(|r| (r, render(&d, r)))
.collect();
let whole = render(&d, d.bounds());
assert_eq!(banded.len(), whole.px.len());
let diff = banded.iter().zip(&whole.px).position(|(a, b)| a != b);
assert!(diff.is_none(), "{depth:?}: band render differs at {diff:?}");
// A small part (an edit's damage) and a tiled render agree with the whole render too.
for (part, p) in &parts {
let part = *part;
for y in part.y0..part.y1 {
for x in part.x0..part.x1 {
assert_eq!(p.get(x, y), whole.get(x, y), "{depth:?}: part {part:?} differs at ({x},{y})");
}
}
}
assert!(render_tiled(&d, d.bounds(), 16).px == whole.px, "{depth:?}: tiled render differs");
}
}
#[test]
fn shapeless_effect_maps_cover_only_the_band_being_rendered() {
// Transparency Shapes Layer off makes an effect region the whole canvas: on a 1920 × 103675
// page each stroked type layer's maps took ~2.4 GB, for every layer at once (#1909). Rendering
// a band builds them over that band (plus the effects' reach) only.
let d = shapeless_fx_doc(SampleType::U8);
let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&d, &patterns);
let l = &d.layers[9];
let whole = effect_maps(l, Rect::new(0, 256, 48, 320), &cx).bytes();
cx.set_fx_clip(Rect::new(0, 256, 48, 320));
let band = effect_maps(l, Rect::new(0, 256, 48, 320), &cx).bytes();
assert!(band * 4 < whole, "band maps {band} B vs whole-canvas maps {whole} B");
// The next band lets the previous band's maps go.
cx.set_fx_clip(Rect::new(0, 320, 48, 384));
let _ = effect_maps(l, Rect::new(0, 320, 48, 384), &cx);
assert_eq!(cx.fx_maps.lock().unwrap().len(), 2, "the unclipped maps and this band's");
// Composited over more than the band (a group's effect shape renders its children over the
// group's whole region): the whole region.
let full = effect_region(l, Rect::new(0, 320, 48, 384), &cx).0;
assert_eq!(
effect_region(l, Rect::new(0, 200, 48, 500), &cx),
(layer_bounds(l, cx.canvas).inflate(effects::margin(l)).intersect(&cx.canvas.inflate(effects::margin(l))), false)
);
assert!(full.height() < 100, "{full:?}");
// Effects whose maps aren't local keep the whole region.
let mut g = l.clone();
g.effects.items = vec![Effect::OuterGlow(glow(GlowTechnique::Precise, GlowSource::Edge))];
assert_eq!(
effect_region(&g, Rect::new(0, 320, 48, 384), &cx),
(layer_bounds(&g, cx.canvas).inflate(effects::margin(&g)).intersect(&cx.canvas.inflate(effects::margin(&g))), false)
);
}
// ---------- PSD-fidelity effect semantics (fitted on Photoshop composites) ----------
#[test]
fn stroke_edges_use_the_chamfer_when_narrow_and_the_exact_distance_when_wide() {
// #3646: a narrow stroke's outer edge keeps the 5 × 5 chamfer, whose sub-pixel seeding
// Photoshop matches at small offsets (see `stroke_outside_exact`); only a wide one — where the
// chamfer's octagon facets (see `a_wide_outside_stroke_is_round_not_a_polygon`) — uses the
// exact Euclidean transform.
// Square 10..30, outside stroke 3: (9, 7) is offset (1, 3) from the corner pixel. Photoshop
// measures √5 + 1 = 3.236 there, so coverage is 3 + 1 − 3.236 = 0.764 (the exact distance is
// 3.162, coverage 0.838).
let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
let a = 1.0 - px(&d, 9, 7)[0]; // blue stroke over white: red channel drops by coverage
assert!((a - 0.764).abs() < 0.01, "{a}");
// (2, 2) is the same in both metrics (two diagonal steps).
let a = 1.0 - px(&d, 8, 8)[0];
assert!((a - 1.0).abs() < 0.01, "{a}");
}
#[test]
fn the_stroke_metric_switches_at_the_fitted_threshold() {
// The widest outside stroke any Photoshop oracle matches with the chamfer is 18 px
// (ag-psd read-write/strokes): its octagon deviates 0.48 px from a circle there; 20 px keeps
// it. Wider strokes (the 60 px #3646 case) are round.
assert!(!crate::effects::stroke_outside_exact(18.0));
assert!(crate::effects::stroke_outside_exact(crate::effects::STROKE_CHAMFER_MAX));
assert!(crate::effects::stroke_outside_exact(60.0));
}
#[test]
fn a_wide_outside_stroke_is_round_not_a_polygon() {
// A point source dilated by 60 px must stay a circle. The 5 × 5 chamfer's octagonal level set
// made the boundary swing ~2.5 px at this radius (~10 px at 245 px) — the "low-poly" #3646.
let mut d = doc_white(170, 170);
let mut l = solid_layer("dot", Rect::new(85, 85, 86, 86), [1.0, 0.0, 0.0, 1.0]);
l.effects.items = vec![stroke(60.0, StrokePosition::Outside)];
d.layers.push(l);
let buf = render(&d, Rect::new(0, 0, 170, 170));
let at = |x: i32, y: i32| buf.px[(y as usize) * 170 + x as usize];
let (mut lo, mut hi) = (f64::MAX, 0.0f64);
let stroke_at = |x: i32, y: i32| x >= 0 && y >= 0 && x < 170 && y < 170 && at(x, y)[2] > 0.5 && at(x, y)[0] < 0.5;
for k in 0..72 {
let th = (k as f64) * std::f64::consts::TAU / 72.0;
let (dx, dy) = (th.cos(), th.sin());
let mut r = 0.0f64;
let mut t = 0.0f64;
while t < 84.0 {
if stroke_at((85.0 + dx * t).round() as i32, (85.0 + dy * t).round() as i32) {
r = t;
}
t += 0.25;
}
lo = lo.min(r);
hi = hi.max(r);
}
assert!(hi - lo <= 2.0, "the 60 px stroke boundary swings {lo}..{hi} px — a polygon, not a circle (#3646)");
}
#[test]
fn inside_stroke_respects_partial_edge_coverage() {
// A 25 %-alpha edge column puts the edge 0.75 px further out: a 3 px inside stroke then
// reaches 75 % into the fourth column, and the edge column takes the stroke colour at 25 %.
let mut d = doc_white(40, 40);
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 11, 30), &[1.0, 0.0, 0.0, 0.25]);
l.effects.items = vec![stroke(3.0, StrokePosition::Inside)];
d.layers.push(l);
let edge = px(&d, 10, 20);
assert!(close4(edge, [0.75, 0.75, 1.0, 1.0]), "edge = blue stroke at 25 % over white: {edge:?}");
let p = px(&d, 13, 20);
assert!((p[2] - 0.75).abs() < 0.02 && (p[0] - 0.25).abs() < 0.02, "{p:?}");
}
#[test]
fn emboss_shades_the_composited_layer() {
// Emboss styles paint over the layer as composited (for type: mixed at the text gamma), so a
// Normal white highlight `k` on an edge pixel keeps (1 − R) = (1 − k)(1 − C) whatever C is:
// the ratio of type to raster results equals that of their plain composites.
let emboss = Effect::BevelEmboss(photocraft_doc::Bevel {
enabled: true,
style: photocraft_doc::BevelStyle::Emboss,
technique: photocraft_doc::BevelTechnique::Smooth,
depth: 1.0,
up: true,
size: 6.0,
soften: 0.0,
angle: 90.0,
altitude: 30.0,
use_global_light: false,
gloss_contour: photocraft_doc::Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
highlight_color: Color::WHITE,
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0),
shadow_color: Color::BLACK,
contour: None,
texture: None,
});
let make = |text: bool, fx: bool| {
let mut d = doc_white(40, 40);
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [0.2, 0.1, 0.6, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 11), &[0.2, 0.1, 0.6, 0.5]);
if text {
let t = photocraft_doc::TextLayer { cache: l.surface().cloned(), ..Default::default() };
l = Layer::new("t", LayerContent::Text(t));
}
if fx {
l.effects.items = vec![emboss.clone()];
}
d.layers.push(l);
px(&d, 20, 10)
};
let (ct, cr, rt, rr) = (make(true, false), make(false, false), make(true, true), make(false, true));
assert!(rr[1] > cr[1] + 0.05, "the top edge is lit: {rr:?} vs {cr:?}");
assert!((ct[1] - cr[1]).abs() > 0.02, "text gamma changes the edge: {ct:?} {cr:?}");
for c in 0..3 {
let want = (1.0 - ct[c]) / (1.0 - cr[c]);
assert!(((1.0 - rt[c]) / (1.0 - rr[c]) - want).abs() < 2e-3, "channel {c}: {rt:?} {rr:?} {ct:?} {cr:?}");
}
}
#[test]
fn interior_effects_keep_the_layer_alpha() {
// A colour overlay replaces a half-transparent pixel's colour without adding coverage.
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 0.5]);
l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::rgb(0.0, 1.0, 0.0) }];
d.layers.push(l);
assert!(close4(px(&d, 20, 20), [0.0, 1.0, 0.0, 0.5]), "{:?}", px(&d, 20, 20));
}
#[test]
fn upper_stroke_blends_with_the_backdrop_not_the_lower_stroke() {
// Multiply yellow (3 px) listed above a Normal blue (8 px): the inner ring multiplies the
// white backdrop (stays yellow), the outer ring is blue.
let yellow = Effect::Stroke(StrokeFx {
common: FxCommon::new(photocraft_color::BlendMode::Multiply, 1.0),
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)),
overprint: false,
});
let d = fx_doc(vec![yellow, stroke(8.0, StrokePosition::Outside)]);
assert!(close4(px(&d, 8, 20), [1.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 8, 20));
assert!(close4(px(&d, 4, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 4, 20));
}
#[test]
fn shadow_knockout_needs_see_through_fill() {
// At 100 % fill an anti-aliased edge is not attenuated twice: the result equals no knockout.
let mut base = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
base.layers.push(solid_layer("bg", Rect::new(0, 0, 40, 40), [1.0; 4]));
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 11, 30), &[1.0, 0.0, 0.0, 0.5]);
l.effects.items = vec![Effect::DropShadow(Shadow { distance: 0.0, spread: 1.0, size: 4.0, ..shadow(0.0, 0.0) })];
base.layers.push(l);
let on = px(&base, 10, 20);
if let Effect::DropShadow(s) = &mut base.layers[1].effects.items[0] {
s.knocks_out = false;
}
assert!(close4(on, px(&base, 10, 20)), "{on:?}");
}
#[test]
fn shadow_knockout_beneath_a_layer_in_a_blending_mode() {
// Layer Knocks Out Drop Shadow keeps the shadow out from under the layer's shape. In Normal
// mode an opaque layer covers it anyway; in a mode that blends with what's beneath, the shadow
// would show through: under a white Exclusion square on white, it read as white (white over
// the black shadow) instead of black (white over white), so a logo's Exclusion bar with a
// drop shadow came out blank. A red Multiply square came out black instead of red.
for (blend, colour, inside, depth) in [
(photocraft_color::BlendMode::Exclusion, [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
(photocraft_color::BlendMode::Multiply, [1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]),
]
.into_iter()
.flat_map(|c| SampleType::ALL.map(|depth| (c, depth)))
.map(|((b, c, i), depth)| (b, c, i, depth))
{
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, depth);
d.layers.push(solid_layer("bg", Rect::new(0, 0, 40, 40), [1.0; 4]));
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), colour);
l.blend = blend;
l.effects.items = vec![Effect::DropShadow(shadow(0.0, 90.0))];
d.layers.push(l);
assert!(close4(px(&d, 20, 20), inside), "{blend:?} {depth:?}: {:?}", px(&d, 20, 20));
// Not knocked out, the shadow shows through the blend, as in Photoshop with the box unticked.
if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] {
s.knocks_out = false;
}
assert!(!close4(px(&d, 20, 20), inside), "{blend:?} {depth:?} without knockout: {:?}", px(&d, 20, 20));
}
}
#[test]
fn linked_pattern_overlay_anchors_at_the_effects_reference_point() {
use photocraft_doc::pattern::Pattern;
// 2 × 1 tile: red, blue. Anchored at x = 11 (reference point), x = 11 is red, 12 blue.
let mut tile = photocraft_raster::Surface::new(PixelFormat::RGBA8);
tile.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
tile.fill_rect(Rect::new(1, 0, 2, 1), &[0.0, 0.0, 1.0, 1.0]);
let pat = Pattern::new("rb", tile, 2, 1);
let overlay = Effect::PatternOverlay {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
name: "rb".into(),
id: pat.id.clone(),
scale: 1.0,
angle: 0.0,
link: true,
phase: (0.0, 0.0),
};
let mut d = fx_doc(vec![overlay]);
d.patterns.push(pat);
// Without a reference point the layer's top-left (10) anchors the tiling.
assert!(close4(px(&d, 10, 20), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 10, 20));
d.layers[1].effects.reference = Some((11.0, 0.0));
assert!(close4(px(&d, 11, 20), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 11, 20));
assert!(close4(px(&d, 12, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 12, 20));
}
#[test]
fn channel_restrictions_keep_the_backdrop() {
// Blue left out (Photoshop's Advanced Blending › Channels: R, G only).
let mut d = doc_white(8, 8);
let mut l = solid_layer("dark", Rect::new(0, 0, 8, 8), [0.2, 0.3, 0.4, 1.0]);
l.excluded_channels = 0b100;
d.layers.push(l);
assert!(close4(px(&d, 1, 1), [0.2, 0.3, 1.0, 1.0]));
// Adjustment layers honour it too.
let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
inv.excluded_channels = 0b001;
d.layers.push(inv);
assert!(close4(px(&d, 1, 1), [0.2, 0.7, 0.0, 1.0]));
// CMYK documents composite in display RGB: no exact equivalent, ignored.
assert_eq!(channel_weights(&d.layers[1], ColorMode::Cmyk), None);
assert_eq!(channel_weights(&d.layers[1], ColorMode::Rgb), Some([1.0, 1.0, 0.0]));
}
fn range(black: [u8; 2], white: [u8; 2]) -> photocraft_doc::BlendRange {
photocraft_doc::BlendRange { black, white }
}
const FULL: photocraft_doc::BlendRange = photocraft_doc::BlendRange::FULL;
#[test]
fn blend_if_this_layer_hides_by_the_layers_own_value() {
// Mid-grey backdrop; the layer is black on the left, white on the right.
let mut d = doc_white(8, 8);
d.layers.push(solid_layer("grey", Rect::new(0, 0, 8, 8), [0.5, 0.5, 0.5, 1.0]));
let mut l = solid_layer("bw", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
// Gray › This Layer: black point at 50 hides the blacks.
l.blend_if.set(0, [range([50, 50], [255, 255]), FULL]);
d.layers.push(l);
assert!(close4(px(&d, 1, 1), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&d, 1, 1));
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
// White point at 200 hides the whites as well.
d.layers[2].blend_if.set(0, [range([50, 50], [200, 200]), FULL]);
assert!(close4(px(&d, 6, 1), [0.5, 0.5, 0.5, 1.0]));
// Back to the defaults: everything shows again.
d.layers[2].blend_if.set(0, [FULL, FULL]);
assert!(d.layers[2].blend_if.is_default());
assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0]));
}
#[test]
fn blend_if_underlying_layer_hides_by_the_backdrop_value() {
// Backdrop black on the left, white on the right; a red layer over all of it.
let mut d = doc_white(8, 8);
d.layers.push(solid_layer("black", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0]));
let mut l = solid_layer("red", Rect::new(0, 0, 8, 8), [1.0, 0.0, 0.0, 1.0]);
// Gray › Underlying Layer: white point at 128 = only over the darks (sky-replacement style).
l.blend_if.set(0, [FULL, range([0, 0], [128, 128])]);
d.layers.push(l);
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
}
/// An 8-bit backdrop at level `k` counts as `k` for a hard Underlying black point at `k`, also
/// over a semi-transparent backdrop, where compositing leaves the value a few millionths off the
/// level (19.999998 for 20 before #4395); the GPU compositor agrees.
#[test]
fn blend_if_hard_point_includes_its_own_level() {
for k in 0..=255u8 {
let v = f32::from(k) / 255.0;
let mut d = Document::new("t", Size::new(2, 1), ColorMode::Rgb, SampleType::U8);
d.layers.push(solid_layer("under", Rect::new(0, 0, 2, 1), [v, v, v, 184.0 / 255.0]));
let mut l = solid_layer("top", Rect::new(0, 0, 2, 1), [1.0, 0.0, 0.0, 1.0]);
l.blend_if.set(1, [FULL, range([k, k], [255, 255])]);
d.layers.push(l);
assert!(close4(px(&d, 0, 0), [1.0, 0.0, 0.0, 1.0]), "level {k} is hidden by a black point at {k}");
}
}
#[test]
fn blend_if_split_point_fades_like_opacity() {
// A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which
// is exactly the layer at 50% opacity, also over a semi-transparent backdrop.
let v = 100.0 / 255.0;
let mut d = doc_white(4, 4);
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.2, 1.0, 0.5]);
let mut l = solid_layer("v", Rect::new(0, 0, 4, 4), [v, v, v, 1.0]);
l.blend = BlendMode::Multiply;
let mut half = l.clone();
half.opacity = 0.5;
l.blend_if.set(0, [range([50, 150], [255, 255]), FULL]);
let mut d2 = d.clone();
d.layers.push(l);
d2.layers.push(half);
let (a, b) = (px(&d, 1, 1), px(&d2, 1, 1));
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{a:?} vs {b:?}");
}
#[test]
fn blend_if_per_channel_ranges() {
// Blue › This Layer: hide pixels whose blue is above 100.
let mut d = doc_white(8, 8);
let mut l = solid_layer("c", Rect::new(0, 0, 4, 8), [1.0, 0.0, 0.0, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[0.0, 0.0, 1.0, 1.0]);
l.blend_if.set(3, [range([0, 0], [100, 100]), FULL]);
d.layers.push(l);
assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0]));
// Red › Underlying Layer: the backdrop's red (255) is above 254 → hidden everywhere.
d.layers[1].blend_if = Default::default();
d.layers[1].blend_if.set(1, [FULL, range([0, 0], [254, 254])]);
assert!(close4(px(&d, 1, 1), [1.0; 4]));
}
#[test]
fn blend_if_on_adjustment_and_clipped_layers() {
let mut d = doc_white(8, 8);
d.layers.push(solid_layer("dark", Rect::new(0, 0, 4, 8), [0.1, 0.1, 0.1, 1.0]));
// Invert, but only where the backdrop is dark (Underlying white point at 128).
let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
inv.blend_if.set(0, [FULL, range([0, 0], [128, 128])]);
d.layers.push(inv);
assert!(close4(px(&d, 1, 1), [0.9, 0.9, 0.9, 1.0]));
assert!(close4(px(&d, 6, 1), [1.0; 4]));
// A clipped layer judges "underlying" by its clipping base.
let mut d = doc_white(8, 8);
let mut base = solid_layer("base", Rect::new(0, 0, 8, 8), [0.0, 0.0, 0.0, 1.0]);
base.surface_mut().unwrap().fill_rect(Rect::new(4, 0, 8, 8), &[0.9, 0.9, 0.9, 1.0]);
let mut clip = solid_layer("clip", Rect::new(0, 0, 8, 8), [0.0, 1.0, 0.0, 1.0]);
clip.clipped = true;
clip.blend_if.set(0, [FULL, range([128, 128], [255, 255])]);
d.layers.push(base);
d.layers.push(clip);
assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0]));
assert!(close4(px(&d, 6, 1), [0.0, 1.0, 0.0, 1.0]));
}
#[test]
fn blend_if_modes() {
// Grayscale documents: the single channel's entry (or the gray entry) applies.
let mut d = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::WHITE);
let mut l = Layer::raster("k", d.pixel_format());
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 1.0]);
l.blend_if.set(1, [range([10, 10], [255, 255]), FULL]);
d.layers.push(l);
assert!(close4(px(&d, 1, 1), [1.0; 4]), "{:?}", px(&d, 1, 1));
// CMYK/Lab composite in display RGB: kept for round trip, not applied.
assert!(blend_if_active(&d.layers[1], ColorMode::Rgb));
assert!(blend_if_active(&d.layers[1], ColorMode::Grayscale));
assert!(!blend_if_active(&d.layers[1], ColorMode::Cmyk));
assert!(!blend_if_active(&d.layers[1], ColorMode::Lab));
assert!(!blend_if_active(&d.layers[0], ColorMode::Rgb));
}
#[test]
fn effect_maps_built_inside_parallel_tiles_do_not_deadlock() {
// A map large enough for the blur to go multi-threaded, built while rayon renders tiles that
// all wait on the same map (a rayon-parallel blur deadlocked here).
let mut d = doc_white(900, 700);
let mut l = solid_layer("fx", Rect::new(100, 100, 800, 600), [0.2, 0.4, 0.9, 1.0]);
l.effects.items = vec![photocraft_doc::Effect::default_drop_shadow()];
if let photocraft_doc::Effect::DropShadow(s) = &mut l.effects.items[0] {
s.size = 30.0;
}
d.layers.push(l);
let out = render(&d, d.bounds());
assert_eq!(out.px.len(), 900 * 700);
}
#[test]
fn adjustment_results_are_rounded_to_the_document_depth() {
for (depth, q) in [(SampleType::U8, Some(255.0f32)), (SampleType::U16, Some(32768.0)), (SampleType::F32, None)] {
let mut d = Document::with_background("q", Size::new(4, 4), ColorMode::Rgb, depth, Color::rgb(0.3, 0.6, 0.9));
d.layers.push(Layer::new("lv", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.37, offset: 0.0, gamma: 1.0 })));
let p = px(&d, 1, 1);
match q {
Some(q) => assert!(p.iter().all(|v| ((v * q).round() - v * q).abs() < 1e-3), "{depth:?} {p:?}"),
None => assert!(p[..3].iter().any(|v| ((v * 255.0).round() - v * 255.0).abs() > 1e-3), "{p:?}"),
}
}
}
// Ground truth captured from Adobe Photoshop 2026 (27.10.0): a 0..255 ramp pushed through modern
// (non-legacy) Brightness/Contrast via `executeAction("BrgC", ... useLegacy=false)`. These pin our
// reverse-engineered curves (see crates/compose/src/adjust.rs modern_* and log/devlog.md).
#[test]
fn modern_contrast_matches_photoshop() {
// (contrast, [out at x = 0,16,32,64,96,128,160,192,224,255])
let cases: [(f32, [u8; 10]); 4] = [
(50.0, [0, 11, 23, 52, 87, 128, 169, 204, 233, 255]),
(-50.0, [0, 21, 41, 76, 105, 128, 151, 180, 215, 255]),
(100.0, [0, 5, 14, 40, 78, 128, 178, 216, 242, 255]),
(-25.0, [0, 19, 37, 70, 101, 128, 155, 186, 220, 255]),
];
let xs = [0usize, 16, 32, 64, 96, 128, 160, 192, 224, 255];
for (c, out) in cases {
for (i, &x) in xs.iter().enumerate() {
let got = adjust::modern_contrast(x as f32 / 255.0, c) * 255.0;
let err = (got - out[i] as f32).abs();
assert!(err <= 2.0, "contrast {c} x={x}: got {got:.1} want {} (err {err:.1})", out[i]);
}
}
}
/// Over the whole slider range the curve stays monotone and inside 0..1, lowering it undoes
/// raising it by as much, and odd input never panics.
#[test]
fn modern_brightness_is_monotone_and_lowering_inverts_raising() {
for b in (-150..=150).step_by(5).map(|b| b as f32) {
let mut prev = 0.0f32;
for k in 0..=1020 {
let v = adjust::modern_brightness(k as f32 / 1020.0, b);
assert!((0.0..=1.0).contains(&v) && v >= prev - 1e-6, "brightness {b} at {k}/1020: {v} after {prev}");
prev = v;
}
assert!(
(adjust::modern_brightness(1.0, b) - 1.0).abs() < 1e-5 && adjust::modern_brightness(0.0, b).abs() < 1e-5,
"brightness {b} pins black and white"
);
for k in 0..=50 {
let x = k as f32 / 50.0;
let back = adjust::modern_brightness(adjust::modern_brightness(x, b), -b);
assert!((back - x).abs() < 2e-3, "brightness {b}: {x} -> {back}");
}
}
for v in [f32::NAN, f32::INFINITY, -3.0, 7.0] {
let _ = adjust::modern_brightness(v, 40.0);
let _ = adjust::modern_brightness(0.5, v);
}
assert_eq!(adjust::modern_lut(0.0, 0.0).len(), 4096);
// The table agrees with the curves it samples, lowered (inverted in one pass) or raised.
for (b, c) in [(-50.0, 30.0), (-150.0, -50.0), (40.0, 0.0), (0.0, 100.0)] {
let t = adjust::modern_lut(b, c);
for k in (0..4096).step_by(37) {
let x = k as f32 / 4095.0;
let want = adjust::modern_contrast(adjust::modern_brightness(x, b), c);
assert!((t[k] - want).abs() < 1e-3, "({b}, {c}) at {k}: {} vs {want}", t[k]);
}
}
}
#[test]
fn modern_brightness_matches_photoshop() {
let cases: [(f32, [u8; 10]); 2] = [(50.0, [0, 22, 44, 88, 132, 171, 203, 228, 246, 255]), (-50.0, [0, 12, 23, 47, 70, 93, 118, 148, 186, 255])];
let xs = [0usize, 16, 32, 64, 96, 128, 160, 192, 224, 255];
for (b, out) in cases {
for (i, &x) in xs.iter().enumerate() {
let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0;
let err = (got - out[i] as f32).abs();
// Within about a level of Photoshop's (rounded) output everywhere.
assert!(err <= 1.2, "brightness {b} x={x}: got {got:.1} want {} (err {err:.1})", out[i]);
}
}
// Photoshop 27.11's brightness +25 with contrast +30 (photoshop corpus rgb8
// brightness-contrast.psd): what the oracle's levels become.
let oracle = [
(8, 7),
(24, 23),
(48, 49),
(72, 78),
(88, 99),
(104, 120),
(128, 154),
(144, 174),
(160, 192),
(176, 208),
(192, 222),
(200, 228),
(216, 239),
(232, 247),
(240, 250),
(248, 253),
];
for (x, want) in oracle {
let got = adjust::modern_contrast(adjust::modern_brightness(x as f32 / 255.0, 25.0), 30.0) * 255.0;
assert!((got - want as f32).abs() <= 1.0, "+25/+30 x={x}: got {got:.2} want {want}");
}
// psd-tools' brightnesscontrast_rgb.psd masks four layers to their own columns: the brightness
// each applies, read back through its (exact) contrast curve.
// (brightness, tolerance in levels, [(input level, Photoshop's output level)])
type Measured = (f32, f32, &'static [(u32, f32)]);
let measured: [Measured; 4] = [
(-95.0, 1.5, &[(24, 13.3), (64, 35.1), (96, 52.9), (128, 69.7), (160, 89.6), (192, 114.4), (224, 151.6)]),
(-31.0, 1.5, &[(24, 20.3), (64, 53.4), (96, 79.0), (128, 105.6), (160, 132.3), (192, 161.8), (224, 197.4)]),
(23.0, 1.5, &[(24, 27.9), (64, 74.0), (96, 110.9), (128, 147.7), (160, 182.9), (192, 213.7), (224, 239.6)]),
(150.0, 6.0, &[(24, 60.3), (64, 162.1), (96, 214.5), (128, 238.4), (160, 248.9)]),
];
for (b, tol, points) in measured {
for &(x, want) in points {
let got = adjust::modern_brightness(x as f32 / 255.0, b) * 255.0;
assert!((got - want).abs() <= tol, "brightness {b} x={x}: got {got:.1} want {want}");
}
}
// Endpoints and the zero slider are exact.
assert_eq!(adjust::modern_brightness(0.0, 120.0), 0.0);
assert_eq!(adjust::modern_brightness(1.0, -120.0), 1.0);
assert_eq!(adjust::modern_brightness(0.37, 0.0), 0.37);
assert_eq!(adjust::modern_contrast(0.37, 0.0), 0.37);
}
// Levels ground truth from Adobe Photoshop 2026 (adjustLevels on a 0..255 ramp). Verifies our
// `levels()` matches the real app, including the gamma>1 shadow toe (initial slope 2^gamma).
#[test]
fn levels_matches_photoshop() {
let ident = |g: f32| LevelsChannel { in_black: 0.0, in_white: 1.0, gamma: g, out_black: 0.0, out_white: 1.0 };
// gamma 2.0 (lifts shadows): x -> Photoshop out. The deep shadow is the toe-bounded region.
let g2: [(usize, u8); 8] = [(1, 4), (8, 30), (16, 55), (32, 90), (64, 128), (128, 181), (192, 221), (224, 239)];
for (x, ps) in g2 {
let got = (adjust::levels(&ident(2.0), x as f32 / 255.0) * 255.0).round();
assert!((got - ps as f32).abs() <= 4.0, "levels gamma 2.0 x={x}: got {got} want {ps}");
}
// gamma 0.5 (darkens midtones) is exact: out = (x/255)^2.
for x in [0usize, 32, 64, 128, 192, 255] {
let got = (adjust::levels(&ident(0.5), x as f32 / 255.0) * 255.0).round();
let want = ((x as f32 / 255.0).powi(2) * 255.0).round();
assert_eq!(got, want, "levels gamma 0.5 x={x}");
}
// Endpoints pinned; identity is identity. (The gamma>1 toe ends before white, so white
// stays exactly 1.0.)
assert_eq!(adjust::levels(&ident(2.5), 0.0), 0.0);
assert_eq!((adjust::levels(&ident(2.5), 1.0) * 255.0).round(), 255.0);
assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4);
assert!((adjust::levels(&ident(1.0), 0.37) - 0.37).abs() < 1e-6);
}
// Photoshop (psd-tools levels_grayscale, 8-bit): input range 44..214, gamma 1.78. The stretch
// is rounded to whole levels before the gamma (45 → 1.5 → 2, 47 → 4.5 → 5), so the shadows
// step unevenly: 45, 46, 47, 48, 52, 69 → 7, 10, 17, 20, 37, 88.
#[test]
fn levels_work_on_whole_levels() {
let ch = LevelsChannel { in_black: 44.0 / 255.0, in_white: 214.0 / 255.0, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
let adj = Adjustment::Levels {
master: LevelsChannel::default(),
per_channel: [ch.clone(), ch.clone(), ch.clone()],
space: Default::default(),
black: LevelsChannel::default(),
};
let luts = adjust::tone_luts_q(&adj, Some(255.0));
let at = |v: u8| {
let x = f32::from(v) / 255.0 * 4095.0;
let (i, f) = (x.floor() as usize, x.fract());
let y = luts[0][i] * (1.0 - f) + luts[0][(i + 1).min(4095)] * f;
(y * 255.0).round()
};
for (v, ps) in [(44u8, 0.0f32), (45, 7.0), (46, 10.0), (47, 17.0), (48, 20.0), (52, 37.0), (69, 88.0), (100, 137.0), (214, 255.0)] {
assert!((at(v) - ps).abs() <= 1.0, "levels {v}: got {} want {ps}", at(v));
}
// 16-bit and float stay smooth (no 8-bit staircase).
for q in [Some(32768.0), None] {
let a = adjust::levels_q(&ch, 0.2, q);
let b = adjust::levels_q(&ch, 0.2 + 1.0 / 2048.0, q);
assert!(b > a && b - a < 2.0 / 255.0, "{q:?}: {a} {b}");
}
// Hostile quantum values fall back to the continuous curve.
for q in [Some(0.0), Some(f32::NAN), Some(-3.0)] {
assert_eq!(adjust::levels_q(&ch, 0.4, q), adjust::levels(&ch, 0.4));
}
}
// Every Levels step lands on a whole level in 8 bits, and the gamma's shadow toe starts at
// slope 2^gamma (`adjust::levels_gamma`). Level → Photoshop's level, exact, from:
// - psd-tools levels_rgb.psd, Levels 2 (each channel, then the master): master 0..213, gamma
// 1.22, output 48..255; R gamma 0.47 to 0..194; B 61..255, gamma 0.47, to 0..213. Narrow
// output ranges merge neighbouring levels (R 43..48 → 58, G 8, 9 → 63, B 89, 90 → 54).
// - the photoshop corpus's rgb8 levels.psd: master 15..230, gamma 1.3, output 10..245.
// - psd-tools levels_grayscale.psd, Levels 3: 44..214, gamma 1.78 (52 → 37 is one level off).
#[test]
fn levels_steps_match_photoshop() {
let ch = |ib: f32, iw: f32, gamma: f32, ob: f32, ow: f32| LevelsChannel {
in_black: ib / 255.0,
in_white: iw / 255.0,
gamma,
out_black: ob / 255.0,
out_white: ow / 255.0,
};
let id = LevelsChannel::default();
let levels = |master: LevelsChannel, per_channel: [LevelsChannel; 3]| Adjustment::Levels {
master,
per_channel,
space: Default::default(),
black: LevelsChannel::default(),
};
let run = |adj: &Adjustment, xs: &[u8]| {
let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect();
let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px };
adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8));
b.px
};
let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]);
let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200];
let want: [[u8; 21]; 3] = [
[48, 48, 48, 50, 50, 58, 58, 58, 63, 63, 69, 72, 76, 79, 79, 86, 89, 100, 123, 153, 174],
[51, 63, 63, 72, 72, 103, 106, 109, 116, 122, 133, 141, 145, 149, 150, 160, 165, 178, 204, 228, 244],
[48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 50, 51, 54, 54, 54, 63, 63, 76, 102, 136, 164],
];
for (k, p) in run(&rgb, &xs).iter().enumerate() {
for c in 0..3 {
assert_eq!((p[c] * 255.0).round(), f32::from(want[c][k]), "levels_rgb channel {c} at {}", xs[k]);
}
}
let single = |c: LevelsChannel| levels(c, [id.clone(), id.clone(), id.clone()]);
let oracles: [(Adjustment, &[u8], &[u8]); 2] = [
(
single(ch(15.0, 230.0, 1.3, 10.0, 245.0)),
&[0, 21, 22, 49, 82, 118, 154, 176, 195, 215, 235, 251],
&[10, 25, 27, 66, 106, 144, 178, 198, 215, 232, 245, 245],
),
(
single(ch(44.0, 214.0, 1.78, 0.0, 255.0)),
&[44, 45, 46, 47, 48, 49, 50, 51, 53, 55, 57, 60, 65, 70, 80, 100, 150, 200, 214],
&[0, 7, 10, 17, 20, 26, 29, 35, 43, 51, 58, 66, 79, 89, 107, 137, 196, 243, 255],
),
];
for (adj, xs, want) in &oracles {
for ((p, x), w) in run(adj, xs).iter().zip(*xs).zip(*want) {
assert_eq!((p[0] * 255.0).round(), f32::from(*w), "levels at {x}");
}
}
}
// The shadow toe stays a well-behaved curve for any gamma: from 0 to 1, rising, and without a
// jump where it meets the power curve.
#[test]
fn levels_gamma_toe_is_monotone_for_any_gamma() {
for g in [1.0001, 1.05, 1.5, 3.0, 9.99, 1e6, f32::INFINITY] {
let mut last = 0.0f32;
for k in 0..=4096 {
let u = k as f32 / 4096.0;
let t = adjust::levels_gamma(u, g);
assert!(t.is_finite() && (0.0..=1.0 + 1e-6).contains(&t), "gamma {g} at {u}: {t}");
assert!(t >= last - 1e-6, "gamma {g} falls at {u}: {last} -> {t}");
// Never steeper than the toe's initial slope 2^g.
assert!(k == 0 || t - last <= 2f32.powf(g) / 4096.0 + 1e-4, "gamma {g} jumps at {u}: {last} -> {t}");
last = t;
}
assert!((adjust::levels_gamma(1.0, g) - 1.0).abs() < 1e-6, "gamma {g}: white");
}
}
// The oracle corpus' rgb32 levels.psd: input 15..230, gamma 1.3, output 10..245. In a
// 32-bit document neither range clips and the gamma is a plain power curve mirrored below black:
// 0.0497 → 3 (the clipped curve gives 10), 0.9473 → 255 (245). Integer documents still clip.
#[test]
fn levels_dont_clip_in_32_bit() {
let ch = LevelsChannel { in_black: 15.0 / 255.0, in_white: 230.0 / 255.0, gamma: 1.3, out_black: 10.0 / 255.0, out_white: 245.0 / 255.0 };
let adj = Adjustment::Levels { master: ch, per_channel: Default::default(), space: Default::default(), black: LevelsChannel::default() };
let level = |depth, v| {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [v, v, v, 1.0]);
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Srgb, Some(depth));
(b.px[0][0] * 255.0).round()
};
for (v, want) in [(0.0497, 3.0), (0.0976, 32.0), (0.4508, 140.0), (0.9473, 255.0)] {
assert_eq!(level(SampleType::F32, v), want, "{v}");
}
assert!(level(SampleType::F32, 0.0) < 0.0);
assert!(level(SampleType::F32, 1.0) > 255.0);
assert_eq!(level(SampleType::U8, 0.0), 10.0);
assert_eq!(level(SampleType::U8, 1.0), 245.0);
}
// Exposure linearises RGB documents through a 2.2 power, not the sRGB curve: Photoshop lifts 76
// to 134 with an offset of 0.1738 (psd-tools adjustment_nested_composition_4).
#[test]
fn exposure_offset_uses_gamma_2_2_in_rgb() {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [76.0 / 255.0, 76.0 / 255.0, 76.0 / 255.0, 1.0]);
adjust::apply_with(&Adjustment::Exposure { exposure: 0.0, offset: 0.1738, gamma: 1.0 }, &mut b, adjust::Transfer::Srgb);
assert_eq!((b.px[0][0] * 255.0).round(), 134.0);
assert_eq!(adjust::Transfer::Gamma(1.732).for_exposure(), adjust::Transfer::Gamma(1.732));
}
// 32-bit samples are linear light: +1 stop doubles them as stored, where an 8-bit document goes
// through its tone curve first (photoshop corpus rgb32 and gray32 exposure.psd).
#[test]
fn exposure_scales_32_bit_samples_as_stored() {
let plus_one_stop = Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 };
for (depth, fmt, want) in [(SampleType::F32, PixelFormat::RGBA32F, 0.4), (SampleType::U8, PixelFormat::RGBA8, 0.2744)] {
let mut d = Document::new("e", Size::new(1, 1), ColorMode::Rgb, depth);
let mut l = Layer::raster("px", fmt);
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[0.2, 0.2, 0.2, 1.0]);
d.layers = vec![l, Layer::new("exp", LayerContent::Adjustment(plus_one_stop.clone()))];
let got = px(&d, 0, 0)[0];
assert!((got - want).abs() < 2e-3, "{depth:?}: {got}");
}
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
assert_eq!(adjust::Transfer::for_document(mode, SampleType::F32), adjust::Transfer::Gamma(1.0));
}
}
#[test]
fn float_adjustments_preserve_hdr_range() {
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
let levels = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };
let adjustments = [
(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 }, [4.0, 2.0, 0.5]),
(Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, [8.0, 4.0, 1.0]),
(
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
[4.0, 2.0, 0.5],
),
(
Adjustment::Levels { master: levels, per_channel: Default::default(), space: Default::default(), black: Default::default() },
[4.0, 2.0, 0.5].map(|v| -0.1 + ((v - 0.25_f32) / 0.5).sqrt() * 1.6),
),
(
Adjustment::Curves {
master: identity.clone(),
per_channel: std::array::from_fn(|_| identity.clone()),
space: Default::default(),
black: Vec::new(),
pencil: false,
},
[4.0, 2.0, 0.5],
),
(
Adjustment::Curves { master: Vec::new(), per_channel: Default::default(), space: Default::default(), black: Vec::new(), pencil: false },
[4.0, 2.0, 0.5],
),
];
for (adj, want) in adjustments {
let mut d = Document::new("HDR", Size::new(1, 1), ColorMode::Rgb, SampleType::F32);
let mut l = Layer::raster("pixels", PixelFormat::RGBA32F);
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[4.0, 2.0, 0.5, 0.75]);
d.layers = vec![l, Layer::new("adjustment", LayerContent::Adjustment(adj.clone()))];
let got = px(&d, 0, 0);
for (a, b) in got[..3].iter().zip(want) {
assert!((a - b).abs() < 1e-5, "{adj:?}: {got:?} vs {want:?}");
}
assert_eq!(got[3], 0.75);
}
}
#[test]
fn float_tone_adjustments_preserve_negative_and_untouched_channels() {
let identity = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 1.0 }];
let edited = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.5 }];
for (adj, want) in [
(
Adjustment::Levels { master: Default::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() },
[-0.1, 4.0, 0.5],
),
(
Adjustment::Curves {
master: identity.clone(),
per_channel: [identity.clone(), Vec::new(), edited],
space: Default::default(),
black: Vec::new(),
pencil: false,
},
[-0.1, 4.0, 0.25],
),
] {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [-0.1, 4.0, 0.5, 0.75]);
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (a, b) in b.px[0][..3].iter().zip(want) {
assert!((a - b).abs() < 1e-5, "{adj:?}: {:?} vs {want:?}", b);
}
assert_eq!(b.px[0][3], 0.75);
}
}
#[test]
fn float_exposure_stops_round_trip_without_losing_highlights() {
let input = [4.0, -2.0, 0.5, 0.75];
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), input);
for exposure in [1.0, -1.0] {
adjust::apply_depth(&Adjustment::Exposure { exposure, offset: 0.0, gamma: 1.0 }, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
}
assert_eq!(b.px[0], input);
}
#[test]
fn float_exposure_preserves_signed_gamma_and_offset() {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [-0.5, 0.0, 4.0, 0.75]);
adjust::apply_depth(&Adjustment::Exposure { exposure: 1.0, offset: -0.25, gamma: 2.0 }, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (got, want) in b.px[0].iter().zip([-1.25_f32.sqrt(), -0.5, 7.75_f32.sqrt(), 0.75]) {
assert!((got - want).abs() < 1e-6, "{:?}", b.px[0]);
}
}
#[test]
fn float_levels_apply_channel_before_master_outside_unit_range() {
let channel = LevelsChannel { in_black: 0.5, in_white: 1.5, ..Default::default() };
let master = LevelsChannel { out_black: -0.1, out_white: 1.5, ..Default::default() };
let adj = Adjustment::Levels { master, per_channel: std::array::from_fn(|_| channel.clone()), space: Default::default(), black: Default::default() };
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [4.0, 2.0, -0.1, 1.0]);
adjust::apply_depth(&adj, &mut b, adjust::Transfer::Gamma(1.0), Some(SampleType::F32));
for (got, want) in b.px[0].iter().zip([5.5, 2.3, -1.06, 1.0]) {
assert!((got - want).abs() < 1e-6, "{:?}", b.px[0]);
}
}
// A 30° Reflected gradient fill on a 4 × 4 canvas renders as Photoshop's (its end point snaps to
// the corner: t = |x − y| / 4) at every depth.
#[test]
fn small_gradient_fill_matches_photoshop_at_all_depths() {
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 1.0))];
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = Document::new("g", Size::new(4, 4), ColorMode::Rgb, depth);
let fill = Fill::gradient(stops.clone(), 30.0, 1.0, photocraft_doc::GradientStyle::Reflected, false);
d.layers.push(Layer::new("g", LayerContent::Fill(fill)));
let out = flatten(&d);
for y in 0..4usize {
for x in 0..4usize {
let want = (x as f32 - y as f32).abs() / 4.0;
let got = out.px[y * 4 + x][0];
assert!((got - want).abs() < 1.0 / 255.0, "{depth:?} ({x},{y}): {got} vs {want}");
}
}
}
}
/// A shape layer's gradient fill must render the same pixels as the compositor's gradient
/// fill layers, for every non-default gradient field (midpoints, opacity stops, centre offset,
/// dither, unsorted stops) and all five styles, in both frames: "Align with layer" on lays the
/// gradient out over the shape's bounds, off over the canvas. `photocraft-vector` cannot
/// depend on this crate, so it carries a copy of this ramp and geometry — this test is what
/// keeps the two in step (only interior pixels, where the shape's coverage is exactly 1).
#[test]
fn shape_layer_gradients_match_fill_layers() {
use photocraft_doc::GradientStyle;
let canvas = Rect::new(0, 0, 64, 48);
// rect(x, y, w, h): the shape spans (16, 8)–(64, 48), so its whole-pixel bounds sit strictly
// inside the canvas — "Align with layer" then picks a different frame than the canvas.
let path = photocraft_vector::shapes::rect(16.0, 8.0, 48.0, 40.0);
let layer_frame = Rect::new(16, 8, 64, 48);
let grad = |style: GradientStyle, midpoints: Vec<f32>, opacity_stops: Vec<(f32, f32)>, offset: (f32, f32), dither: bool, unsorted: bool, align: bool| {
let mut stops = vec![(0.0, Color::BLACK), (0.5, Color::rgb(1.0, 0.25, 0.5)), (1.0, Color::WHITE)];
if unsorted {
stops.reverse();
}
Fill::Gradient { stops, angle: 30.0, scale: 1.7, style, reverse: false, opacity_stops, midpoints, offset, dither, align }
};
let styles = [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond];
// (name, midpoints, opacity stops, centre offset, dither, unsorted stops) per case.
type Case = (&'static str, Vec<f32>, Vec<(f32, f32)>, (f32, f32), bool, bool);
let cases: Vec<Case> = vec![
("plain", vec![], vec![], (0.0, 0.0), false, false),
("midpoints", vec![0.25, 0.9], vec![], (0.0, 0.0), false, false),
("opacity stops", vec![], vec![(0.0, 1.0), (0.5, 0.2), (1.0, 0.9)], (0.0, 0.0), false, false),
("offset", vec![], vec![], (0.3, -0.2), false, false),
("dither", vec![], vec![], (0.0, 0.0), true, false),
("all of them", vec![0.75], vec![(0.0, 1.0), (1.0, 0.4)], (0.1, 0.1), true, true),
];
for style in styles {
for (name, mids, opac, offset, dither, unsorted) in &cases {
for align in [true, false] {
let f = grad(style, mids.clone(), opac.clone(), *offset, *dither, *unsorted, align);
let frame = if align { layer_frame } else { canvas };
let want = gradient_fill::render(&f, canvas, frame);
let sh = photocraft_doc::vector::ShapeLayer { path: path.clone(), fill: Some(f), ..Default::default() };
let s = photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, canvas);
let mut worst = 0.0f32;
for y in 9..47 {
for x in 17..63 {
let p = want[(y as usize) * canvas.width() as usize + x as usize];
for (got, want_ch) in (0..4usize).map(|ch| (s.sample_channel(x, y, ch), p[ch])) {
worst = worst.max((got - want_ch).abs());
}
}
}
assert!(worst <= 1.5 / 255.0, "{style:?} {name} align={align}: worst delta {worst}");
}
}
}
}
/// A tall document with soft content, a translucent region and an adjustment.
fn tall_doc(w: u32, h: u32) -> Document {
let mut d = doc_white(w, h);
let mut l = Layer::raster("grad", PixelFormat::RGBA8);
let s = l.surface_mut().unwrap();
for y in 0..h as i32 {
s.fill_rect(Rect::new(0, y, w as i32 / 2, y + 1), &[y as f32 / h as f32, 0.3, 1.0 - y as f32 / h as f32, 1.0]);
}
s.fill_rect(Rect::new(w as i32 / 2, 0, w as i32, h as i32 / 3), &[0.9, 0.1, 0.1, 0.4]);
d.layers.push(l);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
d
}
#[test]
fn render_tiled_in_place_matches_one_tile_with_many_bands() {
// Enough bands for the band-parallel path, and few for the tile-parallel one.
let d = tall_doc(37, 900);
let whole = render_tiled(&d, d.bounds(), 10_000);
for tile in [5, 64, 300] {
assert_eq!(render_tiled(&d, d.bounds(), tile), whole, "tile {tile}");
}
let r = Rect::new(3, 17, 30, 811);
assert_eq!(render_tiled(&d, r, 4).px, render_tiled(&d, r, 10_000).px);
assert!(render_tiled(&d, Rect::new(5, 5, 5, 900), 4).px.is_empty());
}
#[test]
fn render_bands_cover_the_rect_in_order() {
let d = tall_doc(20, 700);
let r = Rect::new(2, 10, 18, 690);
let mut rows = Vec::new();
let mut px = Vec::new();
render_bands(&d, r, 100, |b| -> Result<(), ()> {
assert_eq!((b.rect.x0, b.rect.x1), (r.x0, r.x1));
rows.push((b.rect.y0, b.rect.y1));
px.extend(b.px);
Ok(())
})
.unwrap();
// 100 rows round up to one 256-row tile band.
assert_eq!(rows, vec![(10, 266), (266, 522), (522, 690)]);
assert_eq!(px, render(&d, r).px);
// The sink's error stops the render.
let mut n = 0;
assert_eq!(
render_bands(&d, r, 1, |_| {
n += 1;
Err("stop")
}),
Err("stop")
);
assert_eq!(n, 1);
assert_eq!(band_rows_for(14_000, 0) % RENDER_TILE, 0);
assert!(band_rows_for(1, 0) > 0 && band_rows_for(0, -5) > 0);
}
/// The previous thumbnail: flatten, then area-average (premultiplied).
fn reference_reduce(doc: &Document, w: usize, h: usize) -> Vec<[f32; 4]> {
let full = flatten(doc);
let (fw, fh) = (full.rect.width() as usize, full.rect.height() as usize);
let mut out = Vec::new();
for ty in 0..h {
let (y0, y1) = (ty * fh / h, ((ty + 1) * fh / h).max(ty * fh / h + 1).min(fh));
for tx in 0..w {
let (x0, x1) = (tx * fw / w, ((tx + 1) * fw / w).max(tx * fw / w + 1).min(fw));
let mut acc = [0.0f32; 4];
for y in y0..y1 {
for x in x0..x1 {
let p = full.px[y * fw + x];
for c in 0..3 {
acc[c] += p[c] * p[3];
}
acc[3] += p[3];
}
}
let n = ((y1 - y0) * (x1 - x0)).max(1) as f32;
out.push(if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3] / n] } else { [0.0; 4] });
}
}
out
}
#[test]
fn reduced_render_matches_the_full_composite_averaged() {
let d = tall_doc(53, 1000);
for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 512), (1, 1, 256)] {
let got = render_reduced_in_bands(&d, d.bounds(), w, h, None, band);
assert_eq!((got.rect.width(), got.rect.height()), (w, h));
assert_eq!(got.px, reference_reduce(&d, w as usize, h as usize), "{w}x{h} band {band}");
}
// Full size is the composite itself; an empty document reduces to transparency.
assert_eq!(render_reduced(&d, 53, 1000).px, flatten(&d).px);
let empty = Document::new("e", Size::new(0, 0), ColorMode::Rgb, SampleType::U8);
assert_eq!(render_reduced(&empty, 4, 4).px, vec![[0.0; 4]]);
}
#[test]
fn reduced_damage_matches_the_whole_reduction() {
// A reduced canvas texture updates only what a stroke touched: those pixels must equal the
// whole reduction's, for uneven factors, damage on span edges and bands smaller than the area.
let d = tall_doc(53, 1000);
for (w, h, band) in [(10, 190, 0), (7, 33, 256), (53, 999, 64), (53, 1000, 0), (1, 1, 256)] {
let all = render_reduced_in_bands(&d, d.bounds(), w, h, None, band);
for dmg in [Rect::new(0, 0, 1, 1), Rect::new(5, 17, 6, 18), Rect::new(12, 300, 40, 701), Rect::new(-9, 990, 80, 2000), Rect::new(0, 0, 53, 1000)] {
let part = render_reduced_in_bands(&d, d.bounds(), w, h, Some(dmg), band);
let r = part.rect;
assert!(!r.is_empty() && r.x0 >= 0 && r.y0 >= 0 && r.x1 as u32 <= w && r.y1 as u32 <= h, "{w}x{h} {dmg:?} -> {r:?}");
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
assert_eq!(part.get(x, y), all.get(x, y), "{w}x{h} band {band} {dmg:?} at {x},{y}");
}
}
// Exactly the output pixels whose source area meets the damage.
let span = |t: u32, f: u32, n: u32| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f));
let d = dmg.intersect(&Rect::new(0, 0, 53, 1000));
for y in 0..h {
for x in 0..w {
let ((x0, x1), (y0, y1)) = (span(x, 53, w), span(y, 1000, h));
let meets = !Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32).intersect(&d).is_empty();
assert_eq!(r.contains(x as i32, y as i32), meets, "{w}x{h} {dmg:?} at {x},{y}");
}
}
}
assert!(render_reduced_in_bands(&d, d.bounds(), w, h, Some(Rect::new(60, 0, 70, 10)), band).rect.is_empty());
}
}
#[test]
fn reduced_render_of_an_area_past_the_canvas() {
// The Crop tool shows layer pixels beyond the canvas: rendered in place, they must equal the
// same pixels on a canvas that holds them (adjustment layers apply out there too).
let paint = |d: &mut Document, dx: i32, dy: i32| {
let mut l = Layer::raster("big", PixelFormat::RGBA8);
let s = l.surface_mut().unwrap();
for y in -10..40 {
for x in -20..60 {
let a = if x < 0 || y >= 30 { 0.6 } else { 1.0 };
s.fill_rect(Rect::new(x + dx, y + dy, x + dx + 1, y + dy + 1), &[(x + 20) as f32 / 80.0, (y + 10) as f32 / 50.0, 0.5, a]);
}
}
d.layers.push(l);
d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)));
};
let mut d = Document::new("d", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
paint(&mut d, 0, 0);
let mut whole = Document::new("w", Size::new(80, 50), ColorMode::Rgb, SampleType::U8);
paint(&mut whole, 20, 10);
let area = Rect::new(-20, -10, 60, 40);
for (w, h) in [(80, 50), (8, 5), (27, 13), (1, 1)] {
let got = render_reduced_rect(&d, area, w, h);
assert_eq!(got.rect, Rect::new(0, 0, w as i32, h as i32));
assert_eq!(got.px, render_reduced(&whole, w, h).px, "{w}x{h}");
}
// Past every layer there is nothing to show.
assert!(render_reduced_rect(&d, Rect::new(100, 100, 120, 110), 20, 10).px.iter().all(|p| p[3] == 0.0));
}
#[test]
fn large_documents_thumbnail_from_a_proxy() {
// 4200 × 4200 (17.6 MP) is above PROXY_THUMBNAIL_PIXELS: the proxy path must still give a
// thumbnail of the right size whose colours match the exact reduction.
let mut d = doc_white(4200, 4200);
d.layers.push(solid_layer("red", Rect::new(0, 0, 2100, 4200), [1.0, 0.0, 0.0, 1.0]));
let t = thumbnail(&d, 100);
assert_eq!((t.width, t.height), (100, 100));
let at = |x: usize, y: usize| t.pixels[(y * 100 + x) * 4..(y * 100 + x) * 4 + 4].to_vec();
assert_eq!(at(10, 50), vec![255, 0, 0, 255]);
assert_eq!(at(90, 50), vec![255, 255, 255, 255]);
// Effects don't scale with a proxy: such documents reduce the exact composite.
assert!(proxy::proxy_faithful(&d));
d.layers[1].effects.items.push(photocraft_doc::Effect::default_drop_shadow());
assert!(!proxy::proxy_faithful(&d));
}
/// Layers whose effect maps are built with nested Rayon work (a gradient fill's rows render in
/// parallel), each carrying a different effect.
#[cfg(not(target_arch = "wasm32"))]
fn nested_parallel_fx_doc() -> Document {
use photocraft_doc::{Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, StrokeFx, StrokePosition};
let mut d = doc_white(512, 512);
let g = |a: f32| Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::rgb(0.2, 0.5, 0.9))], a, 1.0, photocraft_doc::GradientStyle::Linear, false);
let mut shadow = Layer::new("shadow", LayerContent::Fill(g(0.0)));
shadow.effects.items = vec![Effect::default_drop_shadow()];
let mut stroke = Layer::new("stroke", LayerContent::Fill(g(45.0)));
stroke.opacity = 0.6;
stroke.effects.items = vec![Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 3.0,
position: StrokePosition::Inside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})];
let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0)));
glow.opacity = 0.5;
glow.effects.items = vec![Effect::InnerGlow(Glow {
common: FxCommon::new(BlendMode::Screen, 0.75),
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.7)),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 9.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
})];
// A chiselled bevel: its distance transforms run on threads of their own, inside the tile that builds them.
let mut chisel = solid_layer("chisel", Rect::new(60, 60, 450, 450), [0.8, 0.6, 0.2, 1.0]);
chisel.effects.items = vec![Effect::BevelEmboss(Bevel {
enabled: true,
style: BevelStyle::InnerBevel,
technique: BevelTechnique::ChiselHard,
depth: 1.5,
up: true,
size: 9.0,
soften: 0.0,
angle: 120.0,
altitude: 32.0,
use_global_light: false,
gloss_contour: Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
shadow_color: Color::BLACK,
contour: None,
texture: None,
})];
// Unaffected layers whose rows also render in parallel keep both workers stealing.
let base = Layer::new("base", LayerContent::Fill(g(30.0)));
let mut top = Layer::new("top", LayerContent::Fill(g(60.0)));
top.opacity = 0.3;
d.layers.extend([base, shadow, stroke, glow, chisel, top]);
d
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn effect_maps_with_nested_rayon_work_do_not_deadlock() {
// #276: a tile building a layer's effect maps inside a once-init ran Rayon work; waiting on
// it, its worker stole another tile of the same layer, which then blocked on the init held
// further up its own stack. Eight threads (two rarely steal across that wait) and 64 tiles make that stealing likely; the cache is
// purged every round so the maps are rebuilt. A watchdog turns a hang into a failure.
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let Ok(pool) = rayon::ThreadPoolBuilder::new().num_threads(8).build() else { return };
let d = nested_parallel_fx_doc();
let first = pool.install(|| render_tiled(&d, d.bounds(), 64));
for _ in 0..100 {
purge_effect_cache();
let out = pool.install(|| render_tiled(&d, d.bounds(), 64));
assert!(out.px == first.px, "renders differ between rounds");
}
let _ = tx.send(());
});
match rx.recv_timeout(std::time::Duration::from_secs(120)) {
Ok(()) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("effect-map rendering deadlocked (#276)"),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("render thread panicked"),
}
}
// Photo Filter multiplies D50 XYZ (relative to white) by the filter colour's, then restores the
// encoded luminosity like the Luminosity blend mode; 32-bit documents keep luminance Y instead.
// Samples from the photoshop corpus photo-filter.psd (Warming Filter (85), density 40 %, Preserve
// Luminosity): pure blue gains red, and white stays white (#442).
#[test]
fn photo_filter_matches_photoshop() {
let filter = |color: [f32; 3], depth, v: [f32; 3]| {
let adj = Adjustment::PhotoFilter { color, density: 0.4, preserve_luminosity: true };
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [v[0], v[1], v[2], 1.0]);
adjust::apply_depth(&adj, &mut b, adjust::Transfer::for_document(ColorMode::Rgb, depth), Some(depth));
[b.px[0][0], b.px[0][1], b.px[0][2]].map(|c| c * 255.0)
};
let warming = [236.0 / 255.0, 138.0 / 255.0, 0.0];
for depth in [SampleType::U8, SampleType::U16] {
for (v, ps) in [
([1.0, 0.0, 0.0], [239.0, 7.0, 7.0]),
([0.0, 1.0, 0.0], [69.0, 220.0, 0.0]),
([0.0, 0.0, 1.0], [46.0, 0.0, 130.0]),
([1.0, 1.0, 0.0], [255.0, 240.0, 80.0]),
([0.5019, 0.5019, 0.5019], [140.0, 124.0, 116.0]),
([0.949, 0.949, 0.949], [255.0, 238.0, 229.0]),
([1.0, 1.0, 1.0], [255.0, 255.0, 255.0]),
([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
] {
let got = filter(warming, depth, v);
assert!(got.iter().zip(ps).all(|(g, p)| (g - p).abs() <= 1.0), "{depth:?} {v:?}: got {got:?} want {ps:?}");
}
}
// The 32-bit file picks the same numbers as linear values (Lab 81.5, 9.41, 82.19). Its fit is
// looser (mean 0.9/255 over the file, 6.9/255 with a plain RGB multiply), and Photoshop's
// results there are unclipped: above 1 and below 0.
let warming32 = [0.966_445_6, 0.761_806_3, 0.0];
for (v, ps) in [([0.2159, 0.2159, 0.2159], [65.7, 53.4, 39.5]), ([1.0, 1.0, 0.0], [271.7, 250.9, -9.0]), ([1.0, 0.0, 0.0], [262.2, -2.0, -1.2])] {
let got = filter(warming32, SampleType::F32, v);
assert!(got.iter().zip(ps).all(|(g, p)| (g - p).abs() <= 2.5), "F32 {v:?}: got {got:?} want {ps:?}");
}
}
#[test]
fn rendering_restores_the_lab_mix_flag_it_found() {
// #1112: a render set LAB_MIX for its document, then cleared it. A nested rayon job (a
// gradient fill, say) can let a thread render another tile in the middle of an outer Lab
// tile, and clearing the flag made the rest of that outer tile mix in sRGB.
for mode in [ColorMode::Lab, ColorMode::Rgb] {
let doc = Document::with_background("t", Size::new(8, 8), mode, SampleType::U8, Color::WHITE);
// Single-tile and multi-tile paths (the latter also prepares effect maps under the flag).
for tile in [RENDER_TILE, 4] {
for outer in [true, false] {
psblend::LAB_MIX.with(|l| l.set(outer));
let _ = render_tiled(&doc, doc.bounds(), tile);
let after = psblend::LAB_MIX.with(|l| l.get());
psblend::LAB_MIX.with(|l| l.set(false));
assert_eq!(after, outer, "{mode:?} with tile {tile} inside a tile that had LAB_MIX = {outer}");
}
}
}
}
/// Vibrance of an 8-bit sRGB colour (0–255 in and out).
fn vibrance_255(c: [f32; 3], vibrance: f32, saturation: f32) -> [f32; 3] {
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [c[0] / 255.0, c[1] / 255.0, c[2] / 255.0, 1.0]);
adjust::apply(&Adjustment::Vibrance { vibrance, saturation }, &mut buf);
let p = buf.px[0];
[p[0] * 255.0, p[1] * 255.0, p[2] * 255.0]
}
fn assert_near_255(got: [f32; 3], want: [f32; 3], tol: f32, what: &str) {
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol), "{what}: got {got:?}, Photoshop {want:?}");
}
// Photoshop 25.4 references (16-bit runs of 8-bit colours in an sRGB document).
#[test]
fn vibrance_saturation_matches_photoshop() {
// −100 greys to 0.288 R + 0.712 G in linear light: blue has no weight, yellow stays bright.
for (c, g) in [([251.0, 201.0, 0.0], 217.0), ([255.0, 0.0, 0.0], 146.0), ([0.0, 255.0, 0.0], 219.4), ([0.0, 0.0, 255.0], 0.0), ([255.0, 255.0, 0.0], 255.0)]
{
assert_near_255(vibrance_255(c, 0.0, -100.0), [g; 3], 0.5, "saturation -100");
}
for (c, s, want) in [
([255.0, 128.0, 0.0], -50.0, [220.7, 155.2, 129.4]),
([224.0, 176.0, 144.0], -50.0, [208.6, 183.9, 169.9]),
([64.0, 128.0, 192.0], 50.0, [0.0, 134.3, 218.7]),
([224.0, 176.0, 144.0], 100.0, [251.3, 158.5, 51.7]),
([32.0, 96.0, 64.0], 100.0, [0.0, 106.8, 32.8]),
] {
assert_near_255(vibrance_255(c, 0.0, s), want, 0.5, "saturation");
}
}
#[test]
fn negative_vibrance_matches_photoshop() {
for (c, v, want) in [
([255.0, 0.0, 0.0], -100.0, [255.0, 137.0, 137.0]),
([128.0, 0.0, 0.0], -100.0, [128.0, 65.7, 65.7]),
([224.0, 176.0, 144.0], -100.0, [213.2, 197.4, 188.8]),
([64.0, 128.0, 192.0], -100.0, [124.4, 149.0, 185.0]),
([255.0, 128.0, 0.0], -50.0, [255.0, 152.0, 99.1]),
([251.0, 201.0, 0.0], -50.0, [251.0, 208.1, 97.4]),
([32.0, 96.0, 64.0], -50.0, [50.1, 91.7, 68.6]),
] {
assert_near_255(vibrance_255(c, v, 0.0), want, 0.6, "vibrance");
}
// Vibrance applies first, then Saturation.
assert_near_255(vibrance_255([64.0, 128.0, 192.0], 50.0, -50.0), [86.5, 117.8, 158.4], 3.0, "vibrance then saturation");
}
#[test]
fn positive_vibrance_follows_photoshop_closely() {
// A fit, not exact: within a few levels, saturated colours untouched, skin damped.
for (c, v, want, tol) in [
([255.0, 128.0, 0.0], 50.0, [255.0, 128.0, 0.0], 0.5),
([144.0, 160.0, 176.0], 100.0, [121.2, 152.6, 180.5], 1.5),
([64.0, 128.0, 192.0], 50.0, [50.6, 125.1, 192.8], 4.0),
([224.0, 176.0, 144.0], 50.0, [224.9, 173.4, 138.0], 3.0),
([224.0, 176.0, 144.0], 100.0, [226.6, 166.8, 122.4], 5.0),
] {
assert_near_255(vibrance_255(c, v, 0.0), want, tol, "vibrance");
}
}
#[test]
fn clipped_brightness_and_desaturation_whiten_a_lighter_color_logo() {
// постер.psd: a yellow logo in Lighter Color over the same yellow, with Brightness/Contrast +150
// and Vibrance › Saturation −100 clipped to it. Photoshop shows it white (254); 0.5.0 left it
// yellow (its Saturation −100 greyed (255, 255, 0) to 128, darker than the yellow beneath).
let yellow = [251.0 / 255.0, 201.0 / 255.0, 0.0, 1.0];
let mut d = doc_white(2, 1);
d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &yellow);
let mut logo = solid_layer("logo", Rect::new(0, 0, 1, 1), yellow);
logo.blend = BlendMode::LighterColor;
d.layers.push(logo);
for adj in [Adjustment::BrightnessContrast { brightness: 150.0, contrast: 0.0, legacy: false }, Adjustment::Vibrance { vibrance: 0.0, saturation: -100.0 }]
{
let mut l = Layer::new("adj", LayerContent::Adjustment(adj));
l.clipped = true;
d.layers.push(l);
}
let p = px(&d, 0, 0);
assert!(p[..3].iter().all(|v| *v * 255.0 >= 252.0), "logo whitened: {p:?}");
assert!(close4(px(&d, 1, 0), yellow), "the yellow beside it is untouched");
}
#[test]
fn outer_glow_does_not_paint_through_a_zero_fill_layer() {
use photocraft_doc::{Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique};
let background = [0.18, 0.42, 0.85, 1.0];
let rect = Rect::new(24, 24, 72, 72);
for technique in [GlowTechnique::Softer, GlowTechnique::Precise] {
let mut d = doc_white(96, 96);
let bounds = d.bounds();
d.layers[0].surface_mut().unwrap().fill_rect(bounds, &background);
let mut layer = solid_layer("glow", rect, [1.0, 0.92, 0.25, 1.0]);
layer.fill_opacity = 0.0;
layer.effects.items.push(Effect::OuterGlow(Glow {
common: FxCommon::new(BlendMode::Normal, 1.0),
paint: FxPaint::Color(Color::rgb(1.0, 0.94, 0.28)),
technique,
spread: 0.5,
size: 12.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
}));
d.layers.push(layer);
let center = px(&d, 48, 48);
assert!(close4(center, background), "{technique:?}: glow leaked into fully transparent fill: {center:?}");
let distant = px(&d, 5, 5);
assert!(close4(distant, background), "{technique:?}: changed distant background: {distant:?}");
let halo = px(&d, 22, 48);
assert!(halo[0] > background[0] + 0.1, "{technique:?}: exterior halo disappeared: {halo:?}");
d.layers[1].fill_opacity = 1.0;
let opaque = px(&d, 48, 48);
assert!(opaque[0] > 0.9 && opaque[1] > 0.8, "{technique:?}: opaque fill disappeared: {opaque:?}");
}
}
// Lab documents: Invert mirrors L, a and b; Threshold splits on L with a neutral result; Gradient
// Map follows L. Rendered through the document compositor at every depth.
#[test]
fn lab_documents_evaluate_invert_threshold_and_gradient_map_on_lab() {
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
let c = lab_to_srgb([30.0, 12.0, -18.0]);
for depth in SampleType::ALL {
let render = |adj: Adjustment| {
let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(c[0], c[1], c[2]));
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
let p = flatten(&d).px[5];
srgb_to_lab([p[0], p[1], p[2]])
};
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
let inv = render(Adjustment::Invert);
// 8-bit bytes become 255 - v (a, b one step past the mirror); 16 bits mirror them exactly.
let want = if depth == SampleType::U8 { [70.0, -13.0, 17.0] } else { [70.0, -12.0, 18.0] };
assert!(near(inv, want, 0.5), "{depth:?}: {inv:?}");
let th = render(Adjustment::Threshold { level: 0.25 });
assert!(near(th, [100.0, 0.0, 0.0], 0.5), "{depth:?}: L 30 is above level 64: {th:?}");
let gm = render(Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false });
let want = srgb_to_lab([0.3; 3]);
assert!(near(gm, want, 1.0), "{depth:?}: L 30 → 30 % of the way: {gm:?} vs {want:?}");
}
}
/// `adj` over a one-colour Lab document (background `rgb`) at `depth`, read back as Lab.
fn lab_doc_adjusted(rgb: [f32; 3], adj: Adjustment, depth: SampleType) -> [f32; 3] {
let mut doc = Document::with_background("lab", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(rgb[0], rgb[1], rgb[2]));
doc.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
let p = px(&doc, 1, 1);
photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])
}
fn near3(got: [f32; 3], want: [f32; 3], tol: f32, what: &str) {
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol), "{what}: got {got:?}, want {want:?}");
}
/// A muted, in-gamut colour (L ≈ 46, a ≈ 10, b ≈ -20) and its Lab coordinates.
const MUTED: [f32; 3] = [0.40, 0.42, 0.60];
#[test]
fn lab_documents_invert_and_posterize_l_a_and_b() {
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
for depth in SampleType::ALL {
// Invert: L -> 100 - L and a, b -> their opposites (-a - 1 on the 8-bit 0..255 grid).
let inv = lab_doc_adjusted(MUTED, Adjustment::Invert, depth);
let step = if depth == SampleType::U8 { 1.0 } else { 0.0 };
near3(inv, [100.0 - lab[0], -lab[1] - step, -lab[2] - step], 1.0, &format!("{depth:?} invert"));
// An RGB invert would land elsewhere: lightness 100 - L is the Lab rule.
let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v));
assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}");
// Posterize, three levels: the same steps on L, a and b (here all land on the middle
// step: L ≈ 50 and a, b ≈ -1 (0 at 16 bits, whose middle level is 128), a neutral grey).
let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth);
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize_in(v, 3, Some(depth)));
let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0];
assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}");
near3(post, want, 1.0, &format!("{depth:?} posterize"));
}
}
/// A Lab document of one opaque layer holding stored L, a, b `lab` (normalised as Lab surfaces
/// store them), with `adjustments` above it.
fn lab_stack(lab: [f32; 3], depth: SampleType, adjustments: Vec<Adjustment>) -> Document {
let mut doc = Document::new("lab", Size::new(4, 4), ColorMode::Lab, depth);
let fmt = doc.pixel_format();
let mut layer = Layer::raster("bg", fmt);
if let Some(s) = layer.surface_mut() {
s.fill_rect(Rect::new(0, 0, 4, 4), &[lab[0], lab[1], lab[2], 1.0][..fmt.channels()]);
}
doc.layers.push(layer);
doc.layers.extend(adjustments.into_iter().map(|a| Layer::new("adj", LayerContent::Adjustment(a))));
doc
}
/// Display RGB of stored Lab `n`.
fn lab_display(n: [f32; 3]) -> [f32; 3] {
photocraft_color::convert::lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
}
// A saturated purple far outside sRGB (L 60, a +90, b -90). Read through display RGB it clips,
// so Invert and Levels on L, a and b saw the clipped colour: the photoshop corpus's lab8 and
// lab16 invert.psd were up to 11 levels off there. Worked out on the stored values, Invert gives
// the bytes' complements (L 153 → 102, a 218 → 37, b 38 → 217), as Photoshop does.
#[test]
fn lab_adjustments_work_on_the_stored_values() {
let byte = |v: u8| f32::from(v) / 255.0;
let doc = lab_stack([byte(153), byte(218), byte(38)], SampleType::U8, vec![Adjustment::Invert]);
let p = px(&doc, 1, 1);
let want = lab_display([byte(102), byte(37), byte(217)]);
near3([p[0], p[1], p[2]], want, 1e-4, "inverted out-of-gamut purple");
// Through clipped display RGB the result is another colour.
let clipped = photocraft_color::convert::srgb_to_lab(lab_display([byte(153), byte(218), byte(38)]));
let via_rgb = lab_display([1.0 - clipped[0] / 100.0, (-clipped[1] - 1.0 + 128.0) / 255.0, (-clipped[2] - 1.0 + 128.0) / 255.0]);
assert!(via_rgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 4.0 / 255.0), "the clipped path differs: {via_rgb:?} vs {want:?}");
// Levels on L only (a Lab record), half masked by opacity: L 0.6 → 0.6^(1/2) mixed halfway.
let ch = LevelsChannel { gamma: 2.0, ..Default::default() };
let levels = Adjustment::Levels {
master: LevelsChannel::default(),
per_channel: [ch.clone(), LevelsChannel::default(), LevelsChannel::default()],
space: ToneSpace::Lab,
black: LevelsChannel::default(),
};
let mut doc = lab_stack([0.6, byte(218), byte(38)], SampleType::U16, vec![levels]);
if let Some(l) = doc.layers.last_mut() {
l.opacity = 0.5;
}
let p = px(&doc, 1, 1);
let l = 0.6 + (adjust::levels_q(&ch, 0.6, Some(32768.0)) - 0.6) * 0.5;
near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity");
}
// An adjustment's result in a Lab document is whole samples, as Photoshop stores it: Curves
// taking L 100 to 120 of 255 shows L 120 exactly (the photoshop corpus's lab8 curves.psd is exact
// so, where the unrounded result left saturated colours a level off on screen), at 8 bits; 16 bits
// round to 1/32768.
#[test]
fn lab_adjustment_results_are_whole_samples() {
let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::<Vec<_>>();
let curves = |l: &[(f32, f32)]| Adjustment::Curves {
master: Vec::new(),
per_channel: [pts(l), Vec::new(), Vec::new()],
space: ToneSpace::Lab,
black: Vec::new(),
pencil: false,
};
// A straight line from (0, 0) through (100, 120.4): L 100 lands between two bytes.
let adj = curves(&[(0.0, 0.0), (100.0 / 255.0, 120.4 / 255.0), (1.0, 1.0)]);
for (depth, q) in [(SampleType::U8, 255.0f32), (SampleType::U16, 32768.0)] {
let doc = lab_stack([100.0 / 255.0, 0.5, 0.5], depth, vec![adj.clone()]);
let p = px(&doc, 1, 1);
let stack = native_lab::LabStack::new(&doc);
assert!(stack.is_some_and(|s| s.adjusts()), "{depth:?}: the native path");
let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])[0] / 100.0;
assert!((l * q - (l * q).round()).abs() < 0.02, "{depth:?}: L {} is between two samples", l * q);
}
}
// Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB
// compositor clipped them on the way through display RGB).
#[test]
fn lab_flatten_keeps_the_stored_values() {
let lab = [0.6, 218.0 / 255.0, 38.0 / 255.0];
for depth in [SampleType::U8, SampleType::U16] {
let doc = lab_stack(lab, depth, vec![]);
let s = flatten_to_surface(&doc, doc.pixel_format(), None);
let got = s.pixel(2, 2);
assert!(got.iter().zip(lab).all(|(g, w)| (g - w).abs() < 1e-3), "{depth:?}: {got:?}");
}
}
// Anything the stored values can't carry exactly keeps the RGB compositor.
#[test]
fn lab_stacks_with_other_layers_keep_the_rgb_compositor() {
let lab = [0.6, 0.5, 0.5];
assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![Adjustment::Invert])).is_some_and(|s| s.adjusts()));
let hue = Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() };
assert!(native_lab::LabStack::new(&lab_stack(lab, SampleType::U8, vec![hue])).is_none());
let mut multiply = lab_stack(lab, SampleType::U8, vec![Adjustment::Invert]);
if let Some(l) = multiply.layers.last_mut() {
l.blend = photocraft_color::blend::BlendMode::Multiply;
}
assert!(native_lab::LabStack::new(&multiply).is_none());
let rgb = Document::with_background("rgb", Size::new(4, 4), ColorMode::Rgb, SampleType::U8, Color::rgb(0.5, 0.5, 0.5));
assert!(native_lab::LabStack::new(&rgb).is_none());
}
#[test]
fn lab_documents_brightness_contrast_moves_lightness_only() {
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
for depth in SampleType::ALL {
for legacy in [true, false] {
let got = lab_doc_adjusted(MUTED, Adjustment::BrightnessContrast { brightness: 40.0, contrast: 20.0, legacy }, depth);
assert!(got[0] > lab[0] + 5.0, "{depth:?} legacy {legacy}: {got:?}");
near3([lab[0], got[1], got[2]], lab, 0.75, &format!("{depth:?} legacy {legacy}: a, b kept"));
}
}
}
#[test]
fn lab_documents_threshold_on_lightness_with_a_neutral_result() {
for depth in SampleType::ALL {
// A saturated yellow is light (L ≈ 97): white, not a coloured result.
let yellow = lab_doc_adjusted([1.0, 1.0, 0.0], Adjustment::Threshold { level: 0.5 }, depth);
near3(yellow, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} yellow"));
// Pure blue has L ≈ 30: black.
let blue = lab_doc_adjusted([0.0, 0.0, 1.0], Adjustment::Threshold { level: 0.5 }, depth);
near3(blue, [0.0, 0.0, 0.0], 0.5, &format!("{depth:?} blue"));
// Mid grey L 53.4 against level 128 (n 0.502): Lab L decides, not RGB luminance.
let level = 0.52;
let grey = lab_doc_adjusted([0.5; 3], Adjustment::Threshold { level }, depth);
near3(grey, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} grey"));
}
}
#[test]
fn lab_documents_gradient_map_follows_lightness() {
let stops = vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])];
// A grey and a saturated colour of the same L map to the same colour.
let red = [0.9, 0.1, 0.1];
let l = photocraft_color::convert::srgb_to_lab(red)[0];
let grey = photocraft_color::convert::lab_to_srgb([l, 0.0, 0.0]);
for depth in SampleType::ALL {
for reverse in [false, true] {
let adj = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
let a = lab_doc_adjusted(red, adj.clone(), depth);
let b = lab_doc_adjusted(grey, adj.clone(), depth);
near3(a, b, 1.0, &format!("{depth:?} reverse {reverse}"));
let t = if reverse { 1.0 - l / 100.0 } else { l / 100.0 };
let want = photocraft_color::convert::srgb_to_lab([t, t, 1.0 - t]);
near3(a, want, 1.5, &format!("{depth:?} reverse {reverse}"));
}
}
// In RGB documents the map still follows RGB luminance: red and grey differ there.
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]);
let adj = Adjustment::GradientMap { stops, reverse: false, dither: false };
adjust::apply_in(&adj, &mut buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
let mut grey_buf = Buffer::filled(Rect::new(0, 0, 1, 1), [grey[0], grey[1], grey[2], 1.0]);
adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]);
}
// ---------- Effect-map identity is computed once per layer per render (#4437) ----------
/// Effect layers of every kind the cache key walks: a plain layer, a styled group with a styled
/// child, and a layer whose mask hides its effects (rendered from a temporary copy).
fn identity_doc(depth: SampleType) -> Document {
let mut d = Document::with_background("t", Size::new(96, 96), ColorMode::Rgb, depth, Color::WHITE);
let fmt = d.pixel_format();
let mut sq = Layer::raster("sq", fmt);
sq.surface_mut().unwrap().fill_rect(Rect::new(8, 8, 40, 40), &[1.0, 0.0, 0.0, 1.0]);
sq.effects.items = vec![stroke(3.0, StrokePosition::Outside), Effect::DropShadow(shadow(4.0, 120.0))];
d.layers.push(sq);
let mut child = Layer::raster("child", fmt);
child.surface_mut().unwrap().fill_rect(Rect::new(50, 10, 86, 46), &[0.0, 0.6, 0.2, 1.0]);
child.effects.items = vec![stroke(2.0, StrokePosition::Outside)];
let mut group = Layer::group("g", vec![child]);
group.effects.items = vec![Effect::DropShadow(shadow(5.0, 60.0))];
d.layers.push(group);
let mut hidden = Layer::raster("hidden", fmt);
hidden.surface_mut().unwrap().fill_rect(Rect::new(10, 54, 46, 90), &[0.1, 0.2, 0.9, 1.0]);
hidden.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
hidden.mask = Some(LayerMask::reveal_all());
hidden.advanced.layer_mask_hides_effects = true;
d.layers.push(hidden);
d
}
#[test]
fn memoised_effect_identity_leaves_every_render_unchanged() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let d = identity_doc(depth);
let b = d.bounds();
// One tile for the whole canvas never goes through `prepare_effects`; small tiles do.
purge_effect_cache();
let whole = render_tiled(&d, b, 4096);
purge_effect_cache();
let tiled = render_tiled(&d, b, 16);
let warm = render_tiled(&d, b, 24);
assert!(whole.px == tiled.px, "{depth:?}: tiled differs from one tile");
assert!(whole.px == warm.px, "{depth:?}: warm differs from cold");
assert!(whole.px.iter().any(|p| *p != [1.0; 4]), "{depth:?}: the effects should draw");
// Edits still invalidate: a changed layer must not keep its old maps.
let mut e = d.clone();
e.layers[1].effects.items = vec![Effect::DropShadow(shadow(9.0, 200.0))];
let edited = render_tiled(&e, b, 16);
purge_effect_cache();
assert!(edited.px == render_tiled(&e, b, 4096).px, "{depth:?}: stale maps after an edit");
assert!(edited.px != whole.px, "{depth:?}: the edit should show");
}
}
#[test]
fn effect_identity_is_built_once_per_layer_not_per_tile() {
let mut d = identity_doc(SampleType::U8);
// Leave out the temporary-copy layer: it is rebuilt per call by design.
d.layers.pop();
let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&d, &patterns);
let b = d.bounds();
let tiles = 6 * 6;
let first = render_tiled_with(&d, b, 16, &cx);
let built = cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed);
// sq, the group and its child: three effect layers, however many tiles.
assert_eq!(built, 3, "identity built {built} times over {tiles} tiles");
// A second render with the same context finds all of them memoised.
let second = render_tiled_with(&d, b, 16, &cx);
assert_eq!(cx.layer_keys.built.load(std::sync::atomic::Ordering::Relaxed), 3);
assert!(first.px == second.px);
}
#[test]
fn render_refuses_rects_beyond_the_pixel_cap() {
let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
// 2^32 - 1 px wide: the width wraps negative as an i32, which once took the single-tile path.
for tile in [crate::RENDER_TILE, 1 << 20] {
let b = crate::render_tiled(&doc, Rect::new(i32::MIN, 0, i32::MAX, 1), tile);
assert!(b.px.is_empty() && b.rect.is_empty());
}
}
// The effect-map cache key covers Transparency Shapes Layer and Blend If: both change the shape
// the maps are built from (a layer's own, or a styled group's children composite) without
// touching pixels, masks or effects, and stale maps kept stroking a hole the layer no longer
// had. Each case renders with the cache warm, edits, renders again and compares with a cold
// render. (A purge by a concurrent test only makes the warm render cold, never a false failure.)
#[test]
fn effect_cache_follows_transparency_shapes_layer() {
let mut d = doc_white(32, 32);
let mut l = solid_layer("holed", Rect::new(0, 0, 32, 32), [0.9, 0.2, 0.2, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(12, 12, 20, 20), &[0.0, 0.0, 0.0, 0.0]);
l.effects.items = vec![stroke(2.0, StrokePosition::Outside)];
assert!(l.advanced.transparency_shapes);
d.layers.push(l);
let on = render(&d, d.bounds());
d.layers[1].advanced.transparency_shapes = false;
let warm = render(&d, d.bounds());
purge_effect_cache();
let cold = render(&d, d.bounds());
assert!(on.px != cold.px, "the stroke should stop rimming the hole");
assert!(warm.px == cold.px, "stale effect maps after turning Transparency Shapes Layer off");
}
#[test]
fn effect_cache_follows_a_group_childs_blend_if() {
let mut d = doc_white(32, 32);
let mut child = solid_layer("bw", Rect::new(4, 4, 16, 28), [0.0, 0.0, 0.0, 1.0]);
child.surface_mut().unwrap().fill_rect(Rect::new(16, 4, 28, 28), &[1.0, 1.0, 1.0, 1.0]);
let mut g = Layer::group("g", vec![child]);
g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })];
d.layers.push(g);
let before = render(&d, d.bounds());
let LayerContent::Group(grp) = &mut d.layers[1].content else { panic!("not a group") };
// Gray › This Layer: black point at 128 hides the black half.
grp.children[0].blend_if.set(0, [range([128, 128], [255, 255]), FULL]);
let warm = render(&d, d.bounds());
purge_effect_cache();
let cold = render(&d, d.bounds());
assert!(before.px != cold.px, "Blend If should change the group's shadow");
assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed");
}
/// A document whose one-colour areas are found: shared background tiles, a painted patch over
/// tile edges, a uniform mask tile, an adjustment, a group, a 16-bit layer and a hidden one.
fn sparse_doc(w: u32, h: u32) -> Document {
let mut d = doc_white(w, h);
let mut paint = Layer::raster("paint", PixelFormat::RGBA8);
let s = paint.surface_mut().unwrap();
for y in 200..330 {
s.fill_rect(Rect::new(240, y, 520, y + 1), &[(y - 200) as f32 / 130.0, 0.4, 0.7, 0.8]);
}
s.fill_rect(Rect::new(0, 600, w as i32 / 2, 900), &[0.2, 0.9, 0.1, 0.5]);
let mut m = LayerMask::reveal_all();
m.surface.fill_rect(Rect::new(0, 512, 256, 768), &[0.25]);
m.surface.fill_rect(Rect::new(300, 700, 310, 710), &[0.0]);
paint.mask = Some(m);
paint.blend = BlendMode::Multiply;
let mut deep = solid_layer("deep", Rect::new(100, 100, 700, 400), [0.3, 0.3, 0.9, 0.6]);
deep.content = LayerContent::Raster(deep.surface().unwrap().convert(PixelFormat::new(ColorMode::Rgb, SampleType::U16, true)));
let mut hidden = solid_layer("hidden", Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0]);
hidden.visible = false;
d.layers.push(Layer::group("g", vec![paint, deep, hidden]));
d.layers.push(Layer::new("levels", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.0, gamma: 1.1 })));
d
}
#[test]
fn one_colour_areas_reduce_like_composited_ones() {
// #4017: one-colour tiles are composited once and summed per run of columns: the same
// averages as compositing every pixel (to float rounding), and a damage render gives exactly
// the whole reduction's pixels, so a reduced canvas updated in parts shows no seams.
let d = sparse_doc(2000, 2100);
for (b, w, h, band) in [
(d.bounds(), 37, 41, 0),
(d.bounds(), 250, 2100, 256),
(d.bounds(), 1999, 3, 512),
(Rect::new(-300, -77, 2200, 2300), 61, 29, 0),
(Rect::new(13, 5, 2000, 2100), 1, 1, 256),
] {
let reference = render_reduced_with(&d, b, w, h, None, band, false, &mut ReducedStats::default());
let mut stats = ReducedStats::default();
let all = render_reduced_with(&d, b, w, h, None, band, true, &mut stats);
assert!(stats.uniform);
assert!(stats.composited < u64::from(b.width()) * u64::from(b.height()) / 2, "{b:?}: {stats:?}");
assert_eq!(all.rect, reference.rect);
for (p, q) in all.px.iter().zip(&reference.px) {
assert!(p.iter().zip(q).all(|(a, b)| (a - b).abs() < 1e-4), "{b:?} {w}x{h} band {band}: {p:?} vs {q:?}");
}
for dmg in [Rect::new(250, 210, 260, 220), Rect::new(0, 0, 2000, 2100), Rect::new(-50, 640, 3000, 641), Rect::new(1500, 1500, 1600, 1900)] {
let part = render_reduced_with(&d, b, w, h, Some(dmg), band, true, &mut ReducedStats::default());
for y in part.rect.y0..part.rect.y1 {
for x in part.rect.x0..part.rect.x1 {
assert_eq!(part.get(x, y), all.get(x, y), "{b:?} {w}x{h} band {band} {dmg:?} at {x},{y}");
}
}
}
}
}
#[test]
fn layers_that_read_other_pixels_are_composited_pixel_by_pixel() {
let base = sparse_doc(300, 300);
let edits: [fn(&mut Layer); 4] = [
|l| l.blend = BlendMode::Dissolve,
|l| l.mask.get_or_insert_with(LayerMask::reveal_all).feather = 3.0,
|l| l.effects.items.push(stroke(2.0, StrokePosition::Outside)),
|l| l.content = LayerContent::Fill(Fill::Solid(Color::BLACK)),
];
for edit in edits {
let mut d = base.clone();
edit(&mut d.layers[0]);
let mut stats = ReducedStats::default();
render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats);
assert!(!stats.uniform);
assert_eq!(stats.composited, 300 * 300);
}
let mut d = base.clone();
d.layers.push(Layer::new("map", LayerContent::Adjustment(Adjustment::GradientMap { stops: vec![], reverse: false, dither: true })));
let mut stats = ReducedStats::default();
render_reduced_with(&d, d.bounds(), 30, 30, None, 0, true, &mut stats);
assert!(!stats.uniform);
}
#[test]
fn a_gigapixel_new_document_reduces_without_compositing_its_canvas() {
// #4017: the reduced canvas of a 10 x 5 m, 200 ppi document (3.1 GP) composited every pixel
// (25 s on 32 threads, all cores busy). Only the partial tiles at its right and bottom edges
// hold two values; the rest is one shared white tile.
let (w, h) = (78_740u32, 39_370u32);
for fill in [Some(Color::WHITE), Some(Color::rgba(0.2, 0.4, 0.6, 1.0)), None] {
let mut d = match fill {
Some(c) => Document::with_background("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8, c),
None => Document::new("big", Size::new(w, h), ColorMode::Rgb, SampleType::U8),
};
d.layers.push(Layer::raster("Layer 1", PixelFormat::RGBA8));
let mut stats = ReducedStats::default();
let r = render_reduced_with(&d, d.bounds(), 3937, 1969, None, 0, true, &mut stats);
let edge_tiles = u64::from(w.div_ceil(256) + h.div_ceil(256) - 1);
assert!(stats.composited <= edge_tiles * 256 * 256, "{fill:?}: {stats:?}");
let want = fill.map_or([0.0; 4], |c| [c.c[0], c.c[1], c.c[2], 1.0]);
assert!(r.px.iter().all(|p| close4(*p, want)), "{fill:?}");
}
}
#[test]
fn shape_layer_bounds_includes_vector_bounds_clipped_to_canvas_and_preserves_effects() {
let canvas = Rect::new(0, 0, 100, 100);
let subpath = photocraft_doc::vector::Subpath::polygon(&[(-20.0, -20.0), (50.0, -20.0), (50.0, 50.0), (-20.0, 50.0)]);
let path = photocraft_doc::vector::Path { subpaths: vec![subpath], ..Default::default() };
let sh = photocraft_doc::vector::ShapeLayer {
path,
fill: Some(photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK)),
stroke: Some(photocraft_doc::vector::ShapeStroke { width: 4.0, opacity: 1.0, ..Default::default() }),
..Default::default()
};
let mut layer = Layer::new("shape", LayerContent::Shape(sh));
// Vector bounds reach outside canvas (-24..54), but layer_bounds clips vector bounds to canvas:
let b = layer_bounds(&layer, canvas);
assert_eq!(b, Rect::new(0, 0, 54, 54), "shape vector bounds must clip to canvas and not reach past it");
// When the shape has layer effects, layer_bounds preserves effects union handling:
layer.effects.items = vec![Effect::DropShadow(Shadow { size: 5.0, spread: 0.0, ..shadow(5.0, 90.0) })];
let b_fx = layer_bounds(&layer, canvas);
assert!(!b_fx.is_empty());
assert!(b_fx.contains_rect(&b));
}
#[test]
#[ignore = "timing: cargo test --release -p photocraft-compose modern_lut_timing -- --ignored --nocapture"]
fn modern_lut_timing() {
let t = std::time::Instant::now();
for _ in 0..100 {
std::hint::black_box(adjust::modern_lut(-50.0, 30.0));
}
println!("modern_lut(-50, 30): {:?} each", t.elapsed() / 100);
}
#[test]
#[ignore = "timing: cargo test --release -p photocraft-compose cmyk_ink_render_timing -- --ignored --nocapture"]
fn cmyk_ink_render_timing() {
// 24 MP CMYK8 photo-sized noise with one adjustment layer: the ink path (Invert) against the
// RGB compositor (Exposure, which the inks don't carry).
let (w, h) = (6000, 4000);
let mut d = Document::new("c", Size::new(w, h), ColorMode::Cmyk, SampleType::U8);
let mut l = Layer::raster("bg", d.pixel_format());
let data: Vec<f32> = (0..(w * h) as usize)
.flat_map(|i| [(i % 251) as f32 / 255.0, (i % 241) as f32 / 255.0, (i % 239) as f32 / 255.0, (i % 97) as f32 / 255.0, 1.0])
.collect();
l.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &data);
d.layers.push(l);
for adj in [Adjustment::Invert, Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }] {
d.layers.truncate(1);
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj.clone())));
let _ = render(&d, Rect::new(0, 0, 512, 512));
let t = std::time::Instant::now();
let b = render(&d, d.bounds());
println!("{}: {:?} ({} px)", adj.label(), t.elapsed(), b.px.len());
}
}
// A pass-through group with an adjustment layer reaches the layers beneath, unless the group is
// the base of a clipping group: then Photoshop renders it isolated, and an adjustment with no
// content of its own in the group changes nothing (psd-tools passthrough_clipping_mask_adjustment).
#[test]
fn a_pass_through_clipping_base_keeps_its_adjustments_inside() {
let base = || {
let mut d = doc_white(4, 4);
let group = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]);
d.layers.push(group);
d
};
// Unclipped: the invert reaches the white background.
assert!(close4(px(&base(), 1, 1), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&base(), 1, 1));
// With a layer clipped to the group: the background stays white.
let mut d = base();
let mut clip = Layer::raster("clipped", PixelFormat::RGBA8);
clip.clipped = true;
d.layers.push(clip);
assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(&d, 1, 1));
}
// A cache entry reserves the addresses of the tiles its key hashes, not their pixels (#4530): once
// the document lets go of a styled layer or group (an edit replaced its tiles, or it was
// deleted), the effect cache keeps none of its pixel memory alive, and the maps are still
// reused while the layer is unchanged.
#[test]
fn effect_cache_keeps_no_pixels_of_a_dropped_layer() {
let mut d = doc_white(300, 300);
let mut l = solid_layer("styled", Rect::new(20, 20, 280, 280), [0.9, 0.2, 0.2, 1.0]);
l.effects.items = vec![stroke(3.0, StrokePosition::Outside)];
d.layers.push(l);
let mut g = Layer::group("g", vec![solid_layer("child", Rect::new(40, 40, 120, 120), [0.1, 0.2, 0.9, 1.0])]);
g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })];
d.layers.push(g);
let first = render(&d, d.bounds());
let tile = |l: &Layer| l.surface().and_then(|s| s.tiles().next()).map(|(_, t)| t.clone()).unwrap();
let own = tile(&d.layers[1]);
let LayerContent::Group(grp) = &d.layers[2].content else { panic!("not a group") };
let child = tile(&grp.children[0]);
assert_eq!(render(&d, d.bounds()).px, first.px, "warm render");
drop(d);
// Only this test's handles are left: the cache pins neither the layer's nor the child's pixels.
assert_eq!(std::sync::Arc::strong_count(&own), 1);
assert_eq!(std::sync::Arc::strong_count(&child), 1);
}
// Concurrent callers share one memoized effect identity.
#[test]
fn concurrent_effect_identity_misses_compute_once() {
let mut doc = identity_doc(SampleType::U8);
// Make the existing pure identity hash long enough to expose overlapping misses.
doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512];
let layer = &doc.layers[1];
let keys = LayerKeys::default();
let start = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
let handles: Vec<_> = (0..8)
.map(|_| {
scope.spawn(|| {
start.wait();
keys.identity(layer, Some(&doc))
})
})
.collect();
let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert!(ids.iter().all(|id| *id == ids[0]));
});
assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity");
}
#[test]
fn reused_render_matches_previous_allocating_stack() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut doc = Document::new("equivalence", Size::new(513, 7), ColorMode::Rgb, depth);
let rect = doc.bounds();
for (name, color) in [("bottom", [0.2, 0.4, 0.8, 1.0]), ("top", [1.5, -0.25, 0.6, 0.5])] {
let mut layer = Layer::raster(name, doc.pixel_format());
layer.surface_mut().unwrap().fill_rect(rect, &color);
doc.layers.push(layer);
}
let mut levels = Adjustment::identity_levels();
if let Adjustment::Levels { master, .. } = &mut levels {
master.gamma = 1.4;
}
for adj in [levels, Adjustment::identity_curves()] {
let mut layer = Layer::new("tone", LayerContent::Adjustment(adj));
layer.opacity = 0.6;
doc.layers.push(layer);
}
let mut previous = Buffer::transparent(rect);
in_blend_space(false, depth == SampleType::F32, || {
for layer in &doc.layers {
if let LayerContent::Adjustment(adj) = &layer.content {
let mut adjusted = previous.clone();
adjust::apply_in(adj, &mut adjusted, adjust::Transfer::for_document(doc.mode, depth), Some(depth), doc.mode);
for (b, a) in previous.px.iter_mut().zip(&adjusted.px) {
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
*b = [
b[0] + (blended[0] - b[0]) * layer.opacity,
b[1] + (blended[1] - b[1]) * layer.opacity,
b[2] + (blended[2] - b[2]) * layer.opacity,
b[3],
];
}
if let Some(q) = adjustment_quantum(depth) {
quantize(&mut previous, q);
}
} else {
let content = surface_to_buffer(layer.surface().unwrap(), rect);
blend_into_fill(&mut previous, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
}
}
});
for tile in [1, 127, 256, 1024] {
assert_buffer_bits(&render_tiled(&doc, rect, tile), &previous);
}
}
}
#[test]
fn fully_masked_adjustment_tiles_preserve_output_and_skip_tone_work() {
let rect = Rect::new(-1, -1, 2, 1);
for depth in SampleType::ALL {
for partial in [false, true] {
let mut doc = Document::new("masked", Size::new(3, 2), ColorMode::Rgb, depth);
let mut adj = Adjustment::identity_curves();
if let Adjustment::Curves { master, .. } = &mut adj {
master[1].output = 0.8;
}
let mut layer = Layer::new("curves", LayerContent::Adjustment(adj.clone()));
layer.opacity = 0.6;
let mut mask = LayerMask::hide_all();
if partial {
mask.surface.write_pixel(0, 0, &[0.75]);
}
layer.mask = Some(mask);
doc.layers.push(layer);
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
let cx = Ctx::for_doc(&doc, &patterns);
let before = Buffer::filled(rect, [0.2, -0.0, 0.7, 0.6]);
let mut adjusted = before.clone();
adjust::apply_in(&adj, &mut adjusted, cx.transfer, Some(depth), doc.mode);
let mv = mask_vals(&doc.layers[0], rect, &cx);
let mut expected = before.clone();
for (i, (b, a)) in expected.px.iter_mut().zip(&adjusted.px).enumerate() {
let k = 0.6 * mask_k(&mv, i);
if k > 0.0 {
for c in 0..3 {
b[c] += (a[c] - b[c]) * k;
}
}
}
let clipped = if partial { vec![] } else { vec![solid_layer("clipped", rect, [0.9, 0.1, 0.2, 1.0])] };
let mut actual = before;
composite_layer_plain(&doc.layers[0], &clipped, &mut actual, &cx, advanced::Scope::ROOT);
assert_buffer_bits(&actual, &expected);
assert_eq!(cx.tone_luts[0].1.get().is_some(), partial);
}
}
}