Skip to main content

sheetkit_core/
image.rs

1//! Image insertion and management.
2//!
3//! Provides types for configuring image placement in worksheets and
4//! helpers for building the corresponding drawing XML structures.
5
6use sheetkit_xml::drawing::{
7    AExt, Blip, BlipFill, CNvPicPr, CNvPr, ClientData, Extent, FillRect, MarkerType, NvPicPr,
8    Offset, OneCellAnchor, Picture, PrstGeom, SpPr, Stretch, WsDr, Xfrm,
9};
10
11use crate::error::{Error, Result};
12use crate::utils::cell_ref::cell_name_to_coordinates;
13
14/// EMU (English Metric Units) per pixel at 96 DPI.
15/// 1 inch = 914400 EMU, 1 inch = 96 pixels => 1 pixel = 9525 EMU.
16pub const EMU_PER_PIXEL: u64 = 9525;
17
18/// Supported image formats.
19#[derive(Debug, Clone, PartialEq)]
20pub enum ImageFormat {
21    /// PNG image.
22    Png,
23    /// JPEG image.
24    Jpeg,
25    /// GIF image.
26    Gif,
27    /// BMP image.
28    Bmp,
29    /// ICO image.
30    Ico,
31    /// TIFF image.
32    Tiff,
33    /// SVG image.
34    Svg,
35    /// EMF (Enhanced Metafile) image.
36    Emf,
37    /// EMZ (compressed EMF) image.
38    Emz,
39    /// WMF (Windows Metafile) image.
40    Wmf,
41    /// WMZ (compressed WMF) image.
42    Wmz,
43}
44
45impl ImageFormat {
46    /// Parse an extension string into an `ImageFormat`.
47    ///
48    /// Accepts common aliases such as `"jpg"` for JPEG and `"tif"` for TIFF.
49    /// Returns `Error::UnsupportedImageFormat` for unrecognised strings.
50    pub fn from_extension(ext: &str) -> Result<Self> {
51        match ext.to_ascii_lowercase().as_str() {
52            "png" => Ok(ImageFormat::Png),
53            "jpeg" | "jpg" => Ok(ImageFormat::Jpeg),
54            "gif" => Ok(ImageFormat::Gif),
55            "bmp" => Ok(ImageFormat::Bmp),
56            "ico" => Ok(ImageFormat::Ico),
57            "tiff" | "tif" => Ok(ImageFormat::Tiff),
58            "svg" => Ok(ImageFormat::Svg),
59            "emf" => Ok(ImageFormat::Emf),
60            "emz" => Ok(ImageFormat::Emz),
61            "wmf" => Ok(ImageFormat::Wmf),
62            "wmz" => Ok(ImageFormat::Wmz),
63            _ => Err(Error::UnsupportedImageFormat {
64                format: ext.to_string(),
65            }),
66        }
67    }
68
69    /// Return the MIME content type string for this image format.
70    pub fn content_type(&self) -> &str {
71        match self {
72            ImageFormat::Png => "image/png",
73            ImageFormat::Jpeg => "image/jpeg",
74            ImageFormat::Gif => "image/gif",
75            ImageFormat::Bmp => "image/bmp",
76            ImageFormat::Ico => "image/x-icon",
77            ImageFormat::Tiff => "image/tiff",
78            ImageFormat::Svg => "image/svg+xml",
79            ImageFormat::Emf => "image/x-emf",
80            ImageFormat::Emz => "image/x-emz",
81            ImageFormat::Wmf => "image/x-wmf",
82            ImageFormat::Wmz => "image/x-wmz",
83        }
84    }
85
86    /// Return the file extension for this image format.
87    pub fn extension(&self) -> &str {
88        match self {
89            ImageFormat::Png => "png",
90            ImageFormat::Jpeg => "jpeg",
91            ImageFormat::Gif => "gif",
92            ImageFormat::Bmp => "bmp",
93            ImageFormat::Ico => "ico",
94            ImageFormat::Tiff => "tiff",
95            ImageFormat::Svg => "svg",
96            ImageFormat::Emf => "emf",
97            ImageFormat::Emz => "emz",
98            ImageFormat::Wmf => "wmf",
99            ImageFormat::Wmz => "wmz",
100        }
101    }
102}
103
104/// Configuration for inserting an image into a worksheet.
105#[derive(Debug, Clone)]
106pub struct ImageConfig {
107    /// Raw image bytes.
108    pub data: Vec<u8>,
109    /// Image format.
110    pub format: ImageFormat,
111    /// Anchor cell reference (e.g., `"B2"`).
112    pub from_cell: String,
113    /// Image width in pixels.
114    pub width_px: u32,
115    /// Image height in pixels.
116    pub height_px: u32,
117}
118
119/// Convert pixel dimensions to EMU.
120pub fn pixels_to_emu(px: u32) -> u64 {
121    px as u64 * EMU_PER_PIXEL
122}
123
124/// Build a drawing XML structure containing a single image.
125///
126/// The image is anchored to a single cell (one-cell anchor) with explicit
127/// width and height in EMU derived from pixel dimensions.
128pub fn build_drawing_with_image(image_ref_id: &str, config: &ImageConfig) -> Result<WsDr> {
129    let (col, row) = cell_name_to_coordinates(&config.from_cell)?;
130    // MarkerType uses 0-based column and row indices
131    let from = MarkerType {
132        col: col - 1,
133        col_off: 0,
134        row: row - 1,
135        row_off: 0,
136    };
137
138    let cx = pixels_to_emu(config.width_px);
139    let cy = pixels_to_emu(config.height_px);
140
141    let pic = Picture {
142        nv_pic_pr: NvPicPr {
143            c_nv_pr: CNvPr {
144                id: 2,
145                name: "Picture 1".to_string(),
146            },
147            c_nv_pic_pr: CNvPicPr {},
148        },
149        blip_fill: BlipFill {
150            blip: Blip {
151                r_embed: image_ref_id.to_string(),
152            },
153            stretch: Stretch {
154                fill_rect: FillRect {},
155            },
156        },
157        sp_pr: SpPr {
158            xfrm: Xfrm {
159                off: Offset { x: 0, y: 0 },
160                ext: AExt { cx, cy },
161            },
162            prst_geom: PrstGeom {
163                prst: "rect".to_string(),
164            },
165        },
166    };
167
168    let anchor = OneCellAnchor {
169        from,
170        ext: Extent { cx, cy },
171        pic: Some(pic),
172        client_data: ClientData {},
173    };
174
175    Ok(WsDr {
176        one_cell_anchors: vec![anchor],
177        ..WsDr::default()
178    })
179}
180
181/// Add an image anchor to an existing drawing.
182///
183/// If a drawing already exists for a sheet (e.g., it already has a chart),
184/// this function adds the image anchor to it.
185pub fn add_image_to_drawing(
186    drawing: &mut WsDr,
187    image_ref_id: &str,
188    config: &ImageConfig,
189    pic_id: u32,
190) -> Result<()> {
191    let (col, row) = cell_name_to_coordinates(&config.from_cell)?;
192    let from = MarkerType {
193        col: col - 1,
194        col_off: 0,
195        row: row - 1,
196        row_off: 0,
197    };
198
199    let cx = pixels_to_emu(config.width_px);
200    let cy = pixels_to_emu(config.height_px);
201
202    let pic = Picture {
203        nv_pic_pr: NvPicPr {
204            c_nv_pr: CNvPr {
205                id: pic_id,
206                name: format!("Picture {}", pic_id - 1),
207            },
208            c_nv_pic_pr: CNvPicPr {},
209        },
210        blip_fill: BlipFill {
211            blip: Blip {
212                r_embed: image_ref_id.to_string(),
213            },
214            stretch: Stretch {
215                fill_rect: FillRect {},
216            },
217        },
218        sp_pr: SpPr {
219            xfrm: Xfrm {
220                off: Offset { x: 0, y: 0 },
221                ext: AExt { cx, cy },
222            },
223            prst_geom: PrstGeom {
224                prst: "rect".to_string(),
225            },
226        },
227    };
228
229    drawing.one_cell_anchors.push(OneCellAnchor {
230        from,
231        ext: Extent { cx, cy },
232        pic: Some(pic),
233        client_data: ClientData {},
234    });
235
236    Ok(())
237}
238
239/// Validate an ImageConfig.
240pub fn validate_image_config(config: &ImageConfig) -> Result<()> {
241    if config.data.is_empty() {
242        return Err(Error::Internal("image data is empty".to_string()));
243    }
244    if config.width_px == 0 || config.height_px == 0 {
245        return Err(Error::Internal(
246            "image dimensions must be non-zero".to_string(),
247        ));
248    }
249    // Validate the cell reference
250    cell_name_to_coordinates(&config.from_cell)?;
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_emu_per_pixel_constant() {
260        assert_eq!(EMU_PER_PIXEL, 9525);
261    }
262
263    #[test]
264    fn test_pixels_to_emu() {
265        assert_eq!(pixels_to_emu(1), 9525);
266        assert_eq!(pixels_to_emu(100), 952500);
267        assert_eq!(pixels_to_emu(1000), 9525000);
268        assert_eq!(pixels_to_emu(0), 0);
269    }
270
271    #[test]
272    fn test_image_format_content_type_original() {
273        assert_eq!(ImageFormat::Png.content_type(), "image/png");
274        assert_eq!(ImageFormat::Jpeg.content_type(), "image/jpeg");
275        assert_eq!(ImageFormat::Gif.content_type(), "image/gif");
276    }
277
278    #[test]
279    fn test_image_format_content_type_new_formats() {
280        assert_eq!(ImageFormat::Bmp.content_type(), "image/bmp");
281        assert_eq!(ImageFormat::Ico.content_type(), "image/x-icon");
282        assert_eq!(ImageFormat::Tiff.content_type(), "image/tiff");
283        assert_eq!(ImageFormat::Svg.content_type(), "image/svg+xml");
284        assert_eq!(ImageFormat::Emf.content_type(), "image/x-emf");
285        assert_eq!(ImageFormat::Emz.content_type(), "image/x-emz");
286        assert_eq!(ImageFormat::Wmf.content_type(), "image/x-wmf");
287        assert_eq!(ImageFormat::Wmz.content_type(), "image/x-wmz");
288    }
289
290    #[test]
291    fn test_image_format_extension_original() {
292        assert_eq!(ImageFormat::Png.extension(), "png");
293        assert_eq!(ImageFormat::Jpeg.extension(), "jpeg");
294        assert_eq!(ImageFormat::Gif.extension(), "gif");
295    }
296
297    #[test]
298    fn test_image_format_extension_new_formats() {
299        assert_eq!(ImageFormat::Bmp.extension(), "bmp");
300        assert_eq!(ImageFormat::Ico.extension(), "ico");
301        assert_eq!(ImageFormat::Tiff.extension(), "tiff");
302        assert_eq!(ImageFormat::Svg.extension(), "svg");
303        assert_eq!(ImageFormat::Emf.extension(), "emf");
304        assert_eq!(ImageFormat::Emz.extension(), "emz");
305        assert_eq!(ImageFormat::Wmf.extension(), "wmf");
306        assert_eq!(ImageFormat::Wmz.extension(), "wmz");
307    }
308
309    #[test]
310    fn test_from_extension_original_formats() {
311        assert_eq!(
312            ImageFormat::from_extension("png").unwrap(),
313            ImageFormat::Png
314        );
315        assert_eq!(
316            ImageFormat::from_extension("jpeg").unwrap(),
317            ImageFormat::Jpeg
318        );
319        assert_eq!(
320            ImageFormat::from_extension("jpg").unwrap(),
321            ImageFormat::Jpeg
322        );
323        assert_eq!(
324            ImageFormat::from_extension("gif").unwrap(),
325            ImageFormat::Gif
326        );
327    }
328
329    #[test]
330    fn test_from_extension_new_formats() {
331        assert_eq!(
332            ImageFormat::from_extension("bmp").unwrap(),
333            ImageFormat::Bmp
334        );
335        assert_eq!(
336            ImageFormat::from_extension("ico").unwrap(),
337            ImageFormat::Ico
338        );
339        assert_eq!(
340            ImageFormat::from_extension("tiff").unwrap(),
341            ImageFormat::Tiff
342        );
343        assert_eq!(
344            ImageFormat::from_extension("tif").unwrap(),
345            ImageFormat::Tiff
346        );
347        assert_eq!(
348            ImageFormat::from_extension("svg").unwrap(),
349            ImageFormat::Svg
350        );
351        assert_eq!(
352            ImageFormat::from_extension("emf").unwrap(),
353            ImageFormat::Emf
354        );
355        assert_eq!(
356            ImageFormat::from_extension("emz").unwrap(),
357            ImageFormat::Emz
358        );
359        assert_eq!(
360            ImageFormat::from_extension("wmf").unwrap(),
361            ImageFormat::Wmf
362        );
363        assert_eq!(
364            ImageFormat::from_extension("wmz").unwrap(),
365            ImageFormat::Wmz
366        );
367    }
368
369    #[test]
370    fn test_from_extension_case_insensitive() {
371        assert_eq!(
372            ImageFormat::from_extension("PNG").unwrap(),
373            ImageFormat::Png
374        );
375        assert_eq!(
376            ImageFormat::from_extension("Jpeg").unwrap(),
377            ImageFormat::Jpeg
378        );
379        assert_eq!(
380            ImageFormat::from_extension("TIFF").unwrap(),
381            ImageFormat::Tiff
382        );
383        assert_eq!(
384            ImageFormat::from_extension("SVG").unwrap(),
385            ImageFormat::Svg
386        );
387        assert_eq!(
388            ImageFormat::from_extension("Emf").unwrap(),
389            ImageFormat::Emf
390        );
391    }
392
393    #[test]
394    fn test_from_extension_unknown_returns_error() {
395        let result = ImageFormat::from_extension("webp");
396        assert!(result.is_err());
397        let err = result.unwrap_err();
398        assert!(matches!(err, Error::UnsupportedImageFormat { .. }));
399        assert!(err.to_string().contains("webp"));
400    }
401
402    #[test]
403    fn test_from_extension_empty_returns_error() {
404        let result = ImageFormat::from_extension("");
405        assert!(result.is_err());
406        assert!(matches!(
407            result.unwrap_err(),
408            Error::UnsupportedImageFormat { .. }
409        ));
410    }
411
412    #[test]
413    fn test_from_extension_roundtrip() {
414        let formats = [
415            ImageFormat::Png,
416            ImageFormat::Jpeg,
417            ImageFormat::Gif,
418            ImageFormat::Bmp,
419            ImageFormat::Ico,
420            ImageFormat::Tiff,
421            ImageFormat::Svg,
422            ImageFormat::Emf,
423            ImageFormat::Emz,
424            ImageFormat::Wmf,
425            ImageFormat::Wmz,
426        ];
427        for fmt in &formats {
428            let ext = fmt.extension();
429            let parsed = ImageFormat::from_extension(ext).unwrap();
430            assert_eq!(&parsed, fmt);
431        }
432    }
433
434    #[test]
435    fn test_build_drawing_with_image() {
436        let config = ImageConfig {
437            data: vec![0x89, 0x50, 0x4E, 0x47],
438            format: ImageFormat::Png,
439            from_cell: "B2".to_string(),
440            width_px: 400,
441            height_px: 300,
442        };
443
444        let dr = build_drawing_with_image("rId1", &config).unwrap();
445
446        assert!(dr.two_cell_anchors.is_empty());
447        assert_eq!(dr.one_cell_anchors.len(), 1);
448
449        let anchor = &dr.one_cell_anchors[0];
450        assert_eq!(anchor.from.col, 1);
451        assert_eq!(anchor.from.row, 1);
452        assert_eq!(anchor.ext.cx, 400 * 9525);
453        assert_eq!(anchor.ext.cy, 300 * 9525);
454
455        let pic = anchor.pic.as_ref().unwrap();
456        assert_eq!(pic.blip_fill.blip.r_embed, "rId1");
457        assert_eq!(pic.sp_pr.prst_geom.prst, "rect");
458    }
459
460    #[test]
461    fn test_build_drawing_with_image_a1() {
462        let config = ImageConfig {
463            data: vec![0xFF, 0xD8],
464            format: ImageFormat::Jpeg,
465            from_cell: "A1".to_string(),
466            width_px: 200,
467            height_px: 100,
468        };
469
470        let dr = build_drawing_with_image("rId2", &config).unwrap();
471        let anchor = &dr.one_cell_anchors[0];
472        assert_eq!(anchor.from.col, 0);
473        assert_eq!(anchor.from.row, 0);
474    }
475
476    #[test]
477    fn test_build_drawing_with_image_invalid_cell() {
478        let config = ImageConfig {
479            data: vec![0x89],
480            format: ImageFormat::Png,
481            from_cell: "INVALID".to_string(),
482            width_px: 100,
483            height_px: 100,
484        };
485
486        let result = build_drawing_with_image("rId1", &config);
487        assert!(result.is_err());
488    }
489
490    #[test]
491    fn test_build_drawing_with_new_format() {
492        let config = ImageConfig {
493            data: vec![0x42, 0x4D],
494            format: ImageFormat::Bmp,
495            from_cell: "D4".to_string(),
496            width_px: 320,
497            height_px: 240,
498        };
499
500        let dr = build_drawing_with_image("rId1", &config).unwrap();
501        assert_eq!(dr.one_cell_anchors.len(), 1);
502        let anchor = &dr.one_cell_anchors[0];
503        assert_eq!(anchor.from.col, 3);
504        assert_eq!(anchor.from.row, 3);
505        assert_eq!(anchor.ext.cx, 320 * 9525);
506        assert_eq!(anchor.ext.cy, 240 * 9525);
507    }
508
509    #[test]
510    fn test_validate_image_config_ok() {
511        let config = ImageConfig {
512            data: vec![1, 2, 3],
513            format: ImageFormat::Png,
514            from_cell: "A1".to_string(),
515            width_px: 100,
516            height_px: 100,
517        };
518        assert!(validate_image_config(&config).is_ok());
519    }
520
521    #[test]
522    fn test_validate_image_config_new_format_ok() {
523        let config = ImageConfig {
524            data: vec![1, 2, 3],
525            format: ImageFormat::Svg,
526            from_cell: "A1".to_string(),
527            width_px: 100,
528            height_px: 100,
529        };
530        assert!(validate_image_config(&config).is_ok());
531    }
532
533    #[test]
534    fn test_validate_image_config_empty_data() {
535        let config = ImageConfig {
536            data: vec![],
537            format: ImageFormat::Png,
538            from_cell: "A1".to_string(),
539            width_px: 100,
540            height_px: 100,
541        };
542        assert!(validate_image_config(&config).is_err());
543    }
544
545    #[test]
546    fn test_validate_image_config_zero_width() {
547        let config = ImageConfig {
548            data: vec![1],
549            format: ImageFormat::Png,
550            from_cell: "A1".to_string(),
551            width_px: 0,
552            height_px: 100,
553        };
554        assert!(validate_image_config(&config).is_err());
555    }
556
557    #[test]
558    fn test_validate_image_config_zero_height() {
559        let config = ImageConfig {
560            data: vec![1],
561            format: ImageFormat::Png,
562            from_cell: "A1".to_string(),
563            width_px: 100,
564            height_px: 0,
565        };
566        assert!(validate_image_config(&config).is_err());
567    }
568
569    #[test]
570    fn test_validate_image_config_invalid_cell() {
571        let config = ImageConfig {
572            data: vec![1],
573            format: ImageFormat::Png,
574            from_cell: "ZZZZZ0".to_string(),
575            width_px: 100,
576            height_px: 100,
577        };
578        assert!(validate_image_config(&config).is_err());
579    }
580
581    #[test]
582    fn test_add_image_to_existing_drawing() {
583        let mut dr = WsDr::default();
584
585        let config = ImageConfig {
586            data: vec![1, 2, 3],
587            format: ImageFormat::Png,
588            from_cell: "C5".to_string(),
589            width_px: 200,
590            height_px: 150,
591        };
592
593        add_image_to_drawing(&mut dr, "rId3", &config, 3).unwrap();
594
595        assert_eq!(dr.one_cell_anchors.len(), 1);
596        let anchor = &dr.one_cell_anchors[0];
597        assert_eq!(anchor.from.col, 2);
598        assert_eq!(anchor.from.row, 4);
599        assert_eq!(
600            anchor.pic.as_ref().unwrap().nv_pic_pr.c_nv_pr.name,
601            "Picture 2"
602        );
603    }
604
605    #[test]
606    fn test_emu_calculation_accuracy() {
607        assert_eq!(pixels_to_emu(96), 914400);
608    }
609}