Skip to main content

oxiui_render_soft/
blend.rs

1//! Extended compositing helpers: blend modes + premultiplied-alpha utilities.
2//!
3//! The default [`Framebuffer::blend`](crate::framebuffer::Framebuffer::blend)
4//! path uses *straight-alpha* source-over (Porter–Duff `over`). This module
5//! adds Photoshop / SVG / CSS classics — `multiply`, `screen`, `overlay`,
6//! `darken`, `lighten` — plus the premultiplied-alpha helpers callers need
7//! when they want to do bulk maths in linear-ish space and only de-multiply
8//! at the very end.
9//!
10//! All blend modes work on straight-alpha sRGB components in `[0, 255]`
11//! (i.e. exactly what the framebuffer stores), so they're additive: callers
12//! just pick a [`BlendMode`] and call [`blend_pixel`] (or
13//! [`composite_into`]) — `Framebuffer` itself is unchanged.
14//!
15//! ## Premultiplied-alpha pipeline
16//!
17//! The internal `blend_over_premult_u8` helper performs Porter–Duff
18//! source-over entirely in integer premultiplied-alpha space:
19//!   1. Convert source and destination from straight-alpha to premult (u16).
20//!   2. Blend in premult space: `out_r = src_pm + dst_pm * (255 - src_a) / 255`.
21//!   3. Convert the premultiplied result back to straight-alpha for storage.
22//!
23//! This avoids the per-pixel division inherent in straight-alpha compositing
24//! while keeping the public API and framebuffer format unchanged.
25
26use crate::framebuffer::{pack_rgba, unpack, Framebuffer};
27
28/// Compositing operator selected by [`blend_pixel`] /
29/// [`composite_into`].
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub enum BlendMode {
32    /// Standard Porter–Duff `source-over` (straight alpha).
33    Normal,
34    /// `C = src * dst` per colour channel (darkening filter).
35    Multiply,
36    /// `C = 1 - (1 - src) * (1 - dst)` (lightening filter).
37    Screen,
38    /// Per-channel: multiply when `dst < 0.5`, screen otherwise.
39    Overlay,
40    /// `C = min(src, dst)` per channel.
41    Darken,
42    /// `C = max(src, dst)` per channel.
43    Lighten,
44}
45
46/// A straight-alpha RGBA pixel as floats in `[0, 1]`.
47///
48/// Conversions to/from the framebuffer `0xAARRGGBB` format use the helpers
49/// at the bottom of this file (`to_unit` / `from_unit`).
50#[derive(Clone, Copy, Debug, PartialEq)]
51pub struct RgbaUnit {
52    /// Red channel, `[0, 1]`.
53    pub r: f32,
54    /// Green channel, `[0, 1]`.
55    pub g: f32,
56    /// Blue channel, `[0, 1]`.
57    pub b: f32,
58    /// Alpha channel, `[0, 1]`.
59    pub a: f32,
60}
61
62impl RgbaUnit {
63    /// Construct a pixel from raw float components (clamped to `[0, 1]`).
64    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
65        Self {
66            r: r.clamp(0.0, 1.0),
67            g: g.clamp(0.0, 1.0),
68            b: b.clamp(0.0, 1.0),
69            a: a.clamp(0.0, 1.0),
70        }
71    }
72
73    /// Construct from an unsigned-byte `(r, g, b, a)` tuple.
74    pub fn from_bytes(r: u8, g: u8, b: u8, a: u8) -> Self {
75        Self {
76            r: r as f32 / 255.0,
77            g: g as f32 / 255.0,
78            b: b as f32 / 255.0,
79            a: a as f32 / 255.0,
80        }
81    }
82
83    /// Round to nearest `(r, g, b, a)` bytes.
84    pub fn to_bytes(self) -> (u8, u8, u8, u8) {
85        (
86            (self.r.clamp(0.0, 1.0) * 255.0).round() as u8,
87            (self.g.clamp(0.0, 1.0) * 255.0).round() as u8,
88            (self.b.clamp(0.0, 1.0) * 255.0).round() as u8,
89            (self.a.clamp(0.0, 1.0) * 255.0).round() as u8,
90        )
91    }
92
93    /// Multiply RGB by alpha — produces premultiplied form for fast bulk maths.
94    pub fn premultiply(self) -> Self {
95        Self {
96            r: self.r * self.a,
97            g: self.g * self.a,
98            b: self.b * self.a,
99            a: self.a,
100        }
101    }
102
103    /// Inverse of [`premultiply`](Self::premultiply); safe when `a == 0`
104    /// (returns transparent black rather than `NaN`).
105    pub fn unpremultiply(self) -> Self {
106        if self.a <= f32::EPSILON {
107            Self {
108                r: 0.0,
109                g: 0.0,
110                b: 0.0,
111                a: 0.0,
112            }
113        } else {
114            Self {
115                r: self.r / self.a,
116                g: self.g / self.a,
117                b: self.b / self.a,
118                a: self.a,
119            }
120        }
121    }
122}
123
124/// Convert a framebuffer packed pixel to floats in `[0, 1]`.
125pub fn to_unit(px: u32) -> RgbaUnit {
126    let (r, g, b, a) = unpack(px);
127    RgbaUnit::from_bytes(r, g, b, a)
128}
129
130/// Pack floats back into a framebuffer `0xAARRGGBB` value.
131pub fn from_unit(p: RgbaUnit) -> u32 {
132    let (r, g, b, a) = p.to_bytes();
133    pack_rgba(r, g, b, a)
134}
135
136// ---------------------------------------------------------------------------
137// Integer premultiplied-alpha pipeline helpers
138// ---------------------------------------------------------------------------
139
140/// Convert a straight-alpha pixel `(r, g, b, a)` to premultiplied-alpha `u16`
141/// components: `(r * a / 255, g * a / 255, b * a / 255, a)`.
142///
143/// Using `u16` avoids overflow: `255 * 255 = 65025 < u16::MAX`.
144#[inline]
145fn to_premult(r: u8, g: u8, b: u8, a: u8) -> (u16, u16, u16, u16) {
146    let a16 = a as u16;
147    (
148        (r as u16 * a16 + 127) / 255,
149        (g as u16 * a16 + 127) / 255,
150        (b as u16 * a16 + 127) / 255,
151        a16,
152    )
153}
154
155/// Convert premultiplied-alpha components `(pr, pg, pb, a)` back to
156/// straight-alpha bytes. Safe when `a == 0` (returns transparent black).
157#[inline]
158fn from_premult(pr: u16, pg: u16, pb: u16, a: u16) -> (u8, u8, u8, u8) {
159    if a == 0 {
160        return (0, 0, 0, 0);
161    }
162    let half = a / 2;
163    (
164        ((pr * 255 + half) / a).min(255) as u8,
165        ((pg * 255 + half) / a).min(255) as u8,
166        ((pb * 255 + half) / a).min(255) as u8,
167        a as u8,
168    )
169}
170
171/// Porter–Duff source-over in integer premultiplied-alpha space.
172///
173/// `src` and `dst` are straight-alpha `(r, g, b, a)` bytes.  The computation:
174///   1. Premultiply both.
175///   2. Blend: `out_r = src_pm + dst_pm * (255 - src_a) / 255`.
176///   3. Compute `out_a = src_a + dst_a * (255 - src_a) / 255`.
177///   4. Un-premultiply the result back to straight-alpha.
178///
179/// The framebuffer continues to store `0xAARRGGBB` straight-alpha — this
180/// function does not change the storage invariant.
181#[inline]
182#[allow(clippy::too_many_arguments)]
183pub(crate) fn blend_over_premult_u8(
184    src_r: u8,
185    src_g: u8,
186    src_b: u8,
187    src_a: u8,
188    dst_r: u8,
189    dst_g: u8,
190    dst_b: u8,
191    dst_a: u8,
192) -> (u8, u8, u8, u8) {
193    let (spr, spg, spb, sa) = to_premult(src_r, src_g, src_b, src_a);
194    let (dpr, dpg, dpb, da) = to_premult(dst_r, dst_g, dst_b, dst_a);
195    let inv_sa = 255u16 - sa;
196    let out_r = spr + (dpr * inv_sa + 127) / 255;
197    let out_g = spg + (dpg * inv_sa + 127) / 255;
198    let out_b = spb + (dpb * inv_sa + 127) / 255;
199    let out_a = sa + (da * inv_sa + 127) / 255;
200    from_premult(out_r, out_g, out_b, out_a)
201}
202
203/// Apply `mode` to compose `src` over `dst`, returning the result in
204/// straight-alpha float form.
205///
206/// All non-`Normal` modes use the standard SVG-compositing recipe:
207/// the per-channel colour blend function `B(Cb, Cs)` is computed, then
208/// combined with the alphas:
209///
210/// ```text
211///   Co = (1 - dst.a) * Cs + (1 - src.a) * Cb + src.a * dst.a * B(Cb, Cs)
212///   Ao = src.a + dst.a * (1 - src.a)
213/// ```
214///
215/// This matches SVG 1.1 "Composite + Blend" semantics and CSS
216/// `background-blend-mode`.
217pub fn blend_mode(mode: BlendMode, src: RgbaUnit, dst: RgbaUnit) -> RgbaUnit {
218    match mode {
219        BlendMode::Normal => over(src, dst),
220        BlendMode::Multiply => mode_combine(src, dst, |a, b| a * b),
221        BlendMode::Screen => mode_combine(src, dst, |a, b| a + b - a * b),
222        BlendMode::Overlay => mode_combine(src, dst, |cs, cb| {
223            // SVG: overlay(Cb, Cs) = hard-light(Cs, Cb).
224            if cb <= 0.5 {
225                2.0 * cs * cb
226            } else {
227                1.0 - 2.0 * (1.0 - cs) * (1.0 - cb)
228            }
229        }),
230        BlendMode::Darken => mode_combine(src, dst, f32::min),
231        BlendMode::Lighten => mode_combine(src, dst, f32::max),
232    }
233}
234
235/// Plain Porter–Duff source-over in straight-alpha space.
236fn over(src: RgbaUnit, dst: RgbaUnit) -> RgbaUnit {
237    let out_a = src.a + dst.a * (1.0 - src.a);
238    if out_a <= f32::EPSILON {
239        return RgbaUnit {
240            r: 0.0,
241            g: 0.0,
242            b: 0.0,
243            a: 0.0,
244        };
245    }
246    let blend = |s: f32, d: f32| (s * src.a + d * dst.a * (1.0 - src.a)) / out_a;
247    RgbaUnit {
248        r: blend(src.r, dst.r),
249        g: blend(src.g, dst.g),
250        b: blend(src.b, dst.b),
251        a: out_a,
252    }
253}
254
255/// Generic SVG-style blend: applies `blend_fn` per colour channel and
256/// composes the result with proper alpha handling.
257fn mode_combine<F>(src: RgbaUnit, dst: RgbaUnit, blend_fn: F) -> RgbaUnit
258where
259    F: Fn(f32, f32) -> f32,
260{
261    let out_a = src.a + dst.a * (1.0 - src.a);
262    if out_a <= f32::EPSILON {
263        return RgbaUnit {
264            r: 0.0,
265            g: 0.0,
266            b: 0.0,
267            a: 0.0,
268        };
269    }
270    // Premultiplied per the SVG compositing equation.
271    let channel = |cs: f32, cb: f32| -> f32 {
272        let b = blend_fn(cs.clamp(0.0, 1.0), cb.clamp(0.0, 1.0));
273        let v = (1.0 - dst.a) * cs * src.a + (1.0 - src.a) * cb * dst.a + src.a * dst.a * b;
274        // De-premultiply by out_a so the framebuffer stays in straight-alpha form.
275        (v / out_a).clamp(0.0, 1.0)
276    };
277    RgbaUnit {
278        r: channel(src.r, dst.r),
279        g: channel(src.g, dst.g),
280        b: channel(src.b, dst.b),
281        a: out_a,
282    }
283}
284
285/// Apply `mode` to blend `src` over the pixel currently at `(x, y)` in `fb`.
286/// Out-of-bounds writes are ignored. For `Normal` this is equivalent to
287/// [`Framebuffer::blend`](crate::framebuffer::Framebuffer::blend).
288pub fn blend_pixel(fb: &mut Framebuffer, x: u32, y: u32, src: RgbaUnit, mode: BlendMode) {
289    if x >= fb.width() || y >= fb.height() {
290        return;
291    }
292    let Some(dst_px) = fb.get(x, y) else { return };
293    let dst = to_unit(dst_px);
294    let out = blend_mode(mode, src, dst);
295    fb.set(x, y, from_unit(out));
296}
297
298/// Composite a rectangular `src` slab (interpreted as straight-alpha
299/// RGBA8 row-major, `w * h * 4` bytes) into `fb` at `(dst_x, dst_y)`
300/// using `mode`. Returns the number of pixels written.
301///
302/// For [`BlendMode::Normal`] the composite is done in integer premultiplied-
303/// alpha space (via `blend_over_premult_u8`) so the blend math is exact and
304/// avoids the per-pixel float division. Other modes fall back to the f32 path.
305pub fn composite_into(
306    fb: &mut Framebuffer,
307    src: &[u8],
308    w: u32,
309    h: u32,
310    dst_x: i64,
311    dst_y: i64,
312    mode: BlendMode,
313) -> usize {
314    if w == 0 || h == 0 {
315        return 0;
316    }
317    // Compute the required source length in `usize` with checked
318    // arithmetic. Doing this as `w * h * 4` in `u32` can wrap for large
319    // `w`/`h`, producing a too-small guard value that would defeat the
320    // `src.len()` bounds check and let the loop below index past the end of
321    // `src`. An overflow here means the caller-supplied dimensions can never
322    // be satisfied by any real `src` slice, so treat it as "no data".
323    let required_len = (w as usize)
324        .checked_mul(h as usize)
325        .and_then(|n| n.checked_mul(4));
326    let Some(required_len) = required_len else {
327        return 0;
328    };
329    if src.len() < required_len {
330        return 0;
331    }
332    let mut written = 0;
333    for j in 0..h {
334        for i in 0..w {
335            // `usize` math mirrors the checked guard above, so this index
336            // can never overflow now that `w * h * 4 <= src.len()` is known.
337            let si = (j as usize * w as usize + i as usize) * 4;
338            let r = src[si];
339            let g = src[si + 1];
340            let b = src[si + 2];
341            let a = src[si + 3];
342            if a == 0 {
343                continue;
344            }
345            let px = dst_x + i as i64;
346            let py = dst_y + j as i64;
347            if px < 0 || py < 0 {
348                continue;
349            }
350            let pu = px as u32;
351            let pv = py as u32;
352            if pu >= fb.width() || pv >= fb.height() {
353                continue;
354            }
355            if mode == BlendMode::Normal {
356                // Fast path: premultiplied-alpha source-over in integer space.
357                // Framebuffer stays in straight-alpha 0xAARRGGBB.
358                let dst_px = fb.get(pu, pv).unwrap_or(0);
359                let (dr, dg, db, da) = unpack(dst_px);
360                let (or_, og, ob, oa) = blend_over_premult_u8(r, g, b, a, dr, dg, db, da);
361                fb.set(pu, pv, crate::framebuffer::pack_rgba(or_, og, ob, oa));
362            } else {
363                blend_pixel(fb, pu, pv, RgbaUnit::from_bytes(r, g, b, a), mode);
364            }
365            written += 1;
366        }
367    }
368    written
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use oxiui_core::Color;
375
376    fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
377        (a - b).abs() <= eps
378    }
379
380    #[test]
381    fn round_trip_unit_bytes() {
382        let p = RgbaUnit::from_bytes(123, 45, 200, 99);
383        let (r, g, b, a) = p.to_bytes();
384        assert_eq!((r, g, b, a), (123, 45, 200, 99));
385    }
386
387    #[test]
388    fn premultiply_inverse() {
389        let p = RgbaUnit::new(0.8, 0.4, 0.2, 0.5);
390        let q = p.premultiply().unpremultiply();
391        assert!(approx_eq(q.r, p.r, 1e-5));
392        assert!(approx_eq(q.g, p.g, 1e-5));
393        assert!(approx_eq(q.b, p.b, 1e-5));
394        assert!(approx_eq(q.a, p.a, 1e-5));
395    }
396
397    #[test]
398    fn premultiply_zero_alpha_safe() {
399        let p = RgbaUnit::new(0.9, 0.9, 0.9, 0.0);
400        let q = p.premultiply().unpremultiply();
401        assert!(approx_eq(q.a, 0.0, 1e-5));
402        assert!(approx_eq(q.r, 0.0, 1e-5));
403    }
404
405    #[test]
406    fn multiply_white_is_identity() {
407        // src=white opaque, mode=Multiply over red dst → red preserved.
408        let src = RgbaUnit::from_bytes(255, 255, 255, 255);
409        let dst = RgbaUnit::from_bytes(255, 0, 0, 255);
410        let out = blend_mode(BlendMode::Multiply, src, dst);
411        let (r, g, b, a) = out.to_bytes();
412        assert_eq!((r, g, b, a), (255, 0, 0, 255));
413    }
414
415    #[test]
416    fn screen_black_is_identity() {
417        let src = RgbaUnit::from_bytes(0, 0, 0, 255);
418        let dst = RgbaUnit::from_bytes(80, 120, 200, 255);
419        let out = blend_mode(BlendMode::Screen, src, dst);
420        let (r, g, b, _) = out.to_bytes();
421        // Screen with black should keep dst exactly.
422        assert!((r as i32 - 80).abs() <= 1);
423        assert!((g as i32 - 120).abs() <= 1);
424        assert!((b as i32 - 200).abs() <= 1);
425    }
426
427    #[test]
428    fn darken_picks_min_per_channel() {
429        let src = RgbaUnit::from_bytes(100, 200, 50, 255);
430        let dst = RgbaUnit::from_bytes(150, 50, 50, 255);
431        let out = blend_mode(BlendMode::Darken, src, dst);
432        let (r, g, b, _) = out.to_bytes();
433        assert!((r as i32 - 100).abs() <= 1);
434        assert!((g as i32 - 50).abs() <= 1);
435        assert!((b as i32 - 50).abs() <= 1);
436    }
437
438    #[test]
439    fn lighten_picks_max_per_channel() {
440        let src = RgbaUnit::from_bytes(100, 200, 50, 255);
441        let dst = RgbaUnit::from_bytes(150, 50, 50, 255);
442        let out = blend_mode(BlendMode::Lighten, src, dst);
443        let (r, g, b, _) = out.to_bytes();
444        assert!((r as i32 - 150).abs() <= 1);
445        assert!((g as i32 - 200).abs() <= 1);
446        assert!((b as i32 - 50).abs() <= 1);
447    }
448
449    #[test]
450    fn overlay_changes_with_dst_mid() {
451        // Dst at 0.5 → overlay is the boundary; verify it produces some
452        // smooth blend with a grey src (no NaN / no panic).
453        let src = RgbaUnit::from_bytes(128, 128, 128, 255);
454        let dst = RgbaUnit::from_bytes(128, 128, 128, 255);
455        let out = blend_mode(BlendMode::Overlay, src, dst);
456        let (r, _, _, a) = out.to_bytes();
457        assert_eq!(a, 255);
458        // Result must remain in-gamut (r is u8, so always <= 255; just verify
459        // we got a valid result).
460        let _ = r;
461    }
462
463    #[test]
464    fn over_zero_alpha_safe() {
465        let src = RgbaUnit::new(0.0, 0.0, 0.0, 0.0);
466        let dst = RgbaUnit::new(0.0, 0.0, 0.0, 0.0);
467        let out = blend_mode(BlendMode::Normal, src, dst);
468        let (_, _, _, a) = out.to_bytes();
469        assert_eq!(a, 0);
470    }
471
472    #[test]
473    fn composite_into_writes_pixels() {
474        let mut fb = Framebuffer::with_fill(4, 4, Color(0, 0, 0, 255));
475        // 2x2 all-white opaque src.
476        let src = vec![
477            255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
478        ];
479        let written = composite_into(&mut fb, &src, 2, 2, 1, 1, BlendMode::Normal);
480        assert_eq!(written, 4);
481        let result = fb.get_rgba(1, 1).unwrap_or_default();
482        assert_eq!(result, (255, 255, 255, 255));
483    }
484
485    #[test]
486    fn blend_pixel_out_of_bounds_noop() {
487        let mut fb = Framebuffer::with_fill(2, 2, Color(10, 20, 30, 255));
488        blend_pixel(
489            &mut fb,
490            99,
491            99,
492            RgbaUnit::from_bytes(255, 255, 255, 255),
493            BlendMode::Normal,
494        );
495        let v = fb.get_rgba(0, 0).unwrap_or((0, 0, 0, 0));
496        assert_eq!(v, (10, 20, 30, 255));
497    }
498
499    #[test]
500    fn blend_multiply_black_is_black() {
501        // Multiply any colour with opaque black → black.
502        let black = RgbaUnit::from_bytes(0, 0, 0, 255);
503        let src = RgbaUnit::from_bytes(200, 100, 50, 255);
504        let out = blend_mode(BlendMode::Multiply, src, black);
505        let (r, g, b, _) = out.to_bytes();
506        assert_eq!((r, g, b), (0, 0, 0), "multiply with black must give black");
507    }
508
509    #[test]
510    fn blend_screen_white_is_white() {
511        // Screen any colour with opaque white → white.
512        let white = RgbaUnit::from_bytes(255, 255, 255, 255);
513        let src = RgbaUnit::from_bytes(100, 150, 200, 255);
514        let out = blend_mode(BlendMode::Screen, src, white);
515        let (r, g, b, _) = out.to_bytes();
516        assert_eq!(
517            (r, g, b),
518            (255, 255, 255),
519            "screen with white must give white"
520        );
521    }
522
523    #[test]
524    fn premultiply_roundtrip() {
525        // Premultiply then unpremultiply should recover the original (within rounding).
526        let p = RgbaUnit::new(0.7, 0.3, 0.9, 0.6);
527        let q = p.premultiply().unpremultiply();
528        assert!(approx_eq(q.r, p.r, 1e-4), "r: {:.5} vs {:.5}", q.r, p.r);
529        assert!(approx_eq(q.g, p.g, 1e-4), "g: {:.5} vs {:.5}", q.g, p.g);
530        assert!(approx_eq(q.b, p.b, 1e-4), "b: {:.5} vs {:.5}", q.b, p.b);
531        assert!(approx_eq(q.a, p.a, 1e-5), "a: {:.5} vs {:.5}", q.a, p.a);
532    }
533
534    // -----------------------------------------------------------------------
535    // S2: premultiplied-alpha golden-value + signature-stability tests
536    // -----------------------------------------------------------------------
537
538    #[test]
539    fn test_premult_blend_golden() {
540        // Blend semi-transparent red (r=255, g=0, b=0, a=128) over opaque white
541        // (r=255, g=255, b=255, a=255).
542        //
543        // Expected result (straight-alpha source-over):
544        //   out_a = 128 + 255 * (255 - 128) / 255  = 128 + 127 = 255
545        //   out_r = (255 * 128 + 255 * 255 * 127/255) / 255
546        //         = (32640 + 32385) / 255 ≈ 255
547        //   out_g = (0   * 128 + 255 * 127) / 255 ≈ 127
548        //   out_b = (0   * 128 + 255 * 127) / 255 ≈ 127
549        //
550        // Golden values: (255, 127, 127, 255), tolerance ±2.
551        let (r, g, b, a) = blend_over_premult_u8(255, 0, 0, 128, 255, 255, 255, 255);
552        assert!((r as i32 - 255).abs() <= 2, "r={r} expected ~255");
553        assert!((g as i32 - 127).abs() <= 2, "g={g} expected ~127");
554        assert!((b as i32 - 127).abs() <= 2, "b={b} expected ~127");
555        assert!((a as i32 - 255).abs() <= 2, "a={a} expected ~255");
556    }
557
558    #[test]
559    fn test_blend_signatures_stable() {
560        // Compile-time + runtime stability check: call every public blend fn
561        // with valid args and verify it does not panic.
562
563        // blend_mode
564        let src = RgbaUnit::from_bytes(128, 64, 32, 200);
565        let dst = RgbaUnit::from_bytes(10, 20, 30, 255);
566        let _ = blend_mode(BlendMode::Normal, src, dst);
567        let _ = blend_mode(BlendMode::Multiply, src, dst);
568        let _ = blend_mode(BlendMode::Screen, src, dst);
569        let _ = blend_mode(BlendMode::Overlay, src, dst);
570        let _ = blend_mode(BlendMode::Darken, src, dst);
571        let _ = blend_mode(BlendMode::Lighten, src, dst);
572
573        // to_unit / from_unit round-trip
574        let px = crate::framebuffer::pack_rgba(100, 150, 200, 180);
575        let unit = to_unit(px);
576        let _ = from_unit(unit);
577
578        // blend_pixel (out-of-bounds is a no-op; just verify no panic)
579        let mut fb = Framebuffer::with_fill(4, 4, Color(0, 0, 0, 255));
580        blend_pixel(&mut fb, 0, 0, src, BlendMode::Normal);
581        blend_pixel(&mut fb, 99, 99, src, BlendMode::Screen); // out-of-bounds noop
582
583        // composite_into (minimal 1×1)
584        let src_bytes = [255u8, 0, 0, 128];
585        let written = composite_into(&mut fb, &src_bytes, 1, 1, 0, 0, BlendMode::Normal);
586        assert!(written <= 1);
587    }
588
589    // -----------------------------------------------------------------------
590    // Security regression: `composite_into` size math overflow
591    // -----------------------------------------------------------------------
592
593    #[test]
594    fn composite_into_near_u32_boundary_does_not_overflow_or_panic() {
595        // `w * h` alone (70_000 * 70_000 ≈ 4.9e9) already exceeds
596        // `u32::MAX` (≈4.29e9), so computing the required length as
597        // `w * h * 4` in `u32` either panics (debug overflow checks) or
598        // wraps to a small value that would defeat the `src.len()` bounds
599        // check and let the pixel loop index past the end of `src`. The
600        // fixed implementation must recognise the size is unsatisfiable and
601        // return 0 without panicking or reading/writing out of bounds.
602        let mut fb = Framebuffer::with_fill(4, 4, Color(0, 0, 0, 255));
603        let src = vec![0u8; 16]; // far too small for any real w*h*4
604        let written = composite_into(&mut fb, &src, 70_000, 70_000, 0, 0, BlendMode::Normal);
605        assert_eq!(written, 0, "oversized w*h*4 must be rejected, not wrapped");
606
607        // Framebuffer must be untouched.
608        for y in 0..4 {
609            for x in 0..4 {
610                assert_eq!(fb.get_rgba(x, y), Some((0, 0, 0, 255)));
611            }
612        }
613    }
614
615    #[test]
616    fn composite_into_exact_u32_boundary_dims_no_panic() {
617        // `w * h * 4` computed as a pure `u32` product wraps to exactly 0 for
618        // these dimensions (`65536 * 65536 * 4 == 2^32 == 0 mod 2^32`), which
619        // would make the old `src.len() < 0` guard always false — i.e. it
620        // would accept *any* `src`, however small, and then index far past
621        // its end. Confirm the fixed guard rejects this instead.
622        let mut fb = Framebuffer::with_fill(2, 2, Color(0, 0, 0, 255));
623        let src = vec![0u8; 4];
624        let written = composite_into(&mut fb, &src, 65_536, 65_536, 0, 0, BlendMode::Normal);
625        assert_eq!(written, 0);
626    }
627}