Skip to main content

rasterrocket_render/
image.rs

1//! Image and image-mask rendering — replaces `Splash::fillImageMask`,
2//! `Splash::drawImage`, and the four `scaleImage*` / `scaleMask*` helpers.
3//!
4//! # Scope
5//!
6//! Only axis-aligned scaling (`matrix[1] == 0 && matrix[2] == 0`) with optional
7//! vertical flip (negative Y scale) is implemented.  Rotated or skewed transforms
8//! return [`ImageResult::ArbitraryTransformSkipped`] so the caller can fall back
9//! to a general path.
10//!
11//! # Scaling strategy
12//!
13//! Four Bresenham variants mirror the C++ `scaleMask*` / `scaleImage*` family:
14//!
15//! | Y axis  | X axis  | Method                                        |
16//! |---------|---------|-----------------------------------------------|
17//! | down ↓  | down ↓  | Box-filter (average) in both axes             |
18//! | down ↓  | up   ↑  | Box-filter in Y, nearest-neighbour in X       |
19//! | up   ↑  | down ↓  | Nearest-neighbour in Y, box-filter in X       |
20//! | up   ↑  | up   ↑  | Nearest-neighbour in both axes                |
21//!
22//! All four variants process source rows exactly once, allocating no extra heap
23//! per row.  "Down" means `scaled < src`; "up" means `scaled ≥ src`.
24//!
25//! # Pixel-count contract
26//!
27//! [`draw_image`] derives the component count directly from `P::BYTES`,
28//! eliminating any possibility of a caller passing a mismatched value.
29//!
30//! # C++ equivalents
31//!
32//! - `Splash::fillImageMask`
33//! - `Splash::drawImage`
34//! - `Splash::scaleMask{YdownXdown,YdownXup,YupXdown,YupXup}`
35//! - `Splash::scaleImage{YdownXdown,YdownXup,YupXdown,YupXup}`
36//! - `Splash::blitMask`
37
38use crate::bitmap::Bitmap;
39use crate::clip::{Clip, ClipResult};
40use crate::pipe::{self, PipeSrc, PipeState};
41use crate::types::PixelMode;
42use color::Pixel;
43use color::convert::splash_floor;
44
45// ── Compile-time sanity ───────────────────────────────────────────────────────
46
47/// Maximum number of colour components supported by any pixel mode.
48/// `DeviceN8` is the widest at 8 bytes per pixel.
49const MAX_NCOMPS: usize = 8;
50
51// ── Public traits ─────────────────────────────────────────────────────────────
52
53/// Caller-supplied source for a colour image: one row at a time.
54///
55/// Matches the `SplashImageSource` callback convention from `splash/SplashTypes.h`.
56pub trait ImageSource: Send {
57    /// Fill `row_buf` with pixel data for source row `y`.
58    ///
59    /// `row_buf.len()` must equal `src_width * ncomps`.
60    fn get_row(&mut self, y: u32, row_buf: &mut [u8]);
61}
62
63/// Caller-supplied source for a 1-bit image mask: one row at a time.
64///
65/// Each row is delivered as MSB-first packed bytes: `src_width.div_ceil(8)`
66/// bytes per row.  Bit 7 of the first byte is the leftmost pixel; bit 0 of
67/// the last byte is the rightmost (with unused padding bits set to 0).
68///
69/// The mask is immediately unpacked to one `u8` per pixel (0 or 255) for
70/// simpler scaling arithmetic, matching the C++ `scaleMask` convention.
71///
72/// Matches `SplashImageMaskSource` from `splash/SplashTypes.h`.
73pub trait MaskSource: Send {
74    /// Fill `row_buf` with 1-bit packed mono mask data (MSB-first).
75    ///
76    /// `row_buf.len()` equals `src_width.div_ceil(8)`.  Implementors must
77    /// write every byte; unused padding bits in the final byte should be 0.
78    fn get_row(&mut self, y: u32, row_buf: &mut [u8]);
79}
80
81// ── Result enum ───────────────────────────────────────────────────────────────
82
83/// Return value from [`fill_image_mask`] and [`draw_image`].
84#[derive(Copy, Clone, Debug, PartialEq, Eq)]
85pub enum ImageResult {
86    /// Rendering completed successfully.
87    Ok,
88    /// Source image has zero width or height; nothing rendered.
89    ZeroImage,
90    /// The transformation matrix is singular (determinant ≈ 0).
91    SingularMatrix,
92    /// The matrix is not axis-aligned; the arbitrary-transform path is not yet
93    /// implemented in Phase 1.  The caller should handle this case.
94    ArbitraryTransformSkipped,
95}
96
97// ── Internal helpers ──────────────────────────────────────────────────────────
98
99/// `imgCoordMungeLower(x)` — C++ non-glyph variant: `floor(x)`.
100#[inline]
101fn coord_lower(x: f64) -> i32 {
102    splash_floor(x)
103}
104
105/// `imgCoordMungeUpper(x)` — C++ non-glyph variant: `floor(x) + 1`.
106#[inline]
107fn coord_upper(x: f64) -> i32 {
108    splash_floor(x) + 1
109}
110
111/// Determinant check: returns `true` when |a·d − b·c| ≥ `eps`.
112///
113/// Mirrors `splashCheckDet` from `splash/SplashMath.h`.
114#[inline]
115fn check_det(a: f64, b: f64, c: f64, d: f64, eps: f64) -> bool {
116    #[expect(
117        clippy::suboptimal_flops,
118        reason = "matches the C++ arithmetic exactly; no numerics benefit here"
119    )]
120    {
121        (a * d - b * c).abs() >= eps
122    }
123}
124
125/// Unpack one packed-bits mask row into one-byte-per-pixel form (0 or 255).
126///
127/// `packed` is MSB-first; `out` receives exactly `width` bytes.
128///
129/// # Panics (debug)
130///
131/// Asserts that `packed.len() >= width.div_ceil(8)`, catching `MaskSource`
132/// implementations that deliver too few bytes.
133fn unpack_mask_row(packed: &[u8], width: usize, out: &mut [u8]) {
134    debug_assert!(
135        packed.len() >= width.div_ceil(8),
136        "unpack_mask_row: packed buffer too short ({} < {})",
137        packed.len(),
138        width.div_ceil(8),
139    );
140    debug_assert_eq!(
141        out.len(),
142        width,
143        "unpack_mask_row: out length must equal width"
144    );
145    for (i, slot) in out.iter_mut().enumerate() {
146        // `packed.get` returns None for any over-short buffer; treat missing bits as 0.
147        let byte = packed.get(i / 8).copied().unwrap_or(0);
148        let bit = (byte >> (7 - (i % 8))) & 1;
149        *slot = if bit != 0 { 255 } else { 0 };
150    }
151}
152
153// ── Shared geometry helpers ───────────────────────────────────────────────────
154
155/// Outcome of axis-aligned bounds computation for `fill_image_mask` / `draw_image`.
156struct ImageBounds {
157    x0: i32,
158    y0: i32,
159    /// Exclusive right edge (pixel column `x1 - 1` is the last painted column).
160    x1: i32,
161    /// Exclusive bottom edge.
162    y1: i32,
163    /// `true` when `matrix[3] < 0` — source rows must be flipped vertically.
164    vflip: bool,
165}
166
167/// Parse the transformation matrix and compute destination pixel bounds.
168///
169/// Returns `Ok(ImageBounds)` for axis-aligned transforms, or an
170/// `Err(ImageResult)` for singular or non-axis-aligned matrices.
171fn compute_axis_aligned_bounds(matrix: &[f64; 6]) -> Result<ImageBounds, ImageResult> {
172    if !check_det(matrix[0], matrix[1], matrix[2], matrix[3], 1e-6) {
173        return Err(ImageResult::SingularMatrix);
174    }
175    let minor_zero = matrix[1] == 0.0 && matrix[2] == 0.0;
176    if !minor_zero || matrix[0] <= 0.0 {
177        return Err(ImageResult::ArbitraryTransformSkipped);
178    }
179
180    let (y0, y1, vflip) = if matrix[3] > 0.0 {
181        (
182            coord_lower(matrix[5]),
183            coord_upper(matrix[3] + matrix[5]),
184            false,
185        )
186    } else if matrix[3] < 0.0 {
187        (
188            coord_lower(matrix[3] + matrix[5]),
189            coord_upper(matrix[5]),
190            true,
191        )
192    } else {
193        // Zero Y scale — degenerate (determinant guard catches this, but be explicit).
194        return Err(ImageResult::SingularMatrix);
195    };
196
197    let x0 = coord_lower(matrix[4]);
198    let x1 = coord_upper(matrix[0] + matrix[4]);
199
200    // Ensure at least 1×1 destination even for very small source scale.
201    let x1 = if x0 == x1 { x1 + 1 } else { x1 };
202    let y1 = if y0 == y1 { y1 + 1 } else { y1 };
203
204    Ok(ImageBounds {
205        x0,
206        y0,
207        x1,
208        y1,
209        vflip,
210    })
211}
212
213/// Flip a flat row-major buffer vertically in-place.
214///
215/// `row_stride` is the byte length of one row (`scaled_w * ncomps` for images,
216/// `scaled_w` for masks).  Rows `0` and `height-1` are swapped, then `1` and
217/// `height-2`, and so on.  A one-row or zero-row buffer is a no-op.
218fn vflip_rows(data: &mut [u8], row_stride: usize) {
219    if row_stride == 0 {
220        return;
221    }
222    let nrows = data.len() / row_stride;
223    let mut lo = 0usize;
224    let mut hi = nrows.saturating_sub(1);
225    while lo < hi {
226        // Split the slice at `hi * row_stride`; `lo`'th row lies in the lower half.
227        let (lower, upper) = data.split_at_mut(hi * row_stride);
228        lower[lo * row_stride..lo * row_stride + row_stride]
229            .swap_with_slice(&mut upper[..row_stride]);
230        lo += 1;
231        hi -= 1;
232    }
233}
234
235/// Advance a Bresenham accumulator and return the step size for this iteration.
236///
237/// Classic Bresenham integer scaling:
238/// - `acc` is the running error, updated in place.
239/// - `q` is `src % scaled` (the fractional remainder).
240/// - `scaled` is the scaled dimension (denominator).
241/// - `p` is `src / scaled` (the floor step).
242///
243/// Returns `p + 1` when the accumulator overflows, `p` otherwise.
244#[inline]
245const fn bresenham_step(acc: &mut usize, q: usize, scaled: usize, p: usize) -> usize {
246    *acc += q;
247    if *acc >= scaled {
248        *acc -= scaled;
249        p + 1
250    } else {
251        p
252    }
253}
254
255// ── Scale-kernel saturation constants ────────────────────────────────────────
256
257/// `sat_factor` for `scale_image_inner` when the destination is a mask.
258///
259/// The kernel computes `(sum * d) >> 23` where `d = sat_factor / divisor`,
260/// so a mask saturates `0..=255` input coverage values to `0..=255` output
261/// coverage. `255u32 << 23 ≈ 2.14e9`.
262const MASK_SAT_FACTOR: u32 = 255u32 << 23;
263
264/// `sat_factor` for `scale_image_inner` when the destination is a colour
265/// image. Per-channel intensity is preserved (no 255× amplification).
266const IMAGE_SAT_FACTOR: u32 = 1u32 << 23;
267
268/// Adapter that exposes a [`MaskSource`] as a single-channel [`ImageSource`].
269///
270/// The mask source produces 1-bit-packed rows; the adapter unpacks each row
271/// into `u8` (0 or 255) on demand, so the scaling kernel sees an ordinary
272/// 1-channel byte image and the same `scale_image_inner` body serves both
273/// `scale_mask` and `scale_image`.
274struct MaskAsImage<'a> {
275    mask: &'a mut dyn MaskSource,
276    /// Scratch for one packed mask row: `src_w.div_ceil(8)` bytes. Owned by
277    /// the adapter so the kernel doesn't need to know about packed-row sizing.
278    packed_buf: Vec<u8>,
279    src_w: usize,
280}
281
282impl<'a> MaskAsImage<'a> {
283    fn new(mask: &'a mut dyn MaskSource, src_w: usize) -> Self {
284        Self {
285            mask,
286            packed_buf: vec![0u8; src_w.div_ceil(8)],
287            src_w,
288        }
289    }
290}
291
292impl ImageSource for MaskAsImage<'_> {
293    fn get_row(&mut self, y: u32, row_buf: &mut [u8]) {
294        self.mask.get_row(y, &mut self.packed_buf);
295        unpack_mask_row(&self.packed_buf, self.src_w, row_buf);
296    }
297}
298
299// ── Mask scaling ──────────────────────────────────────────────────────────────
300
301/// Scale a 1-bit mask to `scaled_w × scaled_h`, producing one `u8` (0–255) per
302/// destination pixel. Exactly mirrors `Splash::scaleMask`.
303///
304/// Thin wrapper over [`scale_image_inner`] via [`MaskAsImage`].
305fn scale_mask(
306    mask_src: &mut dyn MaskSource,
307    src_w: usize,
308    src_h: usize,
309    scaled_w: usize,
310    scaled_h: usize,
311) -> Vec<u8> {
312    let mut adapter = MaskAsImage::new(mask_src, src_w);
313    scale_image_inner(
314        &mut adapter,
315        src_w,
316        src_h,
317        scaled_w,
318        scaled_h,
319        1,
320        MASK_SAT_FACTOR,
321    )
322}
323
324/// Saturating `(sum * d) >> 23` narrow to `u8`.
325///
326/// `sum * d` is widened to `u64` because the worst-case product for the
327/// mask path is `255 * 255 << 23 ≈ 5.5e11`, larger than `u32::MAX`. The
328/// `>> 23` then `min(255)` keeps the result in `u8` range regardless.
329#[inline]
330fn saturate_scaled(sum: u32, d: u32) -> u8 {
331    let scaled = ((u64::from(sum) * u64::from(d)) >> 23).min(255);
332    u8::try_from(scaled).expect("scaled box-filter pixel was just clamped to <= 255")
333}
334
335/// Box-filter divisors for the X-down kernels.
336///
337/// `d_full` is the divisor used when Bresenham picks `x_step = xp`;
338/// `d_plus_one` is used when `x_step = xp + 1`. Both are precomputed once
339/// per output row (or once per call when `y_step` is constant).
340///
341/// `xp = 0` only happens when `scaled_w > src_w` — i.e. an *upsampling*
342/// X dispatch — so the Xdown kernels never see it, but `d_full` is still
343/// defined as 0 for that case to keep the precondition straightforward.
344#[inline]
345fn xdown_divisors(sat_factor: u32, y_step: usize, xp: usize) -> (u32, u32) {
346    let d_full = if xp > 0 {
347        let denom = u32::try_from(y_step.saturating_mul(xp))
348            .expect("y_step * xp fits in u32 for practical image sizes");
349        sat_factor / denom
350    } else {
351        0
352    };
353    let denom_plus = u32::try_from(y_step.saturating_mul(xp + 1))
354        .expect("y_step * (xp+1) fits in u32 for practical image sizes");
355    let d_plus_one = sat_factor / denom_plus;
356    (d_full, d_plus_one)
357}
358
359/// Internal scaling kernel shared by [`scale_mask`] and [`scale_image`].
360///
361/// Dispatches on `(scaled_h vs src_h, scaled_w vs src_w)` to one of four
362/// corner kernels (`ydown_xdown`, `ydown_xup`, `yup_xdown`, `yup_xup`),
363/// parametrised by `ncomps` (1 for mask, N for image) and `sat_factor`
364/// (`MASK_SAT_FACTOR` for mask, `IMAGE_SAT_FACTOR` for image).
365fn scale_image_inner(
366    image_src: &mut dyn ImageSource,
367    src_w: usize,
368    src_h: usize,
369    scaled_w: usize,
370    scaled_h: usize,
371    ncomps: usize,
372    sat_factor: u32,
373) -> Vec<u8> {
374    let mut dest = vec![0u8; scaled_w * scaled_h * ncomps];
375    let mut line_buf = vec![0u8; src_w * ncomps];
376
377    if scaled_h < src_h {
378        if scaled_w < src_w {
379            scale_kernel_ydown_xdown(
380                image_src,
381                src_w,
382                src_h,
383                scaled_w,
384                scaled_h,
385                ncomps,
386                sat_factor,
387                &mut dest,
388                &mut line_buf,
389            );
390        } else {
391            scale_kernel_ydown_xup(
392                image_src,
393                src_w,
394                src_h,
395                scaled_w,
396                scaled_h,
397                ncomps,
398                sat_factor,
399                &mut dest,
400                &mut line_buf,
401            );
402        }
403    } else if scaled_w < src_w {
404        scale_kernel_yup_xdown(
405            image_src,
406            src_w,
407            src_h,
408            scaled_w,
409            scaled_h,
410            ncomps,
411            sat_factor,
412            &mut dest,
413            &mut line_buf,
414        );
415    } else {
416        scale_kernel_yup_xup(
417            image_src,
418            src_w,
419            src_h,
420            scaled_w,
421            scaled_h,
422            ncomps,
423            &mut dest,
424            &mut line_buf,
425        );
426    }
427
428    dest
429}
430
431/// Box-filter downsampling in both Y and X.
432///
433/// C++ provenance: `Splash::scaleMaskYdownXdown` (ncomps=1) and
434/// `Splash::scaleImageYdownXdown` (ncomps=N).
435#[expect(
436    clippy::too_many_arguments,
437    reason = "kernel is private; all params are necessary to share the body across mask + image"
438)]
439fn scale_kernel_ydown_xdown(
440    image_src: &mut dyn ImageSource,
441    src_w: usize,
442    src_h: usize,
443    scaled_w: usize,
444    scaled_h: usize,
445    ncomps: usize,
446    sat_factor: u32,
447    dest: &mut [u8],
448    line_buf: &mut [u8],
449) {
450    let yp = src_h / scaled_h;
451    let yq = src_h % scaled_h;
452    let xp = src_w / scaled_w;
453    let xq = src_w % scaled_w;
454
455    let mut pix_buf = vec![0u32; src_w * ncomps];
456    let mut yt = 0usize;
457    let mut dest_off = 0usize;
458    let mut src_y = 0u32;
459
460    for _dy in 0..scaled_h {
461        let y_step = bresenham_step(&mut yt, yq, scaled_h, yp);
462
463        pix_buf.fill(0);
464        for _ in 0..y_step {
465            image_src.get_row(src_y, line_buf);
466            src_y += 1;
467            for (pix, &lb) in pix_buf.iter_mut().zip(line_buf.iter()) {
468                *pix += u32::from(lb);
469            }
470        }
471
472        let (d_full, d_plus_one) = xdown_divisors(sat_factor, y_step, xp);
473
474        let mut xt = 0usize;
475        let mut xx = 0usize;
476        for _dx in 0..scaled_w {
477            let x_step = bresenham_step(&mut xt, xq, scaled_w, xp);
478            let d = if x_step == xp + 1 { d_plus_one } else { d_full };
479            for c in 0..ncomps {
480                let sum: u32 = (0..x_step).map(|i| pix_buf[(xx + i) * ncomps + c]).sum();
481                dest[dest_off + c] = saturate_scaled(sum, d);
482            }
483            xx += x_step;
484            dest_off += ncomps;
485        }
486    }
487}
488
489/// Box-filter Y downsampling, nearest-neighbor X upsampling.
490///
491/// C++ provenance: `Splash::scaleMaskYdownXup` / `Splash::scaleImageYdownXup`.
492#[expect(
493    clippy::too_many_arguments,
494    reason = "kernel is private; all params are necessary to share the body across mask + image"
495)]
496fn scale_kernel_ydown_xup(
497    image_src: &mut dyn ImageSource,
498    src_w: usize,
499    src_h: usize,
500    scaled_w: usize,
501    scaled_h: usize,
502    ncomps: usize,
503    sat_factor: u32,
504    dest: &mut [u8],
505    line_buf: &mut [u8],
506) {
507    let yp = src_h / scaled_h;
508    let yq = src_h % scaled_h;
509    let xp = scaled_w / src_w;
510    let xq = scaled_w % src_w;
511
512    let mut pix_buf = vec![0u32; src_w * ncomps];
513    let mut yt = 0usize;
514    let mut dest_off = 0usize;
515    let mut src_y = 0u32;
516
517    for _dy in 0..scaled_h {
518        let y_step = bresenham_step(&mut yt, yq, scaled_h, yp);
519
520        pix_buf.fill(0);
521        for _ in 0..y_step {
522            image_src.get_row(src_y, line_buf);
523            src_y += 1;
524            for (pix, &lb) in pix_buf.iter_mut().zip(line_buf.iter()) {
525                *pix += u32::from(lb);
526            }
527        }
528
529        let d = sat_factor
530            / u32::try_from(y_step).expect("y_step ≤ src_h fits in u32 for practical image sizes");
531        let mut xt = 0usize;
532
533        let mut pix_vals = [0u8; MAX_NCOMPS];
534        for sx in 0..src_w {
535            let x_step = bresenham_step(&mut xt, xq, src_w, xp);
536            let base = sx * ncomps;
537            for c in 0..ncomps {
538                pix_vals[c] = saturate_scaled(pix_buf[base + c], d);
539            }
540            for _ in 0..x_step {
541                dest[dest_off..dest_off + ncomps].copy_from_slice(&pix_vals[..ncomps]);
542                dest_off += ncomps;
543            }
544        }
545    }
546}
547
548/// Nearest-neighbor Y upsampling, box-filter X downsampling.
549///
550/// C++ provenance: `Splash::scaleMaskYupXdown` / `Splash::scaleImageYupXdown`.
551#[expect(
552    clippy::too_many_arguments,
553    reason = "kernel is private; all params are necessary to share the body across mask + image"
554)]
555fn scale_kernel_yup_xdown(
556    image_src: &mut dyn ImageSource,
557    src_w: usize,
558    src_h: usize,
559    scaled_w: usize,
560    scaled_h: usize,
561    ncomps: usize,
562    sat_factor: u32,
563    dest: &mut [u8],
564    line_buf: &mut [u8],
565) {
566    let yp = scaled_h / src_h;
567    let yq = scaled_h % src_h;
568    let xp = src_w / scaled_w;
569    let xq = src_w % scaled_w;
570
571    // y_step factor is 1 for this kernel — the divisors don't depend on Y.
572    let (d_full, d_plus_one) = xdown_divisors(sat_factor, 1, xp);
573
574    let mut yt = 0usize;
575    let mut dest_off = 0usize;
576
577    for sy in 0..src_h {
578        let y_step = bresenham_step(&mut yt, yq, src_h, yp);
579
580        let src_y = u32::try_from(sy)
581            .expect("source row index ≤ src_h fits in u32 for practical image sizes");
582        image_src.get_row(src_y, line_buf);
583
584        let row_start = dest_off;
585        let mut xt = 0usize;
586        let mut xx = 0usize;
587
588        for dx in 0..scaled_w {
589            let x_step = bresenham_step(&mut xt, xq, scaled_w, xp);
590            let d = if x_step == xp + 1 { d_plus_one } else { d_full };
591            for c in 0..ncomps {
592                let sum: u32 = (0..x_step)
593                    .map(|i| u32::from(line_buf[(xx + i) * ncomps + c]))
594                    .sum();
595                dest[row_start + dx * ncomps + c] = saturate_scaled(sum, d);
596            }
597            xx += x_step;
598        }
599        dest_off += scaled_w * ncomps;
600
601        for i in 1..y_step {
602            dest.copy_within(
603                row_start..row_start + scaled_w * ncomps,
604                row_start + i * scaled_w * ncomps,
605            );
606        }
607        dest_off += (y_step - 1) * scaled_w * ncomps;
608    }
609}
610
611/// Nearest-neighbor upsampling in both axes.
612///
613/// C++ provenance: `Splash::scaleMaskYupXup` / `Splash::scaleImageYupXup`.
614/// No saturation is needed — each output pixel is a direct copy of one source
615/// pixel. (For the mask path, source bytes are already 0 or 255 by
616/// construction in [`unpack_mask_row`].)
617#[expect(
618    clippy::too_many_arguments,
619    reason = "kernel is private; all params are necessary to share the body across mask + image"
620)]
621fn scale_kernel_yup_xup(
622    image_src: &mut dyn ImageSource,
623    src_w: usize,
624    src_h: usize,
625    scaled_w: usize,
626    scaled_h: usize,
627    ncomps: usize,
628    dest: &mut [u8],
629    line_buf: &mut [u8],
630) {
631    let yp = scaled_h / src_h;
632    let yq = scaled_h % src_h;
633    let xp = scaled_w / src_w;
634    let xq = scaled_w % src_w;
635
636    let mut yt = 0usize;
637    let mut dest_off = 0usize;
638
639    for sy in 0..src_h {
640        let y_step = bresenham_step(&mut yt, yq, src_h, yp);
641
642        let src_y = u32::try_from(sy)
643            .expect("source row index ≤ src_h fits in u32 for practical image sizes");
644        image_src.get_row(src_y, line_buf);
645
646        let row_start = dest_off;
647        let mut xt = 0usize;
648        let mut xx = 0usize;
649
650        for sx in 0..src_w {
651            let x_step = bresenham_step(&mut xt, xq, src_w, xp);
652            let pix_start = sx * ncomps;
653            for j in 0..x_step {
654                let off = row_start + (xx + j) * ncomps;
655                dest[off..off + ncomps].copy_from_slice(&line_buf[pix_start..pix_start + ncomps]);
656            }
657            xx += x_step;
658        }
659        dest_off += scaled_w * ncomps;
660
661        for i in 1..y_step {
662            dest.copy_within(
663                row_start..row_start + scaled_w * ncomps,
664                row_start + i * scaled_w * ncomps,
665            );
666        }
667        dest_off += (y_step - 1) * scaled_w * ncomps;
668    }
669}
670
671// ── Image scaling ─────────────────────────────────────────────────────────────
672
673/// Scale a colour image to `scaled_w × scaled_h`.
674///
675/// Output is `scaled_w * scaled_h * ncomps` bytes (row-major, no padding).
676/// Thin wrapper over [`scale_image_inner`].
677fn scale_image(
678    image_src: &mut dyn ImageSource,
679    src_w: usize,
680    src_h: usize,
681    scaled_w: usize,
682    scaled_h: usize,
683    ncomps: usize,
684) -> Vec<u8> {
685    scale_image_inner(
686        image_src,
687        src_w,
688        src_h,
689        scaled_w,
690        scaled_h,
691        ncomps,
692        IMAGE_SAT_FACTOR,
693    )
694}
695
696// ── Row-as-pattern helper ─────────────────────────────────────────────────────
697
698/// A [`crate::pipe::Pattern`] that serves one pre-scaled image row.
699///
700/// `data` must be exactly `(x1 - x0 + 1) * P::BYTES` bytes — the same length
701/// as the `out` buffer that `render_span` will pass to `fill_span`.  The
702/// caller (`blit_image`) guarantees this because it slices `scaled_img` with
703/// `count * ncomps` where `ncomps == P::BYTES` (enforced by `draw_image`'s
704/// `debug_assert`).
705struct ImageRowPattern<'a> {
706    /// Pixel bytes for the visible span of one destination row.
707    data: &'a [u8],
708}
709
710impl crate::pipe::Pattern for ImageRowPattern<'_> {
711    fn fill_span(&self, _y: i32, _x0: i32, _x1: i32, out: &mut [u8]) {
712        assert_eq!(
713            out.len(),
714            self.data.len(),
715            "ImageRowPattern::fill_span: out.len()={} != data.len()={} \
716             (ncomps/P::BYTES mismatch — check draw_image caller)",
717            out.len(),
718            self.data.len(),
719        );
720        out.copy_from_slice(self.data);
721    }
722
723    fn is_static_color(&self) -> bool {
724        false
725    }
726}
727
728// ── Mask blitting ─────────────────────────────────────────────────────────────
729
730/// Blit a pre-scaled mask (one `u8` coverage per pixel) onto the bitmap.
731///
732/// For each set pixel (coverage > 0) the fill pattern is applied through the
733/// compositing pipe using `shape = coverage`.  This mirrors `Splash::blitMask`
734/// in the non-AA path.
735#[expect(
736    clippy::too_many_arguments,
737    reason = "mirrors Splash::blitMask API; all params necessary"
738)]
739#[expect(
740    clippy::cast_possible_truncation,
741    clippy::cast_possible_wrap,
742    reason = "run_shape.len() / scaled_w / scaled_h ≤ bitmap dims ≤ i32::MAX for practical image sizes"
743)]
744fn blit_mask<P: Pixel>(
745    bitmap: &mut Bitmap<P>,
746    clip: &Clip,
747    pipe: &PipeState<'_>,
748    src: &PipeSrc<'_>,
749    scaled_mask: &[u8],
750    scaled_w: i32,
751    scaled_h: i32,
752    x_dest: i32,
753    y_dest: i32,
754    clip_all_inside: bool,
755) {
756    #[expect(
757        clippy::cast_possible_wrap,
758        reason = "bitmap dims ≤ i32::MAX in practice"
759    )]
760    let bmp_w = bitmap.width as i32;
761    #[expect(
762        clippy::cast_possible_wrap,
763        reason = "bitmap dims ≤ i32::MAX in practice"
764    )]
765    let bmp_h = bitmap.height as i32;
766
767    for dy in 0..scaled_h {
768        let y = y_dest + dy;
769        if y < 0 || y >= bmp_h {
770            continue;
771        }
772        #[expect(clippy::cast_sign_loss, reason = "dy ≥ 0")]
773        let row_off = dy as usize * scaled_w as usize;
774        #[expect(clippy::cast_sign_loss, reason = "y ≥ 0 by guard above")]
775        let y_u = y as u32;
776
777        let mut run_start: Option<i32> = None;
778        let mut run_shape: Vec<u8> = Vec::new();
779
780        macro_rules! flush_run {
781            () => {
782                if let Some(rs) = run_start.take() {
783                    let rx1 = rs + run_shape.len() as i32 - 1;
784                    #[expect(clippy::cast_sign_loss, reason = "rs ≥ 0")]
785                    let byte_off = rs as usize * P::BYTES;
786                    #[expect(clippy::cast_sign_loss, reason = "rx1 ≥ rs ≥ 0")]
787                    let byte_end = (rx1 as usize + 1) * P::BYTES;
788                    #[expect(clippy::cast_sign_loss, reason = "rs ≥ 0")]
789                    let alpha_lo = rs as usize;
790                    #[expect(clippy::cast_sign_loss, reason = "rx1 ≥ rs ≥ 0")]
791                    let alpha_hi = rx1 as usize;
792                    let (row, alpha) = bitmap.row_and_alpha_mut(y_u);
793                    let dst_pixels = &mut row[byte_off..byte_end];
794                    let dst_alpha = alpha.map(|a| &mut a[alpha_lo..=alpha_hi]);
795                    pipe::render_span::<P>(
796                        pipe,
797                        src,
798                        dst_pixels,
799                        dst_alpha,
800                        Some(&run_shape),
801                        rs,
802                        rx1,
803                        y,
804                    );
805                    run_shape.clear();
806                }
807            };
808        }
809
810        for dx in 0..scaled_w {
811            let x = x_dest + dx;
812            if x < 0 || x >= bmp_w {
813                flush_run!();
814                continue;
815            }
816            #[expect(clippy::cast_sign_loss, reason = "dx ≥ 0")]
817            let coverage = scaled_mask[row_off + dx as usize];
818            let inside_clip = clip_all_inside || clip.test(x, y);
819
820            if coverage > 0 && inside_clip {
821                if run_start.is_none() {
822                    run_start = Some(x);
823                }
824                run_shape.push(coverage);
825            } else {
826                flush_run!();
827            }
828        }
829        flush_run!();
830    }
831}
832
833// ── Image blitting ────────────────────────────────────────────────────────────
834
835/// Emit one contiguous span of pre-scaled image pixels through the pipe.
836///
837/// `img_row` is the full scaled row for scanline `y`; `x_src_off` is the
838/// pixel offset into that row for pixel `x0`.  `x0`/`x1` are inclusive
839/// destination coordinates and must be within bitmap bounds.
840#[expect(
841    clippy::too_many_arguments,
842    reason = "all context is necessary for a span emit"
843)]
844fn emit_image_span<P: Pixel>(
845    bitmap: &mut Bitmap<P>,
846    pipe: &PipeState<'_>,
847    img_row: &[u8],
848    ncomps: usize,
849    x_src_off: usize,
850    x0: i32,
851    x1: i32,
852    y: i32,
853) {
854    #[expect(clippy::cast_sign_loss, reason = "x1 ≥ x0 ≥ 0 by caller invariant")]
855    let count = (x1 - x0 + 1) as usize;
856    let data = &img_row[x_src_off * ncomps..(x_src_off + count) * ncomps];
857    let row_src = PipeSrc::Pattern(&ImageRowPattern { data });
858    #[expect(clippy::cast_sign_loss, reason = "x0 ≥ 0 by caller invariant")]
859    let byte_off = x0 as usize * P::BYTES;
860    #[expect(clippy::cast_sign_loss, reason = "x1 ≥ x0 ≥ 0 by caller invariant")]
861    let byte_end = (x1 as usize + 1) * P::BYTES;
862    #[expect(clippy::cast_sign_loss, reason = "x0 ≥ 0 by caller invariant")]
863    let (row, alpha) = bitmap.row_and_alpha_mut(y as u32);
864    let dst_pixels = &mut row[byte_off..byte_end];
865    #[expect(clippy::cast_sign_loss, reason = "x0/x1 ≥ 0 by caller invariant")]
866    let dst_alpha = alpha.map(|a| &mut a[x0 as usize..=x1 as usize]);
867    pipe::render_span::<P>(pipe, &row_src, dst_pixels, dst_alpha, None, x0, x1, y);
868}
869
870/// Blit a pre-scaled colour image onto the bitmap.
871///
872/// The entire row (or per-pixel runs for partial clip) is emitted through
873/// `render_span`.  Mirrors `Splash::blitImage` non-AA path.
874///
875/// # Panics (debug)
876///
877/// Asserts `ncomps == P::BYTES`; a mismatch means the scaled image buffer was
878/// built with the wrong component count for the destination pixel format.
879#[expect(
880    clippy::too_many_arguments,
881    reason = "mirrors Splash::blitImage API; all params necessary"
882)]
883fn blit_image<P: Pixel>(
884    bitmap: &mut Bitmap<P>,
885    clip: &Clip,
886    pipe: &PipeState<'_>,
887    scaled_img: &[u8],
888    scaled_w: i32,
889    scaled_h: i32,
890    x_dest: i32,
891    y_dest: i32,
892    clip_res: ClipResult,
893) {
894    let ncomps = P::BYTES;
895    debug_assert!(
896        ncomps <= MAX_NCOMPS,
897        "blit_image: P::BYTES={ncomps} exceeds MAX_NCOMPS={MAX_NCOMPS}",
898    );
899
900    #[expect(
901        clippy::cast_possible_wrap,
902        reason = "bitmap dims ≤ i32::MAX in practice"
903    )]
904    let bmp_w = bitmap.width as i32;
905    #[expect(
906        clippy::cast_possible_wrap,
907        reason = "bitmap dims ≤ i32::MAX in practice"
908    )]
909    let bmp_h = bitmap.height as i32;
910
911    let clip_all_inside = clip_res == ClipResult::AllInside;
912
913    for dy in 0..scaled_h {
914        let y = y_dest + dy;
915        if y < 0 || y >= bmp_h {
916            continue;
917        }
918        #[expect(clippy::cast_sign_loss, reason = "dy ≥ 0 and scaled_w ≥ 0")]
919        let img_row_off = dy as usize * scaled_w as usize * ncomps;
920        #[expect(
921            clippy::cast_sign_loss,
922            reason = "scaled_w ≥ 0 (it is the dest rect width)"
923        )]
924        let img_row = &scaled_img[img_row_off..img_row_off + scaled_w as usize * ncomps];
925
926        let x_lo = x_dest.max(0);
927        let x_hi = (x_dest + scaled_w - 1).min(bmp_w - 1);
928        if x_lo > x_hi {
929            continue;
930        }
931
932        if clip_all_inside {
933            #[expect(clippy::cast_sign_loss, reason = "x_lo ≥ x_dest ≥ 0 after clamp")]
934            let x_src_off = (x_lo - x_dest) as usize;
935            emit_image_span::<P>(bitmap, pipe, img_row, ncomps, x_src_off, x_lo, x_hi, y);
936        } else {
937            // Partial clip: walk pixels left-to-right, collecting contiguous
938            // clip-passing runs and emitting each run as a single span.
939            let mut run_x0: Option<i32> = None;
940            let mut run_x1 = x_lo; // last pixel appended to the current run
941
942            for dx in 0..scaled_w {
943                let x = x_dest + dx;
944                let in_bmp = x >= x_lo && x <= x_hi;
945                let visible = in_bmp && clip.test(x, y);
946
947                if visible {
948                    if run_x0.is_none() {
949                        run_x0 = Some(x);
950                    }
951                    run_x1 = x;
952                } else if let Some(x0) = run_x0.take() {
953                    // Run ended: emit it.
954                    #[expect(clippy::cast_sign_loss, reason = "x0 ≥ x_dest ≥ 0 inside bmp bounds")]
955                    let x_src_off = (x0 - x_dest) as usize;
956                    emit_image_span::<P>(bitmap, pipe, img_row, ncomps, x_src_off, x0, run_x1, y);
957                }
958            }
959            // Emit any run still open at the end of the row.
960            if let Some(x0) = run_x0 {
961                #[expect(clippy::cast_sign_loss, reason = "x0 ≥ x_dest ≥ 0 inside bmp bounds")]
962                let x_src_off = (x0 - x_dest) as usize;
963                emit_image_span::<P>(bitmap, pipe, img_row, ncomps, x_src_off, x0, run_x1, y);
964            }
965        }
966    }
967}
968
969// ─────────────────────────────────────────────────────────────────────────────
970// Public entry points
971// ─────────────────────────────────────────────────────────────────────────────
972
973/// Fill a 1-bit image mask using the current fill pattern.
974///
975/// Only axis-aligned transforms (`matrix[1] == 0 && matrix[2] == 0`,
976/// `matrix[0] > 0`) are implemented.  For rotated or skewed matrices
977/// [`ImageResult::ArbitraryTransformSkipped`] is returned so the caller can
978/// use a general path.
979///
980/// The mask is scaled to the destination pixel grid using Bresenham
981/// box-filter (downscale) or nearest-neighbour (upscale).
982///
983/// # C++ equivalent
984///
985/// `Splash::fillImageMask`.
986#[expect(
987    clippy::too_many_arguments,
988    reason = "mirrors Splash::fillImageMask API; all params necessary"
989)]
990pub fn fill_image_mask<P: Pixel>(
991    bitmap: &mut Bitmap<P>,
992    clip: &Clip,
993    pipe: &PipeState<'_>,
994    src: &PipeSrc<'_>,
995    mask_src: &mut dyn MaskSource,
996    src_w: u32,
997    src_h: u32,
998    matrix: &[f64; 6],
999) -> ImageResult {
1000    if src_w == 0 || src_h == 0 {
1001        return ImageResult::ZeroImage;
1002    }
1003
1004    let bounds = match compute_axis_aligned_bounds(matrix) {
1005        Ok(b) => b,
1006        Err(e) => return e,
1007    };
1008    let ImageBounds {
1009        x0,
1010        y0,
1011        x1,
1012        y1,
1013        vflip,
1014    } = bounds;
1015
1016    let clip_res = clip.test_rect(x0, y0, x1 - 1, y1 - 1);
1017    if clip_res == ClipResult::AllOutside {
1018        return ImageResult::Ok;
1019    }
1020
1021    #[expect(
1022        clippy::cast_sign_loss,
1023        reason = "x1 > x0 is guaranteed by compute_axis_aligned_bounds"
1024    )]
1025    let scaled_w = (x1 - x0) as usize;
1026    #[expect(
1027        clippy::cast_sign_loss,
1028        reason = "y1 > y0 is guaranteed by compute_axis_aligned_bounds"
1029    )]
1030    let scaled_h = (y1 - y0) as usize;
1031
1032    let mut scaled = scale_mask(mask_src, src_w as usize, src_h as usize, scaled_w, scaled_h);
1033
1034    if vflip {
1035        vflip_rows(&mut scaled, scaled_w);
1036    }
1037
1038    blit_mask::<P>(
1039        bitmap,
1040        clip,
1041        pipe,
1042        src,
1043        &scaled,
1044        #[expect(
1045            clippy::cast_possible_truncation,
1046            clippy::cast_possible_wrap,
1047            reason = "scaled_w ≤ bitmap.width ≤ i32::MAX"
1048        )]
1049        {
1050            scaled_w as i32
1051        },
1052        #[expect(
1053            clippy::cast_possible_truncation,
1054            clippy::cast_possible_wrap,
1055            reason = "scaled_h ≤ bitmap.height ≤ i32::MAX"
1056        )]
1057        {
1058            scaled_h as i32
1059        },
1060        x0,
1061        y0,
1062        clip_res == ClipResult::AllInside,
1063    );
1064
1065    ImageResult::Ok
1066}
1067
1068/// Render a colour image with transformation.
1069///
1070/// Only axis-aligned transforms are handled (Phase 2 scope).  For rotated or
1071/// skewed matrices [`ImageResult::ArbitraryTransformSkipped`] is returned.
1072///
1073/// `src_mode` conveys the colour space of the source data; it is stored for
1074/// future use in colour-space conversion but not acted on in Phase 2 — the
1075/// caller is responsible for ensuring `ncomps == P::BYTES`.
1076///
1077/// # Panics (debug)
1078///
1079/// Asserts `ncomps == P::BYTES`.  In release builds a mismatch produces
1080/// silently wrong colours; callers must match pixel formats.
1081///
1082/// # C++ equivalent
1083///
1084/// `Splash::drawImage`.
1085#[expect(
1086    clippy::too_many_arguments,
1087    reason = "mirrors Splash::drawImage API; all params necessary"
1088)]
1089pub fn draw_image<P: Pixel>(
1090    bitmap: &mut Bitmap<P>,
1091    clip: &Clip,
1092    pipe: &PipeState<'_>,
1093    image_src: &mut dyn ImageSource,
1094    src_mode: PixelMode,
1095    src_w: u32,
1096    src_h: u32,
1097    matrix: &[f64; 6],
1098) -> ImageResult {
1099    // Record src_mode for future colour-space conversion; unused in Phase 2.
1100    let _ = src_mode;
1101
1102    let ncomps = P::BYTES;
1103    debug_assert!(
1104        ncomps <= MAX_NCOMPS,
1105        "draw_image: P::BYTES={ncomps} exceeds MAX_NCOMPS={MAX_NCOMPS}",
1106    );
1107
1108    if src_w == 0 || src_h == 0 {
1109        return ImageResult::ZeroImage;
1110    }
1111
1112    let bounds = match compute_axis_aligned_bounds(matrix) {
1113        Ok(b) => b,
1114        Err(e) => return e,
1115    };
1116    let ImageBounds {
1117        x0,
1118        y0,
1119        x1,
1120        y1,
1121        vflip,
1122    } = bounds;
1123
1124    let clip_res = clip.test_rect(x0, y0, x1 - 1, y1 - 1);
1125    if clip_res == ClipResult::AllOutside {
1126        return ImageResult::Ok;
1127    }
1128
1129    #[expect(
1130        clippy::cast_sign_loss,
1131        reason = "x1 > x0 is guaranteed by compute_axis_aligned_bounds"
1132    )]
1133    let scaled_w = (x1 - x0) as usize;
1134    #[expect(
1135        clippy::cast_sign_loss,
1136        reason = "y1 > y0 is guaranteed by compute_axis_aligned_bounds"
1137    )]
1138    let scaled_h = (y1 - y0) as usize;
1139
1140    let mut scaled = scale_image(
1141        image_src,
1142        src_w as usize,
1143        src_h as usize,
1144        scaled_w,
1145        scaled_h,
1146        ncomps,
1147    );
1148
1149    if vflip {
1150        vflip_rows(&mut scaled, scaled_w * ncomps);
1151    }
1152
1153    blit_image::<P>(
1154        bitmap,
1155        clip,
1156        pipe,
1157        &scaled,
1158        #[expect(
1159            clippy::cast_possible_truncation,
1160            clippy::cast_possible_wrap,
1161            reason = "scaled_w ≤ bitmap.width ≤ i32::MAX"
1162        )]
1163        {
1164            scaled_w as i32
1165        },
1166        #[expect(
1167            clippy::cast_possible_truncation,
1168            clippy::cast_possible_wrap,
1169            reason = "scaled_h ≤ bitmap.height ≤ i32::MAX"
1170        )]
1171        {
1172            scaled_h as i32
1173        },
1174        x0,
1175        y0,
1176        clip_res,
1177    );
1178
1179    ImageResult::Ok
1180}
1181
1182// ─────────────────────────────────────────────────────────────────────────────
1183// Tests
1184// ─────────────────────────────────────────────────────────────────────────────
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::*;
1189    use crate::bitmap::Bitmap;
1190    use crate::clip::Clip;
1191    use crate::pipe::PipeSrc;
1192    use crate::testutil::{make_clip, simple_pipe};
1193    use color::Rgb8;
1194
1195    // ── MaskSource / ImageSource stubs ────────────────────────────────────────
1196
1197    /// A `MaskSource` that delivers a solid white (all-set) 1-bit mask.
1198    struct SolidMask;
1199    impl MaskSource for SolidMask {
1200        fn get_row(&mut self, _y: u32, row_buf: &mut [u8]) {
1201            row_buf.fill(0xFF);
1202        }
1203    }
1204
1205    /// A `MaskSource` that delivers alternating byte pattern.
1206    struct CheckerMask;
1207    impl MaskSource for CheckerMask {
1208        fn get_row(&mut self, _y: u32, row_buf: &mut [u8]) {
1209            for (i, b) in row_buf.iter_mut().enumerate() {
1210                *b = if i % 2 == 0 { 0xAA } else { 0x55 };
1211            }
1212        }
1213    }
1214
1215    /// An `ImageSource` that delivers a solid colour.
1216    struct SolidColor {
1217        r: u8,
1218        g: u8,
1219        b: u8,
1220    }
1221    impl ImageSource for SolidColor {
1222        fn get_row(&mut self, _y: u32, row_buf: &mut [u8]) {
1223            for chunk in row_buf.chunks_exact_mut(3) {
1224                chunk[0] = self.r;
1225                chunk[1] = self.g;
1226                chunk[2] = self.b;
1227            }
1228        }
1229    }
1230
1231    // ── Tests ─────────────────────────────────────────────────────────────────
1232
1233    /// A 4×4 solid-white mask scaled 1:1 onto an 8×8 canvas should paint a
1234    /// 4×4 rectangle of the fill colour.
1235    #[test]
1236    fn fill_image_mask_solid_paints_rect() {
1237        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1238        let clip = make_clip(8, 8);
1239        let pipe = simple_pipe();
1240        let color = [255u8, 0, 0]; // red
1241        let src = PipeSrc::Solid(&color);
1242
1243        let mut mask = SolidMask;
1244        // mat: scale 4 → 4, translate to (2,2): [4,0,0,4,2,2]
1245        let mat = [4.0f64, 0.0, 0.0, 4.0, 2.0, 2.0];
1246        let result = fill_image_mask::<Rgb8>(&mut bmp, &clip, &pipe, &src, &mut mask, 4, 4, &mat);
1247
1248        assert_eq!(result, ImageResult::Ok);
1249        for y in 2..6u32 {
1250            for x in 2..6usize {
1251                assert_eq!(bmp.row(y)[x].r, 255, "row={y} col={x}");
1252            }
1253        }
1254        assert_eq!(bmp.row(0)[0].r, 0, "outside should be unpainted");
1255    }
1256
1257    /// Vertically-flipped mask completes without panic.
1258    #[test]
1259    fn fill_image_mask_vflip_no_crash() {
1260        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1261        let clip = make_clip(8, 8);
1262        let pipe = simple_pipe();
1263        let color = [0u8, 255, 0];
1264        let src = PipeSrc::Solid(&color);
1265
1266        let mut mask = SolidMask;
1267        // mat[3] < 0 → vflip
1268        let mat = [4.0f64, 0.0, 0.0, -4.0, 2.0, 6.0];
1269        let result = fill_image_mask::<Rgb8>(&mut bmp, &clip, &pipe, &src, &mut mask, 4, 4, &mat);
1270        assert_eq!(result, ImageResult::Ok);
1271    }
1272
1273    /// Singular matrix → `SingularMatrix` result.
1274    #[test]
1275    fn fill_image_mask_singular_matrix() {
1276        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1277        let clip = make_clip(8, 8);
1278        let pipe = simple_pipe();
1279        let color = [0u8, 0, 0];
1280        let src = PipeSrc::Solid(&color);
1281        let mut mask = SolidMask;
1282
1283        let mat = [0.0f64, 0.0, 0.0, 0.0, 0.0, 0.0]; // det = 0
1284        let result = fill_image_mask::<Rgb8>(&mut bmp, &clip, &pipe, &src, &mut mask, 4, 4, &mat);
1285        assert_eq!(result, ImageResult::SingularMatrix);
1286    }
1287
1288    /// `draw_image` with an identity-scale matrix paints a solid-colour rectangle.
1289    #[test]
1290    fn draw_image_solid_color() {
1291        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1292        let clip = make_clip(8, 8);
1293        let pipe = simple_pipe();
1294
1295        let mut img_src = SolidColor { r: 0, g: 0, b: 200 };
1296        let mat = [4.0f64, 0.0, 0.0, 4.0, 0.0, 0.0];
1297        let result = draw_image::<Rgb8>(
1298            &mut bmp,
1299            &clip,
1300            &pipe,
1301            &mut img_src,
1302            crate::types::PixelMode::Rgb8,
1303            4,
1304            4,
1305            &mat,
1306        );
1307
1308        assert_eq!(result, ImageResult::Ok);
1309        // mat=[4,0,0,4,0,0]: coord_upper(4+0)=5, so dest covers rows/cols 0..5 (5 pixels).
1310        for y in 0..5u32 {
1311            for x in 0..5usize {
1312                assert_eq!(bmp.row(y)[x].b, 200, "row={y} col={x}");
1313            }
1314        }
1315        // Row 6 (beyond the 5-pixel extent) should be unpainted.
1316        assert_eq!(bmp.row(6)[0].b, 0, "row 6 should be unpainted");
1317    }
1318
1319    /// Arbitrary-transform matrix → `ArbitraryTransformSkipped`.
1320    #[test]
1321    fn draw_image_arbitrary_transform_skipped() {
1322        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1323        let clip = make_clip(8, 8);
1324        let pipe = simple_pipe();
1325        let mut img_src = SolidColor {
1326            r: 100,
1327            g: 100,
1328            b: 100,
1329        };
1330
1331        let mat = [2.0f64, 1.0, 1.0, 2.0, 0.0, 0.0];
1332        let result = draw_image::<Rgb8>(
1333            &mut bmp,
1334            &clip,
1335            &pipe,
1336            &mut img_src,
1337            crate::types::PixelMode::Rgb8,
1338            4,
1339            4,
1340            &mat,
1341        );
1342        assert_eq!(result, ImageResult::ArbitraryTransformSkipped);
1343    }
1344
1345    /// Zero-size image → `ZeroImage`.
1346    #[test]
1347    fn draw_image_zero_size() {
1348        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1349        let clip = make_clip(8, 8);
1350        let pipe = simple_pipe();
1351        let mut img_src = SolidColor { r: 1, g: 2, b: 3 };
1352
1353        let mat = [4.0f64, 0.0, 0.0, 4.0, 0.0, 0.0];
1354        let result = draw_image::<Rgb8>(
1355            &mut bmp,
1356            &clip,
1357            &pipe,
1358            &mut img_src,
1359            crate::types::PixelMode::Rgb8,
1360            0,
1361            4,
1362            &mat,
1363        );
1364        assert_eq!(result, ImageResult::ZeroImage);
1365    }
1366
1367    /// Upsampling: 2×2 source → 4×4 destination.
1368    #[test]
1369    fn draw_image_upsample_2x2_to_4x4() {
1370        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1371        let clip = make_clip(8, 8);
1372        let pipe = simple_pipe();
1373
1374        let mut img_src = SolidColor {
1375            r: 128,
1376            g: 64,
1377            b: 32,
1378        };
1379        let mat = [4.0f64, 0.0, 0.0, 4.0, 0.0, 0.0];
1380        let result = draw_image::<Rgb8>(
1381            &mut bmp,
1382            &clip,
1383            &pipe,
1384            &mut img_src,
1385            crate::types::PixelMode::Rgb8,
1386            2,
1387            2,
1388            &mat,
1389        );
1390        assert_eq!(result, ImageResult::Ok);
1391        for y in 0..4u32 {
1392            for x in 0..4usize {
1393                assert_eq!(bmp.row(y)[x].r, 128, "row={y} col={x} R");
1394                assert_eq!(bmp.row(y)[x].g, 64, "row={y} col={x} G");
1395                assert_eq!(bmp.row(y)[x].b, 32, "row={y} col={x} B");
1396            }
1397        }
1398    }
1399
1400    /// Downsampling: 4×4 source → 2×2 destination (solid colour averages to itself).
1401    #[test]
1402    fn draw_image_downsample_4x4_to_2x2() {
1403        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1404        let clip = make_clip(8, 8);
1405        let pipe = simple_pipe();
1406
1407        let mut img_src = SolidColor {
1408            r: 200,
1409            g: 100,
1410            b: 50,
1411        };
1412        let mat = [2.0f64, 0.0, 0.0, 2.0, 0.0, 0.0];
1413        let result = draw_image::<Rgb8>(
1414            &mut bmp,
1415            &clip,
1416            &pipe,
1417            &mut img_src,
1418            crate::types::PixelMode::Rgb8,
1419            4,
1420            4,
1421            &mat,
1422        );
1423        assert_eq!(result, ImageResult::Ok);
1424        for y in 0..2u32 {
1425            for x in 0..2usize {
1426                assert_eq!(bmp.row(y)[x].r, 200, "row={y} col={x} R");
1427            }
1428        }
1429    }
1430
1431    /// Mask with checker pattern: some pixels painted, some not.
1432    #[test]
1433    fn fill_image_mask_checker_partial() {
1434        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1435        let clip = make_clip(8, 8);
1436        let pipe = simple_pipe();
1437        let color = [255u8, 255, 0]; // yellow
1438        let src = PipeSrc::Solid(&color);
1439
1440        let mut mask = CheckerMask;
1441        let mat = [8.0f64, 0.0, 0.0, 8.0, 0.0, 0.0];
1442        let result = fill_image_mask::<Rgb8>(&mut bmp, &clip, &pipe, &src, &mut mask, 8, 8, &mat);
1443        assert_eq!(result, ImageResult::Ok);
1444        let any_painted = (0..8u32).any(|y| (0..8usize).any(|x| bmp.row(y)[x].r > 0));
1445        assert!(any_painted, "at least some pixels must be painted");
1446    }
1447
1448    /// `unpack_mask_row` correctness: 0xAA = 10101010 binary.
1449    #[test]
1450    fn unpack_mask_row_aa() {
1451        let packed = [0xAAu8];
1452        let mut out = [0u8; 8];
1453        unpack_mask_row(&packed, 8, &mut out);
1454        assert_eq!(out, [255, 0, 255, 0, 255, 0, 255, 0]);
1455    }
1456
1457    /// `scale_mask` identity: 4×1 source → 4×1 dest, solid white.
1458    #[test]
1459    fn scale_mask_identity_solid() {
1460        struct IdentityMask;
1461        impl MaskSource for IdentityMask {
1462            fn get_row(&mut self, _y: u32, row_buf: &mut [u8]) {
1463                row_buf.fill(0xFF);
1464            }
1465        }
1466        let mut ms = IdentityMask;
1467        let out = scale_mask(&mut ms, 4, 1, 4, 1);
1468        assert_eq!(out, [255u8, 255, 255, 255]);
1469    }
1470
1471    /// `vflip_rows` with stride 2: rows [A,B,C] become [C,B,A].
1472    #[test]
1473    fn vflip_rows_three_rows() {
1474        let mut data = vec![
1475            1u8, 2, // row 0
1476            3, 4, // row 1
1477            5, 6,
1478        ]; // row 2
1479        vflip_rows(&mut data, 2);
1480        assert_eq!(data, [5, 6, 3, 4, 1, 2]);
1481    }
1482
1483    /// `vflip_rows` with a single row is a no-op.
1484    #[test]
1485    fn vflip_rows_single_row_noop() {
1486        let mut data = vec![7u8, 8, 9];
1487        vflip_rows(&mut data, 3);
1488        assert_eq!(data, [7, 8, 9]);
1489    }
1490
1491    /// `vflip_rows` with an empty slice is a no-op (does not panic).
1492    #[test]
1493    fn vflip_rows_empty_noop() {
1494        let mut data: Vec<u8> = vec![];
1495        vflip_rows(&mut data, 1);
1496        assert!(data.is_empty());
1497    }
1498
1499    /// Vertical flip of a 2-row image actually reverses row order.
1500    #[test]
1501    fn draw_image_vflip_reverses_rows() {
1502        // Source: row 0 = red (255,0,0), row 1 = blue (0,0,255).
1503        struct TwoRowImage;
1504        impl ImageSource for TwoRowImage {
1505            fn get_row(&mut self, y: u32, row_buf: &mut [u8]) {
1506                let (r, g, b) = if y == 0 { (255, 0, 0) } else { (0, 0, 255) };
1507                for chunk in row_buf.chunks_exact_mut(3) {
1508                    chunk[0] = r;
1509                    chunk[1] = g;
1510                    chunk[2] = b;
1511                }
1512            }
1513        }
1514
1515        let mut bmp: Bitmap<Rgb8> = Bitmap::new(4, 4, 1, false);
1516        let clip = make_clip(4, 4);
1517        let pipe = simple_pipe();
1518
1519        // mat=[2,0,0,-2,0,2]: positive X scale 2, negative Y scale -2 (vflip), translate (0,2).
1520        // Y bounds: coord_lower(-2+2)=0, coord_upper(2)=3 → rows 0..3.
1521        let mat = [2.0f64, 0.0, 0.0, -2.0, 0.0, 2.0];
1522        let result = draw_image::<Rgb8>(
1523            &mut bmp,
1524            &clip,
1525            &pipe,
1526            &mut TwoRowImage,
1527            crate::types::PixelMode::Rgb8,
1528            2,
1529            2,
1530            &mat,
1531        );
1532        assert_eq!(result, ImageResult::Ok);
1533        // After vflip: source row 1 (blue) maps to dest top, row 0 (red) to dest bottom.
1534        // Dest rows 0..1 should be blue (0,0,255), rows 2..3 should be red (255,0,0).
1535        // (Exact row mapping depends on Bresenham split; just verify both colours appear.)
1536        let has_red = (0..3u32).any(|y| bmp.row(y)[0].r == 255 && bmp.row(y)[0].b == 0);
1537        let has_blue = (0..3u32).any(|y| bmp.row(y)[0].b == 255 && bmp.row(y)[0].r == 0);
1538        assert!(has_red, "vflip: expected red pixels in output");
1539        assert!(has_blue, "vflip: expected blue pixels in output");
1540    }
1541
1542    /// Partial-clip path: only pixels inside the clip rect are painted.
1543    #[test]
1544    fn draw_image_partial_clip_paints_only_inside() {
1545        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 1, false);
1546        // Clip to columns 2..5 only.
1547        let clip = Clip::new(2.0, 0.0, 4.999, 7.999, false);
1548        let pipe = simple_pipe();
1549
1550        let mut img_src = SolidColor {
1551            r: 255,
1552            g: 255,
1553            b: 255,
1554        };
1555        // Fill the full 8×8 canvas.
1556        let mat = [8.0f64, 0.0, 0.0, 8.0, 0.0, 0.0];
1557        let result = draw_image::<Rgb8>(
1558            &mut bmp,
1559            &clip,
1560            &pipe,
1561            &mut img_src,
1562            crate::types::PixelMode::Rgb8,
1563            8,
1564            8,
1565            &mat,
1566        );
1567        assert_eq!(result, ImageResult::Ok);
1568
1569        for y in 0..8u32 {
1570            // Columns 0-1 must be unpainted.
1571            assert_eq!(bmp.row(y)[0].r, 0, "col 0 should be clipped");
1572            assert_eq!(bmp.row(y)[1].r, 0, "col 1 should be clipped");
1573            // Columns 2-4 must be painted.
1574            assert_eq!(bmp.row(y)[2].r, 255, "col 2 should be painted (y={y})");
1575            assert_eq!(bmp.row(y)[3].r, 255, "col 3 should be painted (y={y})");
1576            // Column 5+ must be unpainted.
1577            assert_eq!(bmp.row(y)[5].r, 0, "col 5 should be clipped");
1578        }
1579    }
1580
1581    // ── Golden hash tests for all 8 scale kernels ─────────────────────────────
1582    //
1583    // These pin the byte-exact output of every dispatch branch of `scale_mask`
1584    // and `scale_image` across deterministic sources and ratio combinations
1585    // that exercise both Bresenham branches (q ≠ 0, multiple wraps per span).
1586    //
1587    // The hashes are produced by an inline FNV-1a-64 — stable across Rust
1588    // versions, unlike `std::collections::hash_map::DefaultHasher`. They
1589    // guarantee that any refactor preserving the current behaviour will still
1590    // pass; any byte-level drift in any of the 8 kernels will fail loudly.
1591
1592    /// FNV-1a 64-bit. Spec-stable across Rust versions and platforms; the
1593    /// chosen polynomial constants are the canonical FNV-1a values. Used
1594    /// purely to compact the per-kernel byte-pin into a single comparable
1595    /// value in the assertions below.
1596    fn fnv1a64(bytes: &[u8]) -> u64 {
1597        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
1598        for &b in bytes {
1599            h ^= u64::from(b);
1600            h = h.wrapping_mul(0x0000_0100_0000_01b3);
1601        }
1602        h
1603    }
1604
1605    /// A deterministic 1-bit `MaskSource` whose bit at `(x, y)` is set iff
1606    /// `(x * 7 + y * 13) % 5 < 3`. The choice of `5 / 7 / 13` gives a
1607    /// nontrivial spatial pattern: rows and columns don't repeat with small
1608    /// period, and roughly 60 % of pixels are set, so the box-filtered
1609    /// downsamples produce non-trivial gradient values.
1610    struct GoldenMask;
1611    impl MaskSource for GoldenMask {
1612        fn get_row(&mut self, y: u32, row_buf: &mut [u8]) {
1613            for (byte_idx, slot) in row_buf.iter_mut().enumerate() {
1614                let mut packed: u8 = 0;
1615                // Test callers pass row widths ≤ 11 px → row_buf ≤ 2 bytes →
1616                // byte_idx ≤ 1, trivially in u32.
1617                #[expect(
1618                    clippy::cast_possible_truncation,
1619                    reason = "callers pass widths ≤ 11 px; byte_idx ≤ 1 fits in u32"
1620                )]
1621                let byte_idx_u32 = byte_idx as u32;
1622                for bit in 0..8u32 {
1623                    let x = byte_idx_u32 * 8 + bit;
1624                    let on = (x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13))) % 5 < 3;
1625                    if on {
1626                        packed |= 1 << (7 - bit);
1627                    }
1628                }
1629                *slot = packed;
1630            }
1631        }
1632    }
1633
1634    /// A deterministic multi-channel `ImageSource`: channel `c` of pixel
1635    /// `(x, y)` is `(x * (17 + c) + y * (23 + c)) % 256` taken as `u8`. With
1636    /// `ncomps = 3` the three channels evolve independently of each other,
1637    /// so a refactor that crossed channel boundaries would show up.
1638    struct GoldenImage {
1639        ncomps: usize,
1640    }
1641    impl ImageSource for GoldenImage {
1642        fn get_row(&mut self, y: u32, row_buf: &mut [u8]) {
1643            let width = row_buf.len() / self.ncomps;
1644            // Test callers pass widths ≤ 11 px and ncomps ≤ 4, so x and c both
1645            // fit in u32. The final `& 0xFF` truncation is intentional — that's
1646            // how the per-pixel byte is derived.
1647            #[expect(
1648                clippy::cast_possible_truncation,
1649                reason = "callers bound width ≤ 11 and ncomps ≤ 4; 0xFF mask is intentional"
1650            )]
1651            for x in 0..width {
1652                for c in 0..self.ncomps {
1653                    let mul = 17u32 + c as u32;
1654                    let add = 23u32 + c as u32;
1655                    let v = (x as u32)
1656                        .wrapping_mul(mul)
1657                        .wrapping_add(y.wrapping_mul(add));
1658                    row_buf[x * self.ncomps + c] = (v & 0xFF) as u8;
1659                }
1660            }
1661        }
1662    }
1663
1664    /// Mask, both axes downsampling: src 11×13 → scaled 5×7.
1665    /// Ratios coprime in both axes (`13 % 7 = 6`, `11 % 5 = 1`) → Bresenham
1666    /// `q ≠ 0` and the accumulator wraps multiple times per span.
1667    #[test]
1668    fn scale_mask_ydown_xdown_golden() {
1669        let out = scale_mask(&mut GoldenMask, 11, 13, 5, 7);
1670        assert_eq!(out.len(), 5 * 7);
1671        assert_eq!(fnv1a64(&out), 0xC72C_2A67_D157_65F4);
1672    }
1673
1674    /// Mask, Y down + X up: src 11×13 → scaled 23×7.
1675    #[test]
1676    fn scale_mask_ydown_xup_golden() {
1677        let out = scale_mask(&mut GoldenMask, 11, 13, 23, 7);
1678        assert_eq!(out.len(), 23 * 7);
1679        assert_eq!(fnv1a64(&out), 0x3C80_F065_9EB6_35B6);
1680    }
1681
1682    /// Mask, Y up + X down: src 11×13 → scaled 5×29.
1683    #[test]
1684    fn scale_mask_yup_xdown_golden() {
1685        let out = scale_mask(&mut GoldenMask, 11, 13, 5, 29);
1686        assert_eq!(out.len(), 5 * 29);
1687        assert_eq!(fnv1a64(&out), 0x8056_EED7_DF0E_665E);
1688    }
1689
1690    /// Mask, both axes upsampling: src 5×7 → scaled 23×29.
1691    #[test]
1692    fn scale_mask_yup_xup_golden() {
1693        let out = scale_mask(&mut GoldenMask, 5, 7, 23, 29);
1694        assert_eq!(out.len(), 23 * 29);
1695        assert_eq!(fnv1a64(&out), 0x5940_5245_567D_707F);
1696    }
1697
1698    /// Image (ncomps = 3), both axes downsampling.
1699    #[test]
1700    fn scale_image_ydown_xdown_golden() {
1701        let mut src = GoldenImage { ncomps: 3 };
1702        let out = scale_image(&mut src, 11, 13, 5, 7, 3);
1703        assert_eq!(out.len(), 5 * 7 * 3);
1704        assert_eq!(fnv1a64(&out), 0x6CDB_D839_4499_6365);
1705    }
1706
1707    /// Image (ncomps = 3), Y down + X up.
1708    #[test]
1709    fn scale_image_ydown_xup_golden() {
1710        let mut src = GoldenImage { ncomps: 3 };
1711        let out = scale_image(&mut src, 11, 13, 23, 7, 3);
1712        assert_eq!(out.len(), 23 * 7 * 3);
1713        assert_eq!(fnv1a64(&out), 0xA8DF_24A4_C3D9_F281);
1714    }
1715
1716    /// Image (ncomps = 3), Y up + X down.
1717    #[test]
1718    fn scale_image_yup_xdown_golden() {
1719        let mut src = GoldenImage { ncomps: 3 };
1720        let out = scale_image(&mut src, 11, 13, 5, 29, 3);
1721        assert_eq!(out.len(), 5 * 29 * 3);
1722        assert_eq!(fnv1a64(&out), 0xBB21_A5B6_484F_2159);
1723    }
1724
1725    /// Image (ncomps = 4 — covers CMYK channel-count path), both axes
1726    /// upsampling: src 5×7 → scaled 23×29.
1727    #[test]
1728    fn scale_image_yup_xup_golden() {
1729        let mut src = GoldenImage { ncomps: 4 };
1730        let out = scale_image(&mut src, 5, 7, 23, 29, 4);
1731        assert_eq!(out.len(), 23 * 29 * 4);
1732        assert_eq!(fnv1a64(&out), 0xA063_5327_4D9F_A0C1);
1733    }
1734}