Skip to main content

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::new(
14//!     "BinData/image1.png",
15//!     HwpUnit::from_mm(50.0).unwrap(),
16//!     HwpUnit::from_mm(30.0).unwrap(),
17//!     ImageFormat::Png,
18//! );
19//! assert!(img.path.ends_with(".png"));
20//! ```
21
22use std::borrow::Cow;
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)]
56#[non_exhaustive]
57pub struct Image {
58    /// Relative path within the document package (e.g. `"BinData/image1.png"`).
59    pub path: String,
60    /// Display width.
61    pub width: HwpUnit,
62    /// Display height.
63    pub height: HwpUnit,
64    /// Image format hint.
65    pub format: ImageFormat,
66    /// Optional image caption.
67    pub caption: Option<Caption>,
68    /// Optional placement/presentation metadata.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub placement: Option<ImagePlacement>,
71}
72
73impl Image {
74    /// Creates a new image reference.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use hwpforge_core::image::{Image, ImageFormat};
80    /// use hwpforge_foundation::HwpUnit;
81    ///
82    /// let img = Image::new(
83    ///     "images/photo.png",
84    ///     HwpUnit::from_mm(100.0).unwrap(),
85    ///     HwpUnit::from_mm(75.0).unwrap(),
86    ///     ImageFormat::Png,
87    /// );
88    /// assert_eq!(img.path, "images/photo.png");
89    /// ```
90    #[must_use]
91    pub fn new(
92        path: impl Into<String>,
93        width: HwpUnit,
94        height: HwpUnit,
95        format: ImageFormat,
96    ) -> Self {
97        Self { path: path.into(), width, height, format, caption: None, placement: None }
98    }
99
100    /// Creates an image reference by inferring the format from the file extension.
101    ///
102    /// The extension is case-insensitive. Unrecognized extensions produce
103    /// [`ImageFormat::Unknown`] containing the lowercase extension string.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use hwpforge_core::image::{Image, ImageFormat};
109    /// use hwpforge_foundation::HwpUnit;
110    ///
111    /// let w = HwpUnit::from_mm(100.0).unwrap();
112    /// let h = HwpUnit::from_mm(75.0).unwrap();
113    ///
114    /// let img = Image::from_path("photos/hero.png", w, h);
115    /// assert_eq!(img.format, ImageFormat::Png);
116    ///
117    /// let img_jpg = Image::from_path("scan.JPG", w, h);
118    /// assert_eq!(img_jpg.format, ImageFormat::Jpeg);
119    ///
120    /// let img_unknown = Image::from_path("diagram.svg", w, h);
121    /// assert_eq!(img_unknown.format, ImageFormat::Unknown("svg".to_string()));
122    /// ```
123    #[must_use]
124    pub fn from_path(path: impl Into<String>, width: HwpUnit, height: HwpUnit) -> Self {
125        let path: String = path.into();
126        let format = ImageFormat::from_extension(&path);
127        Self { path, width, height, format, caption: None, placement: None }
128    }
129
130    /// Attaches a caption to the image.
131    #[must_use]
132    pub fn with_caption(mut self, caption: Caption) -> Self {
133        self.caption = Some(caption);
134        self
135    }
136
137    /// Attaches placement metadata while preserving the existing constructor API.
138    #[must_use]
139    pub fn with_placement(mut self, placement: ImagePlacement) -> Self {
140        self.placement = Some(placement);
141        self
142    }
143}
144
145impl std::fmt::Display for Image {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        write!(
148            f,
149            "Image({}, {:.1}mm x {:.1}mm)",
150            self.format,
151            self.width.to_mm(),
152            self.height.to_mm()
153        )
154    }
155}
156
157/// Optional object-placement metadata for images.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
159pub struct ImagePlacement {
160    /// Text wrapping mode around the image object.
161    pub text_wrap: ImageTextWrap,
162    /// Side flow policy around the wrapped object.
163    pub text_flow: ImageTextFlow,
164    /// Whether the object behaves like an inline character.
165    pub treat_as_char: bool,
166    /// Whether surrounding text should flow with the object.
167    pub flow_with_text: bool,
168    /// Whether overlapping other objects is allowed.
169    pub allow_overlap: bool,
170    /// Vertical anchor reference for `vert_offset`.
171    pub vert_rel_to: ImageRelativeTo,
172    /// Horizontal anchor reference for `horz_offset`.
173    pub horz_rel_to: ImageRelativeTo,
174    /// Vertical offset from `vert_rel_to`.
175    pub vert_offset: HwpUnit,
176    /// Horizontal offset from `horz_rel_to`.
177    pub horz_offset: HwpUnit,
178}
179
180impl ImagePlacement {
181    /// Legacy inline defaults used by the pre-placement HWPX image path.
182    pub fn legacy_inline_defaults() -> Self {
183        Self {
184            text_wrap: ImageTextWrap::TopAndBottom,
185            text_flow: ImageTextFlow::BothSides,
186            treat_as_char: true,
187            flow_with_text: false,
188            allow_overlap: false,
189            vert_rel_to: ImageRelativeTo::Para,
190            horz_rel_to: ImageRelativeTo::Para,
191            vert_offset: HwpUnit::ZERO,
192            horz_offset: HwpUnit::ZERO,
193        }
194    }
195}
196
197/// Text wrapping mode for placed images.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
199#[non_exhaustive]
200pub enum ImageTextWrap {
201    /// Place text above and below the object.
202    TopAndBottom,
203    /// Wrap text on the object's sides.
204    Square,
205    /// Place the object behind text.
206    BehindText,
207    /// Place the object in front of text.
208    InFrontOfText,
209    /// Tight text wrapping around the object.
210    Tight,
211    /// Through-style wrapping.
212    Through,
213    /// Any wrap value not modeled explicitly.
214    Other(String),
215}
216
217impl ImageTextWrap {
218    /// Converts a raw HWPX wrap string into a typed value.
219    pub fn from_hwpx(value: &str) -> Self {
220        match value {
221            "TOP_AND_BOTTOM" => Self::TopAndBottom,
222            "SQUARE" => Self::Square,
223            "BEHIND_TEXT" => Self::BehindText,
224            "IN_FRONT_OF_TEXT" => Self::InFrontOfText,
225            "TIGHT" => Self::Tight,
226            "THROUGH" => Self::Through,
227            other => Self::Other(other.to_string()),
228        }
229    }
230
231    /// Returns the HWPX serialization string for this wrap mode.
232    pub fn as_hwpx_str(&self) -> Cow<'_, str> {
233        match self {
234            Self::TopAndBottom => Cow::Borrowed("TOP_AND_BOTTOM"),
235            Self::Square => Cow::Borrowed("SQUARE"),
236            Self::BehindText => Cow::Borrowed("BEHIND_TEXT"),
237            Self::InFrontOfText => Cow::Borrowed("IN_FRONT_OF_TEXT"),
238            Self::Tight => Cow::Borrowed("TIGHT"),
239            Self::Through => Cow::Borrowed("THROUGH"),
240            Self::Other(value) => Cow::Borrowed(value.as_str()),
241        }
242    }
243}
244
245/// Text flow mode for placed images.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
247#[non_exhaustive]
248pub enum ImageTextFlow {
249    /// Text can flow on both sides.
250    BothSides,
251    /// Text can flow only on the left side.
252    LeftOnly,
253    /// Text can flow only on the right side.
254    RightOnly,
255    /// Use the side with the larger available space.
256    LargestOnly,
257    /// Any flow value not modeled explicitly.
258    Other(String),
259}
260
261impl ImageTextFlow {
262    /// Converts a raw HWPX flow string into a typed value.
263    pub fn from_hwpx(value: &str) -> Self {
264        match value {
265            "BOTH_SIDES" => Self::BothSides,
266            "LEFT_ONLY" => Self::LeftOnly,
267            "RIGHT_ONLY" => Self::RightOnly,
268            "LARGEST_ONLY" => Self::LargestOnly,
269            other => Self::Other(other.to_string()),
270        }
271    }
272
273    /// Returns the HWPX serialization string for this flow mode.
274    pub fn as_hwpx_str(&self) -> Cow<'_, str> {
275        match self {
276            Self::BothSides => Cow::Borrowed("BOTH_SIDES"),
277            Self::LeftOnly => Cow::Borrowed("LEFT_ONLY"),
278            Self::RightOnly => Cow::Borrowed("RIGHT_ONLY"),
279            Self::LargestOnly => Cow::Borrowed("LARGEST_ONLY"),
280            Self::Other(value) => Cow::Borrowed(value.as_str()),
281        }
282    }
283}
284
285/// Anchor target for image placement offsets.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
287#[non_exhaustive]
288pub enum ImageRelativeTo {
289    /// Anchor offsets to the paper.
290    Paper,
291    /// Anchor offsets to the page.
292    Page,
293    /// Anchor offsets to the paragraph.
294    Para,
295    /// Anchor offsets to the column.
296    Column,
297    /// Anchor offsets to the character box.
298    Character,
299    /// Anchor offsets to the line box.
300    Line,
301    /// Any anchor value not modeled explicitly.
302    Other(String),
303}
304
305impl ImageRelativeTo {
306    /// Converts a raw HWPX anchor string into a typed value.
307    pub fn from_hwpx(value: &str) -> Self {
308        match value {
309            "PAPER" => Self::Paper,
310            "PAGE" => Self::Page,
311            "PARA" => Self::Para,
312            "COLUMN" => Self::Column,
313            "CHAR" => Self::Character,
314            "LINE" => Self::Line,
315            other => Self::Other(other.to_string()),
316        }
317    }
318
319    /// Returns the HWPX serialization string for this anchor mode.
320    pub fn as_hwpx_str(&self) -> Cow<'_, str> {
321        match self {
322            Self::Paper => Cow::Borrowed("PAPER"),
323            Self::Page => Cow::Borrowed("PAGE"),
324            Self::Para => Cow::Borrowed("PARA"),
325            Self::Column => Cow::Borrowed("COLUMN"),
326            Self::Character => Cow::Borrowed("CHAR"),
327            Self::Line => Cow::Borrowed("LINE"),
328            Self::Other(value) => Cow::Borrowed(value.as_str()),
329        }
330    }
331}
332
333/// Supported image formats.
334///
335/// Marked `#[non_exhaustive]` so new formats can be added in future
336/// phases without a breaking change.
337///
338/// # Examples
339///
340/// ```
341/// use hwpforge_core::image::ImageFormat;
342///
343/// let fmt = ImageFormat::Png;
344/// assert_eq!(fmt.to_string(), "PNG");
345///
346/// let unknown = ImageFormat::Unknown("SVG".to_string());
347/// assert_eq!(unknown.to_string(), "svg");
348/// ```
349#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
350#[non_exhaustive]
351pub enum ImageFormat {
352    /// Portable Network Graphics.
353    Png,
354    /// JPEG.
355    Jpeg,
356    /// Graphics Interchange Format.
357    Gif,
358    /// Windows Bitmap.
359    Bmp,
360    /// Windows Metafile.
361    Wmf,
362    /// Enhanced Metafile.
363    Emf,
364    /// Unrecognized format with its extension or MIME type.
365    Unknown(String),
366}
367
368impl ImageFormat {
369    /// Infers an [`ImageFormat`] from a file path's extension.
370    ///
371    /// The extension is extracted from everything after the last `'.'` in the
372    /// path string and matched case-insensitively. If no dot is found, or the
373    /// extension is not recognized, [`ImageFormat::Unknown`] is returned
374    /// containing the lowercase extension (or an empty string when absent).
375    ///
376    /// # Examples
377    ///
378    /// ```
379    /// use hwpforge_core::image::ImageFormat;
380    ///
381    /// assert_eq!(ImageFormat::from_extension("photo.png"),  ImageFormat::Png);
382    /// assert_eq!(ImageFormat::from_extension("image.JPG"),  ImageFormat::Jpeg);
383    /// assert_eq!(ImageFormat::from_extension("file.jpeg"), ImageFormat::Jpeg);
384    /// assert_eq!(ImageFormat::from_extension("doc.gif"),   ImageFormat::Gif);
385    /// assert_eq!(ImageFormat::from_extension("img.bmp"),   ImageFormat::Bmp);
386    /// assert_eq!(ImageFormat::from_extension("chart.wmf"), ImageFormat::Wmf);
387    /// assert_eq!(ImageFormat::from_extension("dia.emf"),   ImageFormat::Emf);
388    /// assert_eq!(
389    ///     ImageFormat::from_extension("file.xyz"),
390    ///     ImageFormat::Unknown("xyz".to_string()),
391    /// );
392    /// assert_eq!(
393    ///     ImageFormat::from_extension("noext"),
394    ///     ImageFormat::Unknown(String::new()),
395    /// );
396    /// assert_eq!(ImageFormat::from_extension("multi.dot.png"), ImageFormat::Png);
397    /// ```
398    pub fn from_extension(path: &str) -> Self {
399        // Only treat the suffix as an extension if a dot is actually present.
400        let ext_lower = path.rfind('.').map(|i| path[i + 1..].to_ascii_lowercase());
401        match ext_lower.as_deref() {
402            Some("png") => Self::Png,
403            Some("jpg" | "jpeg") => Self::Jpeg,
404            Some("gif") => Self::Gif,
405            Some("bmp") => Self::Bmp,
406            Some("wmf") => Self::Wmf,
407            Some("emf") => Self::Emf,
408            Some(ext) => Self::Unknown(ext.to_string()),
409            None => Self::Unknown(String::new()),
410        }
411    }
412}
413
414impl std::fmt::Display for ImageFormat {
415    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416        match self {
417            Self::Png => write!(f, "PNG"),
418            Self::Jpeg => write!(f, "JPEG"),
419            Self::Gif => write!(f, "GIF"),
420            Self::Bmp => write!(f, "BMP"),
421            Self::Wmf => write!(f, "WMF"),
422            Self::Emf => write!(f, "EMF"),
423            Self::Unknown(s) => {
424                let lower = s.to_ascii_lowercase();
425                write!(f, "{lower}")
426            }
427        }
428    }
429}
430
431// ---------------------------------------------------------------------------
432// ImageStore
433// ---------------------------------------------------------------------------
434
435/// Storage for binary image data keyed by path.
436///
437/// Maps image paths (e.g. `"image1.jpg"`) to their binary content.
438/// Used by the encoder to embed images into HWPX archives and by the
439/// decoder to extract them.
440///
441/// # Examples
442///
443/// ```
444/// use hwpforge_core::image::ImageStore;
445///
446/// let mut store = ImageStore::new();
447/// store.insert("logo.png", vec![0x89, 0x50, 0x4E, 0x47]);
448/// assert_eq!(store.len(), 1);
449/// assert!(store.get("logo.png").is_some());
450/// ```
451#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
452pub struct ImageStore {
453    images: HashMap<String, Vec<u8>>,
454}
455
456impl ImageStore {
457    /// Creates an empty image store.
458    pub fn new() -> Self {
459        Self { images: HashMap::new() }
460    }
461
462    /// Inserts an image with the given key and binary data.
463    ///
464    /// If the key already exists, the data is replaced.
465    pub fn insert(&mut self, key: impl Into<String>, data: Vec<u8>) {
466        self.images.insert(key.into(), data);
467    }
468
469    /// Returns the binary data for the given key, if present.
470    pub fn get(&self, key: &str) -> Option<&[u8]> {
471        self.images.get(key).map(|v| v.as_slice())
472    }
473
474    /// Returns the number of stored images.
475    pub fn len(&self) -> usize {
476        self.images.len()
477    }
478
479    /// Returns `true` if the store contains no images.
480    pub fn is_empty(&self) -> bool {
481        self.images.is_empty()
482    }
483
484    /// Iterates over all `(key, data)` pairs.
485    pub fn iter(&self) -> impl Iterator<Item = (&str, &[u8])> {
486        self.images.iter().map(|(k, v)| (k.as_str(), v.as_slice()))
487    }
488}
489
490impl FromIterator<(String, Vec<u8>)> for ImageStore {
491    fn from_iter<I: IntoIterator<Item = (String, Vec<u8>)>>(iter: I) -> Self {
492        Self { images: iter.into_iter().collect() }
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    fn sample_image() -> Image {
501        Image::new(
502            "BinData/image1.png",
503            HwpUnit::from_mm(50.0).unwrap(),
504            HwpUnit::from_mm(30.0).unwrap(),
505            ImageFormat::Png,
506        )
507    }
508
509    #[test]
510    fn new_constructor() {
511        let img = sample_image();
512        assert_eq!(img.path, "BinData/image1.png");
513        assert_eq!(img.format, ImageFormat::Png);
514    }
515
516    #[test]
517    fn from_path_constructor() {
518        let img = Image::from_path(
519            "test.jpeg",
520            HwpUnit::from_mm(10.0).unwrap(),
521            HwpUnit::from_mm(10.0).unwrap(),
522        );
523        assert_eq!(img.format, ImageFormat::Jpeg);
524    }
525
526    #[test]
527    fn builder_attaches_caption() {
528        let img = sample_image().with_caption(Caption::default());
529        assert!(img.caption.is_some());
530    }
531
532    #[test]
533    fn display_format() {
534        let img = sample_image();
535        let s = img.to_string();
536        assert!(s.contains("PNG"), "display: {s}");
537        assert!(s.contains("50.0"), "display: {s}");
538        assert!(s.contains("30.0"), "display: {s}");
539    }
540
541    #[test]
542    fn image_format_display() {
543        assert_eq!(ImageFormat::Png.to_string(), "PNG");
544        assert_eq!(ImageFormat::Jpeg.to_string(), "JPEG");
545        assert_eq!(ImageFormat::Gif.to_string(), "GIF");
546        assert_eq!(ImageFormat::Bmp.to_string(), "BMP");
547        assert_eq!(ImageFormat::Wmf.to_string(), "WMF");
548        assert_eq!(ImageFormat::Emf.to_string(), "EMF");
549        assert_eq!(ImageFormat::Unknown("TIFF".to_string()).to_string(), "tiff");
550    }
551
552    #[test]
553    fn equality() {
554        let a = sample_image();
555        let b = sample_image();
556        assert_eq!(a, b);
557    }
558
559    #[test]
560    fn inequality_on_different_paths() {
561        let a = sample_image();
562        let mut b = sample_image();
563        b.path = "other.png".to_string();
564        assert_ne!(a, b);
565    }
566
567    #[test]
568    fn clone_independence() {
569        let img = sample_image();
570        let mut cloned = img.clone();
571        cloned.path = "modified.png".to_string();
572        assert_eq!(img.path, "BinData/image1.png");
573    }
574
575    #[test]
576    fn serde_roundtrip() {
577        let img = sample_image();
578        let json = serde_json::to_string(&img).unwrap();
579        let back: Image = serde_json::from_str(&json).unwrap();
580        assert_eq!(img, back);
581    }
582
583    #[test]
584    fn placement_roundtrip() {
585        let img = sample_image().with_placement(ImagePlacement {
586            text_wrap: ImageTextWrap::Square,
587            text_flow: ImageTextFlow::RightOnly,
588            treat_as_char: false,
589            flow_with_text: true,
590            allow_overlap: true,
591            vert_rel_to: ImageRelativeTo::Paper,
592            horz_rel_to: ImageRelativeTo::Page,
593            vert_offset: HwpUnit::new(1200).unwrap(),
594            horz_offset: HwpUnit::new(3400).unwrap(),
595        });
596        let json = serde_json::to_string(&img).unwrap();
597        let back: Image = serde_json::from_str(&json).unwrap();
598        assert_eq!(img, back);
599    }
600
601    #[test]
602    fn serde_unknown_format_roundtrip() {
603        let img = Image::new(
604            "test.svg",
605            HwpUnit::from_mm(10.0).unwrap(),
606            HwpUnit::from_mm(10.0).unwrap(),
607            ImageFormat::Unknown("SVG".to_string()),
608        );
609        let json = serde_json::to_string(&img).unwrap();
610        let back: Image = serde_json::from_str(&json).unwrap();
611        assert_eq!(img, back);
612    }
613
614    #[test]
615    fn image_format_hash() {
616        use std::collections::HashSet;
617        let mut set = HashSet::new();
618        set.insert(ImageFormat::Png);
619        set.insert(ImageFormat::Jpeg);
620        set.insert(ImageFormat::Png);
621        assert_eq!(set.len(), 2);
622    }
623
624    #[test]
625    fn from_string_path() {
626        let path = String::from("dynamic/path.bmp");
627        let img = Image::new(path, HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Bmp);
628        assert_eq!(img.path, "dynamic/path.bmp");
629    }
630
631    // -----------------------------------------------------------------------
632    // ImageStore tests
633    // -----------------------------------------------------------------------
634
635    #[test]
636    fn image_store_new_is_empty() {
637        let store = ImageStore::new();
638        assert!(store.is_empty());
639        assert_eq!(store.len(), 0);
640    }
641
642    #[test]
643    fn image_store_insert_and_get() {
644        let mut store = ImageStore::new();
645        store.insert("logo.png", vec![0x89, 0x50, 0x4E, 0x47]);
646        assert_eq!(store.len(), 1);
647        assert!(!store.is_empty());
648        assert_eq!(store.get("logo.png"), Some(&[0x89, 0x50, 0x4E, 0x47][..]));
649    }
650
651    #[test]
652    fn image_store_get_missing() {
653        let store = ImageStore::new();
654        assert!(store.get("nonexistent.png").is_none());
655    }
656
657    #[test]
658    fn image_store_insert_replaces() {
659        let mut store = ImageStore::new();
660        store.insert("img.png", vec![1, 2, 3]);
661        store.insert("img.png", vec![4, 5, 6]);
662        assert_eq!(store.len(), 1);
663        assert_eq!(store.get("img.png"), Some(&[4, 5, 6][..]));
664    }
665
666    #[test]
667    fn image_store_multiple_images() {
668        let mut store = ImageStore::new();
669        store.insert("a.png", vec![1]);
670        store.insert("b.jpg", vec![2]);
671        store.insert("c.gif", vec![3]);
672        assert_eq!(store.len(), 3);
673    }
674
675    #[test]
676    fn image_store_iter() {
677        let mut store = ImageStore::new();
678        store.insert("a.png", vec![1]);
679        store.insert("b.jpg", vec![2]);
680        let pairs: Vec<_> = store.iter().collect();
681        assert_eq!(pairs.len(), 2);
682    }
683
684    #[test]
685    fn image_store_from_iterator() {
686        let items = vec![("a.png".to_string(), vec![1, 2]), ("b.jpg".to_string(), vec![3, 4])];
687        let store: ImageStore = items.into_iter().collect();
688        assert_eq!(store.len(), 2);
689        assert_eq!(store.get("a.png"), Some(&[1, 2][..]));
690    }
691
692    #[test]
693    fn image_store_default() {
694        let store = ImageStore::default();
695        assert!(store.is_empty());
696    }
697
698    #[test]
699    fn image_store_clone_independence() {
700        let mut store = ImageStore::new();
701        store.insert("img.png", vec![1, 2, 3]);
702        let mut cloned = store.clone();
703        cloned.insert("other.png", vec![4, 5]);
704        assert_eq!(store.len(), 1);
705        assert_eq!(cloned.len(), 2);
706    }
707
708    #[test]
709    fn image_store_equality() {
710        let mut a = ImageStore::new();
711        a.insert("img.png", vec![1, 2, 3]);
712        let mut b = ImageStore::new();
713        b.insert("img.png", vec![1, 2, 3]);
714        assert_eq!(a, b);
715    }
716
717    #[test]
718    fn image_store_serde_roundtrip() {
719        let mut store = ImageStore::new();
720        store.insert("logo.png", vec![0x89, 0x50]);
721        let json = serde_json::to_string(&store).unwrap();
722        let back: ImageStore = serde_json::from_str(&json).unwrap();
723        assert_eq!(store, back);
724    }
725
726    #[test]
727    fn image_store_string_key() {
728        let mut store = ImageStore::new();
729        let key = String::from("dynamic/path.png");
730        store.insert(key, vec![42]);
731        assert!(store.get("dynamic/path.png").is_some());
732    }
733
734    // -----------------------------------------------------------------------
735    // ImageFormat::from_extension tests
736    // -----------------------------------------------------------------------
737
738    #[test]
739    fn from_extension_png() {
740        assert_eq!(ImageFormat::from_extension("photo.png"), ImageFormat::Png);
741    }
742
743    #[test]
744    fn from_extension_jpg_uppercase() {
745        assert_eq!(ImageFormat::from_extension("image.JPG"), ImageFormat::Jpeg);
746    }
747
748    #[test]
749    fn from_extension_jpeg() {
750        assert_eq!(ImageFormat::from_extension("file.jpeg"), ImageFormat::Jpeg);
751    }
752
753    #[test]
754    fn from_extension_gif() {
755        assert_eq!(ImageFormat::from_extension("doc.gif"), ImageFormat::Gif);
756    }
757
758    #[test]
759    fn from_extension_bmp() {
760        assert_eq!(ImageFormat::from_extension("img.bmp"), ImageFormat::Bmp);
761    }
762
763    #[test]
764    fn from_extension_wmf() {
765        assert_eq!(ImageFormat::from_extension("chart.wmf"), ImageFormat::Wmf);
766    }
767
768    #[test]
769    fn from_extension_emf() {
770        assert_eq!(ImageFormat::from_extension("dia.emf"), ImageFormat::Emf);
771    }
772
773    #[test]
774    fn from_extension_unknown() {
775        assert_eq!(
776            ImageFormat::from_extension("file.xyz"),
777            ImageFormat::Unknown("xyz".to_string()),
778        );
779    }
780
781    #[test]
782    fn from_extension_no_extension() {
783        assert_eq!(ImageFormat::from_extension("noext"), ImageFormat::Unknown(String::new()));
784    }
785
786    #[test]
787    fn from_extension_multi_dot() {
788        assert_eq!(ImageFormat::from_extension("multi.dot.png"), ImageFormat::Png);
789    }
790
791    // -----------------------------------------------------------------------
792    // Image::from_path tests
793    // -----------------------------------------------------------------------
794
795    #[test]
796    fn from_path_infers_format() {
797        let w = HwpUnit::from_mm(100.0).unwrap();
798        let h = HwpUnit::from_mm(75.0).unwrap();
799
800        let img = Image::from_path("photos/hero.png", w, h);
801        assert_eq!(img.format, ImageFormat::Png);
802        assert_eq!(img.path, "photos/hero.png");
803        assert_eq!(img.width, w);
804        assert_eq!(img.height, h);
805        assert!(img.caption.is_none());
806    }
807
808    #[test]
809    fn from_path_jpeg_uppercase() {
810        let w = HwpUnit::ZERO;
811        let h = HwpUnit::ZERO;
812        let img = Image::from_path("scan.JPG", w, h);
813        assert_eq!(img.format, ImageFormat::Jpeg);
814    }
815
816    #[test]
817    fn from_path_unknown_extension() {
818        let w = HwpUnit::ZERO;
819        let h = HwpUnit::ZERO;
820        let img = Image::from_path("diagram.svg", w, h);
821        assert_eq!(img.format, ImageFormat::Unknown("svg".to_string()));
822    }
823
824    #[test]
825    fn from_path_string_owned() {
826        let w = HwpUnit::ZERO;
827        let h = HwpUnit::ZERO;
828        let path = String::from("owned/path.bmp");
829        let img = Image::from_path(path, w, h);
830        assert_eq!(img.format, ImageFormat::Bmp);
831        assert_eq!(img.path, "owned/path.bmp");
832    }
833
834    #[test]
835    fn unknown_format_display_normalizes_to_lowercase() {
836        assert_eq!(ImageFormat::Unknown("SVG".to_string()).to_string(), "svg");
837        assert_eq!(ImageFormat::Unknown("Tiff".to_string()).to_string(), "tiff");
838        assert_eq!(ImageFormat::Unknown("webp".to_string()).to_string(), "webp");
839    }
840
841    #[test]
842    fn unknown_format_casing_inequality() {
843        // Unknown preserves the stored string for equality, even though display normalizes
844        let upper = ImageFormat::Unknown("SVG".to_string());
845        let lower = ImageFormat::Unknown("svg".to_string());
846        assert_ne!(upper, lower, "Different casing in Unknown produces inequality");
847        // But display output is identical
848        assert_eq!(upper.to_string(), lower.to_string());
849    }
850}