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::collections::HashMap;
23
24use hwpforge_foundation::HwpUnit;
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27
28use crate::caption::Caption;
29use crate::object_id::ObjectId;
30use crate::placement::ObjectPlacement;
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<ObjectPlacement>,
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: ObjectPlacement) -> 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/// Supported image formats.
173///
174/// Marked `#[non_exhaustive]` so new formats can be added in future
175/// phases without a breaking change.
176///
177/// # Examples
178///
179/// ```
180/// use hwpforge_core::image::ImageFormat;
181///
182/// let fmt = ImageFormat::Png;
183/// assert_eq!(fmt.to_string(), "PNG");
184///
185/// let unknown = ImageFormat::Unknown("SVG".to_string());
186/// assert_eq!(unknown.to_string(), "svg");
187/// ```
188#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
189#[non_exhaustive]
190pub enum ImageFormat {
191    /// Portable Network Graphics.
192    Png,
193    /// JPEG.
194    Jpeg,
195    /// Graphics Interchange Format.
196    Gif,
197    /// Windows Bitmap.
198    Bmp,
199    /// Windows Metafile.
200    Wmf,
201    /// Enhanced Metafile.
202    Emf,
203    /// Unrecognized format with its extension or MIME type.
204    Unknown(String),
205}
206
207impl ImageFormat {
208    /// Infers an [`ImageFormat`] from a file path's extension.
209    ///
210    /// The extension is extracted from everything after the last `'.'` in the
211    /// path string and matched case-insensitively. If no dot is found, or the
212    /// extension is not recognized, [`ImageFormat::Unknown`] is returned
213    /// containing the lowercase extension (or an empty string when absent).
214    ///
215    /// # Examples
216    ///
217    /// ```
218    /// use hwpforge_core::image::ImageFormat;
219    ///
220    /// assert_eq!(ImageFormat::from_extension("photo.png"),  ImageFormat::Png);
221    /// assert_eq!(ImageFormat::from_extension("image.JPG"),  ImageFormat::Jpeg);
222    /// assert_eq!(ImageFormat::from_extension("file.jpeg"), ImageFormat::Jpeg);
223    /// assert_eq!(ImageFormat::from_extension("doc.gif"),   ImageFormat::Gif);
224    /// assert_eq!(ImageFormat::from_extension("img.bmp"),   ImageFormat::Bmp);
225    /// assert_eq!(ImageFormat::from_extension("chart.wmf"), ImageFormat::Wmf);
226    /// assert_eq!(ImageFormat::from_extension("dia.emf"),   ImageFormat::Emf);
227    /// assert_eq!(
228    ///     ImageFormat::from_extension("file.xyz"),
229    ///     ImageFormat::Unknown("xyz".to_string()),
230    /// );
231    /// assert_eq!(
232    ///     ImageFormat::from_extension("noext"),
233    ///     ImageFormat::Unknown(String::new()),
234    /// );
235    /// assert_eq!(ImageFormat::from_extension("multi.dot.png"), ImageFormat::Png);
236    /// ```
237    pub fn from_extension(path: &str) -> Self {
238        // Only treat the suffix as an extension if a dot is actually present.
239        let ext_lower = path.rfind('.').map(|i| path[i + 1..].to_ascii_lowercase());
240        match ext_lower.as_deref() {
241            Some("png") => Self::Png,
242            Some("jpg" | "jpeg") => Self::Jpeg,
243            Some("gif") => Self::Gif,
244            Some("bmp") => Self::Bmp,
245            Some("wmf") => Self::Wmf,
246            Some("emf") => Self::Emf,
247            Some(ext) => Self::Unknown(ext.to_string()),
248            None => Self::Unknown(String::new()),
249        }
250    }
251
252    /// Sniffs an [`ImageFormat`] from the leading magic bytes.
253    ///
254    /// Extension-derived formats ([`Self::from_extension`]) are diagnostic
255    /// hints only — file names cannot be trusted. Byte sniffing is the
256    /// ground truth for admission decisions (e.g. the Markdown → HWPX image
257    /// embed loader only packages bytes whose magic identifies a format
258    /// HWPX `BinData` natively carries).
259    ///
260    /// Returns `None` for empty, truncated, or unrecognized bytes — callers
261    /// must not guess from content. Magic table (shares the render-side
262    /// sniffer's rules in `smithy-pdf`, with two deliberate divergences for
263    /// ingestion, W6 §12b·§12-r2):
264    ///
265    /// - PNG `89 50 4E 47 0D 0A 1A 0A` · JPEG `FF D8 FF` · GIF `GIF87a`/`GIF89a`
266    /// - BMP `BM` **plus structural header checks** — `bfOffBits`
267    ///   (offset 10) within `14..=len` and a known DIB header size
268    ///   (offset 14 ∈ {12, 40, 52, 56, 64, 108, 124}). The 2-byte magic
269    ///   alone admits arbitrary bytes starting with `BM` into packages
270    ///   (ingestion is stricter than the render sniffer, which only gates
271    ///   an error path).
272    /// - EMF record type `01 00 00 00` + `" EMF"` signature at offset 40
273    /// - WMF **placeable only** (`D7 CD C6 9A`) — standard WMF's magic is
274    ///   too weak and would misfire, so it stays unrecognized
275    /// - WebP is **intentionally absent** (render sniffer knows it): HWPX
276    ///   `BinData` carry of WebP is unverified against Hancom, so ingestion
277    ///   refuses it until a native fixture proves it (§12-r2).
278    ///
279    /// # Examples
280    ///
281    /// ```
282    /// use hwpforge_core::image::ImageFormat;
283    ///
284    /// let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0];
285    /// assert_eq!(ImageFormat::sniff(&png), Some(ImageFormat::Png));
286    /// assert_eq!(ImageFormat::sniff(b"GIF89a\x00"), Some(ImageFormat::Gif));
287    /// assert_eq!(ImageFormat::sniff(b"not an image"), None);
288    /// assert_eq!(ImageFormat::sniff(&[]), None);
289    /// ```
290    #[must_use]
291    pub fn sniff(bytes: &[u8]) -> Option<Self> {
292        if bytes.len() >= 8 && bytes[..8] == [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
293            return Some(Self::Png);
294        }
295        if bytes.len() >= 3 && bytes[..3] == [0xFF, 0xD8, 0xFF] {
296            return Some(Self::Jpeg);
297        }
298        if bytes.len() >= 6 && (&bytes[..6] == b"GIF87a" || &bytes[..6] == b"GIF89a") {
299            return Some(Self::Gif);
300        }
301        if bytes.len() >= 18 && &bytes[..2] == b"BM" {
302            let off_bits = u32::from_le_bytes([bytes[10], bytes[11], bytes[12], bytes[13]]);
303            let dib_size = u32::from_le_bytes([bytes[14], bytes[15], bytes[16], bytes[17]]);
304            let off_ok = off_bits >= 14 && (off_bits as usize) <= bytes.len();
305            let dib_ok = matches!(dib_size, 12 | 40 | 52 | 56 | 64 | 108 | 124);
306            if off_ok && dib_ok {
307                return Some(Self::Bmp);
308            }
309            return None;
310        }
311        if bytes.len() >= 44 && bytes[..4] == [0x01, 0, 0, 0] && &bytes[40..44] == b" EMF" {
312            return Some(Self::Emf);
313        }
314        if bytes.len() >= 4 && bytes[..4] == [0xD7, 0xCD, 0xC6, 0x9A] {
315            return Some(Self::Wmf);
316        }
317        None
318    }
319
320    /// Canonical file extension for a sniffed format (used to build
321    /// synthetic package keys like `image1.png`).
322    ///
323    /// Returns `None` for [`Self::Unknown`] — unknown formats never get a
324    /// synthetic key (they are not admitted into packages).
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// use hwpforge_core::image::ImageFormat;
330    ///
331    /// assert_eq!(ImageFormat::Jpeg.canonical_extension(), Some("jpg"));
332    /// assert_eq!(ImageFormat::Unknown("svg".into()).canonical_extension(), None);
333    /// ```
334    #[must_use]
335    pub fn canonical_extension(&self) -> Option<&'static str> {
336        match self {
337            Self::Png => Some("png"),
338            Self::Jpeg => Some("jpg"),
339            Self::Gif => Some("gif"),
340            Self::Bmp => Some("bmp"),
341            Self::Wmf => Some("wmf"),
342            Self::Emf => Some("emf"),
343            Self::Unknown(_) => None,
344        }
345    }
346}
347
348impl std::fmt::Display for ImageFormat {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        match self {
351            Self::Png => write!(f, "PNG"),
352            Self::Jpeg => write!(f, "JPEG"),
353            Self::Gif => write!(f, "GIF"),
354            Self::Bmp => write!(f, "BMP"),
355            Self::Wmf => write!(f, "WMF"),
356            Self::Emf => write!(f, "EMF"),
357            Self::Unknown(s) => {
358                let lower = s.to_ascii_lowercase();
359                write!(f, "{lower}")
360            }
361        }
362    }
363}
364
365// ---------------------------------------------------------------------------
366// ImageStore
367// ---------------------------------------------------------------------------
368
369/// Storage for binary image data keyed by path.
370///
371/// Maps image paths (e.g. `"image1.jpg"`) to their binary content.
372/// Used by the encoder to embed images into HWPX archives and by the
373/// decoder to extract them.
374///
375/// # Examples
376///
377/// ```
378/// use hwpforge_core::image::ImageStore;
379///
380/// let mut store = ImageStore::new();
381/// store.insert("logo.png", vec![0x89, 0x50, 0x4E, 0x47]);
382/// assert_eq!(store.len(), 1);
383/// assert!(store.get("logo.png").is_some());
384/// ```
385#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
386pub struct ImageStore {
387    images: HashMap<String, Vec<u8>>,
388}
389
390impl ImageStore {
391    /// Creates an empty image store.
392    pub fn new() -> Self {
393        Self { images: HashMap::new() }
394    }
395
396    /// Inserts an image with the given key and binary data.
397    ///
398    /// If the key already exists, the data is replaced.
399    pub fn insert(&mut self, key: impl Into<String>, data: Vec<u8>) {
400        self.images.insert(key.into(), data);
401    }
402
403    /// Returns the binary data for the given key, if present.
404    pub fn get(&self, key: &str) -> Option<&[u8]> {
405        self.images.get(key).map(|v| v.as_slice())
406    }
407
408    /// Returns the number of stored images.
409    pub fn len(&self) -> usize {
410        self.images.len()
411    }
412
413    /// Returns `true` if the store contains no images.
414    pub fn is_empty(&self) -> bool {
415        self.images.is_empty()
416    }
417
418    /// Iterates over all `(key, data)` pairs.
419    pub fn iter(&self) -> impl Iterator<Item = (&str, &[u8])> {
420        self.images.iter().map(|(k, v)| (k.as_str(), v.as_slice()))
421    }
422}
423
424impl FromIterator<(String, Vec<u8>)> for ImageStore {
425    fn from_iter<I: IntoIterator<Item = (String, Vec<u8>)>>(iter: I) -> Self {
426        Self { images: iter.into_iter().collect() }
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn sample_image() -> Image {
435        Image::new(
436            "BinData/image1.png",
437            HwpUnit::from_mm(50.0).unwrap(),
438            HwpUnit::from_mm(30.0).unwrap(),
439            ImageFormat::Png,
440        )
441    }
442
443    #[test]
444    fn new_constructor() {
445        let img = sample_image();
446        assert_eq!(img.path, "BinData/image1.png");
447        assert_eq!(img.format, ImageFormat::Png);
448    }
449
450    #[test]
451    fn from_path_constructor() {
452        let img = Image::from_path(
453            "test.jpeg",
454            HwpUnit::from_mm(10.0).unwrap(),
455            HwpUnit::from_mm(10.0).unwrap(),
456        );
457        assert_eq!(img.format, ImageFormat::Jpeg);
458    }
459
460    #[test]
461    fn builder_attaches_caption() {
462        let img = sample_image().with_caption(Caption::default());
463        assert!(img.caption.is_some());
464    }
465
466    #[test]
467    fn display_format() {
468        let img = sample_image();
469        let s = img.to_string();
470        assert!(s.contains("PNG"), "display: {s}");
471        assert!(s.contains("50.0"), "display: {s}");
472        assert!(s.contains("30.0"), "display: {s}");
473    }
474
475    #[test]
476    fn image_format_display() {
477        assert_eq!(ImageFormat::Png.to_string(), "PNG");
478        assert_eq!(ImageFormat::Jpeg.to_string(), "JPEG");
479        assert_eq!(ImageFormat::Gif.to_string(), "GIF");
480        assert_eq!(ImageFormat::Bmp.to_string(), "BMP");
481        assert_eq!(ImageFormat::Wmf.to_string(), "WMF");
482        assert_eq!(ImageFormat::Emf.to_string(), "EMF");
483        assert_eq!(ImageFormat::Unknown("TIFF".to_string()).to_string(), "tiff");
484    }
485
486    #[test]
487    fn equality() {
488        let a = sample_image();
489        let b = sample_image();
490        assert_eq!(a, b);
491    }
492
493    #[test]
494    fn inequality_on_different_paths() {
495        let a = sample_image();
496        let mut b = sample_image();
497        b.path = "other.png".to_string();
498        assert_ne!(a, b);
499    }
500
501    #[test]
502    fn clone_independence() {
503        let img = sample_image();
504        let mut cloned = img.clone();
505        cloned.path = "modified.png".to_string();
506        assert_eq!(img.path, "BinData/image1.png");
507    }
508
509    #[test]
510    fn serde_roundtrip() {
511        let img = sample_image();
512        let json = serde_json::to_string(&img).unwrap();
513        let back: Image = serde_json::from_str(&json).unwrap();
514        assert_eq!(img, back);
515    }
516
517    #[test]
518    fn placement_roundtrip() {
519        use crate::placement::{ObjectPlacement, ObjectRelativeTo, ObjectTextFlow, ObjectTextWrap};
520        let img = sample_image().with_placement(ObjectPlacement {
521            text_wrap: ObjectTextWrap::Square,
522            text_flow: ObjectTextFlow::RightOnly,
523            treat_as_char: false,
524            flow_with_text: true,
525            allow_overlap: true,
526            vert_rel_to: ObjectRelativeTo::Paper,
527            horz_rel_to: ObjectRelativeTo::Page,
528            vert_offset: HwpUnit::new(1200).unwrap(),
529            horz_offset: HwpUnit::new(3400).unwrap(),
530        });
531        let json = serde_json::to_string(&img).unwrap();
532        let back: Image = serde_json::from_str(&json).unwrap();
533        assert_eq!(img, back);
534    }
535
536    #[test]
537    fn serde_unknown_format_roundtrip() {
538        let img = Image::new(
539            "test.svg",
540            HwpUnit::from_mm(10.0).unwrap(),
541            HwpUnit::from_mm(10.0).unwrap(),
542            ImageFormat::Unknown("SVG".to_string()),
543        );
544        let json = serde_json::to_string(&img).unwrap();
545        let back: Image = serde_json::from_str(&json).unwrap();
546        assert_eq!(img, back);
547    }
548
549    #[test]
550    fn image_format_hash() {
551        use std::collections::HashSet;
552        let mut set = HashSet::new();
553        set.insert(ImageFormat::Png);
554        set.insert(ImageFormat::Jpeg);
555        set.insert(ImageFormat::Png);
556        assert_eq!(set.len(), 2);
557    }
558
559    #[test]
560    fn from_string_path() {
561        let path = String::from("dynamic/path.bmp");
562        let img = Image::new(path, HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Bmp);
563        assert_eq!(img.path, "dynamic/path.bmp");
564    }
565
566    // -----------------------------------------------------------------------
567    // ImageStore tests
568    // -----------------------------------------------------------------------
569
570    #[test]
571    fn image_store_new_is_empty() {
572        let store = ImageStore::new();
573        assert!(store.is_empty());
574        assert_eq!(store.len(), 0);
575    }
576
577    #[test]
578    fn image_store_insert_and_get() {
579        let mut store = ImageStore::new();
580        store.insert("logo.png", vec![0x89, 0x50, 0x4E, 0x47]);
581        assert_eq!(store.len(), 1);
582        assert!(!store.is_empty());
583        assert_eq!(store.get("logo.png"), Some(&[0x89, 0x50, 0x4E, 0x47][..]));
584    }
585
586    #[test]
587    fn image_store_get_missing() {
588        let store = ImageStore::new();
589        assert!(store.get("nonexistent.png").is_none());
590    }
591
592    #[test]
593    fn image_store_insert_replaces() {
594        let mut store = ImageStore::new();
595        store.insert("img.png", vec![1, 2, 3]);
596        store.insert("img.png", vec![4, 5, 6]);
597        assert_eq!(store.len(), 1);
598        assert_eq!(store.get("img.png"), Some(&[4, 5, 6][..]));
599    }
600
601    #[test]
602    fn image_store_multiple_images() {
603        let mut store = ImageStore::new();
604        store.insert("a.png", vec![1]);
605        store.insert("b.jpg", vec![2]);
606        store.insert("c.gif", vec![3]);
607        assert_eq!(store.len(), 3);
608    }
609
610    #[test]
611    fn image_store_iter() {
612        let mut store = ImageStore::new();
613        store.insert("a.png", vec![1]);
614        store.insert("b.jpg", vec![2]);
615        let pairs: Vec<_> = store.iter().collect();
616        assert_eq!(pairs.len(), 2);
617    }
618
619    #[test]
620    fn image_store_from_iterator() {
621        let items = vec![("a.png".to_string(), vec![1, 2]), ("b.jpg".to_string(), vec![3, 4])];
622        let store: ImageStore = items.into_iter().collect();
623        assert_eq!(store.len(), 2);
624        assert_eq!(store.get("a.png"), Some(&[1, 2][..]));
625    }
626
627    #[test]
628    fn image_store_default() {
629        let store = ImageStore::default();
630        assert!(store.is_empty());
631    }
632
633    #[test]
634    fn image_store_clone_independence() {
635        let mut store = ImageStore::new();
636        store.insert("img.png", vec![1, 2, 3]);
637        let mut cloned = store.clone();
638        cloned.insert("other.png", vec![4, 5]);
639        assert_eq!(store.len(), 1);
640        assert_eq!(cloned.len(), 2);
641    }
642
643    #[test]
644    fn image_store_equality() {
645        let mut a = ImageStore::new();
646        a.insert("img.png", vec![1, 2, 3]);
647        let mut b = ImageStore::new();
648        b.insert("img.png", vec![1, 2, 3]);
649        assert_eq!(a, b);
650    }
651
652    #[test]
653    fn image_store_serde_roundtrip() {
654        let mut store = ImageStore::new();
655        store.insert("logo.png", vec![0x89, 0x50]);
656        let json = serde_json::to_string(&store).unwrap();
657        let back: ImageStore = serde_json::from_str(&json).unwrap();
658        assert_eq!(store, back);
659    }
660
661    #[test]
662    fn image_store_string_key() {
663        let mut store = ImageStore::new();
664        let key = String::from("dynamic/path.png");
665        store.insert(key, vec![42]);
666        assert!(store.get("dynamic/path.png").is_some());
667    }
668
669    // -----------------------------------------------------------------------
670    // ImageFormat::from_extension tests
671    // -----------------------------------------------------------------------
672
673    #[test]
674    fn from_extension_png() {
675        assert_eq!(ImageFormat::from_extension("photo.png"), ImageFormat::Png);
676    }
677
678    #[test]
679    fn from_extension_jpg_uppercase() {
680        assert_eq!(ImageFormat::from_extension("image.JPG"), ImageFormat::Jpeg);
681    }
682
683    #[test]
684    fn from_extension_jpeg() {
685        assert_eq!(ImageFormat::from_extension("file.jpeg"), ImageFormat::Jpeg);
686    }
687
688    #[test]
689    fn from_extension_gif() {
690        assert_eq!(ImageFormat::from_extension("doc.gif"), ImageFormat::Gif);
691    }
692
693    #[test]
694    fn from_extension_bmp() {
695        assert_eq!(ImageFormat::from_extension("img.bmp"), ImageFormat::Bmp);
696    }
697
698    #[test]
699    fn from_extension_wmf() {
700        assert_eq!(ImageFormat::from_extension("chart.wmf"), ImageFormat::Wmf);
701    }
702
703    #[test]
704    fn from_extension_emf() {
705        assert_eq!(ImageFormat::from_extension("dia.emf"), ImageFormat::Emf);
706    }
707
708    #[test]
709    fn from_extension_unknown() {
710        assert_eq!(
711            ImageFormat::from_extension("file.xyz"),
712            ImageFormat::Unknown("xyz".to_string()),
713        );
714    }
715
716    #[test]
717    fn from_extension_no_extension() {
718        assert_eq!(ImageFormat::from_extension("noext"), ImageFormat::Unknown(String::new()));
719    }
720
721    #[test]
722    fn from_extension_multi_dot() {
723        assert_eq!(ImageFormat::from_extension("multi.dot.png"), ImageFormat::Png);
724    }
725
726    // -----------------------------------------------------------------------
727    // ImageFormat::sniff tests (W6 §12b — magic 판별, 추측 금지)
728    // -----------------------------------------------------------------------
729
730    #[test]
731    fn sniff_known_magics() {
732        let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0];
733        assert_eq!(ImageFormat::sniff(&png), Some(ImageFormat::Png));
734        assert_eq!(ImageFormat::sniff(&[0xFF, 0xD8, 0xFF, 0xE0]), Some(ImageFormat::Jpeg));
735        assert_eq!(ImageFormat::sniff(b"GIF87a\x00"), Some(ImageFormat::Gif));
736        assert_eq!(ImageFormat::sniff(b"GIF89a\x00"), Some(ImageFormat::Gif));
737        // BMP: 유효 구조 헤더 (BITMAPCOREHEADER — bfOffBits=26, DIB=12).
738        let mut bmp = Vec::from(&b"BM"[..]);
739        bmp.extend_from_slice(&[0u8; 8]); // size(4)+reserved(4)
740        bmp.extend_from_slice(&26u32.to_le_bytes()); // bfOffBits
741        bmp.extend_from_slice(&12u32.to_le_bytes()); // DIB header size
742        bmp.extend_from_slice(&[0u8; 8]); // 나머지 코어 헤더
743        assert_eq!(ImageFormat::sniff(&bmp), Some(ImageFormat::Bmp));
744        let mut emf = vec![0x01, 0, 0, 0];
745        emf.extend_from_slice(&[0u8; 36]);
746        emf.extend_from_slice(b" EMF");
747        assert_eq!(ImageFormat::sniff(&emf), Some(ImageFormat::Emf));
748        assert_eq!(ImageFormat::sniff(&[0xD7, 0xCD, 0xC6, 0x9A, 0, 0]), Some(ImageFormat::Wmf));
749    }
750
751    #[test]
752    fn sniff_truncated_and_weak_magics_are_none() {
753        assert_eq!(ImageFormat::sniff(&[]), None);
754        // BMP 2바이트 magic 단독은 약함 — 14바이트 헤더 미달 = None.
755        assert_eq!(ImageFormat::sniff(b"BM"), None);
756        // PNG/JPEG/GIF 절단 prefix.
757        assert_eq!(ImageFormat::sniff(&[0x89, b'P']), None);
758        assert_eq!(ImageFormat::sniff(&[0xFF, 0xD8]), None);
759        assert_eq!(ImageFormat::sniff(b"GIF8"), None);
760        // EMF: 레코드 타입만으론 부족 (오프셋 40 시그니처 필요).
761        assert_eq!(ImageFormat::sniff(&[0x01, 0, 0, 0, 0, 0, 0, 0]), None);
762    }
763
764    #[test]
765    fn sniff_bmp_requires_structural_header() {
766        // 2바이트 magic 만으론 임의 바이트 반입 가능 (독립 리뷰 M1) —
767        // bfOffBits·DIB 크기 구조 검사로 차단.
768        assert_eq!(ImageFormat::sniff(b"BMsecret-credential-material-here-0123456789"), None);
769        // 구 14바이트 규칙이 수용하던 제로 헤더도 거부 (DIB 0 미지).
770        let mut zeros = Vec::from(&b"BM"[..]);
771        zeros.extend_from_slice(&[0u8; 20]);
772        assert_eq!(ImageFormat::sniff(&zeros), None);
773        // bfOffBits 가 파일 길이를 초과하면 거부.
774        let mut oob = Vec::from(&b"BM"[..]);
775        oob.extend_from_slice(&[0u8; 8]);
776        oob.extend_from_slice(&999u32.to_le_bytes());
777        oob.extend_from_slice(&40u32.to_le_bytes());
778        assert_eq!(ImageFormat::sniff(&oob), None);
779    }
780
781    #[test]
782    fn sniff_never_guesses_from_content() {
783        assert_eq!(ImageFormat::sniff(b"<svg xmlns=\"http\""), None);
784        assert_eq!(ImageFormat::sniff(&[0u8; 64]), None);
785        // RIFF 컨테이너(WAV 등) = None — WebP 포함 미지원 (HWPX BinData
786        // 캐리 대상 아님).
787        let mut wav = Vec::from(&b"RIFF"[..]);
788        wav.extend_from_slice(&[0x10, 0, 0, 0]);
789        wav.extend_from_slice(b"WAVEfmt ");
790        assert_eq!(ImageFormat::sniff(&wav), None);
791    }
792
793    #[test]
794    fn canonical_extension_covers_all_known() {
795        assert_eq!(ImageFormat::Png.canonical_extension(), Some("png"));
796        assert_eq!(ImageFormat::Jpeg.canonical_extension(), Some("jpg"));
797        assert_eq!(ImageFormat::Gif.canonical_extension(), Some("gif"));
798        assert_eq!(ImageFormat::Bmp.canonical_extension(), Some("bmp"));
799        assert_eq!(ImageFormat::Wmf.canonical_extension(), Some("wmf"));
800        assert_eq!(ImageFormat::Emf.canonical_extension(), Some("emf"));
801        assert_eq!(ImageFormat::Unknown("svg".into()).canonical_extension(), None);
802    }
803
804    // -----------------------------------------------------------------------
805    // Image::from_path tests
806    // -----------------------------------------------------------------------
807
808    #[test]
809    fn from_path_infers_format() {
810        let w = HwpUnit::from_mm(100.0).unwrap();
811        let h = HwpUnit::from_mm(75.0).unwrap();
812
813        let img = Image::from_path("photos/hero.png", w, h);
814        assert_eq!(img.format, ImageFormat::Png);
815        assert_eq!(img.path, "photos/hero.png");
816        assert_eq!(img.width, w);
817        assert_eq!(img.height, h);
818        assert!(img.caption.is_none());
819    }
820
821    #[test]
822    fn from_path_jpeg_uppercase() {
823        let w = HwpUnit::ZERO;
824        let h = HwpUnit::ZERO;
825        let img = Image::from_path("scan.JPG", w, h);
826        assert_eq!(img.format, ImageFormat::Jpeg);
827    }
828
829    #[test]
830    fn from_path_unknown_extension() {
831        let w = HwpUnit::ZERO;
832        let h = HwpUnit::ZERO;
833        let img = Image::from_path("diagram.svg", w, h);
834        assert_eq!(img.format, ImageFormat::Unknown("svg".to_string()));
835    }
836
837    #[test]
838    fn from_path_string_owned() {
839        let w = HwpUnit::ZERO;
840        let h = HwpUnit::ZERO;
841        let path = String::from("owned/path.bmp");
842        let img = Image::from_path(path, w, h);
843        assert_eq!(img.format, ImageFormat::Bmp);
844        assert_eq!(img.path, "owned/path.bmp");
845    }
846
847    #[test]
848    fn unknown_format_display_normalizes_to_lowercase() {
849        assert_eq!(ImageFormat::Unknown("SVG".to_string()).to_string(), "svg");
850        assert_eq!(ImageFormat::Unknown("Tiff".to_string()).to_string(), "tiff");
851        assert_eq!(ImageFormat::Unknown("webp".to_string()).to_string(), "webp");
852    }
853
854    #[test]
855    fn unknown_format_casing_inequality() {
856        // Unknown preserves the stored string for equality, even though display normalizes
857        let upper = ImageFormat::Unknown("SVG".to_string());
858        let lower = ImageFormat::Unknown("svg".to_string());
859        assert_ne!(upper, lower, "Different casing in Unknown produces inequality");
860        // But display output is identical
861        assert_eq!(upper.to_string(), lower.to_string());
862    }
863}