1use crate::types::BlendMode;
12use color::convert::div255;
13
14#[must_use]
18pub const fn blend_normal(src: u8, _dst: u8) -> u8 {
19 src
20}
21
22#[must_use]
24pub fn blend_multiply(src: u8, dst: u8) -> u8 {
25 div255(u32::from(src) * u32::from(dst))
26}
27
28#[must_use]
30pub fn blend_screen(src: u8, dst: u8) -> u8 {
31 let s = u32::from(src);
32 let d = u32::from(dst);
33 #[expect(
34 clippy::cast_possible_truncation,
35 reason = "s + d - div255(s*d) ≤ 255: Screen result is always in [0,255]"
36 )]
37 let v = (s + d - u32::from(div255(s * d))) as u8;
38 v
39}
40
41#[must_use]
43pub fn blend_overlay(src: u8, dst: u8) -> u8 {
44 if dst < 0x80 {
45 div255(u32::from(src) * 2 * u32::from(dst))
46 } else {
47 let s = u32::from(255 - src);
48 let d = u32::from(255 - dst);
49 255 - div255(2 * s * d)
50 }
51}
52
53#[must_use]
55pub fn blend_darken(src: u8, dst: u8) -> u8 {
56 src.min(dst)
57}
58
59#[must_use]
61pub fn blend_lighten(src: u8, dst: u8) -> u8 {
62 src.max(dst)
63}
64
65#[must_use]
67pub fn blend_color_dodge(src: u8, dst: u8) -> u8 {
68 if src == 255 {
69 255
70 } else {
71 ((u32::from(dst) * 255) / u32::from(255 - src)).min(255) as u8
72 }
73}
74
75#[must_use]
77pub fn blend_color_burn(src: u8, dst: u8) -> u8 {
78 if src == 0 {
79 0
80 } else {
81 let x = u32::from(255 - dst) * 255 / u32::from(src);
82 #[expect(
83 clippy::cast_possible_truncation,
84 reason = "x < 255 is checked above, so 255 - x ≤ 254 which fits u8"
85 )]
86 if x >= 255 { 0 } else { (255 - x) as u8 }
87 }
88}
89
90#[must_use]
92pub fn blend_hard_light(src: u8, dst: u8) -> u8 {
93 blend_overlay(dst, src)
95}
96
97#[must_use]
99pub fn blend_soft_light(src: u8, dst: u8) -> u8 {
100 let s = i32::from(src);
101 let d = i32::from(dst);
102 let result = if s < 0x80 {
103 d - (255 - 2 * s) * d * (255 - d) / (255 * 255)
104 } else {
105 let x = if d < 0x40 {
106 (((16 * d - 12 * 255) * d / 255 + 4 * 255) * d) / 255
107 } else {
108 #[expect(
110 clippy::cast_possible_truncation,
111 reason = "sqrt of non-negative f64; result is in [0,255] before clamp"
112 )]
113 {
114 (f64::from(d) * 255.0).sqrt() as i32
115 }
116 };
117 d + (2 * s - 255) * (x - d) / 255
118 };
119 #[expect(clippy::cast_sign_loss, reason = "value is clamped to [0, 255] above")]
120 {
121 result.clamp(0, 255) as u8
122 }
123}
124
125#[must_use]
127pub const fn blend_difference(src: u8, dst: u8) -> u8 {
128 src.abs_diff(dst)
129}
130
131#[must_use]
133pub fn blend_exclusion(src: u8, dst: u8) -> u8 {
134 let s = u32::from(src);
135 let d = u32::from(dst);
136 (s + d)
137 .saturating_sub(u32::from(div255(2 * s * d)))
138 .min(255) as u8
139}
140
141#[must_use]
145const fn get_lum(r: i32, g: i32, b: i32) -> i32 {
146 (r * 77 + g * 151 + b * 28 + 0x80) >> 8
147}
148
149#[must_use]
151fn get_sat(r: i32, g: i32, b: i32) -> i32 {
152 r.max(g).max(b) - r.min(g).min(b)
153}
154
155fn clip_color(r_in: i32, g_in: i32, b_in: i32) -> (i32, i32, i32) {
157 let lum = get_lum(r_in, g_in, b_in);
158 let rgb_min = r_in.min(g_in).min(b_in);
159 let rgb_max = r_in.max(g_in).max(b_in);
160 if rgb_min < 0 {
161 let d = lum - rgb_min;
162 (
163 (lum + (r_in - lum) * lum / d).clamp(0, 255),
164 (lum + (g_in - lum) * lum / d).clamp(0, 255),
165 (lum + (b_in - lum) * lum / d).clamp(0, 255),
166 )
167 } else if rgb_max > 255 {
168 let d = rgb_max - lum;
169 (
170 (lum + (r_in - lum) * (255 - lum) / d).clamp(0, 255),
171 (lum + (g_in - lum) * (255 - lum) / d).clamp(0, 255),
172 (lum + (b_in - lum) * (255 - lum) / d).clamp(0, 255),
173 )
174 } else {
175 (r_in, g_in, b_in)
176 }
177}
178
179fn set_lum(r: i32, g: i32, b: i32, lum: i32) -> (i32, i32, i32) {
181 let d = lum - get_lum(r, g, b);
182 clip_color(r + d, g + d, b + d)
183}
184
185fn set_sat(r_in: i32, g_in: i32, b_in: i32, sat: i32) -> (i32, i32, i32) {
187 let mut channels = [(r_in, 0usize), (g_in, 1), (b_in, 2)];
189 channels.sort_unstable_by_key(|&(v, _)| v);
190 let (ch_lo, slot_lo) = channels[0];
191 let (ch_md, slot_md) = channels[1];
192 let (ch_hi, slot_hi) = channels[2];
193
194 let mut out = [0i32; 3];
195 if ch_hi > ch_lo {
196 out[slot_md] = ((ch_md - ch_lo) * sat / (ch_hi - ch_lo)).clamp(0, 255);
197 out[slot_hi] = sat.clamp(0, 255);
198 }
199 out[slot_lo] = 0;
200 #[expect(
201 clippy::tuple_array_conversions,
202 reason = "caller API returns a triple; no Into impl for non-Copy i32"
203 )]
204 {
205 let [a, b, c] = out;
206 (a, b, c)
207 }
208}
209
210#[must_use]
212pub fn blend_hue_rgb(src: [u8; 3], dst: [u8; 3]) -> [u8; 3] {
213 let [sr, sg, sb] = src.map(i32::from);
214 let [dr, dg, db] = dst.map(i32::from);
215 let (r0, g0, b0) = set_sat(sr, sg, sb, get_sat(dr, dg, db));
216 let (r1, g1, b1) = set_lum(r0, g0, b0, get_lum(dr, dg, db));
217 #[expect(
218 clippy::cast_possible_truncation,
219 clippy::cast_sign_loss,
220 reason = "clip_color clamps all channels to [0, 255]"
221 )]
222 [r1 as u8, g1 as u8, b1 as u8]
223}
224
225#[must_use]
227pub fn blend_saturation_rgb(src: [u8; 3], dst: [u8; 3]) -> [u8; 3] {
228 let [sr, sg, sb] = src.map(i32::from);
229 let [dr, dg, db] = dst.map(i32::from);
230 let (r0, g0, b0) = set_sat(dr, dg, db, get_sat(sr, sg, sb));
231 let (r1, g1, b1) = set_lum(r0, g0, b0, get_lum(dr, dg, db));
232 #[expect(
233 clippy::cast_possible_truncation,
234 clippy::cast_sign_loss,
235 reason = "clip_color clamps all channels to [0, 255]"
236 )]
237 [r1 as u8, g1 as u8, b1 as u8]
238}
239
240#[must_use]
242pub fn blend_color_rgb(src: [u8; 3], dst: [u8; 3]) -> [u8; 3] {
243 let [sr, sg, sb] = src.map(i32::from);
244 let [dr, dg, db] = dst.map(i32::from);
245 let (r, g, b) = set_lum(sr, sg, sb, get_lum(dr, dg, db));
246 #[expect(
247 clippy::cast_possible_truncation,
248 clippy::cast_sign_loss,
249 reason = "clip_color clamps all channels to [0, 255]"
250 )]
251 [r as u8, g as u8, b as u8]
252}
253
254#[must_use]
256pub fn blend_luminosity_rgb(src: [u8; 3], dst: [u8; 3]) -> [u8; 3] {
257 let [sr, sg, sb] = src.map(i32::from);
258 let [dr, dg, db] = dst.map(i32::from);
259 let (r, g, b) = set_lum(dr, dg, db, get_lum(sr, sg, sb));
260 #[expect(
261 clippy::cast_possible_truncation,
262 clippy::cast_sign_loss,
263 reason = "clip_color clamps all channels to [0, 255]"
264 )]
265 [r as u8, g as u8, b as u8]
266}
267
268pub fn apply_separable(mode: BlendMode, src: &[u8], dst: &[u8], out: &mut [u8]) {
280 debug_assert_eq!(src.len(), dst.len());
281 debug_assert_eq!(src.len(), out.len());
282 let f: fn(u8, u8) -> u8 = match mode {
283 BlendMode::Normal => blend_normal,
284 BlendMode::Multiply => blend_multiply,
285 BlendMode::Screen => blend_screen,
286 BlendMode::Overlay => blend_overlay,
287 BlendMode::Darken => blend_darken,
288 BlendMode::Lighten => blend_lighten,
289 BlendMode::ColorDodge => blend_color_dodge,
290 BlendMode::ColorBurn => blend_color_burn,
291 BlendMode::HardLight => blend_hard_light,
292 BlendMode::SoftLight => blend_soft_light,
293 BlendMode::Difference => blend_difference,
294 BlendMode::Exclusion => blend_exclusion,
295 BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity => {
296 panic!("apply_separable called with non-separable mode {mode:?}")
297 }
298 };
299 for ((s, d), o) in src.iter().zip(dst).zip(out.iter_mut()) {
300 *o = f(*s, *d);
301 }
302}
303
304#[must_use]
314pub fn apply_nonseparable_rgb(mode: BlendMode, src: [u8; 3], dst: [u8; 3]) -> [u8; 3] {
315 match mode {
316 BlendMode::Hue => blend_hue_rgb(src, dst),
317 BlendMode::Saturation => blend_saturation_rgb(src, dst),
318 BlendMode::Color => blend_color_rgb(src, dst),
319 BlendMode::Luminosity => blend_luminosity_rgb(src, dst),
320 _ => panic!("apply_nonseparable_rgb called with separable mode {mode:?}"),
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn multiply_identity() {
330 assert_eq!(blend_multiply(255, 200), 200);
331 assert_eq!(blend_multiply(200, 255), 200);
332 assert_eq!(blend_multiply(0, 200), 0);
333 assert_eq!(blend_multiply(200, 0), 0);
334 }
335
336 #[test]
337 fn screen_identity() {
338 assert_eq!(blend_screen(0, 200), 200);
339 assert_eq!(blend_screen(200, 0), 200);
340 assert_eq!(blend_screen(255, 100), 255);
341 }
342
343 #[test]
344 fn difference_is_abs_diff() {
345 for a in (0u8..=255).step_by(13) {
346 for b in (0u8..=255).step_by(7) {
347 assert_eq!(blend_difference(a, b), a.abs_diff(b));
348 }
349 }
350 }
351
352 #[test]
353 fn color_dodge_saturates_at_src_255() {
354 assert_eq!(blend_color_dodge(255, 100), 255);
355 }
356
357 #[test]
358 fn color_burn_zeroes_at_src_0() {
359 assert_eq!(blend_color_burn(0, 100), 0);
360 }
361
362 #[test]
363 fn hard_light_matches_overlay_swapped() {
364 for s in (0u8..=255).step_by(17) {
365 for d in (0u8..=255).step_by(11) {
366 assert_eq!(
367 blend_hard_light(s, d),
368 blend_overlay(d, s),
369 "hard_light({s},{d}) should equal overlay({d},{s})"
370 );
371 }
372 }
373 }
374
375 #[test]
376 fn get_lum_white_is_255() {
377 assert_eq!(get_lum(255, 255, 255), 255);
378 }
379
380 #[test]
381 fn get_lum_black_is_0() {
382 assert_eq!(get_lum(0, 0, 0), 0);
383 }
384
385 #[test]
386 fn get_sat_grey_is_0() {
387 assert_eq!(get_sat(128, 128, 128), 0);
388 }
389
390 #[test]
391 fn luminosity_grey_dst_stays_grey() {
392 let src = [200u8, 50, 50];
394 let dst = [128u8, 128, 128];
395 let out = blend_luminosity_rgb(src, dst);
396 assert_eq!(out[0], out[1]);
397 assert_eq!(out[1], out[2]);
398 }
399
400 #[test]
401 fn apply_separable_multiply_all_channels() {
402 let src = [100u8, 200, 50];
403 let dst = [255u8, 128, 0];
404 let mut out = [0u8; 3];
405 apply_separable(BlendMode::Multiply, &src, &dst, &mut out);
406 assert_eq!(out[0], blend_multiply(100, 255));
407 assert_eq!(out[1], blend_multiply(200, 128));
408 assert_eq!(out[2], blend_multiply(50, 0));
409 }
410
411 #[test]
412 #[should_panic(expected = "apply_separable called with non-separable mode")]
413 fn apply_separable_panics_on_nonseparable() {
414 let mut out = [0u8; 3];
415 apply_separable(BlendMode::Hue, &[0; 3], &[0; 3], &mut out);
416 }
417
418 #[test]
419 fn soft_light_midpoint() {
420 let r = blend_soft_light(128, 128);
422 assert!(
423 (i32::from(r) - 128).abs() <= 2,
424 "soft_light(128,128)={r}, expected ~128"
425 );
426 }
427
428 #[test]
429 fn exclusion_is_screen_minus_extra_mul() {
430 for s in (0u8..=255).step_by(23) {
434 for d in (0u8..=255).step_by(19) {
435 let ex = blend_exclusion(s, d);
436 let sc = blend_screen(s, d);
437 assert!(ex <= sc, "exclusion({s},{d})={ex} > screen({s},{d})={sc}");
438 }
439 }
440 }
441
442 #[test]
443 fn screen_result_is_always_valid_u8() {
444 for s in 0u8..=255 {
446 for d in 0u8..=255 {
447 let result = blend_screen(s, d);
448 assert!(
451 result >= s || result >= d,
452 "screen({s},{d})={result} below both inputs"
453 );
454 }
455 }
456 }
457}