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::{Dimension, ElementPlacement, ElementSized, Positioned};
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    /// Glow effect
63    Glow,
64    /// Soft edges
65    SoftEdges,
66    /// Inner shadow
67    InnerShadow,
68    /// Blur effect
69    Blur,
70}
71
72/// Image metadata and properties
73#[derive(Clone, Debug)]
74pub struct Image {
75    pub filename: String,
76    pub width: u32,      // in EMU
77    pub height: u32,     // in EMU
78    pub x: u32,          // Position X in EMU
79    pub y: u32,          // Position Y in EMU
80    pub format: String,  // PNG, JPG, GIF, etc.
81    /// Image data source (file path, base64, or bytes)
82    pub source: Option<ImageSource>,
83    /// Image cropping
84    pub crop: Option<Crop>,
85    /// Image effects
86    pub effects: Vec<ImageEffect>,
87    /// Accessibility alt text (description)
88    pub alt_text: Option<String>,
89}
90
91impl Image {
92    /// Create a new image
93    pub fn new(filename: &str, width: u32, height: u32, format: &str) -> Self {
94        Image {
95            filename: filename.to_string(),
96            width,
97            height,
98            x: 0,
99            y: 0,
100            format: format.to_uppercase(),
101            source: Some(ImageSource::File(filename.to_string())),
102            crop: None,
103            effects: Vec::new(),
104            alt_text: None,
105        }
106    }
107
108    /// Create an image from a file path, automatically detecting dimensions
109    pub fn from_path<P: AsRef<Path>>(path: P) -> std::result::Result<Self, String> {
110        let path = path.as_ref();
111        let filename = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "image.png".to_string());
112        let path_str = path.to_string_lossy().to_string();
113        
114        let data = std::fs::read(path)
115            .map_err(|e| format!("Failed to open image: {e}"))?;
116        let (w, h, format) = read_image_dimensions(&data)
117            .ok_or_else(|| "Failed to detect image dimensions (unsupported format)".to_string())?;
118            
119        // Convert pixels to EMU (assuming 96 DPI): 1 pixel = 9525 EMU
120        let w_emu = w * 9525;
121        let h_emu = h * 9525;
122        
123        Ok(Image {
124            filename,
125            width: w_emu,
126            height: h_emu,
127            x: 0,
128            y: 0,
129            format,
130            source: Some(ImageSource::File(path_str)),
131            crop: None,
132            effects: Vec::new(),
133            alt_text: None,
134        })
135    }
136    
137    /// Create an image from base64 encoded data
138    ///
139    /// # Example
140    /// ```rust
141    /// use ppt_rs::generator::Image;
142    ///
143    /// let base64_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
144    /// let img = Image::from_base64(base64_data, 100, 100, "PNG")
145    ///     .position(1000000, 1000000);
146    ///
147    /// assert_eq!(img.width, 100);
148    /// assert_eq!(img.height, 100);
149    /// assert_eq!(img.format, "PNG");
150    /// ```
151    pub fn from_base64(data: &str, width: u32, height: u32, format: &str) -> Self {
152        let (filename, fmt) = generate_image_filename(format);
153        Self::with_source(filename, width, height, fmt, ImageSource::Base64(data.to_string()))
154    }
155    
156    /// Create an image from raw bytes
157    pub fn from_bytes(data: Vec<u8>, width: u32, height: u32, format: &str) -> Self {
158        let (filename, fmt) = generate_image_filename(format);
159        Self::with_source(filename, width, height, fmt, ImageSource::Bytes(data))
160    }
161
162    /// Create an image from URL
163    #[cfg(feature = "web2ppt")]
164    pub fn from_url(url: &str, width: u32, height: u32, format: &str) -> Self {
165        let (filename, fmt) = generate_image_filename(format);
166        Self::with_source(filename, width, height, fmt, ImageSource::Url(url.to_string()))
167    }
168
169    /// Internal constructor to avoid repeating struct init
170    fn with_source(filename: String, width: u32, height: u32, format: String, source: ImageSource) -> Self {
171        Image {
172            filename,
173            width,
174            height,
175            x: 0,
176            y: 0,
177            format,
178            source: Some(source),
179            crop: None,
180            effects: Vec::new(),
181            alt_text: None,
182        }
183    }
184
185    /// Set accessibility alt text for the image.
186    pub fn with_alt_text(mut self, alt: &str) -> Self {
187        self.alt_text = Some(alt.to_string());
188        self
189    }
190    
191    /// Get the image data as bytes (decodes base64 if needed)
192    pub fn get_bytes(&self) -> Option<Vec<u8>> {
193        match &self.source {
194            Some(ImageSource::Base64(data)) => {
195                // Decode base64
196                base64_decode(data).ok()
197            }
198            Some(ImageSource::Bytes(data)) => Some(data.clone()),
199            Some(ImageSource::File(path)) => {
200                std::fs::read(path).ok()
201            }
202            #[cfg(feature = "web2ppt")]
203            Some(ImageSource::Url(url)) => {
204                // Use blocking client to fetch image
205                // Set User-Agent to mimic browser to avoid some 403s
206                let client = reqwest::blocking::Client::builder()
207                    .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")
208                    .build()
209                    .ok()?;
210                    
211                match client.get(url).send() {
212                    Ok(resp) => {
213                        if resp.status().is_success() {
214                            resp.bytes().ok().map(|b| b.to_vec())
215                        } else {
216                            None
217                        }
218                    },
219                    Err(_) => None,
220                }
221            }
222            None => None,
223        }
224    }
225
226    /// Set image position
227    pub fn position(mut self, x: u32, y: u32) -> Self {
228        self.x = x;
229        self.y = y;
230        self
231    }
232
233    /// Set image cropping
234    pub fn with_crop(mut self, left: f64, top: f64, right: f64, bottom: f64) -> Self {
235        self.crop = Some(Crop::new(left, top, right, bottom));
236        self
237    }
238
239    /// Add an image effect
240    pub fn with_effect(mut self, effect: ImageEffect) -> Self {
241        self.effects.push(effect);
242        self
243    }
244
245    /// Get aspect ratio
246    pub fn aspect_ratio(&self) -> f64 {
247        self.width as f64 / self.height as f64
248    }
249
250    /// Scale image to width while maintaining aspect ratio
251    pub fn scale_to_width(mut self, width: u32) -> Self {
252        let ratio = self.aspect_ratio();
253        self.width = width;
254        self.height = (width as f64 / ratio) as u32;
255        self
256    }
257
258    /// Scale image to height while maintaining aspect ratio
259    pub fn scale_to_height(mut self, height: u32) -> Self {
260        let ratio = self.aspect_ratio();
261        self.height = height;
262        self.width = (height as f64 * ratio) as u32;
263        self
264    }
265
266    /// Get file extension from filename
267    pub fn extension(&self) -> String {
268        Path::new(&self.filename)
269            .extension()
270            .and_then(|ext| ext.to_str())
271            .map(|s| s.to_lowercase())
272            .unwrap_or_else(|| self.format.to_lowercase())
273    }
274
275    /// Get MIME type for the image format
276    pub fn mime_type(&self) -> String {
277        match self.format.as_str() {
278            "PNG" => "image/png".to_string(),
279            "JPG" | "JPEG" => "image/jpeg".to_string(),
280            "GIF" => "image/gif".to_string(),
281            "BMP" => "image/bmp".to_string(),
282            "TIFF" => "image/tiff".to_string(),
283            "SVG" => "image/svg+xml".to_string(),
284            _ => "application/octet-stream".to_string(),
285        }
286    }
287
288    /// Set position using flexible Dimension units (fluent).
289    pub fn at(mut self, x: Dimension, y: Dimension) -> Self {
290        self.x = x.to_emu_x();
291        self.y = y.to_emu_y();
292        self
293    }
294
295    /// Set size using flexible Dimension units (fluent).
296    pub fn with_dimensions(mut self, width: Dimension, height: Dimension) -> Self {
297        self.width = width.to_emu_x();
298        self.height = height.to_emu_y();
299        self
300    }
301}
302
303impl Positioned for Image {
304    fn x(&self) -> u32 { self.x }
305    fn y(&self) -> u32 { self.y }
306    fn set_position(&mut self, x: u32, y: u32) {
307        self.x = x;
308        self.y = y;
309    }
310}
311
312impl ElementSized for Image {
313    fn width(&self) -> u32 { self.width }
314    fn height(&self) -> u32 { self.height }
315    fn set_size(&mut self, width: u32, height: u32) {
316        self.width = width;
317        self.height = height;
318    }
319}
320
321/// Decode base64 string to bytes
322fn base64_decode(input: &str) -> Result<Vec<u8>, std::io::Error> {
323    // Simple base64 decoder
324    const DECODE_TABLE: [i8; 128] = [
325        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
326        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
327        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63,
328        52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1,
329        -1,  0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14,
330        15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1,
331        -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
332        41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1,
333    ];
334    
335    let input = input.trim().replace(['\n', '\r', ' '], "");
336    let mut output = Vec::with_capacity(input.len() * 3 / 4);
337    let bytes: Vec<u8> = input.bytes().collect();
338    
339    let mut i = 0;
340    while i < bytes.len() {
341        let mut buf = [0u8; 4];
342        let mut pad = 0;
343        
344        for j in 0..4 {
345            if i + j >= bytes.len() || bytes[i + j] == b'=' {
346                buf[j] = 0;
347                pad += 1;
348            } else if bytes[i + j] < 128 && DECODE_TABLE[bytes[i + j] as usize] >= 0 {
349                buf[j] = DECODE_TABLE[bytes[i + j] as usize] as u8;
350            } else {
351                return Err(std::io::Error::new(
352                    std::io::ErrorKind::InvalidData,
353                    "Invalid base64 character",
354                ));
355            }
356        }
357        
358        output.push((buf[0] << 2) | (buf[1] >> 4));
359        if pad < 2 {
360            output.push((buf[1] << 4) | (buf[2] >> 2));
361        }
362        if pad < 1 {
363            output.push((buf[2] << 6) | buf[3]);
364        }
365        
366        i += 4;
367    }
368    
369    Ok(output)
370}
371
372/// Image builder for fluent API
373pub struct ImageBuilder {
374    filename: String,
375    placement: ElementPlacement,
376    format: String,
377    source: Option<ImageSource>,
378    effects: Vec<ImageEffect>,
379    crop: Option<Crop>,
380}
381
382impl ImageBuilder {
383    /// Create a new image builder from file
384    pub fn new(filename: &str, width: u32, height: u32) -> Self {
385        let format = Path::new(filename)
386            .extension()
387            .and_then(|ext| ext.to_str())
388            .map(|s| s.to_uppercase())
389            .unwrap_or_else(|| "PNG".to_string());
390
391        ImageBuilder {
392            filename: filename.to_string(),
393            placement: ElementPlacement::new().with_size(width, height),
394            format,
395            source: Some(ImageSource::File(filename.to_string())),
396            effects: Vec::new(),
397            crop: None,
398        }
399    }
400    
401    /// Create image from file with default size (2 inches square)
402    /// 
403    /// # Example
404    /// ```
405    /// use ppt_rs::generator::ImageBuilder;
406    /// 
407    /// let img = ImageBuilder::from_file("photo.jpg").build();
408    /// ```
409    pub fn from_file(filename: &str) -> Self {
410        let defaults = ElementPlacement::image_defaults();
411        Self::new(filename, defaults.width, defaults.height)
412    }
413    
414    /// Create image builder from base64 data
415    pub fn from_base64(data: &str, width: u32, height: u32, format: &str) -> Self {
416        let (upper, ext) = format_and_ext(format);
417        ImageBuilder {
418            filename: format!("image.{ext}"),
419            placement: ElementPlacement::new().with_size(width, height),
420            format: upper,
421            source: Some(ImageSource::Base64(data.to_string())),
422            effects: Vec::new(),
423            crop: None,
424        }
425    }
426    
427    /// Create image from base64 with default size (2 inches square)
428    /// 
429    /// # Example
430    /// ```
431    /// use ppt_rs::generator::ImageBuilder;
432    /// 
433    /// let img = ImageBuilder::base64("iVBORw0KG...", "PNG").build();
434    /// ```
435    pub fn base64(data: &str, format: &str) -> Self {
436        let defaults = ElementPlacement::image_defaults();
437        Self::from_base64(data, defaults.width, defaults.height, format)
438    }
439    
440    /// Create image builder from bytes
441    pub fn from_bytes(data: Vec<u8>, width: u32, height: u32, format: &str) -> Self {
442        let (upper, ext) = format_and_ext(format);
443        ImageBuilder {
444            filename: format!("image.{ext}"),
445            placement: ElementPlacement::new().with_size(width, height),
446            format: upper,
447            source: Some(ImageSource::Bytes(data)),
448            effects: Vec::new(),
449            crop: None,
450        }
451    }
452    
453    /// Create image from bytes with default size (2 inches square)
454    /// 
455    /// # Example
456    /// ```no_run
457    /// use ppt_rs::generator::ImageBuilder;
458    /// 
459    /// let bytes = std::fs::read("photo.jpg").unwrap();
460    /// let img = ImageBuilder::bytes(bytes, "JPEG").build();
461    /// ```
462    pub fn bytes(data: Vec<u8>, format: &str) -> Self {
463        let defaults = ElementPlacement::image_defaults();
464        Self::from_bytes(data, defaults.width, defaults.height, format)
465    }
466    
467    /// Auto-detect format from bytes and create image with default size
468    /// 
469    /// # Example
470    /// ```no_run
471    /// use ppt_rs::generator::ImageBuilder;
472    /// 
473    /// let bytes = std::fs::read("photo.jpg").unwrap();
474    /// let img = ImageBuilder::auto(bytes).build();
475    /// ```
476    pub fn auto(data: Vec<u8>) -> Self {
477        let defaults = ElementPlacement::image_defaults();
478        
479        // Detect format from magic bytes
480        let format = if data.len() >= 4 {
481            if &data[0..4] == b"\x89PNG" {
482                "PNG"
483            } else if data.len() >= 2 && &data[0..2] == b"\xFF\xD8" {
484                "JPEG"
485            } else if data.len() >= 6 && &data[0..6] == b"GIF89a" || &data[0..6] == b"GIF87a" {
486                "GIF"
487            } else {
488                "PNG" // default
489            }
490        } else {
491            "PNG"
492        };
493        
494        Self::from_bytes(data, defaults.width, defaults.height, format)
495    }
496
497    /// Set image position
498    pub fn position(mut self, x: u32, y: u32) -> Self {
499        self.placement.set_position(x, y);
500        self
501    }
502    
503    /// Set image position at (x, y) - alias for position()
504    pub fn at(self, x: u32, y: u32) -> Self {
505        self.position(x, y)
506    }
507    
508    /// Set image size
509    pub fn size(mut self, width: u32, height: u32) -> Self {
510        self.placement.set_size(width, height);
511        self
512    }
513
514    /// Set image format
515    pub fn format(mut self, format: &str) -> Self {
516        self.format = format.to_uppercase();
517        self
518    }
519
520    /// Scale to width (maintains aspect ratio)
521    pub fn scale_to_width(mut self, width: u32) -> Self {
522        let ratio = self.placement.width as f64 / self.placement.height as f64;
523        self.placement.width = width;
524        self.placement.height = (width as f64 / ratio) as u32;
525        self
526    }
527
528    /// Scale to height (maintains aspect ratio)
529    pub fn scale_to_height(mut self, height: u32) -> Self {
530        let ratio = self.placement.width as f64 / self.placement.height as f64;
531        self.placement.height = height;
532        self.placement.width = (height as f64 * ratio) as u32;
533        self
534    }
535    
536    /// Add shadow effect (chainable)
537    pub fn shadow(mut self) -> Self {
538        self.effects.push(ImageEffect::Shadow);
539        self
540    }
541    
542    /// Add reflection effect (chainable)
543    pub fn reflection(mut self) -> Self {
544        self.effects.push(ImageEffect::Reflection);
545        self
546    }
547    
548    /// Add glow effect (chainable)
549    pub fn glow(mut self) -> Self {
550        self.effects.push(ImageEffect::Glow);
551        self
552    }
553    
554    /// Add soft edges effect (chainable)
555    pub fn soft_edges(mut self) -> Self {
556        self.effects.push(ImageEffect::SoftEdges);
557        self
558    }
559    
560    /// Add inner shadow effect (chainable)
561    pub fn inner_shadow(mut self) -> Self {
562        self.effects.push(ImageEffect::InnerShadow);
563        self
564    }
565    
566    /// Add blur effect (chainable)
567    pub fn blur(mut self) -> Self {
568        self.effects.push(ImageEffect::Blur);
569        self
570    }
571    
572    /// Add crop (chainable)
573    pub fn crop(mut self, left: f64, top: f64, right: f64, bottom: f64) -> Self {
574        self.crop = Some(Crop::new(left, top, right, bottom));
575        self
576    }
577
578    /// Build the image
579    pub fn build(self) -> Image {
580        Image {
581            filename: self.filename,
582            width: self.placement.width,
583            height: self.placement.height,
584            x: self.placement.x,
585            y: self.placement.y,
586            format: self.format,
587            source: self.source,
588            crop: self.crop,
589            effects: self.effects,
590            alt_text: None,
591        }
592    }
593    
594    /// Build with crop
595    pub fn build_with_crop(self, left: f64, top: f64, right: f64, bottom: f64) -> Image {
596        Image {
597            filename: self.filename,
598            width: self.placement.width,
599            height: self.placement.height,
600            x: self.placement.x,
601            y: self.placement.y,
602            format: self.format,
603            source: self.source,
604            crop: Some(Crop::new(left, top, right, bottom)),
605            effects: Vec::new(),
606            alt_text: None,
607        }
608    }
609    
610    /// Build with shadow effect
611    pub fn build_with_shadow(self) -> Image {
612        Image {
613            filename: self.filename,
614            width: self.placement.width,
615            height: self.placement.height,
616            x: self.placement.x,
617            y: self.placement.y,
618            format: self.format,
619            source: self.source,
620            crop: None,
621            effects: vec![ImageEffect::Shadow],
622            alt_text: None,
623        }
624    }
625    
626    /// Build with reflection effect
627    pub fn build_with_reflection(self) -> Image {
628        Image {
629            filename: self.filename,
630            width: self.placement.width,
631            height: self.placement.height,
632            x: self.placement.x,
633            y: self.placement.y,
634            format: self.format,
635            source: self.source,
636            crop: None,
637            effects: vec![ImageEffect::Reflection],
638            alt_text: None,
639        }
640    }
641    
642    /// Build with both shadow and reflection effects
643    pub fn build_with_effects(self) -> Image {
644        Image {
645            filename: self.filename,
646            width: self.placement.width,
647            height: self.placement.height,
648            x: self.placement.x,
649            y: self.placement.y,
650            format: self.format,
651            source: self.source,
652            crop: None,
653            effects: vec![ImageEffect::Shadow, ImageEffect::Reflection],
654            alt_text: None,
655        }
656    }
657    
658    /// Build with glow effect
659    pub fn build_with_glow(self) -> Image {
660        Image {
661            filename: self.filename,
662            width: self.placement.width,
663            height: self.placement.height,
664            x: self.placement.x,
665            y: self.placement.y,
666            format: self.format,
667            source: self.source,
668            crop: None,
669            effects: vec![ImageEffect::Glow],
670            alt_text: None,
671        }
672    }
673    
674    /// Build with soft edges effect
675    pub fn build_with_soft_edges(self) -> Image {
676        Image {
677            filename: self.filename,
678            width: self.placement.width,
679            height: self.placement.height,
680            x: self.placement.x,
681            y: self.placement.y,
682            format: self.format,
683            source: self.source,
684            crop: None,
685            effects: vec![ImageEffect::SoftEdges],
686            alt_text: None,
687        }
688    }
689    
690    /// Build with inner shadow effect
691    pub fn build_with_inner_shadow(self) -> Image {
692        Image {
693            filename: self.filename,
694            width: self.placement.width,
695            height: self.placement.height,
696            x: self.placement.x,
697            y: self.placement.y,
698            format: self.format,
699            source: self.source,
700            crop: None,
701            effects: vec![ImageEffect::InnerShadow],
702            alt_text: None,
703        }
704    }
705    
706    /// Build with blur effect
707    pub fn build_with_blur(self) -> Image {
708        Image {
709            filename: self.filename,
710            width: self.placement.width,
711            height: self.placement.height,
712            x: self.placement.x,
713            y: self.placement.y,
714            format: self.format,
715            source: self.source,
716            crop: None,
717            effects: vec![ImageEffect::Blur],
718            alt_text: None,
719        }
720    }
721}
722
723/// Read image dimensions from file header bytes (PNG, JPEG, GIF, BMP, WebP).
724/// Returns (width, height, format_name) or None if unrecognized.
725fn read_image_dimensions(data: &[u8]) -> Option<(u32, u32, String)> {
726    if data.len() < 10 {
727        return None;
728    }
729    // PNG: 8-byte signature, then IHDR chunk with width/height as big-endian u32
730    if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) && data.len() >= 24 {
731        let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
732        let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
733        return Some((w, h, "PNG".into()));
734    }
735    // JPEG: starts with FF D8, scan for SOF0/SOF2 marker
736    if data.starts_with(&[0xFF, 0xD8]) {
737        return read_jpeg_dimensions(data);
738    }
739    // GIF: "GIF87a" or "GIF89a", width/height as little-endian u16 at offset 6
740    if data.starts_with(b"GIF8") && data.len() >= 10 {
741        let w = u16::from_le_bytes([data[6], data[7]]) as u32;
742        let h = u16::from_le_bytes([data[8], data[9]]) as u32;
743        return Some((w, h, "GIF".into()));
744    }
745    // BMP: "BM", width/height as little-endian u32 at offset 18/22
746    if data.starts_with(b"BM") && data.len() >= 26 {
747        let w = u32::from_le_bytes([data[18], data[19], data[20], data[21]]);
748        let h = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
749        return Some((w, h, "BMP".into()));
750    }
751    // WebP: "RIFF....WEBP", VP8 chunk has dimensions
752    if data.len() >= 30 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
753        // VP8 lossy: width/height at offset 26/28 as little-endian u16
754        if &data[12..16] == b"VP8 " && data.len() >= 30 {
755            let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
756            let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
757            return Some((w, h, "WEBP".into()));
758        }
759        // VP8L lossless: dimensions encoded at offset 21
760        if &data[12..16] == b"VP8L" && data.len() >= 25 {
761            let b0 = data[21] as u32;
762            let b1 = data[22] as u32;
763            let b2 = data[23] as u32;
764            let b3 = data[24] as u32;
765            let bits = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
766            let w = (bits & 0x3FFF) + 1;
767            let h = ((bits >> 14) & 0x3FFF) + 1;
768            return Some((w, h, "WEBP".into()));
769        }
770    }
771    None
772}
773
774/// Scan JPEG markers to find SOF0/SOF2 frame with dimensions
775fn read_jpeg_dimensions(data: &[u8]) -> Option<(u32, u32, String)> {
776    let mut i = 2;
777    while i + 1 < data.len() {
778        if data[i] != 0xFF {
779            i += 1;
780            continue;
781        }
782        let marker = data[i + 1];
783        i += 2;
784        // SOF0 (0xC0) or SOF2 (0xC2): height at +3, width at +5 (big-endian u16)
785        if (marker == 0xC0 || marker == 0xC2) && i + 7 < data.len() {
786            let h = u16::from_be_bytes([data[i + 3], data[i + 4]]) as u32;
787            let w = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
788            return Some((w, h, "JPEG".into()));
789        }
790        // Skip non-SOF markers by reading segment length
791        if marker >= 0xC0 && marker != 0xD9 && marker != 0xDA && i + 1 < data.len() {
792            let len = u16::from_be_bytes([data[i], data[i + 1]]) as usize;
793            i += len;
794        }
795    }
796    None
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    #[test]
804    fn test_image_creation() {
805        let img = Image::new("test.png", 1920, 1080, "PNG");
806        assert_eq!(img.filename, "test.png");
807        assert_eq!(img.width, 1920);
808        assert_eq!(img.height, 1080);
809    }
810
811    #[test]
812    fn test_image_position() {
813        let img = Image::new("test.png", 1920, 1080, "PNG")
814            .position(500000, 1000000);
815        assert_eq!(img.x, 500000);
816        assert_eq!(img.y, 1000000);
817    }
818
819    #[test]
820    fn test_image_aspect_ratio() {
821        let img = Image::new("test.png", 1920, 1080, "PNG");
822        let ratio = img.aspect_ratio();
823        assert!((ratio - 1.777).abs() < 0.01);
824    }
825
826    #[test]
827    fn test_image_scale_to_width() {
828        let img = Image::new("test.png", 1920, 1080, "PNG")
829            .scale_to_width(960);
830        assert_eq!(img.width, 960);
831        assert_eq!(img.height, 540);
832    }
833
834    #[test]
835    fn test_image_scale_to_height() {
836        let img = Image::new("test.png", 1920, 1080, "PNG")
837            .scale_to_height(540);
838        assert_eq!(img.width, 960);
839        assert_eq!(img.height, 540);
840    }
841
842    #[test]
843    fn test_image_extension() {
844        let img = Image::new("photo.jpg", 1920, 1080, "JPEG");
845        assert_eq!(img.extension(), "jpg");
846    }
847
848    #[test]
849    fn test_image_mime_types() {
850        assert_eq!(
851            Image::new("test.png", 100, 100, "PNG").mime_type(),
852            "image/png"
853        );
854        assert_eq!(
855            Image::new("test.jpg", 100, 100, "JPG").mime_type(),
856            "image/jpeg"
857        );
858        assert_eq!(
859            Image::new("test.gif", 100, 100, "GIF").mime_type(),
860            "image/gif"
861        );
862    }
863
864    #[test]
865    fn test_image_builder() {
866        let img = ImageBuilder::new("photo.png", 1920, 1080)
867            .position(500000, 1000000)
868            .scale_to_width(960)
869            .build();
870
871        assert_eq!(img.filename, "photo.png");
872        assert_eq!(img.width, 960);
873        assert_eq!(img.height, 540);
874        assert_eq!(img.x, 500000);
875        assert_eq!(img.y, 1000000);
876    }
877
878    #[test]
879    fn test_image_builder_auto_format() {
880        let img = ImageBuilder::new("photo.jpg", 1920, 1080).build();
881        assert_eq!(img.format, "JPG");
882    }
883    
884    #[test]
885    fn test_image_from_base64() {
886        // 1x1 PNG image in base64
887        let base64_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
888        let img = Image::from_base64(base64_png, 100, 100, "PNG");
889        
890        assert!(img.filename.ends_with(".png"));
891        assert_eq!(img.format, "PNG");
892        assert!(matches!(img.source, Some(ImageSource::Base64(_))));
893    }
894    
895    #[test]
896    fn test_image_from_bytes() {
897        let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG header
898        let img = Image::from_bytes(data.clone(), 100, 100, "PNG");
899        
900        assert_eq!(img.format, "PNG");
901        assert!(matches!(img.source, Some(ImageSource::Bytes(_))));
902    }
903    
904    #[test]
905    fn test_base64_decode() {
906        // Test simple base64 decode
907        let result = base64_decode("SGVsbG8=").unwrap();
908        assert_eq!(result, b"Hello");
909        
910        // Test with padding
911        let result = base64_decode("SGVsbG8gV29ybGQ=").unwrap();
912        assert_eq!(result, b"Hello World");
913    }
914    
915    #[test]
916    fn test_image_get_bytes_base64() {
917        let base64_png = "SGVsbG8="; // "Hello" in base64
918        let img = Image::from_base64(base64_png, 100, 100, "PNG");
919        
920        let bytes = img.get_bytes().unwrap();
921        assert_eq!(bytes, b"Hello");
922    }
923    
924    #[test]
925    fn test_image_builder_from_base64() {
926        let base64_data = "SGVsbG8=";
927        let img = ImageBuilder::from_base64(base64_data, 200, 150, "JPEG")
928            .position(1000, 2000)
929            .build();
930        
931        assert_eq!(img.width, 200);
932        assert_eq!(img.height, 150);
933        assert_eq!(img.x, 1000);
934        assert_eq!(img.y, 2000);
935        assert_eq!(img.format, "JPEG");
936    }
937
938    #[test]
939    fn test_read_png_dimensions() {
940        // Minimal 1x1 PNG
941        let png: Vec<u8> = vec![
942            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // signature
943            0x00, 0x00, 0x00, 0x0D, // IHDR length
944            0x49, 0x48, 0x44, 0x52, // "IHDR"
945            0x00, 0x00, 0x00, 0x01, // width=1
946            0x00, 0x00, 0x00, 0x01, // height=1
947            0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc.
948        ];
949        let (w, h, fmt) = read_image_dimensions(&png).unwrap();
950        assert_eq!((w, h), (1, 1));
951        assert_eq!(fmt, "PNG");
952    }
953
954    #[test]
955    fn test_read_gif_dimensions() {
956        let gif: Vec<u8> = vec![
957            0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a"
958            0x0A, 0x00, // width=10 (little-endian)
959            0x14, 0x00, // height=20
960        ];
961        let (w, h, fmt) = read_image_dimensions(&gif).unwrap();
962        assert_eq!((w, h), (10, 20));
963        assert_eq!(fmt, "GIF");
964    }
965
966    #[test]
967    fn test_read_bmp_dimensions() {
968        let mut bmp = vec![0u8; 26];
969        bmp[0] = 0x42; bmp[1] = 0x4D; // "BM"
970        bmp[18..22].copy_from_slice(&100u32.to_le_bytes()); // width=100
971        bmp[22..26].copy_from_slice(&200u32.to_le_bytes()); // height=200
972        let (w, h, fmt) = read_image_dimensions(&bmp).unwrap();
973        assert_eq!((w, h), (100, 200));
974        assert_eq!(fmt, "BMP");
975    }
976}