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;
6
7/// Image data source
8#[derive(Clone, Debug)]
9pub enum ImageSource {
10    /// Load from file path
11    File(String),
12    /// Base64 encoded data
13    Base64(String),
14    /// Raw bytes
15    Bytes(Vec<u8>),
16    /// Load from URL
17    #[cfg(feature = "web2ppt")]
18    Url(String),
19}
20
21/// Image crop configuration (values 0.0 to 1.0)
22#[derive(Clone, Debug, Default)]
23pub struct Crop {
24    pub left: f64,
25    pub top: f64,
26    pub right: f64,
27    pub bottom: f64,
28}
29
30impl Crop {
31    /// Create a new crop configuration
32    pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Self {
33        Self { left, top, right, bottom }
34    }
35}
36
37/// Image effects
38#[derive(Clone, Debug)]
39pub enum ImageEffect {
40    /// Outer shadow
41    Shadow,
42    /// Reflection
43    Reflection,
44}
45
46/// Image metadata and properties
47#[derive(Clone, Debug)]
48pub struct Image {
49    pub filename: String,
50    pub width: u32,      // in EMU
51    pub height: u32,     // in EMU
52    pub x: u32,          // Position X in EMU
53    pub y: u32,          // Position Y in EMU
54    pub format: String,  // PNG, JPG, GIF, etc.
55    /// Image data source (file path, base64, or bytes)
56    pub source: Option<ImageSource>,
57    /// Image cropping
58    pub crop: Option<Crop>,
59    /// Image effects
60    pub effects: Vec<ImageEffect>,
61}
62
63impl Image {
64    /// Create a new image
65    pub fn new(filename: &str, width: u32, height: u32, format: &str) -> Self {
66        Image {
67            filename: filename.to_string(),
68            width,
69            height,
70            x: 0,
71            y: 0,
72            format: format.to_uppercase(),
73            source: Some(ImageSource::File(filename.to_string())),
74            crop: None,
75            effects: Vec::new(),
76        }
77    }
78
79    /// Create an image from a file path, automatically detecting dimensions
80    pub fn from_path<P: AsRef<Path>>(path: P) -> std::result::Result<Self, String> {
81        let path = path.as_ref();
82        let filename = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "image.png".to_string());
83        let path_str = path.to_string_lossy().to_string();
84        
85        // Read image to get dimensions
86        let reader = ::image::io::Reader::open(path)
87            .map_err(|e| format!("Failed to open image: {}", e))?
88            .with_guessed_format()
89            .map_err(|e| format!("Failed to guess image format: {}", e))?;
90            
91        let format = reader.format().map(|f| format!("{:?}", f)).unwrap_or("PNG".to_string());
92        let (w, h) = reader.into_dimensions()
93            .map_err(|e| format!("Failed to get image dimensions: {}", e))?;
94            
95        // Convert pixels to EMU (assuming 96 DPI)
96        // 1 pixel = 9525 EMU
97        let w_emu = w * 9525;
98        let h_emu = h * 9525;
99        
100        Ok(Image {
101            filename,
102            width: w_emu,
103            height: h_emu,
104            x: 0,
105            y: 0,
106            format,
107            source: Some(ImageSource::File(path_str)),
108            crop: None,
109            effects: Vec::new(),
110        })
111    }
112    
113    /// Create an image from base64 encoded data
114    ///
115    /// # Example
116    /// ```rust
117    /// use ppt_rs::generator::Image;
118    ///
119    /// let base64_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
120    /// let img = Image::from_base64(base64_data, 100, 100, "PNG")
121    ///     .position(1000000, 1000000);
122    ///
123    /// assert_eq!(img.width, 100);
124    /// assert_eq!(img.height, 100);
125    /// assert_eq!(img.format, "PNG");
126    /// ```
127    pub fn from_base64(data: &str, width: u32, height: u32, format: &str) -> Self {
128        let format_upper = format.to_uppercase();
129        let ext = match format_upper.as_str() {
130            "JPEG" => "jpg",
131            _ => &format_upper.to_lowercase(),
132        };
133        let filename = format!("image_{}.{}", uuid::Uuid::new_v4(), ext);
134        
135        Image {
136            filename,
137            width,
138            height,
139            x: 0,
140            y: 0,
141            format: format_upper,
142            source: Some(ImageSource::Base64(data.to_string())),
143            crop: None,
144            effects: Vec::new(),
145        }
146    }
147    
148    /// Create an image from raw bytes
149    pub fn from_bytes(data: Vec<u8>, width: u32, height: u32, format: &str) -> Self {
150        let format_upper = format.to_uppercase();
151        let ext = match format_upper.as_str() {
152            "JPEG" => "jpg",
153            _ => &format_upper.to_lowercase(),
154        };
155        let filename = format!("image_{}.{}", uuid::Uuid::new_v4(), ext);
156        
157        Image {
158            filename,
159            width,
160            height,
161            x: 0,
162            y: 0,
163            format: format_upper,
164            source: Some(ImageSource::Bytes(data)),
165            crop: None,
166            effects: Vec::new(),
167        }
168    }
169
170    /// Create an image from URL
171    #[cfg(feature = "web2ppt")]
172    pub fn from_url(url: &str, width: u32, height: u32, format: &str) -> Self {
173        let format_upper = format.to_uppercase();
174        let ext = match format_upper.as_str() {
175            "JPEG" => "jpg",
176            _ => &format_upper.to_lowercase(),
177        };
178        let filename = format!("image_{}.{}", uuid::Uuid::new_v4(), ext);
179        
180        Image {
181            filename,
182            width,
183            height,
184            x: 0,
185            y: 0,
186            format: format_upper,
187            source: Some(ImageSource::Url(url.to_string())),
188            crop: None,
189            effects: Vec::new(),
190        }
191    }
192    
193    /// Get the image data as bytes (decodes base64 if needed)
194    pub fn get_bytes(&self) -> Option<Vec<u8>> {
195        match &self.source {
196            Some(ImageSource::Base64(data)) => {
197                // Decode base64
198                base64_decode(data).ok()
199            }
200            Some(ImageSource::Bytes(data)) => Some(data.clone()),
201            Some(ImageSource::File(path)) => {
202                std::fs::read(path).ok()
203            }
204            #[cfg(feature = "web2ppt")]
205            Some(ImageSource::Url(url)) => {
206                // Use blocking client to fetch image
207                // Set User-Agent to mimic browser to avoid some 403s
208                let client = reqwest::blocking::Client::builder()
209                    .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")
210                    .build()
211                    .ok()?;
212                    
213                match client.get(url).send() {
214                    Ok(resp) => {
215                        if resp.status().is_success() {
216                            resp.bytes().ok().map(|b| b.to_vec())
217                        } else {
218                            None
219                        }
220                    },
221                    Err(_) => None,
222                }
223            }
224            None => None,
225        }
226    }
227
228    /// Set image position
229    pub fn position(mut self, x: u32, y: u32) -> Self {
230        self.x = x;
231        self.y = y;
232        self
233    }
234
235    /// Set image cropping
236    pub fn with_crop(mut self, left: f64, top: f64, right: f64, bottom: f64) -> Self {
237        self.crop = Some(Crop::new(left, top, right, bottom));
238        self
239    }
240
241    /// Add an image effect
242    pub fn with_effect(mut self, effect: ImageEffect) -> Self {
243        self.effects.push(effect);
244        self
245    }
246
247    /// Get aspect ratio
248    pub fn aspect_ratio(&self) -> f64 {
249        self.width as f64 / self.height as f64
250    }
251
252    /// Scale image to width while maintaining aspect ratio
253    pub fn scale_to_width(mut self, width: u32) -> Self {
254        let ratio = self.aspect_ratio();
255        self.width = width;
256        self.height = (width as f64 / ratio) as u32;
257        self
258    }
259
260    /// Scale image to height while maintaining aspect ratio
261    pub fn scale_to_height(mut self, height: u32) -> Self {
262        let ratio = self.aspect_ratio();
263        self.height = height;
264        self.width = (height as f64 * ratio) as u32;
265        self
266    }
267
268    /// Get file extension from filename
269    pub fn extension(&self) -> String {
270        Path::new(&self.filename)
271            .extension()
272            .and_then(|ext| ext.to_str())
273            .map(|s| s.to_lowercase())
274            .unwrap_or_else(|| self.format.to_lowercase())
275    }
276
277    /// Get MIME type for the image format
278    pub fn mime_type(&self) -> String {
279        match self.format.as_str() {
280            "PNG" => "image/png".to_string(),
281            "JPG" | "JPEG" => "image/jpeg".to_string(),
282            "GIF" => "image/gif".to_string(),
283            "BMP" => "image/bmp".to_string(),
284            "TIFF" => "image/tiff".to_string(),
285            "SVG" => "image/svg+xml".to_string(),
286            _ => "application/octet-stream".to_string(),
287        }
288    }
289}
290
291/// Decode base64 string to bytes
292fn base64_decode(input: &str) -> Result<Vec<u8>, std::io::Error> {
293    // Simple base64 decoder
294    const DECODE_TABLE: [i8; 128] = [
295        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
296        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
297        -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63,
298        52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1,
299        -1,  0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14,
300        15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1,
301        -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
302        41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1,
303    ];
304    
305    let input = input.trim().replace(['\n', '\r', ' '], "");
306    let mut output = Vec::with_capacity(input.len() * 3 / 4);
307    let bytes: Vec<u8> = input.bytes().collect();
308    
309    let mut i = 0;
310    while i < bytes.len() {
311        let mut buf = [0u8; 4];
312        let mut pad = 0;
313        
314        for j in 0..4 {
315            if i + j >= bytes.len() {
316                buf[j] = 0;
317                pad += 1;
318            } else if bytes[i + j] == b'=' {
319                buf[j] = 0;
320                pad += 1;
321            } else if bytes[i + j] < 128 && DECODE_TABLE[bytes[i + j] as usize] >= 0 {
322                buf[j] = DECODE_TABLE[bytes[i + j] as usize] as u8;
323            } else {
324                return Err(std::io::Error::new(
325                    std::io::ErrorKind::InvalidData,
326                    "Invalid base64 character",
327                ));
328            }
329        }
330        
331        output.push((buf[0] << 2) | (buf[1] >> 4));
332        if pad < 2 {
333            output.push((buf[1] << 4) | (buf[2] >> 2));
334        }
335        if pad < 1 {
336            output.push((buf[2] << 6) | buf[3]);
337        }
338        
339        i += 4;
340    }
341    
342    Ok(output)
343}
344
345/// Image builder for fluent API
346pub struct ImageBuilder {
347    filename: String,
348    width: u32,
349    height: u32,
350    x: u32,
351    y: u32,
352    format: String,
353    source: Option<ImageSource>,
354}
355
356impl ImageBuilder {
357    /// Create a new image builder from file
358    pub fn new(filename: &str, width: u32, height: u32) -> Self {
359        let format = Path::new(filename)
360            .extension()
361            .and_then(|ext| ext.to_str())
362            .map(|s| s.to_uppercase())
363            .unwrap_or_else(|| "PNG".to_string());
364
365        ImageBuilder {
366            filename: filename.to_string(),
367            width,
368            height,
369            x: 0,
370            y: 0,
371            format,
372            source: Some(ImageSource::File(filename.to_string())),
373        }
374    }
375    
376    /// Create image builder from base64 data
377    pub fn from_base64(data: &str, width: u32, height: u32, format: &str) -> Self {
378        let format_upper = format.to_uppercase();
379        let ext = match format_upper.as_str() {
380            "JPEG" => "jpg",
381            _ => &format_upper.to_lowercase(),
382        };
383        
384        ImageBuilder {
385            filename: format!("image.{}", ext),
386            width,
387            height,
388            x: 0,
389            y: 0,
390            format: 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 format_upper = format.to_uppercase();
398        let ext = match format_upper.as_str() {
399            "JPEG" => "jpg",
400            _ => &format_upper.to_lowercase(),
401        };
402        
403        ImageBuilder {
404            filename: format!("image.{}", ext),
405            width,
406            height,
407            x: 0,
408            y: 0,
409            format: format_upper,
410            source: Some(ImageSource::Bytes(data)),
411        }
412    }
413
414    /// Set image position
415    pub fn position(mut self, x: u32, y: u32) -> Self {
416        self.x = x;
417        self.y = y;
418        self
419    }
420
421    /// Set image format
422    pub fn format(mut self, format: &str) -> Self {
423        self.format = format.to_uppercase();
424        self
425    }
426
427    /// Scale to width
428    pub fn scale_to_width(mut self, width: u32) -> Self {
429        let ratio = self.width as f64 / self.height as f64;
430        self.width = width;
431        self.height = (width as f64 / ratio) as u32;
432        self
433    }
434
435    /// Scale to height
436    pub fn scale_to_height(mut self, height: u32) -> Self {
437        let ratio = self.width as f64 / self.height as f64;
438        self.height = height;
439        self.width = (height as f64 * ratio) as u32;
440        self
441    }
442
443    /// Build the image
444    pub fn build(self) -> Image {
445        Image {
446            filename: self.filename,
447            width: self.width,
448            height: self.height,
449            x: self.x,
450            y: self.y,
451            format: self.format,
452            source: self.source,
453            crop: None,
454            effects: Vec::new(),
455        }
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_image_creation() {
465        let img = Image::new("test.png", 1920, 1080, "PNG");
466        assert_eq!(img.filename, "test.png");
467        assert_eq!(img.width, 1920);
468        assert_eq!(img.height, 1080);
469    }
470
471    #[test]
472    fn test_image_position() {
473        let img = Image::new("test.png", 1920, 1080, "PNG")
474            .position(500000, 1000000);
475        assert_eq!(img.x, 500000);
476        assert_eq!(img.y, 1000000);
477    }
478
479    #[test]
480    fn test_image_aspect_ratio() {
481        let img = Image::new("test.png", 1920, 1080, "PNG");
482        let ratio = img.aspect_ratio();
483        assert!((ratio - 1.777).abs() < 0.01);
484    }
485
486    #[test]
487    fn test_image_scale_to_width() {
488        let img = Image::new("test.png", 1920, 1080, "PNG")
489            .scale_to_width(960);
490        assert_eq!(img.width, 960);
491        assert_eq!(img.height, 540);
492    }
493
494    #[test]
495    fn test_image_scale_to_height() {
496        let img = Image::new("test.png", 1920, 1080, "PNG")
497            .scale_to_height(540);
498        assert_eq!(img.width, 960);
499        assert_eq!(img.height, 540);
500    }
501
502    #[test]
503    fn test_image_extension() {
504        let img = Image::new("photo.jpg", 1920, 1080, "JPEG");
505        assert_eq!(img.extension(), "jpg");
506    }
507
508    #[test]
509    fn test_image_mime_types() {
510        assert_eq!(
511            Image::new("test.png", 100, 100, "PNG").mime_type(),
512            "image/png"
513        );
514        assert_eq!(
515            Image::new("test.jpg", 100, 100, "JPG").mime_type(),
516            "image/jpeg"
517        );
518        assert_eq!(
519            Image::new("test.gif", 100, 100, "GIF").mime_type(),
520            "image/gif"
521        );
522    }
523
524    #[test]
525    fn test_image_builder() {
526        let img = ImageBuilder::new("photo.png", 1920, 1080)
527            .position(500000, 1000000)
528            .scale_to_width(960)
529            .build();
530
531        assert_eq!(img.filename, "photo.png");
532        assert_eq!(img.width, 960);
533        assert_eq!(img.height, 540);
534        assert_eq!(img.x, 500000);
535        assert_eq!(img.y, 1000000);
536    }
537
538    #[test]
539    fn test_image_builder_auto_format() {
540        let img = ImageBuilder::new("photo.jpg", 1920, 1080).build();
541        assert_eq!(img.format, "JPG");
542    }
543    
544    #[test]
545    fn test_image_from_base64() {
546        // 1x1 PNG image in base64
547        let base64_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
548        let img = Image::from_base64(base64_png, 100, 100, "PNG");
549        
550        assert!(img.filename.ends_with(".png"));
551        assert_eq!(img.format, "PNG");
552        assert!(matches!(img.source, Some(ImageSource::Base64(_))));
553    }
554    
555    #[test]
556    fn test_image_from_bytes() {
557        let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG header
558        let img = Image::from_bytes(data.clone(), 100, 100, "PNG");
559        
560        assert_eq!(img.format, "PNG");
561        assert!(matches!(img.source, Some(ImageSource::Bytes(_))));
562    }
563    
564    #[test]
565    fn test_base64_decode() {
566        // Test simple base64 decode
567        let result = base64_decode("SGVsbG8=").unwrap();
568        assert_eq!(result, b"Hello");
569        
570        // Test with padding
571        let result = base64_decode("SGVsbG8gV29ybGQ=").unwrap();
572        assert_eq!(result, b"Hello World");
573    }
574    
575    #[test]
576    fn test_image_get_bytes_base64() {
577        let base64_png = "SGVsbG8="; // "Hello" in base64
578        let img = Image::from_base64(base64_png, 100, 100, "PNG");
579        
580        let bytes = img.get_bytes().unwrap();
581        assert_eq!(bytes, b"Hello");
582    }
583    
584    #[test]
585    fn test_image_builder_from_base64() {
586        let base64_data = "SGVsbG8=";
587        let img = ImageBuilder::from_base64(base64_data, 200, 150, "JPEG")
588            .position(1000, 2000)
589            .build();
590        
591        assert_eq!(img.width, 200);
592        assert_eq!(img.height, 150);
593        assert_eq!(img.x, 1000);
594        assert_eq!(img.y, 2000);
595        assert_eq!(img.format, "JPEG");
596    }
597}