1use std::io::Cursor;
9
10#[derive(Debug, Clone)]
12pub struct LoadedImage {
13 pub pixel_data: ImagePixelData,
14 pub width_px: u32,
15 pub height_px: u32,
16}
17
18#[derive(Debug, Clone)]
20pub enum ImagePixelData {
21 Jpeg {
23 data: Vec<u8>,
24 color_space: JpegColorSpace,
25 },
26 Decoded {
28 rgb: Vec<u8>,
30 alpha: Option<Vec<u8>>,
32 },
33}
34
35#[derive(Debug, Clone, Copy)]
37pub enum JpegColorSpace {
38 DeviceRGB,
39 DeviceGray,
40}
41
42pub fn load_image(src: &str) -> Result<LoadedImage, String> {
49 let raw_bytes = read_source_bytes(src)?;
50 decode_image_bytes(&raw_bytes)
51}
52
53pub fn load_image_dimensions(src: &str) -> Result<(u32, u32), String> {
56 let raw_bytes = read_source_bytes(src)?;
57 let reader = image::io::Reader::new(Cursor::new(&raw_bytes))
58 .with_guessed_format()
59 .map_err(|e| format!("Image format detection error: {}", e))?;
60 reader
61 .into_dimensions()
62 .map_err(|e| format!("Failed to read image dimensions: {}", e))
63}
64
65fn read_source_bytes(src: &str) -> Result<Vec<u8>, String> {
67 if src.starts_with("data:image/") {
69 let comma_pos = src
70 .find(',')
71 .ok_or_else(|| "Invalid data URI: missing comma".to_string())?;
72 let b64_data = &src[comma_pos + 1..];
73 return base64_decode(b64_data);
74 }
75
76 if src.starts_with('/') || src.starts_with("./") || src.starts_with("../") {
80 #[cfg(not(target_arch = "wasm32"))]
81 {
82 return std::fs::read(src)
83 .map_err(|e| format!("Failed to read image file '{}': {}", src, e));
84 }
85 #[cfg(target_arch = "wasm32")]
86 {
87 return Err(format!(
88 "File path images not supported in WASM: '{}'. Use data URIs or base64.",
89 src
90 ));
91 }
92 }
93
94 base64_decode(src)
96}
97
98fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
99 use base64::Engine;
100 base64::engine::general_purpose::STANDARD
101 .decode(input)
102 .map_err(|e| format!("Base64 decode error: {}", e))
103}
104
105fn decode_image_bytes(data: &[u8]) -> Result<LoadedImage, String> {
107 if data.len() < 4 {
108 return Err("Image data too short".to_string());
109 }
110
111 if is_jpeg(data) {
112 decode_jpeg(data)
113 } else if is_png(data) {
114 decode_png(data)
115 } else if is_webp(data) {
116 decode_webp(data)
117 } else {
118 Err("Unsupported image format (expected JPEG, PNG, or WebP)".to_string())
119 }
120}
121
122fn is_jpeg(data: &[u8]) -> bool {
123 data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8
124}
125
126fn is_png(data: &[u8]) -> bool {
127 data.len() >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47
128}
129
130fn is_webp(data: &[u8]) -> bool {
131 data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP"
132}
133
134fn decode_jpeg(data: &[u8]) -> Result<LoadedImage, String> {
137 let reader = image::io::Reader::new(Cursor::new(data))
138 .with_guessed_format()
139 .map_err(|e| format!("JPEG format detection error: {}", e))?;
140
141 let (width, height) = reader
142 .into_dimensions()
143 .map_err(|e| format!("Failed to read JPEG dimensions: {}", e))?;
144
145 let color_space = detect_jpeg_color_space(data);
148
149 Ok(LoadedImage {
150 pixel_data: ImagePixelData::Jpeg {
151 data: data.to_vec(),
152 color_space,
153 },
154 width_px: width,
155 height_px: height,
156 })
157}
158
159fn detect_jpeg_color_space(data: &[u8]) -> JpegColorSpace {
162 let mut i = 2; while i + 1 < data.len() {
164 if data[i] != 0xFF {
165 break;
166 }
167 let marker = data[i + 1];
168 let is_sof = matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF);
170 if is_sof {
171 if i + 9 < data.len() {
173 let num_components = data[i + 9];
174 return if num_components == 1 {
175 JpegColorSpace::DeviceGray
176 } else {
177 JpegColorSpace::DeviceRGB
178 };
179 }
180 }
181 if i + 3 < data.len() {
183 let seg_len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
184 i += 2 + seg_len;
185 } else {
186 break;
187 }
188 }
189 JpegColorSpace::DeviceRGB
191}
192
193fn decode_webp(data: &[u8]) -> Result<LoadedImage, String> {
195 let reader = image::io::Reader::new(Cursor::new(data))
196 .with_guessed_format()
197 .map_err(|e| format!("WebP format detection error: {}", e))?;
198
199 let img = reader
200 .decode()
201 .map_err(|e| format!("Failed to decode WebP: {}", e))?;
202
203 let rgba = img.to_rgba8();
204 let width = rgba.width();
205 let height = rgba.height();
206
207 let pixel_count = (width * height) as usize;
208 let mut rgb = Vec::with_capacity(pixel_count * 3);
209 let mut alpha = Vec::with_capacity(pixel_count);
210 let mut has_transparency = false;
211
212 for pixel in rgba.pixels() {
213 rgb.push(pixel[0]);
214 rgb.push(pixel[1]);
215 rgb.push(pixel[2]);
216 let a = pixel[3];
217 alpha.push(a);
218 if a != 255 {
219 has_transparency = true;
220 }
221 }
222
223 Ok(LoadedImage {
224 pixel_data: ImagePixelData::Decoded {
225 rgb,
226 alpha: if has_transparency { Some(alpha) } else { None },
227 },
228 width_px: width,
229 height_px: height,
230 })
231}
232
233fn decode_png(data: &[u8]) -> Result<LoadedImage, String> {
235 let reader = image::io::Reader::new(Cursor::new(data))
236 .with_guessed_format()
237 .map_err(|e| format!("PNG format detection error: {}", e))?;
238
239 let img = reader
240 .decode()
241 .map_err(|e| format!("Failed to decode PNG: {}", e))?;
242
243 let rgba = img.to_rgba8();
244 let width = rgba.width();
245 let height = rgba.height();
246
247 let pixel_count = (width * height) as usize;
248 let mut rgb = Vec::with_capacity(pixel_count * 3);
249 let mut alpha = Vec::with_capacity(pixel_count);
250 let mut has_transparency = false;
251
252 for pixel in rgba.pixels() {
253 rgb.push(pixel[0]);
254 rgb.push(pixel[1]);
255 rgb.push(pixel[2]);
256 let a = pixel[3];
257 alpha.push(a);
258 if a != 255 {
259 has_transparency = true;
260 }
261 }
262
263 Ok(LoadedImage {
264 pixel_data: ImagePixelData::Decoded {
265 rgb,
266 alpha: if has_transparency { Some(alpha) } else { None },
267 },
268 width_px: width,
269 height_px: height,
270 })
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_is_jpeg() {
279 assert!(is_jpeg(&[0xFF, 0xD8, 0xFF, 0xE0]));
280 assert!(!is_jpeg(&[0x89, 0x50, 0x4E, 0x47]));
281 assert!(!is_jpeg(&[0xFF]));
282 }
283
284 #[test]
285 fn test_is_png() {
286 assert!(is_png(&[0x89, 0x50, 0x4E, 0x47]));
287 assert!(!is_png(&[0xFF, 0xD8, 0xFF, 0xE0]));
288 assert!(!is_png(&[0x89, 0x50]));
289 }
290
291 #[test]
292 fn test_invalid_data_uri() {
293 let result = load_image("data:image/png;base64");
294 assert!(result.is_err());
295 }
296
297 #[test]
298 fn test_too_short_data() {
299 let result = decode_image_bytes(&[0x00, 0x01]);
300 assert!(result.is_err());
301 }
302
303 #[test]
304 fn test_is_webp() {
305 assert!(is_webp(b"RIFF\x00\x00\x00\x00WEBP"));
307 assert!(!is_webp(b"RIFF\x00\x00\x00\x00WEB"));
309 assert!(!is_webp(b"XXXX\x00\x00\x00\x00WEBP"));
311 assert!(!is_webp(b"RIFF\x00\x00\x00\x00XXXX"));
313 assert!(!is_webp(&[0xFF, 0xD8, 0xFF, 0xE0]));
315 }
316
317 #[test]
318 fn test_decode_webp() {
319 let img = image::RgbaImage::from_fn(2, 2, |_, _| image::Rgba([255, 0, 0, 255]));
321 let mut buf = Vec::new();
322 let encoder = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
323 image::ImageEncoder::write_image(encoder, img.as_raw(), 2, 2, image::ColorType::Rgba8)
324 .unwrap();
325
326 let loaded = decode_image_bytes(&buf).unwrap();
327 assert_eq!(loaded.width_px, 2);
328 assert_eq!(loaded.height_px, 2);
329 match &loaded.pixel_data {
330 ImagePixelData::Decoded { rgb, alpha } => {
331 for i in 0..4 {
333 assert_eq!(rgb[i * 3], 255, "R channel");
334 assert_eq!(rgb[i * 3 + 1], 0, "G channel");
335 assert_eq!(rgb[i * 3 + 2], 0, "B channel");
336 }
337 assert!(alpha.is_none(), "Fully opaque should have no alpha");
338 }
339 _ => panic!("WebP should decode to Decoded variant"),
340 }
341 }
342
343 #[test]
344 fn test_decode_webp_with_alpha() {
345 let img = image::RgbaImage::from_fn(2, 2, |x, _| {
346 if x == 0 {
347 image::Rgba([0, 255, 0, 128])
348 } else {
349 image::Rgba([0, 255, 0, 255])
350 }
351 });
352 let mut buf = Vec::new();
353 let encoder = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
354 image::ImageEncoder::write_image(encoder, img.as_raw(), 2, 2, image::ColorType::Rgba8)
355 .unwrap();
356
357 let loaded = decode_image_bytes(&buf).unwrap();
358 match &loaded.pixel_data {
359 ImagePixelData::Decoded { rgb: _, alpha } => {
360 let alpha = alpha.as_ref().expect("Should have alpha channel");
361 assert_eq!(alpha[0], 128);
362 assert_eq!(alpha[1], 255);
363 }
364 _ => panic!("WebP should decode to Decoded variant"),
365 }
366 }
367
368 #[test]
369 fn test_unsupported_format() {
370 let result = decode_image_bytes(&[0x00, 0x01, 0x02, 0x03, 0x04]);
371 assert!(result.is_err());
372 }
373
374 #[test]
375 fn test_decode_minimal_png() {
376 let mut img = image::RgbaImage::new(1, 1);
378 img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
379
380 let mut buf = Vec::new();
381 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
382 image::ImageEncoder::write_image(encoder, img.as_raw(), 1, 1, image::ColorType::Rgba8)
383 .unwrap();
384
385 let loaded = decode_image_bytes(&buf).unwrap();
386 assert_eq!(loaded.width_px, 1);
387 assert_eq!(loaded.height_px, 1);
388 match &loaded.pixel_data {
389 ImagePixelData::Decoded { rgb, alpha } => {
390 assert_eq!(rgb, &[255, 0, 0]);
391 assert!(alpha.is_none(), "Fully opaque should have no alpha");
392 }
393 _ => panic!("PNG should decode to Decoded variant"),
394 }
395 }
396
397 #[test]
398 fn test_decode_png_with_alpha() {
399 let mut img = image::RgbaImage::new(1, 1);
400 img.put_pixel(0, 0, image::Rgba([255, 0, 0, 128]));
401
402 let mut buf = Vec::new();
403 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
404 image::ImageEncoder::write_image(encoder, img.as_raw(), 1, 1, image::ColorType::Rgba8)
405 .unwrap();
406
407 let loaded = decode_image_bytes(&buf).unwrap();
408 match &loaded.pixel_data {
409 ImagePixelData::Decoded { rgb, alpha } => {
410 assert_eq!(rgb, &[255, 0, 0]);
411 assert_eq!(alpha.as_ref().unwrap(), &[128]);
412 }
413 _ => panic!("PNG should decode to Decoded variant"),
414 }
415 }
416
417 #[test]
418 fn test_decode_minimal_jpeg() {
419 let img = image::RgbImage::from_fn(2, 2, |_, _| image::Rgb([0, 128, 255]));
421
422 let mut buf = Vec::new();
423 let encoder = image::codecs::jpeg::JpegEncoder::new(&mut buf);
424 image::ImageEncoder::write_image(encoder, img.as_raw(), 2, 2, image::ColorType::Rgb8)
425 .unwrap();
426
427 let loaded = decode_image_bytes(&buf).unwrap();
428 assert_eq!(loaded.width_px, 2);
429 assert_eq!(loaded.height_px, 2);
430 match &loaded.pixel_data {
431 ImagePixelData::Jpeg { data, color_space } => {
432 assert!(data.starts_with(&[0xFF, 0xD8]));
433 assert!(matches!(color_space, JpegColorSpace::DeviceRGB));
434 }
435 _ => panic!("JPEG should stay as Jpeg variant"),
436 }
437 }
438
439 #[test]
440 fn test_base64_data_uri() {
441 let mut img = image::RgbaImage::new(1, 1);
443 img.put_pixel(0, 0, image::Rgba([0, 255, 0, 255]));
444
445 let mut buf = Vec::new();
446 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
447 image::ImageEncoder::write_image(encoder, img.as_raw(), 1, 1, image::ColorType::Rgba8)
448 .unwrap();
449
450 use base64::Engine;
451 let b64 = base64::engine::general_purpose::STANDARD.encode(&buf);
452 let data_uri = format!("data:image/png;base64,{}", b64);
453
454 let loaded = load_image(&data_uri).unwrap();
455 assert_eq!(loaded.width_px, 1);
456 assert_eq!(loaded.height_px, 1);
457 }
458}