Skip to main content

slimg_core/
extend.rs

1use crate::codec::ImageData;
2use crate::error::{Error, Result};
3
4/// Fill color for the extended canvas region.
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum FillColor {
7    /// A solid RGBA color.
8    Solid([u8; 4]),
9    /// Fully transparent (RGBA 0,0,0,0).
10    Transparent,
11}
12
13impl FillColor {
14    /// Return the fill as an RGBA quadruplet.
15    pub fn as_rgba(&self) -> [u8; 4] {
16        match *self {
17            FillColor::Solid(c) => c,
18            FillColor::Transparent => [0, 0, 0, 0],
19        }
20    }
21}
22
23/// How to extend (add padding to) an image.
24#[derive(Debug, Clone, PartialEq)]
25pub enum ExtendMode {
26    /// Extend the canvas so the image fits the given aspect ratio (centered).
27    /// `width` and `height` define the ratio (e.g. 16:9).
28    AspectRatio { width: u32, height: u32 },
29    /// Extend the canvas to an exact pixel size (centered).
30    Size { width: u32, height: u32 },
31}
32
33/// Calculate the extended canvas dimensions and the offset at which the
34/// original image should be placed.
35///
36/// Returns `(canvas_w, canvas_h, offset_x, offset_y)`.
37pub fn calculate_extend_region(
38    img_w: u32,
39    img_h: u32,
40    mode: &ExtendMode,
41) -> Result<(u32, u32, u32, u32)> {
42    match *mode {
43        ExtendMode::AspectRatio {
44            width: rw,
45            height: rh,
46        } => {
47            if rw == 0 || rh == 0 {
48                return Err(Error::Extend(
49                    "aspect ratio must be non-zero".to_string(),
50                ));
51            }
52
53            let target_ratio = rw as f64 / rh as f64;
54            let img_ratio = img_w as f64 / img_h as f64;
55
56            let (canvas_w, canvas_h) = if img_ratio < target_ratio {
57                // Image is narrower than target → extend width.
58                let h = img_h;
59                let w = (h as f64 * target_ratio).round() as u32;
60                (w, h)
61            } else {
62                // Image is wider than (or equal to) target → extend height.
63                let w = img_w;
64                let h = (w as f64 / target_ratio).round() as u32;
65                (w, h)
66            };
67
68            let off_x = (canvas_w - img_w) / 2;
69            let off_y = (canvas_h - img_h) / 2;
70
71            Ok((canvas_w, canvas_h, off_x, off_y))
72        }
73        ExtendMode::Size { width, height } => {
74            if width == 0 || height == 0 {
75                return Err(Error::Extend(
76                    "extend dimensions must be non-zero".to_string(),
77                ));
78            }
79            if width < img_w || height < img_h {
80                return Err(Error::Extend(format!(
81                    "target size ({width}x{height}) is smaller than image ({img_w}x{img_h})"
82                )));
83            }
84
85            let off_x = (width - img_w) / 2;
86            let off_y = (height - img_h) / 2;
87
88            Ok((width, height, off_x, off_y))
89        }
90    }
91}
92
93/// Extend an image by adding padding around it.
94pub fn extend(image: &ImageData, mode: &ExtendMode, fill: &FillColor) -> Result<ImageData> {
95    let (canvas_w, canvas_h, off_x, off_y) =
96        calculate_extend_region(image.width, image.height, mode)?;
97
98    let expected_size = image.width as usize * image.height as usize * 4;
99    if image.data.len() != expected_size {
100        return Err(Error::Extend(format!(
101            "invalid image data: expected {} bytes ({}x{}x4), got {}",
102            expected_size, image.width, image.height, image.data.len()
103        )));
104    }
105
106    // No-op: canvas matches image
107    if canvas_w == image.width && canvas_h == image.height {
108        return Ok(image.clone());
109    }
110
111    let bytes_per_pixel = 4usize;
112    let canvas_stride = canvas_w as usize * bytes_per_pixel;
113    let src_stride = image.width as usize * bytes_per_pixel;
114
115    // Fill canvas with background color
116    let mut data = vec![0u8; canvas_h as usize * canvas_stride];
117    if !matches!(fill, FillColor::Transparent) {
118        let fill_rgba = fill.as_rgba();
119        for pixel in data.chunks_exact_mut(bytes_per_pixel) {
120            pixel.copy_from_slice(&fill_rgba);
121        }
122    }
123
124    // Copy original image rows into canvas at offset
125    for row in 0..image.height as usize {
126        let src_offset = row * src_stride;
127        let dst_offset = (off_y as usize + row) * canvas_stride + off_x as usize * bytes_per_pixel;
128        data[dst_offset..dst_offset + src_stride]
129            .copy_from_slice(&image.data[src_offset..src_offset + src_stride]);
130    }
131
132    Ok(ImageData::new(canvas_w, canvas_h, data))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    // ── AspectRatio tests ───────────────────────────────────────────
140
141    #[test]
142    fn aspect_square_on_landscape() {
143        let (w, h, ox, oy) = calculate_extend_region(
144            200,
145            100,
146            &ExtendMode::AspectRatio {
147                width: 1,
148                height: 1,
149            },
150        )
151        .unwrap();
152        assert_eq!((w, h, ox, oy), (200, 200, 0, 50));
153    }
154
155    #[test]
156    fn aspect_square_on_portrait() {
157        let (w, h, ox, oy) = calculate_extend_region(
158            100,
159            200,
160            &ExtendMode::AspectRatio {
161                width: 1,
162                height: 1,
163            },
164        )
165        .unwrap();
166        assert_eq!((w, h, ox, oy), (200, 200, 50, 0));
167    }
168
169    #[test]
170    fn aspect_16_9_on_square() {
171        let (w, h, ox, oy) = calculate_extend_region(
172            100,
173            100,
174            &ExtendMode::AspectRatio {
175                width: 16,
176                height: 9,
177            },
178        )
179        .unwrap();
180        assert_eq!((w, h, ox, oy), (178, 100, 39, 0));
181    }
182
183    #[test]
184    fn aspect_9_16_on_square() {
185        let (w, h, ox, oy) = calculate_extend_region(
186            100,
187            100,
188            &ExtendMode::AspectRatio {
189                width: 9,
190                height: 16,
191            },
192        )
193        .unwrap();
194        assert_eq!((w, h, ox, oy), (100, 178, 0, 39));
195    }
196
197    #[test]
198    fn aspect_same_as_image() {
199        let (w, h, ox, oy) = calculate_extend_region(
200            200,
201            100,
202            &ExtendMode::AspectRatio {
203                width: 2,
204                height: 1,
205            },
206        )
207        .unwrap();
208        assert_eq!((w, h, ox, oy), (200, 100, 0, 0));
209    }
210
211    #[test]
212    fn aspect_zero_ratio_errors() {
213        let result = calculate_extend_region(
214            200,
215            100,
216            &ExtendMode::AspectRatio {
217                width: 0,
218                height: 1,
219            },
220        );
221        assert!(result.is_err());
222    }
223
224    // ── Size tests ──────────────────────────────────────────────────
225
226    #[test]
227    fn size_larger_canvas() {
228        let (w, h, ox, oy) = calculate_extend_region(
229            800,
230            600,
231            &ExtendMode::Size {
232                width: 1000,
233                height: 1000,
234            },
235        )
236        .unwrap();
237        assert_eq!((w, h, ox, oy), (1000, 1000, 100, 200));
238    }
239
240    #[test]
241    fn size_same_as_image() {
242        let (w, h, ox, oy) = calculate_extend_region(
243            800,
244            600,
245            &ExtendMode::Size {
246                width: 800,
247                height: 600,
248            },
249        )
250        .unwrap();
251        assert_eq!((w, h, ox, oy), (800, 600, 0, 0));
252    }
253
254    #[test]
255    fn size_only_width_larger() {
256        let (w, h, ox, oy) = calculate_extend_region(
257            800,
258            600,
259            &ExtendMode::Size {
260                width: 1000,
261                height: 600,
262            },
263        )
264        .unwrap();
265        assert_eq!((w, h, ox, oy), (1000, 600, 100, 0));
266    }
267
268    #[test]
269    fn size_smaller_than_image_errors() {
270        let result = calculate_extend_region(
271            800,
272            600,
273            &ExtendMode::Size {
274                width: 500,
275                height: 500,
276            },
277        );
278        assert!(result.is_err());
279    }
280
281    #[test]
282    fn size_width_smaller_errors() {
283        let result = calculate_extend_region(
284            800,
285            600,
286            &ExtendMode::Size {
287                width: 700,
288                height: 600,
289            },
290        );
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn size_zero_errors() {
296        let result = calculate_extend_region(
297            800,
298            600,
299            &ExtendMode::Size {
300                width: 0,
301                height: 0,
302            },
303        );
304        assert!(result.is_err());
305    }
306
307    // ── extend() pixel tests ──────────────────────────────────────
308
309    use crate::codec::ImageData;
310
311    fn create_test_image(width: u32, height: u32) -> ImageData {
312        let data = vec![128u8; (width * height * 4) as usize];
313        ImageData::new(width, height, data)
314    }
315
316    #[test]
317    fn extend_returns_correct_dimensions() {
318        let img = create_test_image(200, 100);
319        let result = extend(
320            &img,
321            &ExtendMode::AspectRatio {
322                width: 1,
323                height: 1,
324            },
325            &FillColor::Solid([255, 255, 255, 255]),
326        )
327        .unwrap();
328        assert_eq!(result.width, 200);
329        assert_eq!(result.height, 200);
330        assert_eq!(result.data.len(), (200 * 200 * 4) as usize);
331    }
332
333    #[test]
334    fn extend_fills_with_solid_color() {
335        let img = create_test_image(2, 2);
336        let result = extend(
337            &img,
338            &ExtendMode::Size {
339                width: 4,
340                height: 4,
341            },
342            &FillColor::Solid([255, 0, 0, 255]),
343        )
344        .unwrap();
345        // Top-left corner (0,0) should be fill color (red)
346        assert_eq!(result.data[0], 255); // R
347        assert_eq!(result.data[1], 0); // G
348        assert_eq!(result.data[2], 0); // B
349        assert_eq!(result.data[3], 255); // A
350    }
351
352    #[test]
353    fn extend_fills_with_transparent() {
354        let img = create_test_image(2, 2);
355        let result = extend(
356            &img,
357            &ExtendMode::Size {
358                width: 4,
359                height: 4,
360            },
361            &FillColor::Transparent,
362        )
363        .unwrap();
364        assert_eq!(&result.data[0..4], &[0, 0, 0, 0]);
365    }
366
367    #[test]
368    fn extend_preserves_pixel_data() {
369        // Create 2x1 image: pixel(0,0)=[10,20,30,255] pixel(1,0)=[40,50,60,255]
370        let data = vec![10, 20, 30, 255, 40, 50, 60, 255];
371        let img = ImageData::new(2, 1, data);
372
373        // Extend to 4x3 → original centered at offset (1, 1)
374        let result = extend(
375            &img,
376            &ExtendMode::Size {
377                width: 4,
378                height: 3,
379            },
380            &FillColor::Solid([0, 0, 0, 0]),
381        )
382        .unwrap();
383
384        let stride = 4 * 4; // 4 pixels * 4 bytes
385        let offset = 1 * stride + 1 * 4; // row 1, col 1
386        assert_eq!(&result.data[offset..offset + 4], &[10, 20, 30, 255]);
387
388        let offset2 = 1 * stride + 2 * 4;
389        assert_eq!(&result.data[offset2..offset2 + 4], &[40, 50, 60, 255]);
390    }
391
392    #[test]
393    fn extend_noop_when_already_matching() {
394        let img = create_test_image(200, 100);
395        let result = extend(
396            &img,
397            &ExtendMode::AspectRatio {
398                width: 2,
399                height: 1,
400            },
401            &FillColor::Solid([255, 255, 255, 255]),
402        )
403        .unwrap();
404        assert_eq!(result.width, 200);
405        assert_eq!(result.height, 100);
406        assert_eq!(result.data, img.data);
407    }
408}