Skip to main content

ppt_rs/generator/
images.rs

1//! Image handling for PPTX presentations
2//!
3//! Handles image metadata, embedding, and XML generation
4
5use std::path::Path;
6use crate::core::{Positioned, ElementSized, Dimension};
7
8/// Normalize format string and derive file extension
9fn format_and_ext(format: &str) -> (String, String) {
10    let upper = format.to_uppercase();
11    let ext = match upper.as_str() {
12        "JPEG" => "jpg".to_string(),
13        _ => upper.to_lowercase(),
14    };
15    (upper, ext)
16}
17
18/// Generate a unique image filename from format string
19fn generate_image_filename(format: &str) -> (String, String) {
20    let (upper, ext) = format_and_ext(format);
21    let filename = format!("image_{}.{}", uuid::Uuid::new_v4(), ext);
22    (filename, upper)
23}
24
25/// Image data source
26#[derive(Clone, Debug)]
27pub enum ImageSource {
28    /// Load from file path
29    File(String),
30    /// Base64 encoded data
31    Base64(String),
32    /// Raw bytes
33    Bytes(Vec<u8>),
34    /// Load from URL
35    #[cfg(feature = "web2ppt")]
36    Url(String),
37}
38
39/// Image crop configuration (values 0.0 to 1.0)
40#[derive(Clone, Debug, Default)]
41pub struct Crop {
42    pub left: f64,
43    pub top: f64,
44    pub right: f64,
45    pub bottom: f64,
46}
47
48impl Crop {
49    /// Create a new crop configuration
50    pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Self {
51        Self { left, top, right, bottom }
52    }
53}
54
55/// Image effects
56#[derive(Clone, Debug)]
57pub enum ImageEffect {
58    /// Outer shadow
59    Shadow,
60    /// Reflection
61    Reflection,
62}
63
64/// Image metadata and properties
65#[derive(Clone, Debug)]
66pub struct Image {
67    pub filename: String,
68    pub width: u32,      // in EMU
69    pub height: u32,     // in EMU
70    pub x: u32,          // Position X in EMU
71    pub y: u32,          // Position Y in EMU
72    pub format: String,  // PNG, JPG, GIF, etc.
73    /// Image data source (file path, base64, or bytes)
74    pub source: Option<ImageSource>,
75    /// Image cropping
76    pub crop: Option<Crop>,
77    /// Image effects
78    pub effects: Vec<ImageEffect>,
79}
80
81impl Image {
82    /// Create a new image
83    pub fn new(filename: &str, width: u32, height: u32, format: &str) -> Self {
84        Image {
85            filename: filename.to_string(),
86            width,
87            height,
88            x: 0,
89            y: 0,
90            format: format.to_uppercase(),
91            source: Some(ImageSource::File(filename.to_string())),
92            crop: None,
93            effects: Vec::new(),
94        }
95    }
96
97    /// Create an image from a file path, automatically detecting dimensions
98    pub fn from_path<P: AsRef<Path>>(path: P) -> std::result::Result<Self, String> {
99        let path = path.as_ref();
100        let filename = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "image.png".to_string());
101        let path_str = path.to_string_lossy().to_string();
102        
103        let data = std::fs::read(path)
104            .map_err(|e| format!("Failed to open image: {e}"))?;
105        let (w, h, format) = read_image_dimensions(&data)
106            .ok_or_else(|| "Failed to detect image dimensions (unsupported format)".to_string())?;
107            
108        // Convert pixels to EMU (assuming 96 DPI): 1 pixel = 9525 EMU
109        let w_emu = w * 9525;
110        let h_emu = h * 9525;
111        
112        Ok(Image {
113            filename,
114            width: w_emu,
115            height: h_emu,
116            x: 0,
117            y: 0,
118            format,
119            source: Some(ImageSource::File(path_str)),
120            crop: None,
121            effects: Vec::new(),
122        })
123    }
124    
125    /// Create an image from base64 encoded data
126    ///
127    /// # Example
128    /// ```rust
129    /// use ppt_rs::generator::Image;
130    ///
131    /// let base64_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
132    /// let img = Image::from_base64(base64_data, 100, 100, "PNG")
133    ///     .position(1000000, 1000000);
134    ///
135    /// assert_eq!(img.width, 100);
136    /// assert_eq!(img.height, 100);
137    /// assert_eq!(img.format, "PNG");
138    /// ```
139    pub fn from_base64(data: &str, width: u32, height: u32, format: &str) -> Self {
140        let (filename, fmt) = generate_image_filename(format);
141        Self::with_source(filename, width, height, fmt, ImageSource::Base64(data.to_string()))
142    }
143    
144    /// Create an image from raw bytes
145    pub fn from_bytes(data: Vec<u8>, width: u32, height: u32, format: &str) -> Self {
146        let (filename, fmt) = generate_image_filename(format);
147        Self::with_source(filename, width, height, fmt, ImageSource::Bytes(data))
148    }
149
150    /// Create an image from URL
151    #[cfg(feature = "web2ppt")]
152    pub fn from_url(url: &str, width: u32, height: u32, format: &str) -> Self {
153        let (filename, fmt) = generate_image_filename(format);
154        Self::with_source(filename, width, height, fmt, ImageSource::Url(url.to_string()))
155    }
156
157    /// Internal constructor to avoid repeating struct init
158    fn with_source(filename: String, width: u32, height: u32, format: String, source: ImageSource) -> Self {
159        Image {
160            filename,
161            width,
162            height,
163            x: 0,
164            y: 0,
165            format,
166            source: Some(source),
167            crop: None,
168            effects: Vec::new(),
169        }
170    }
171    
172    /// Get the image data as bytes (decodes base64 if needed)
173    pub fn get_bytes(&self) -> Option<Vec<u8>> {
174        match &self.source {
175            Some(ImageSource::Base64(data)) => {
176                // Decode base64
177                base64_decode(data).ok()
178            }
179            Some(ImageSource::Bytes(data)) => Some(data.clone()),
180            Some(ImageSource::File(path)) => {
181                std::fs::read(path).ok()
182            }
183            #[cfg(feature = "web2ppt")]
184            Some(ImageSource::Url(url)) => {
185                // Use blocking client to fetch image
186                // Set User-Agent to mimic browser to avoid some 403s
187                let client = reqwest::blocking::Client::builder()
188                    .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
189                    .build()
190                    .ok()?;
191                    
192                match client.get(url).send() {
193                    Ok(resp) => {
194                        if resp.status().is_success() {
195                            resp.bytes().ok().map(|b| b.to_vec())
196                        } else {
197                            None
198                        }
199                    },
200                    Err(_) => None,
201                }
202            }
203            None => None,
204        }
205    }
206
207    /// Set image position
208    pub fn position(mut self, x: u32, y: u32) -> Self {
209        self.x = x;
210        self.y = y;
211        self
212    }
213
214    /// Set image cropping
215    pub fn with_crop(mut self, left: f64, top: f64, right: f64, bottom: f64) -> Self {
216        self.crop = Some(Crop::new(left, top, right, bottom));
217        self
218    }
219
220    /// Add an image effect
221    pub fn with_effect(mut self, effect: ImageEffect) -> Self {
222        self.effects.push(effect);
223        self
224    }
225
226    /// Get aspect ratio
227    pub fn aspect_ratio(&self) -> f64 {
228        self.width as f64 / self.height as f64
229    }
230
231    /// Scale image to width while maintaining aspect ratio
232    pub fn scale_to_width(mut self, width: u32) -> Self {
233        let ratio = self.aspect_ratio();
234        self.width = width;
235        self.height = (width as f64 / ratio) as u32;
236        self
237    }
238
239    /// Scale image to height while maintaining aspect ratio
240    pub fn scale_to_height(mut self, height: u32) -> Self {
241        let ratio = self.aspect_ratio();
242        self.height = height;
243        self.width = (height as f64 * ratio) as u32;
244        self
245    }
246
247    /// Get file extension from filename
248    pub fn extension(&self) -> String {
249        Path::new(&self.filename)
250            .extension()
251            .and_then(|ext| ext.to_str())
252            .map(|s| s.to_lowercase())
253            .unwrap_or_else(|| self.format.to_lowercase())
254    }
255
256    /// Get MIME type for the image format
257    pub fn mime_type(&self) -> String {
258        match self.format.as_str() {
259            "PNG" => "image/png".to_string(),
260            "JPG" | "JPEG" => "image/jpeg".to_string(),
261            "GIF" => "image/gif".to_string(),
262            "BMP" => "image/bmp".to_string(),
263            "TIFF" => "image/tiff".to_string(),
264            "SVG" => "image/svg+xml".to_string(),
265            _ => "application/octet-stream".to_string(),
266        }
267    }
268
269    /// Set position using flexible Dimension units (fluent).
270    pub fn at(mut self, x: Dimension, y: Dimension) -> Self {
271        self.x = x.to_emu_x();
272        self.y = y.to_emu_y();
273        self
274    }
275
276    /// Set size using flexible Dimension units (fluent).
277    pub fn with_dimensions(mut self, width: Dimension, height: Dimension) -> Self {
278        self.width = width.to_emu_x();
279        self.height = height.to_emu_y();
280        self
281    }
282}
283
284impl Positioned for Image {
285    fn x(&self) -> u32 { self.x }
286    fn y(&self) -> u32 { self.y }
287    fn set_position(&mut self, x: u32, y: u32) {
288        self.x = x;
289        self.y = y;
290    }
291}
292
293impl ElementSized for Image {
294    fn width(&self) -> u32 { self.width }
295    fn height(&self) -> u32 { self.height }
296    fn set_size(&mut self, width: u32, height: u32) {
297        self.width = width;
298        self.height = height;
299    }
300}
301
302/// Decode base64 string to bytes
303fn base64_decode(input: &str) -> Result<Vec<u8>, std::io::Error> {
304    // Simple base64 decoder
305    const DECODE_TABLE: [i8; 128] = [
306        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
307        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
308        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63,
309        52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1,
310        -1,  0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14,
311        15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1,
312        -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
313        41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1,
314    ];
315    
316    let input = input.trim().replace(['\n', '\r', ' '], "");
317    let mut output = Vec::with_capacity(input.len() * 3 / 4);
318    let bytes: Vec<u8> = input.bytes().collect();
319    
320    let mut i = 0;
321    while i < bytes.len() {
322        let mut buf = [0u8; 4];
323        let mut pad = 0;
324        
325        for j in 0..4 {
326            if i + j >= bytes.len() || bytes[i + j] == b'=' {
327                buf[j] = 0;
328                pad += 1;
329            } else if bytes[i + j] < 128 && DECODE_TABLE[bytes[i + j] as usize] >= 0 {
330                buf[j] = DECODE_TABLE[bytes[i + j] as usize] as u8;
331            } else {
332                return Err(std::io::Error::new(
333                    std::io::ErrorKind::InvalidData,
334                    "Invalid base64 character",
335                ));
336            }
337        }
338        
339        output.push((buf[0] << 2) | (buf[1] >> 4));
340        if pad < 2 {
341            output.push((buf[1] << 4) | (buf[2] >> 2));
342        }
343        if pad < 1 {
344            output.push((buf[2] << 6) | buf[3]);
345        }
346        
347        i += 4;
348    }
349    
350    Ok(output)
351}
352
353/// Image builder for fluent API
354pub struct ImageBuilder {
355    filename: String,
356    width: u32,
357    height: u32,
358    x: u32,
359    y: u32,
360    format: String,
361    source: Option<ImageSource>,
362}
363
364impl ImageBuilder {
365    /// Create a new image builder from file
366    pub fn new(filename: &str, width: u32, height: u32) -> Self {
367        let format = Path::new(filename)
368            .extension()
369            .and_then(|ext| ext.to_str())
370            .map(|s| s.to_uppercase())
371            .unwrap_or_else(|| "PNG".to_string());
372
373        ImageBuilder {
374            filename: filename.to_string(),
375            width,
376            height,
377            x: 0,
378            y: 0,
379            format,
380            source: Some(ImageSource::File(filename.to_string())),
381        }
382    }
383    
384    /// Create image builder from base64 data
385    pub fn from_base64(data: &str, width: u32, height: u32, format: &str) -> Self {
386        let (upper, ext) = format_and_ext(format);
387        ImageBuilder {
388            filename: format!("image.{}", ext),
389            width, height, x: 0, y: 0,
390            format: upper,
391            source: Some(ImageSource::Base64(data.to_string())),
392        }
393    }
394    
395    /// Create image builder from bytes
396    pub fn from_bytes(data: Vec<u8>, width: u32, height: u32, format: &str) -> Self {
397        let (upper, ext) = format_and_ext(format);
398        ImageBuilder {
399            filename: format!("image.{}", ext),
400            width, height, x: 0, y: 0,
401            format: upper,
402            source: Some(ImageSource::Bytes(data)),
403        }
404    }
405
406    /// Set image position
407    pub fn position(mut self, x: u32, y: u32) -> Self {
408        self.x = x;
409        self.y = y;
410        self
411    }
412
413    /// Set image format
414    pub fn format(mut self, format: &str) -> Self {
415        self.format = format.to_uppercase();
416        self
417    }
418
419    /// Scale to width
420    pub fn scale_to_width(mut self, width: u32) -> Self {
421        let ratio = self.width as f64 / self.height as f64;
422        self.width = width;
423        self.height = (width as f64 / ratio) as u32;
424        self
425    }
426
427    /// Scale to height
428    pub fn scale_to_height(mut self, height: u32) -> Self {
429        let ratio = self.width as f64 / self.height as f64;
430        self.height = height;
431        self.width = (height as f64 * ratio) as u32;
432        self
433    }
434
435    /// Build the image
436    pub fn build(self) -> Image {
437        Image {
438            filename: self.filename,
439            width: self.width,
440            height: self.height,
441            x: self.x,
442            y: self.y,
443            format: self.format,
444            source: self.source,
445            crop: None,
446            effects: Vec::new(),
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_image_creation() {
457        let img = Image::new("test.png", 1920, 1080, "PNG");
458        assert_eq!(img.filename, "test.png");
459        assert_eq!(img.width, 1920);
460        assert_eq!(img.height, 1080);
461    }
462
463    #[test]
464    fn test_image_position() {
465        let img = Image::new("test.png", 1920, 1080, "PNG")
466            .position(500000, 1000000);
467        assert_eq!(img.x, 500000);
468        assert_eq!(img.y, 1000000);
469    }
470
471    #[test]
472    fn test_image_aspect_ratio() {
473        let img = Image::new("test.png", 1920, 1080, "PNG");
474        let ratio = img.aspect_ratio();
475        assert!((ratio - 1.777).abs() < 0.01);
476    }
477
478    #[test]
479    fn test_image_scale_to_width() {
480        let img = Image::new("test.png", 1920, 1080, "PNG")
481            .scale_to_width(960);
482        assert_eq!(img.width, 960);
483        assert_eq!(img.height, 540);
484    }
485
486    #[test]
487    fn test_image_scale_to_height() {
488        let img = Image::new("test.png", 1920, 1080, "PNG")
489            .scale_to_height(540);
490        assert_eq!(img.width, 960);
491        assert_eq!(img.height, 540);
492    }
493
494    #[test]
495    fn test_image_extension() {
496        let img = Image::new("photo.jpg", 1920, 1080, "JPEG");
497        assert_eq!(img.extension(), "jpg");
498    }
499
500    #[test]
501    fn test_image_mime_types() {
502        assert_eq!(
503            Image::new("test.png", 100, 100, "PNG").mime_type(),
504            "image/png"
505        );
506        assert_eq!(
507            Image::new("test.jpg", 100, 100, "JPG").mime_type(),
508            "image/jpeg"
509        );
510        assert_eq!(
511            Image::new("test.gif", 100, 100, "GIF").mime_type(),
512            "image/gif"
513        );
514    }
515
516    #[test]
517    fn test_image_builder() {
518        let img = ImageBuilder::new("photo.png", 1920, 1080)
519            .position(500000, 1000000)
520            .scale_to_width(960)
521            .build();
522
523        assert_eq!(img.filename, "photo.png");
524        assert_eq!(img.width, 960);
525        assert_eq!(img.height, 540);
526        assert_eq!(img.x, 500000);
527        assert_eq!(img.y, 1000000);
528    }
529
530    #[test]
531    fn test_image_builder_auto_format() {
532        let img = ImageBuilder::new("photo.jpg", 1920, 1080).build();
533        assert_eq!(img.format, "JPG");
534    }
535    
536    #[test]
537    fn test_image_from_base64() {
538        // 1x1 PNG image in base64
539        let base64_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
540        let img = Image::from_base64(base64_png, 100, 100, "PNG");
541        
542        assert!(img.filename.ends_with(".png"));
543        assert_eq!(img.format, "PNG");
544        assert!(matches!(img.source, Some(ImageSource::Base64(_))));
545    }
546    
547    #[test]
548    fn test_image_from_bytes() {
549        let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG header
550        let img = Image::from_bytes(data.clone(), 100, 100, "PNG");
551        
552        assert_eq!(img.format, "PNG");
553        assert!(matches!(img.source, Some(ImageSource::Bytes(_))));
554    }
555    
556    #[test]
557    fn test_base64_decode() {
558        // Test simple base64 decode
559        let result = base64_decode("SGVsbG8=").unwrap();
560        assert_eq!(result, b"Hello");
561        
562        // Test with padding
563        let result = base64_decode("SGVsbG8gV29ybGQ=").unwrap();
564        assert_eq!(result, b"Hello World");
565    }
566    
567    #[test]
568    fn test_image_get_bytes_base64() {
569        let base64_png = "SGVsbG8="; // "Hello" in base64
570        let img = Image::from_base64(base64_png, 100, 100, "PNG");
571        
572        let bytes = img.get_bytes().unwrap();
573        assert_eq!(bytes, b"Hello");
574    }
575    
576    #[test]
577    fn test_image_builder_from_base64() {
578        let base64_data = "SGVsbG8=";
579        let img = ImageBuilder::from_base64(base64_data, 200, 150, "JPEG")
580            .position(1000, 2000)
581            .build();
582        
583        assert_eq!(img.width, 200);
584        assert_eq!(img.height, 150);
585        assert_eq!(img.x, 1000);
586        assert_eq!(img.y, 2000);
587        assert_eq!(img.format, "JPEG");
588    }
589
590    #[test]
591    fn test_read_png_dimensions() {
592        // Minimal 1x1 PNG
593        let png: Vec<u8> = vec![
594            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // signature
595            0x00, 0x00, 0x00, 0x0D, // IHDR length
596            0x49, 0x48, 0x44, 0x52, // "IHDR"
597            0x00, 0x00, 0x00, 0x01, // width=1
598            0x00, 0x00, 0x00, 0x01, // height=1
599            0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc.
600        ];
601        let (w, h, fmt) = read_image_dimensions(&png).unwrap();
602        assert_eq!((w, h), (1, 1));
603        assert_eq!(fmt, "PNG");
604    }
605
606    #[test]
607    fn test_read_gif_dimensions() {
608        let gif: Vec<u8> = vec![
609            0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a"
610            0x0A, 0x00, // width=10 (little-endian)
611            0x14, 0x00, // height=20
612        ];
613        let (w, h, fmt) = read_image_dimensions(&gif).unwrap();
614        assert_eq!((w, h), (10, 20));
615        assert_eq!(fmt, "GIF");
616    }
617
618    #[test]
619    fn test_read_bmp_dimensions() {
620        let mut bmp = vec![0u8; 26];
621        bmp[0] = 0x42; bmp[1] = 0x4D; // "BM"
622        bmp[18..22].copy_from_slice(&100u32.to_le_bytes()); // width=100
623        bmp[22..26].copy_from_slice(&200u32.to_le_bytes()); // height=200
624        let (w, h, fmt) = read_image_dimensions(&bmp).unwrap();
625        assert_eq!((w, h), (100, 200));
626        assert_eq!(fmt, "BMP");
627    }
628}
629
630/// Read image dimensions from file header bytes (PNG, JPEG, GIF, BMP, WebP).
631/// Returns (width, height, format_name) or None if unrecognized.
632fn read_image_dimensions(data: &[u8]) -> Option<(u32, u32, String)> {
633    if data.len() < 10 {
634        return None;
635    }
636    // PNG: 8-byte signature, then IHDR chunk with width/height as big-endian u32
637    if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) && data.len() >= 24 {
638        let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
639        let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
640        return Some((w, h, "PNG".into()));
641    }
642    // JPEG: starts with FF D8, scan for SOF0/SOF2 marker
643    if data.starts_with(&[0xFF, 0xD8]) {
644        return read_jpeg_dimensions(data);
645    }
646    // GIF: "GIF87a" or "GIF89a", width/height as little-endian u16 at offset 6
647    if data.starts_with(b"GIF8") && data.len() >= 10 {
648        let w = u16::from_le_bytes([data[6], data[7]]) as u32;
649        let h = u16::from_le_bytes([data[8], data[9]]) as u32;
650        return Some((w, h, "GIF".into()));
651    }
652    // BMP: "BM", width/height as little-endian u32 at offset 18/22
653    if data.starts_with(b"BM") && data.len() >= 26 {
654        let w = u32::from_le_bytes([data[18], data[19], data[20], data[21]]);
655        let h = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
656        return Some((w, h, "BMP".into()));
657    }
658    // WebP: "RIFF....WEBP", VP8 chunk has dimensions
659    if data.len() >= 30 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
660        // VP8 lossy: width/height at offset 26/28 as little-endian u16
661        if &data[12..16] == b"VP8 " && data.len() >= 30 {
662            let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
663            let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
664            return Some((w, h, "WEBP".into()));
665        }
666        // VP8L lossless: dimensions encoded at offset 21
667        if &data[12..16] == b"VP8L" && data.len() >= 25 {
668            let b0 = data[21] as u32;
669            let b1 = data[22] as u32;
670            let b2 = data[23] as u32;
671            let b3 = data[24] as u32;
672            let bits = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
673            let w = (bits & 0x3FFF) + 1;
674            let h = ((bits >> 14) & 0x3FFF) + 1;
675            return Some((w, h, "WEBP".into()));
676        }
677    }
678    None
679}
680
681/// Scan JPEG markers to find SOF0/SOF2 frame with dimensions
682fn read_jpeg_dimensions(data: &[u8]) -> Option<(u32, u32, String)> {
683    let mut i = 2;
684    while i + 1 < data.len() {
685        if data[i] != 0xFF {
686            i += 1;
687            continue;
688        }
689        let marker = data[i + 1];
690        i += 2;
691        // SOF0 (0xC0) or SOF2 (0xC2): height at +3, width at +5 (big-endian u16)
692        if (marker == 0xC0 || marker == 0xC2) && i + 7 < data.len() {
693            let h = u16::from_be_bytes([data[i + 3], data[i + 4]]) as u32;
694            let w = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
695            return Some((w, h, "JPEG".into()));
696        }
697        // Skip non-SOF markers by reading segment length
698        if marker >= 0xC0 && marker != 0xD9 && marker != 0xDA && i + 1 < data.len() {
699            let len = u16::from_be_bytes([data[i], data[i + 1]]) as usize;
700            i += len;
701        }
702    }
703    None
704}