we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add pixel format types and RGBA8 conversion utilities

Image struct (always RGBA8) with conversions from grayscale,
grayscale+alpha, RGB, RGBA, indexed color, and indexed+alpha.
29 unit tests covering all conversion paths and edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

authored by

Pierre Le Fevre
Claude Opus 4.6
and committed by tangled.org a4f45612 b9387ed7

+625
+1
crates/image/src/lib.rs
··· 1 1 //! Image decoders — PNG (DEFLATE), JPEG, GIF, WebP — pure Rust. 2 2 3 3 pub mod deflate; 4 + pub mod pixel; 4 5 pub mod zlib;
+624
crates/image/src/pixel.rs
··· 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 + 7 + use std::fmt; 8 + 9 + // --------------------------------------------------------------------------- 10 + // Error type 11 + // --------------------------------------------------------------------------- 12 + 13 + /// Errors that can occur during image operations. 14 + #[derive(Debug, Clone, PartialEq, Eq)] 15 + pub 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 + 28 + impl 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 + 49 + pub 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)] 59 + pub 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 + 68 + impl 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). 101 + pub 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). 129 + pub 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). 159 + pub 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. 187 + pub 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. 195 + pub 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. 237 + pub 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)] 285 + mod 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 + }