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