Skip to main content

rasterrocket_render/pipe/
blend.rs

1//! PDF blend modes (PDF spec §11.3.5).
2//!
3//! All separable modes operate per-channel with `src` and `dst` in `[0, 255]` and
4//! return a blended value in `[0, 255]`.  For CMYK/DeviceN the caller inverts the
5//! channel values before calling (`255 - v`) and inverts again after — the
6//! subtractive complement trick used by the C++ `splashOutBlend*` functions.
7//!
8//! Non-separable modes (`Hue`, `Saturation`, `Color`, `Luminosity`) operate on
9//! full RGB triples (always in additive space); CMYK callers convert first.
10
11use crate::types::BlendMode;
12use color::convert::div255;
13
14// ── Separable blend primitives ────────────────────────────────────────────────
15
16/// Normal: result is the source (Porter-Duff over handled in the pipe, not here).
17#[must_use]
18pub const fn blend_normal(src: u8, _dst: u8) -> u8 {
19    src
20}
21
22/// Multiply: `div255(src * dst)`.
23#[must_use]
24pub fn blend_multiply(src: u8, dst: u8) -> u8 {
25    div255(u32::from(src) * u32::from(dst))
26}
27
28/// Screen: `src + dst - div255(src * dst)`.
29#[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/// Overlay: hard-light of dst over src.
42#[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/// Darken: `min(src, dst)`.
54#[must_use]
55pub fn blend_darken(src: u8, dst: u8) -> u8 {
56    src.min(dst)
57}
58
59/// Lighten: `max(src, dst)`.
60#[must_use]
61pub fn blend_lighten(src: u8, dst: u8) -> u8 {
62    src.max(dst)
63}
64
65/// Color dodge: brighten dst to reflect src.
66#[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/// Color burn: darken dst to reflect src.
76#[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/// Hard light: `Overlay(dst, src)` — the src/dst roles are swapped vs Overlay.
91#[must_use]
92pub fn blend_hard_light(src: u8, dst: u8) -> u8 {
93    // hard_light(s, d) == overlay(d, s)
94    blend_overlay(dst, src)
95}
96
97/// Soft light (PDF spec §11.3.5.3).
98#[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            // Integer sqrt: sqrt(255 * d), scaled to [0,255].
109            #[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/// Difference: `|src - dst|`.
126#[must_use]
127pub const fn blend_difference(src: u8, dst: u8) -> u8 {
128    src.abs_diff(dst)
129}
130
131/// Exclusion: `src + dst - 2 × div255(src × dst)`.
132#[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// ── Non-separable helpers (RGB additive space) ────────────────────────────────
142
143/// Luminance of an RGB triple using the PDF/BT.709 weights (integer rounding).
144#[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/// Saturation: max channel − min channel.
150#[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
155/// Clip an out-of-range (r, g, b) triple back into [0, 255] while preserving luminance.
156fn 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
179/// Shift the luminance of (r, g, b) to `lum` while preserving hue/saturation.
180fn 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
185/// Scale (r, g, b) so its saturation equals `sat`, while preserving hue.
186fn set_sat(r_in: i32, g_in: i32, b_in: i32, sat: i32) -> (i32, i32, i32) {
187    // Sort channels into (min, mid, max) keeping track of which index is which.
188    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/// Non-separable Hue blend: hue of src, saturation and luminosity of dst.
211#[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/// Non-separable Saturation blend: saturation of src, hue and luminosity of dst.
226#[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/// Non-separable Color blend: hue and saturation of src, luminosity of dst.
241#[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/// Non-separable Luminosity blend: luminosity of src, hue and saturation of dst.
255#[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
268// ── Dispatch ──────────────────────────────────────────────────────────────────
269
270/// Apply a separable blend mode to each corresponding byte pair from `src` and `dst`.
271///
272/// For CMYK/DeviceN modes the caller must invert before and after (subtractive complement).
273/// `src.len()`, `dst.len()`, and `out.len()` must all be equal.
274///
275/// # Panics
276///
277/// Panics if `mode` is a non-separable variant (`Hue`, `Saturation`, `Color`,
278/// `Luminosity`).  Use [`apply_nonseparable_rgb`] for those.
279pub 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/// Apply a non-separable blend mode (`Hue`/`Saturation`/`Color`/`Luminosity`) to an RGB triple.
305///
306/// `src` and `dst` are in additive space — CMYK callers convert first
307/// (`255 - channel`).  Only the three components are blended; the caller
308/// handles channel `[3]` (K/alpha) separately.
309///
310/// # Panics
311///
312/// Panics if `mode` is a separable variant.  Use [`apply_separable`] for those.
313#[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        // Luminosity of red over grey: result should be grey (all channels equal).
393        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        // For src=128, dst=128: result should be close to 128.
421        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        // Exclusion(s,d) = s + d - 2×div255(s×d)
431        // Screen(s,d)    = s + d - div255(s×d)
432        // So exclusion ≤ screen for all inputs.
433        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        // Screen(s,d) = s + d - div255(s*d). Mathematically ≤ 255; verify exhaustively.
445        for s in 0u8..=255 {
446            for d in 0u8..=255 {
447                let result = blend_screen(s, d);
448                // result is u8 so it's always ≤ 255; check it's ≥ max(s,d) isn't true,
449                // but it should be ≥ both (Screen is always ≥ each input).
450                assert!(
451                    result >= s || result >= d,
452                    "screen({s},{d})={result} below both inputs"
453                );
454            }
455        }
456    }
457}