we (web engine): Experimental web browser project to understand the limits of Claude
1//! Pixel format types and RGBA8 conversion.
2//!
3//! Provides a common `Image` type (always RGBA8) and functions to convert
4//! from various source formats: grayscale, grayscale+alpha, RGB, RGBA,
5//! and indexed (palette) color.
6
7use std::fmt;
8
9// ---------------------------------------------------------------------------
10// Error type
11// ---------------------------------------------------------------------------
12
13/// Errors that can occur during image operations.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum ImageError {
16 /// Image dimensions are zero.
17 ZeroDimension { width: u32, height: u32 },
18 /// Pixel data length does not match expected dimensions.
19 DataLengthMismatch { expected: usize, actual: usize },
20 /// Palette index is out of bounds.
21 PaletteIndexOutOfBounds { index: u8, palette_len: usize },
22 /// Palette is empty.
23 EmptyPalette,
24 /// Generic decoding error.
25 Decode(String),
26}
27
28impl fmt::Display for ImageError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::ZeroDimension { width, height } => {
32 write!(f, "zero dimension: {width}x{height}")
33 }
34 Self::DataLengthMismatch { expected, actual } => {
35 write!(f, "data length mismatch: expected {expected}, got {actual}")
36 }
37 Self::PaletteIndexOutOfBounds { index, palette_len } => {
38 write!(
39 f,
40 "palette index {index} out of bounds (palette has {palette_len} entries)"
41 )
42 }
43 Self::EmptyPalette => write!(f, "empty palette"),
44 Self::Decode(msg) => write!(f, "decode error: {msg}"),
45 }
46 }
47}
48
49pub type Result<T> = std::result::Result<T, ImageError>;
50
51// ---------------------------------------------------------------------------
52// Image
53// ---------------------------------------------------------------------------
54
55/// An image stored as RGBA8 pixel data (4 bytes per pixel).
56///
57/// Pixels are stored in row-major order, top-to-bottom, left-to-right.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Image {
60 /// Width in pixels.
61 pub width: u32,
62 /// Height in pixels.
63 pub height: u32,
64 /// RGBA8 pixel data: `4 * width * height` bytes.
65 pub data: Vec<u8>,
66}
67
68impl Image {
69 /// Create an image from pre-validated RGBA8 data.
70 pub fn new(width: u32, height: u32, data: Vec<u8>) -> Result<Self> {
71 if width == 0 || height == 0 {
72 return Err(ImageError::ZeroDimension { width, height });
73 }
74 let expected = (width as usize) * (height as usize) * 4;
75 if data.len() != expected {
76 return Err(ImageError::DataLengthMismatch {
77 expected,
78 actual: data.len(),
79 });
80 }
81 Ok(Self {
82 width,
83 height,
84 data,
85 })
86 }
87
88 /// Total number of pixels.
89 pub fn pixel_count(&self) -> usize {
90 self.width as usize * self.height as usize
91 }
92}
93
94// ---------------------------------------------------------------------------
95// Conversion functions
96// ---------------------------------------------------------------------------
97
98/// Convert grayscale (1 channel) pixel data to an RGBA8 `Image`.
99///
100/// Each grayscale byte maps to (G, G, G, 255).
101pub fn from_grayscale(width: u32, height: u32, gray: &[u8]) -> Result<Image> {
102 if width == 0 || height == 0 {
103 return Err(ImageError::ZeroDimension { width, height });
104 }
105 let pixel_count = width as usize * height as usize;
106 if gray.len() != pixel_count {
107 return Err(ImageError::DataLengthMismatch {
108 expected: pixel_count,
109 actual: gray.len(),
110 });
111 }
112 let mut data = Vec::with_capacity(pixel_count * 4);
113 for &g in gray {
114 data.push(g);
115 data.push(g);
116 data.push(g);
117 data.push(255);
118 }
119 Ok(Image {
120 width,
121 height,
122 data,
123 })
124}
125
126/// Convert grayscale+alpha (2 channels) pixel data to an RGBA8 `Image`.
127///
128/// Each pair of bytes (G, A) maps to (G, G, G, A).
129pub fn from_grayscale_alpha(width: u32, height: u32, ga: &[u8]) -> Result<Image> {
130 if width == 0 || height == 0 {
131 return Err(ImageError::ZeroDimension { width, height });
132 }
133 let pixel_count = width as usize * height as usize;
134 if ga.len() != pixel_count * 2 {
135 return Err(ImageError::DataLengthMismatch {
136 expected: pixel_count * 2,
137 actual: ga.len(),
138 });
139 }
140 let mut data = Vec::with_capacity(pixel_count * 4);
141 for pair in ga.chunks_exact(2) {
142 let g = pair[0];
143 let a = pair[1];
144 data.push(g);
145 data.push(g);
146 data.push(g);
147 data.push(a);
148 }
149 Ok(Image {
150 width,
151 height,
152 data,
153 })
154}
155
156/// Convert RGB (3 channels) pixel data to an RGBA8 `Image`.
157///
158/// Each triple (R, G, B) maps to (R, G, B, 255).
159pub fn from_rgb(width: u32, height: u32, rgb: &[u8]) -> Result<Image> {
160 if width == 0 || height == 0 {
161 return Err(ImageError::ZeroDimension { width, height });
162 }
163 let pixel_count = width as usize * height as usize;
164 if rgb.len() != pixel_count * 3 {
165 return Err(ImageError::DataLengthMismatch {
166 expected: pixel_count * 3,
167 actual: rgb.len(),
168 });
169 }
170 let mut data = Vec::with_capacity(pixel_count * 4);
171 for triple in rgb.chunks_exact(3) {
172 data.push(triple[0]);
173 data.push(triple[1]);
174 data.push(triple[2]);
175 data.push(255);
176 }
177 Ok(Image {
178 width,
179 height,
180 data,
181 })
182}
183
184/// Convert RGBA (4 channels) pixel data to an RGBA8 `Image`.
185///
186/// This is the identity conversion — validates dimensions and length.
187pub fn from_rgba(width: u32, height: u32, rgba: Vec<u8>) -> Result<Image> {
188 Image::new(width, height, rgba)
189}
190
191/// Convert indexed-color pixel data to an RGBA8 `Image`.
192///
193/// `palette` is a flat array of RGB triples (3 bytes per entry).
194/// `indices` contains one palette index per pixel.
195pub fn from_indexed(width: u32, height: u32, palette: &[u8], indices: &[u8]) -> Result<Image> {
196 if width == 0 || height == 0 {
197 return Err(ImageError::ZeroDimension { width, height });
198 }
199 if palette.is_empty() {
200 return Err(ImageError::EmptyPalette);
201 }
202 let palette_len = palette.len() / 3;
203 let pixel_count = width as usize * height as usize;
204 if indices.len() != pixel_count {
205 return Err(ImageError::DataLengthMismatch {
206 expected: pixel_count,
207 actual: indices.len(),
208 });
209 }
210 let mut data = Vec::with_capacity(pixel_count * 4);
211 for &idx in indices {
212 if (idx as usize) >= palette_len {
213 return Err(ImageError::PaletteIndexOutOfBounds {
214 index: idx,
215 palette_len,
216 });
217 }
218 let offset = idx as usize * 3;
219 data.push(palette[offset]);
220 data.push(palette[offset + 1]);
221 data.push(palette[offset + 2]);
222 data.push(255);
223 }
224 Ok(Image {
225 width,
226 height,
227 data,
228 })
229}
230
231/// Convert indexed-color pixel data with per-entry alpha to an RGBA8 `Image`.
232///
233/// `palette` is a flat array of RGB triples (3 bytes per entry).
234/// `alpha` provides alpha values for palette entries (may be shorter than palette;
235/// missing entries default to 255).
236/// `indices` contains one palette index per pixel.
237pub fn from_indexed_alpha(
238 width: u32,
239 height: u32,
240 palette: &[u8],
241 alpha: &[u8],
242 indices: &[u8],
243) -> Result<Image> {
244 if width == 0 || height == 0 {
245 return Err(ImageError::ZeroDimension { width, height });
246 }
247 if palette.is_empty() {
248 return Err(ImageError::EmptyPalette);
249 }
250 let palette_len = palette.len() / 3;
251 let pixel_count = width as usize * height as usize;
252 if indices.len() != pixel_count {
253 return Err(ImageError::DataLengthMismatch {
254 expected: pixel_count,
255 actual: indices.len(),
256 });
257 }
258 let mut data = Vec::with_capacity(pixel_count * 4);
259 for &idx in indices {
260 if (idx as usize) >= palette_len {
261 return Err(ImageError::PaletteIndexOutOfBounds {
262 index: idx,
263 palette_len,
264 });
265 }
266 let offset = idx as usize * 3;
267 let a = alpha.get(idx as usize).copied().unwrap_or(255);
268 data.push(palette[offset]);
269 data.push(palette[offset + 1]);
270 data.push(palette[offset + 2]);
271 data.push(a);
272 }
273 Ok(Image {
274 width,
275 height,
276 data,
277 })
278}
279
280// ---------------------------------------------------------------------------
281// Tests
282// ---------------------------------------------------------------------------
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 // -- Image::new tests --
289
290 #[test]
291 fn image_new_valid() {
292 let data = vec![0; 2 * 3 * 4]; // 2x3, 4 bytes per pixel
293 let img = Image::new(2, 3, data).unwrap();
294 assert_eq!(img.width, 2);
295 assert_eq!(img.height, 3);
296 assert_eq!(img.pixel_count(), 6);
297 assert_eq!(img.data.len(), 24);
298 }
299
300 #[test]
301 fn image_new_zero_width() {
302 assert!(matches!(
303 Image::new(0, 5, vec![]),
304 Err(ImageError::ZeroDimension {
305 width: 0,
306 height: 5
307 })
308 ));
309 }
310
311 #[test]
312 fn image_new_zero_height() {
313 assert!(matches!(
314 Image::new(5, 0, vec![]),
315 Err(ImageError::ZeroDimension {
316 width: 5,
317 height: 0
318 })
319 ));
320 }
321
322 #[test]
323 fn image_new_data_length_mismatch() {
324 let err = Image::new(2, 2, vec![0; 10]).unwrap_err();
325 assert!(matches!(
326 err,
327 ImageError::DataLengthMismatch {
328 expected: 16,
329 actual: 10
330 }
331 ));
332 }
333
334 // -- Grayscale tests --
335
336 #[test]
337 fn grayscale_basic() {
338 let img = from_grayscale(2, 1, &[0, 128]).unwrap();
339 assert_eq!(img.width, 2);
340 assert_eq!(img.height, 1);
341 assert_eq!(img.data, vec![0, 0, 0, 255, 128, 128, 128, 255]);
342 }
343
344 #[test]
345 fn grayscale_white() {
346 let img = from_grayscale(1, 1, &[255]).unwrap();
347 assert_eq!(img.data, vec![255, 255, 255, 255]);
348 }
349
350 #[test]
351 fn grayscale_zero_dimension() {
352 assert!(matches!(
353 from_grayscale(0, 1, &[]),
354 Err(ImageError::ZeroDimension { .. })
355 ));
356 }
357
358 #[test]
359 fn grayscale_data_mismatch() {
360 assert!(matches!(
361 from_grayscale(2, 2, &[0, 1, 2]),
362 Err(ImageError::DataLengthMismatch {
363 expected: 4,
364 actual: 3
365 })
366 ));
367 }
368
369 // -- Grayscale+Alpha tests --
370
371 #[test]
372 fn grayscale_alpha_basic() {
373 let img = from_grayscale_alpha(1, 2, &[100, 200, 50, 128]).unwrap();
374 assert_eq!(img.data, vec![100, 100, 100, 200, 50, 50, 50, 128]);
375 }
376
377 #[test]
378 fn grayscale_alpha_zero_dimension() {
379 assert!(matches!(
380 from_grayscale_alpha(1, 0, &[]),
381 Err(ImageError::ZeroDimension { .. })
382 ));
383 }
384
385 #[test]
386 fn grayscale_alpha_data_mismatch() {
387 assert!(matches!(
388 from_grayscale_alpha(2, 1, &[0, 1, 2]),
389 Err(ImageError::DataLengthMismatch {
390 expected: 4,
391 actual: 3
392 })
393 ));
394 }
395
396 // -- RGB tests --
397
398 #[test]
399 fn rgb_basic() {
400 let img = from_rgb(1, 2, &[255, 0, 0, 0, 255, 0]).unwrap();
401 assert_eq!(img.data, vec![255, 0, 0, 255, 0, 255, 0, 255]);
402 }
403
404 #[test]
405 fn rgb_zero_dimension() {
406 assert!(matches!(
407 from_rgb(0, 0, &[]),
408 Err(ImageError::ZeroDimension { .. })
409 ));
410 }
411
412 #[test]
413 fn rgb_data_mismatch() {
414 assert!(matches!(
415 from_rgb(2, 1, &[0, 1, 2, 3, 4]),
416 Err(ImageError::DataLengthMismatch {
417 expected: 6,
418 actual: 5
419 })
420 ));
421 }
422
423 // -- RGBA tests --
424
425 #[test]
426 fn rgba_basic() {
427 let data = vec![10, 20, 30, 40, 50, 60, 70, 80];
428 let img = from_rgba(2, 1, data.clone()).unwrap();
429 assert_eq!(img.data, data);
430 }
431
432 #[test]
433 fn rgba_zero_dimension() {
434 assert!(matches!(
435 from_rgba(0, 1, vec![]),
436 Err(ImageError::ZeroDimension { .. })
437 ));
438 }
439
440 #[test]
441 fn rgba_data_mismatch() {
442 assert!(matches!(
443 from_rgba(1, 1, vec![0, 1, 2]),
444 Err(ImageError::DataLengthMismatch {
445 expected: 4,
446 actual: 3
447 })
448 ));
449 }
450
451 // -- Indexed tests --
452
453 #[test]
454 fn indexed_basic() {
455 let palette = [255, 0, 0, 0, 255, 0, 0, 0, 255]; // red, green, blue
456 let indices = [0, 1, 2, 0];
457 let img = from_indexed(2, 2, &palette, &indices).unwrap();
458 assert_eq!(
459 img.data,
460 vec![
461 255, 0, 0, 255, // red
462 0, 255, 0, 255, // green
463 0, 0, 255, 255, // blue
464 255, 0, 0, 255, // red
465 ]
466 );
467 }
468
469 #[test]
470 fn indexed_zero_dimension() {
471 assert!(matches!(
472 from_indexed(0, 1, &[0, 0, 0], &[]),
473 Err(ImageError::ZeroDimension { .. })
474 ));
475 }
476
477 #[test]
478 fn indexed_empty_palette() {
479 assert!(matches!(
480 from_indexed(1, 1, &[], &[0]),
481 Err(ImageError::EmptyPalette)
482 ));
483 }
484
485 #[test]
486 fn indexed_out_of_bounds() {
487 let palette = [255, 0, 0]; // 1 entry
488 assert!(matches!(
489 from_indexed(1, 1, &palette, &[1]),
490 Err(ImageError::PaletteIndexOutOfBounds {
491 index: 1,
492 palette_len: 1
493 })
494 ));
495 }
496
497 #[test]
498 fn indexed_data_mismatch() {
499 let palette = [0, 0, 0];
500 assert!(matches!(
501 from_indexed(2, 2, &palette, &[0, 0]),
502 Err(ImageError::DataLengthMismatch {
503 expected: 4,
504 actual: 2
505 })
506 ));
507 }
508
509 // -- Indexed+Alpha tests --
510
511 #[test]
512 fn indexed_alpha_basic() {
513 let palette = [255, 0, 0, 0, 255, 0]; // red, green
514 let alpha = [128, 64];
515 let indices = [0, 1];
516 let img = from_indexed_alpha(2, 1, &palette, &alpha, &indices).unwrap();
517 assert_eq!(img.data, vec![255, 0, 0, 128, 0, 255, 0, 64]);
518 }
519
520 #[test]
521 fn indexed_alpha_missing_defaults_to_255() {
522 let palette = [255, 0, 0, 0, 255, 0]; // red, green
523 let alpha = [128]; // only first entry has alpha
524 let indices = [0, 1];
525 let img = from_indexed_alpha(2, 1, &palette, &alpha, &indices).unwrap();
526 assert_eq!(img.data, vec![255, 0, 0, 128, 0, 255, 0, 255]);
527 }
528
529 #[test]
530 fn indexed_alpha_empty_alpha() {
531 let palette = [10, 20, 30];
532 let alpha: &[u8] = &[];
533 let indices = [0];
534 let img = from_indexed_alpha(1, 1, &palette, alpha, &indices).unwrap();
535 assert_eq!(img.data, vec![10, 20, 30, 255]);
536 }
537
538 // -- Error Display tests --
539
540 #[test]
541 fn error_display() {
542 assert_eq!(
543 ImageError::ZeroDimension {
544 width: 0,
545 height: 5
546 }
547 .to_string(),
548 "zero dimension: 0x5"
549 );
550 assert_eq!(
551 ImageError::DataLengthMismatch {
552 expected: 100,
553 actual: 50
554 }
555 .to_string(),
556 "data length mismatch: expected 100, got 50"
557 );
558 assert_eq!(
559 ImageError::PaletteIndexOutOfBounds {
560 index: 10,
561 palette_len: 5
562 }
563 .to_string(),
564 "palette index 10 out of bounds (palette has 5 entries)"
565 );
566 assert_eq!(ImageError::EmptyPalette.to_string(), "empty palette");
567 assert_eq!(
568 ImageError::Decode("bad header".to_string()).to_string(),
569 "decode error: bad header"
570 );
571 }
572
573 // -- Larger images --
574
575 #[test]
576 fn grayscale_larger_image() {
577 let width = 10;
578 let height = 10;
579 let gray: Vec<u8> = (0..100).collect();
580 let img = from_grayscale(width, height, &gray).unwrap();
581 assert_eq!(img.data.len(), 400);
582 // Spot-check first and last pixel
583 assert_eq!(&img.data[0..4], &[0, 0, 0, 255]);
584 assert_eq!(&img.data[396..400], &[99, 99, 99, 255]);
585 }
586
587 #[test]
588 fn rgb_larger_image() {
589 let width = 4;
590 let height = 4;
591 let rgb: Vec<u8> = (0..48).collect();
592 let img = from_rgb(width, height, &rgb).unwrap();
593 assert_eq!(img.data.len(), 64);
594 // First pixel: R=0, G=1, B=2, A=255
595 assert_eq!(&img.data[0..4], &[0, 1, 2, 255]);
596 // Second pixel: R=3, G=4, B=5, A=255
597 assert_eq!(&img.data[4..8], &[3, 4, 5, 255]);
598 }
599
600 // -- 1x1 minimum images --
601
602 #[test]
603 fn single_pixel_all_formats() {
604 // Grayscale
605 let img = from_grayscale(1, 1, &[42]).unwrap();
606 assert_eq!(img.data, vec![42, 42, 42, 255]);
607
608 // Grayscale+Alpha
609 let img = from_grayscale_alpha(1, 1, &[42, 100]).unwrap();
610 assert_eq!(img.data, vec![42, 42, 42, 100]);
611
612 // RGB
613 let img = from_rgb(1, 1, &[10, 20, 30]).unwrap();
614 assert_eq!(img.data, vec![10, 20, 30, 255]);
615
616 // RGBA
617 let img = from_rgba(1, 1, vec![10, 20, 30, 40]).unwrap();
618 assert_eq!(img.data, vec![10, 20, 30, 40]);
619
620 // Indexed
621 let img = from_indexed(1, 1, &[10, 20, 30], &[0]).unwrap();
622 assert_eq!(img.data, vec![10, 20, 30, 255]);
623 }
624}