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