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