hwpforge_core/
image.rs

1//! Image types for embedded or referenced images.
2//!
3//! [`Image`] represents an image reference within a document. Core stores
4//! only the path and dimensions -- actual binary data lives in the Smithy
5//! layer (inside the HWPX ZIP or HWP5 BinData stream).
6//!
7//! # Examples
8//!
9//! ```
10//! use hwpforge_core::image::{Image, ImageFormat};
11//! use hwpforge_foundation::HwpUnit;
12//!
13//! let img = Image {
14//!     path: "BinData/image1.png".to_string(),
15//!     width: HwpUnit::from_mm(50.0).unwrap(),
16//!     height: HwpUnit::from_mm(30.0).unwrap(),
17//!     format: ImageFormat::Png,
18//!     caption: None,
19//! };
20//! assert!(img.path.ends_with(".png"));
21//! ```
22
23use std::collections::HashMap;
24
25use hwpforge_foundation::HwpUnit;
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29use crate::caption::Caption;
30
31/// An image reference within the document.
32///
33/// Contains the path to the image resource (relative to the document
34/// package root), its display dimensions, and format hint.
35///
36/// # No Binary Data
37///
38/// Core deliberately holds no image bytes. The Smithy crate resolves
39/// `path` into actual binary data during encode/decode.
40///
41/// # Examples
42///
43/// ```
44/// use hwpforge_core::image::{Image, ImageFormat};
45/// use hwpforge_foundation::HwpUnit;
46///
47/// let img = Image::new(
48///     "BinData/logo.jpeg",
49///     HwpUnit::from_mm(80.0).unwrap(),
50///     HwpUnit::from_mm(40.0).unwrap(),
51///     ImageFormat::Jpeg,
52/// );
53/// assert_eq!(img.format, ImageFormat::Jpeg);
54/// ```
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
56pub struct Image {
57    /// Relative path within the document package (e.g. `"BinData/image1.png"`).
58    pub path: String,
59    /// Display width.
60    pub width: HwpUnit,
61    /// Display height.
62    pub height: HwpUnit,
63    /// Image format hint.
64    pub format: ImageFormat,
65    /// Optional image caption.
66    pub caption: Option<Caption>,
67}
68
69impl Image {
70    /// Creates a new image reference.
71    ///
72    /// # Examples
73    ///
74    /// ```
75    /// use hwpforge_core::image::{Image, ImageFormat};
76    /// use hwpforge_foundation::HwpUnit;
77    ///
78    /// let img = Image::new(
79    ///     "images/photo.png",
80    ///     HwpUnit::from_mm(100.0).unwrap(),
81    ///     HwpUnit::from_mm(75.0).unwrap(),
82    ///     ImageFormat::Png,
83    /// );
84    /// assert_eq!(img.path, "images/photo.png");
85    /// ```
86    pub fn new(
87        path: impl Into<String>,
88        width: HwpUnit,
89        height: HwpUnit,
90        format: ImageFormat,
91    ) -> Self {
92        Self { path: path.into(), width, height, format, caption: None }
93    }
94
95    /// Creates an image reference by inferring the format from the file extension.
96    ///
97    /// The extension is case-insensitive. Unrecognized extensions produce
98    /// [`ImageFormat::Unknown`] containing the lowercase extension string.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use hwpforge_core::image::{Image, ImageFormat};
104    /// use hwpforge_foundation::HwpUnit;
105    ///
106    /// let w = HwpUnit::from_mm(100.0).unwrap();
107    /// let h = HwpUnit::from_mm(75.0).unwrap();
108    ///
109    /// let img = Image::from_path("photos/hero.png", w, h);
110    /// assert_eq!(img.format, ImageFormat::Png);
111    ///
112    /// let img_jpg = Image::from_path("scan.JPG", w, h);
113    /// assert_eq!(img_jpg.format, ImageFormat::Jpeg);
114    ///
115    /// let img_unknown = Image::from_path("diagram.svg", w, h);
116    /// assert_eq!(img_unknown.format, ImageFormat::Unknown("svg".to_string()));
117    /// ```
118    pub fn from_path(path: impl Into<String>, width: HwpUnit, height: HwpUnit) -> Self {
119        let path: String = path.into();
120        let format = ImageFormat::from_extension(&path);
121        Self { path, width, height, format, caption: None }
122    }
123}
124
125impl std::fmt::Display for Image {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(
128            f,
129            "Image({}, {:.1}mm x {:.1}mm)",
130            self.format,
131            self.width.to_mm(),
132            self.height.to_mm()
133        )
134    }
135}
136
137/// Supported image formats.
138///
139/// Marked `#[non_exhaustive]` so new formats can be added in future
140/// phases without a breaking change.
141///
142/// # Examples
143///
144/// ```
145/// use hwpforge_core::image::ImageFormat;
146///
147/// let fmt = ImageFormat::Png;
148/// assert_eq!(fmt.to_string(), "PNG");
149///
150/// let unknown = ImageFormat::Unknown("SVG".to_string());
151/// assert_eq!(unknown.to_string(), "svg");
152/// ```
153#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
154#[non_exhaustive]
155pub enum ImageFormat {
156    /// Portable Network Graphics.
157    Png,
158    /// JPEG.
159    Jpeg,
160    /// Graphics Interchange Format.
161    Gif,
162    /// Windows Bitmap.
163    Bmp,
164    /// Windows Metafile.
165    Wmf,
166    /// Enhanced Metafile.
167    Emf,
168    /// Unrecognized format with its extension or MIME type.
169    Unknown(String),
170}
171
172impl ImageFormat {
173    /// Infers an [`ImageFormat`] from a file path's extension.
174    ///
175    /// The extension is extracted from everything after the last `'.'` in the
176    /// path string and matched case-insensitively. If no dot is found, or the
177    /// extension is not recognized, [`ImageFormat::Unknown`] is returned
178    /// containing the lowercase extension (or an empty string when absent).
179    ///
180    /// # Examples
181    ///
182    /// ```
183    /// use hwpforge_core::image::ImageFormat;
184    ///
185    /// assert_eq!(ImageFormat::from_extension("photo.png"),  ImageFormat::Png);
186    /// assert_eq!(ImageFormat::from_extension("image.JPG"),  ImageFormat::Jpeg);
187    /// assert_eq!(ImageFormat::from_extension("file.jpeg"), ImageFormat::Jpeg);
188    /// assert_eq!(ImageFormat::from_extension("doc.gif"),   ImageFormat::Gif);
189    /// assert_eq!(ImageFormat::from_extension("img.bmp"),   ImageFormat::Bmp);
190    /// assert_eq!(ImageFormat::from_extension("chart.wmf"), ImageFormat::Wmf);
191    /// assert_eq!(ImageFormat::from_extension("dia.emf"),   ImageFormat::Emf);
192    /// assert_eq!(
193    ///     ImageFormat::from_extension("file.xyz"),
194    ///     ImageFormat::Unknown("xyz".to_string()),
195    /// );
196    /// assert_eq!(
197    ///     ImageFormat::from_extension("noext"),
198    ///     ImageFormat::Unknown(String::new()),
199    /// );
200    /// assert_eq!(ImageFormat::from_extension("multi.dot.png"), ImageFormat::Png);
201    /// ```
202    pub fn from_extension(path: &str) -> Self {
203        // Only treat the suffix as an extension if a dot is actually present.
204        let ext_lower = path.rfind('.').map(|i| path[i + 1..].to_ascii_lowercase());
205        match ext_lower.as_deref() {
206            Some("png") => Self::Png,
207            Some("jpg" | "jpeg") => Self::Jpeg,
208            Some("gif") => Self::Gif,
209            Some("bmp") => Self::Bmp,
210            Some("wmf") => Self::Wmf,
211            Some("emf") => Self::Emf,
212            Some(ext) => Self::Unknown(ext.to_string()),
213            None => Self::Unknown(String::new()),
214        }
215    }
216}
217
218impl std::fmt::Display for ImageFormat {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        match self {
221            Self::Png => write!(f, "PNG"),
222            Self::Jpeg => write!(f, "JPEG"),
223            Self::Gif => write!(f, "GIF"),
224            Self::Bmp => write!(f, "BMP"),
225            Self::Wmf => write!(f, "WMF"),
226            Self::Emf => write!(f, "EMF"),
227            Self::Unknown(s) => {
228                let lower = s.to_ascii_lowercase();
229                write!(f, "{lower}")
230            }
231        }
232    }
233}
234
235// ---------------------------------------------------------------------------
236// ImageStore
237// ---------------------------------------------------------------------------
238
239/// Storage for binary image data keyed by path.
240///
241/// Maps image paths (e.g. `"image1.jpg"`) to their binary content.
242/// Used by the encoder to embed images into HWPX archives and by the
243/// decoder to extract them.
244///
245/// # Examples
246///
247/// ```
248/// use hwpforge_core::image::ImageStore;
249///
250/// let mut store = ImageStore::new();
251/// store.insert("logo.png", vec![0x89, 0x50, 0x4E, 0x47]);
252/// assert_eq!(store.len(), 1);
253/// assert!(store.get("logo.png").is_some());
254/// ```
255#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
256pub struct ImageStore {
257    images: HashMap<String, Vec<u8>>,
258}
259
260impl ImageStore {
261    /// Creates an empty image store.
262    pub fn new() -> Self {
263        Self { images: HashMap::new() }
264    }
265
266    /// Inserts an image with the given key and binary data.
267    ///
268    /// If the key already exists, the data is replaced.
269    pub fn insert(&mut self, key: impl Into<String>, data: Vec<u8>) {
270        self.images.insert(key.into(), data);
271    }
272
273    /// Returns the binary data for the given key, if present.
274    pub fn get(&self, key: &str) -> Option<&[u8]> {
275        self.images.get(key).map(|v| v.as_slice())
276    }
277
278    /// Returns the number of stored images.
279    pub fn len(&self) -> usize {
280        self.images.len()
281    }
282
283    /// Returns `true` if the store contains no images.
284    pub fn is_empty(&self) -> bool {
285        self.images.is_empty()
286    }
287
288    /// Iterates over all `(key, data)` pairs.
289    pub fn iter(&self) -> impl Iterator<Item = (&str, &[u8])> {
290        self.images.iter().map(|(k, v)| (k.as_str(), v.as_slice()))
291    }
292}
293
294impl FromIterator<(String, Vec<u8>)> for ImageStore {
295    fn from_iter<I: IntoIterator<Item = (String, Vec<u8>)>>(iter: I) -> Self {
296        Self { images: iter.into_iter().collect() }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn sample_image() -> Image {
305        Image::new(
306            "BinData/image1.png",
307            HwpUnit::from_mm(50.0).unwrap(),
308            HwpUnit::from_mm(30.0).unwrap(),
309            ImageFormat::Png,
310        )
311    }
312
313    #[test]
314    fn new_constructor() {
315        let img = sample_image();
316        assert_eq!(img.path, "BinData/image1.png");
317        assert_eq!(img.format, ImageFormat::Png);
318    }
319
320    #[test]
321    fn struct_literal_construction() {
322        let img = Image {
323            path: "test.jpeg".to_string(),
324            width: HwpUnit::from_mm(10.0).unwrap(),
325            height: HwpUnit::from_mm(10.0).unwrap(),
326            format: ImageFormat::Jpeg,
327            caption: None,
328        };
329        assert_eq!(img.format, ImageFormat::Jpeg);
330    }
331
332    #[test]
333    fn display_format() {
334        let img = sample_image();
335        let s = img.to_string();
336        assert!(s.contains("PNG"), "display: {s}");
337        assert!(s.contains("50.0"), "display: {s}");
338        assert!(s.contains("30.0"), "display: {s}");
339    }
340
341    #[test]
342    fn image_format_display() {
343        assert_eq!(ImageFormat::Png.to_string(), "PNG");
344        assert_eq!(ImageFormat::Jpeg.to_string(), "JPEG");
345        assert_eq!(ImageFormat::Gif.to_string(), "GIF");
346        assert_eq!(ImageFormat::Bmp.to_string(), "BMP");
347        assert_eq!(ImageFormat::Wmf.to_string(), "WMF");
348        assert_eq!(ImageFormat::Emf.to_string(), "EMF");
349        assert_eq!(ImageFormat::Unknown("TIFF".to_string()).to_string(), "tiff");
350    }
351
352    #[test]
353    fn equality() {
354        let a = sample_image();
355        let b = sample_image();
356        assert_eq!(a, b);
357    }
358
359    #[test]
360    fn inequality_on_different_paths() {
361        let a = sample_image();
362        let mut b = sample_image();
363        b.path = "other.png".to_string();
364        assert_ne!(a, b);
365    }
366
367    #[test]
368    fn clone_independence() {
369        let img = sample_image();
370        let mut cloned = img.clone();
371        cloned.path = "modified.png".to_string();
372        assert_eq!(img.path, "BinData/image1.png");
373    }
374
375    #[test]
376    fn serde_roundtrip() {
377        let img = sample_image();
378        let json = serde_json::to_string(&img).unwrap();
379        let back: Image = serde_json::from_str(&json).unwrap();
380        assert_eq!(img, back);
381    }
382
383    #[test]
384    fn serde_unknown_format_roundtrip() {
385        let img = Image::new(
386            "test.svg",
387            HwpUnit::from_mm(10.0).unwrap(),
388            HwpUnit::from_mm(10.0).unwrap(),
389            ImageFormat::Unknown("SVG".to_string()),
390        );
391        let json = serde_json::to_string(&img).unwrap();
392        let back: Image = serde_json::from_str(&json).unwrap();
393        assert_eq!(img, back);
394    }
395
396    #[test]
397    fn image_format_hash() {
398        use std::collections::HashSet;
399        let mut set = HashSet::new();
400        set.insert(ImageFormat::Png);
401        set.insert(ImageFormat::Jpeg);
402        set.insert(ImageFormat::Png);
403        assert_eq!(set.len(), 2);
404    }
405
406    #[test]
407    fn from_string_path() {
408        let path = String::from("dynamic/path.bmp");
409        let img = Image::new(path, HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Bmp);
410        assert_eq!(img.path, "dynamic/path.bmp");
411    }
412
413    // -----------------------------------------------------------------------
414    // ImageStore tests
415    // -----------------------------------------------------------------------
416
417    #[test]
418    fn image_store_new_is_empty() {
419        let store = ImageStore::new();
420        assert!(store.is_empty());
421        assert_eq!(store.len(), 0);
422    }
423
424    #[test]
425    fn image_store_insert_and_get() {
426        let mut store = ImageStore::new();
427        store.insert("logo.png", vec![0x89, 0x50, 0x4E, 0x47]);
428        assert_eq!(store.len(), 1);
429        assert!(!store.is_empty());
430        assert_eq!(store.get("logo.png"), Some(&[0x89, 0x50, 0x4E, 0x47][..]));
431    }
432
433    #[test]
434    fn image_store_get_missing() {
435        let store = ImageStore::new();
436        assert!(store.get("nonexistent.png").is_none());
437    }
438
439    #[test]
440    fn image_store_insert_replaces() {
441        let mut store = ImageStore::new();
442        store.insert("img.png", vec![1, 2, 3]);
443        store.insert("img.png", vec![4, 5, 6]);
444        assert_eq!(store.len(), 1);
445        assert_eq!(store.get("img.png"), Some(&[4, 5, 6][..]));
446    }
447
448    #[test]
449    fn image_store_multiple_images() {
450        let mut store = ImageStore::new();
451        store.insert("a.png", vec![1]);
452        store.insert("b.jpg", vec![2]);
453        store.insert("c.gif", vec![3]);
454        assert_eq!(store.len(), 3);
455    }
456
457    #[test]
458    fn image_store_iter() {
459        let mut store = ImageStore::new();
460        store.insert("a.png", vec![1]);
461        store.insert("b.jpg", vec![2]);
462        let pairs: Vec<_> = store.iter().collect();
463        assert_eq!(pairs.len(), 2);
464    }
465
466    #[test]
467    fn image_store_from_iterator() {
468        let items = vec![("a.png".to_string(), vec![1, 2]), ("b.jpg".to_string(), vec![3, 4])];
469        let store: ImageStore = items.into_iter().collect();
470        assert_eq!(store.len(), 2);
471        assert_eq!(store.get("a.png"), Some(&[1, 2][..]));
472    }
473
474    #[test]
475    fn image_store_default() {
476        let store = ImageStore::default();
477        assert!(store.is_empty());
478    }
479
480    #[test]
481    fn image_store_clone_independence() {
482        let mut store = ImageStore::new();
483        store.insert("img.png", vec![1, 2, 3]);
484        let mut cloned = store.clone();
485        cloned.insert("other.png", vec![4, 5]);
486        assert_eq!(store.len(), 1);
487        assert_eq!(cloned.len(), 2);
488    }
489
490    #[test]
491    fn image_store_equality() {
492        let mut a = ImageStore::new();
493        a.insert("img.png", vec![1, 2, 3]);
494        let mut b = ImageStore::new();
495        b.insert("img.png", vec![1, 2, 3]);
496        assert_eq!(a, b);
497    }
498
499    #[test]
500    fn image_store_serde_roundtrip() {
501        let mut store = ImageStore::new();
502        store.insert("logo.png", vec![0x89, 0x50]);
503        let json = serde_json::to_string(&store).unwrap();
504        let back: ImageStore = serde_json::from_str(&json).unwrap();
505        assert_eq!(store, back);
506    }
507
508    #[test]
509    fn image_store_string_key() {
510        let mut store = ImageStore::new();
511        let key = String::from("dynamic/path.png");
512        store.insert(key, vec![42]);
513        assert!(store.get("dynamic/path.png").is_some());
514    }
515
516    // -----------------------------------------------------------------------
517    // ImageFormat::from_extension tests
518    // -----------------------------------------------------------------------
519
520    #[test]
521    fn from_extension_png() {
522        assert_eq!(ImageFormat::from_extension("photo.png"), ImageFormat::Png);
523    }
524
525    #[test]
526    fn from_extension_jpg_uppercase() {
527        assert_eq!(ImageFormat::from_extension("image.JPG"), ImageFormat::Jpeg);
528    }
529
530    #[test]
531    fn from_extension_jpeg() {
532        assert_eq!(ImageFormat::from_extension("file.jpeg"), ImageFormat::Jpeg);
533    }
534
535    #[test]
536    fn from_extension_gif() {
537        assert_eq!(ImageFormat::from_extension("doc.gif"), ImageFormat::Gif);
538    }
539
540    #[test]
541    fn from_extension_bmp() {
542        assert_eq!(ImageFormat::from_extension("img.bmp"), ImageFormat::Bmp);
543    }
544
545    #[test]
546    fn from_extension_wmf() {
547        assert_eq!(ImageFormat::from_extension("chart.wmf"), ImageFormat::Wmf);
548    }
549
550    #[test]
551    fn from_extension_emf() {
552        assert_eq!(ImageFormat::from_extension("dia.emf"), ImageFormat::Emf);
553    }
554
555    #[test]
556    fn from_extension_unknown() {
557        assert_eq!(
558            ImageFormat::from_extension("file.xyz"),
559            ImageFormat::Unknown("xyz".to_string()),
560        );
561    }
562
563    #[test]
564    fn from_extension_no_extension() {
565        assert_eq!(ImageFormat::from_extension("noext"), ImageFormat::Unknown(String::new()));
566    }
567
568    #[test]
569    fn from_extension_multi_dot() {
570        assert_eq!(ImageFormat::from_extension("multi.dot.png"), ImageFormat::Png);
571    }
572
573    // -----------------------------------------------------------------------
574    // Image::from_path tests
575    // -----------------------------------------------------------------------
576
577    #[test]
578    fn from_path_infers_format() {
579        let w = HwpUnit::from_mm(100.0).unwrap();
580        let h = HwpUnit::from_mm(75.0).unwrap();
581
582        let img = Image::from_path("photos/hero.png", w, h);
583        assert_eq!(img.format, ImageFormat::Png);
584        assert_eq!(img.path, "photos/hero.png");
585        assert_eq!(img.width, w);
586        assert_eq!(img.height, h);
587        assert!(img.caption.is_none());
588    }
589
590    #[test]
591    fn from_path_jpeg_uppercase() {
592        let w = HwpUnit::ZERO;
593        let h = HwpUnit::ZERO;
594        let img = Image::from_path("scan.JPG", w, h);
595        assert_eq!(img.format, ImageFormat::Jpeg);
596    }
597
598    #[test]
599    fn from_path_unknown_extension() {
600        let w = HwpUnit::ZERO;
601        let h = HwpUnit::ZERO;
602        let img = Image::from_path("diagram.svg", w, h);
603        assert_eq!(img.format, ImageFormat::Unknown("svg".to_string()));
604    }
605
606    #[test]
607    fn from_path_string_owned() {
608        let w = HwpUnit::ZERO;
609        let h = HwpUnit::ZERO;
610        let path = String::from("owned/path.bmp");
611        let img = Image::from_path(path, w, h);
612        assert_eq!(img.format, ImageFormat::Bmp);
613        assert_eq!(img.path, "owned/path.bmp");
614    }
615
616    #[test]
617    fn unknown_format_display_normalizes_to_lowercase() {
618        assert_eq!(ImageFormat::Unknown("SVG".to_string()).to_string(), "svg");
619        assert_eq!(ImageFormat::Unknown("Tiff".to_string()).to_string(), "tiff");
620        assert_eq!(ImageFormat::Unknown("webp".to_string()).to_string(), "webp");
621    }
622
623    #[test]
624    fn unknown_format_casing_inequality() {
625        // Unknown preserves the stored string for equality, even though display normalizes
626        let upper = ImageFormat::Unknown("SVG".to_string());
627        let lower = ImageFormat::Unknown("svg".to_string());
628        assert_ne!(upper, lower, "Different casing in Unknown produces inequality");
629        // But display output is identical
630        assert_eq!(upper.to_string(), lower.to_string());
631    }
632}