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