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
253impl std::fmt::Display for ImageFormat {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        match self {
256            Self::Png => write!(f, "PNG"),
257            Self::Jpeg => write!(f, "JPEG"),
258            Self::Gif => write!(f, "GIF"),
259            Self::Bmp => write!(f, "BMP"),
260            Self::Wmf => write!(f, "WMF"),
261            Self::Emf => write!(f, "EMF"),
262            Self::Unknown(s) => {
263                let lower = s.to_ascii_lowercase();
264                write!(f, "{lower}")
265            }
266        }
267    }
268}
269
270// ---------------------------------------------------------------------------
271// ImageStore
272// ---------------------------------------------------------------------------
273
274/// Storage for binary image data keyed by path.
275///
276/// Maps image paths (e.g. `"image1.jpg"`) to their binary content.
277/// Used by the encoder to embed images into HWPX archives and by the
278/// decoder to extract them.
279///
280/// # Examples
281///
282/// ```
283/// use hwpforge_core::image::ImageStore;
284///
285/// let mut store = ImageStore::new();
286/// store.insert("logo.png", vec![0x89, 0x50, 0x4E, 0x47]);
287/// assert_eq!(store.len(), 1);
288/// assert!(store.get("logo.png").is_some());
289/// ```
290#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
291pub struct ImageStore {
292    images: HashMap<String, Vec<u8>>,
293}
294
295impl ImageStore {
296    /// Creates an empty image store.
297    pub fn new() -> Self {
298        Self { images: HashMap::new() }
299    }
300
301    /// Inserts an image with the given key and binary data.
302    ///
303    /// If the key already exists, the data is replaced.
304    pub fn insert(&mut self, key: impl Into<String>, data: Vec<u8>) {
305        self.images.insert(key.into(), data);
306    }
307
308    /// Returns the binary data for the given key, if present.
309    pub fn get(&self, key: &str) -> Option<&[u8]> {
310        self.images.get(key).map(|v| v.as_slice())
311    }
312
313    /// Returns the number of stored images.
314    pub fn len(&self) -> usize {
315        self.images.len()
316    }
317
318    /// Returns `true` if the store contains no images.
319    pub fn is_empty(&self) -> bool {
320        self.images.is_empty()
321    }
322
323    /// Iterates over all `(key, data)` pairs.
324    pub fn iter(&self) -> impl Iterator<Item = (&str, &[u8])> {
325        self.images.iter().map(|(k, v)| (k.as_str(), v.as_slice()))
326    }
327}
328
329impl FromIterator<(String, Vec<u8>)> for ImageStore {
330    fn from_iter<I: IntoIterator<Item = (String, Vec<u8>)>>(iter: I) -> Self {
331        Self { images: iter.into_iter().collect() }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn sample_image() -> Image {
340        Image::new(
341            "BinData/image1.png",
342            HwpUnit::from_mm(50.0).unwrap(),
343            HwpUnit::from_mm(30.0).unwrap(),
344            ImageFormat::Png,
345        )
346    }
347
348    #[test]
349    fn new_constructor() {
350        let img = sample_image();
351        assert_eq!(img.path, "BinData/image1.png");
352        assert_eq!(img.format, ImageFormat::Png);
353    }
354
355    #[test]
356    fn from_path_constructor() {
357        let img = Image::from_path(
358            "test.jpeg",
359            HwpUnit::from_mm(10.0).unwrap(),
360            HwpUnit::from_mm(10.0).unwrap(),
361        );
362        assert_eq!(img.format, ImageFormat::Jpeg);
363    }
364
365    #[test]
366    fn builder_attaches_caption() {
367        let img = sample_image().with_caption(Caption::default());
368        assert!(img.caption.is_some());
369    }
370
371    #[test]
372    fn display_format() {
373        let img = sample_image();
374        let s = img.to_string();
375        assert!(s.contains("PNG"), "display: {s}");
376        assert!(s.contains("50.0"), "display: {s}");
377        assert!(s.contains("30.0"), "display: {s}");
378    }
379
380    #[test]
381    fn image_format_display() {
382        assert_eq!(ImageFormat::Png.to_string(), "PNG");
383        assert_eq!(ImageFormat::Jpeg.to_string(), "JPEG");
384        assert_eq!(ImageFormat::Gif.to_string(), "GIF");
385        assert_eq!(ImageFormat::Bmp.to_string(), "BMP");
386        assert_eq!(ImageFormat::Wmf.to_string(), "WMF");
387        assert_eq!(ImageFormat::Emf.to_string(), "EMF");
388        assert_eq!(ImageFormat::Unknown("TIFF".to_string()).to_string(), "tiff");
389    }
390
391    #[test]
392    fn equality() {
393        let a = sample_image();
394        let b = sample_image();
395        assert_eq!(a, b);
396    }
397
398    #[test]
399    fn inequality_on_different_paths() {
400        let a = sample_image();
401        let mut b = sample_image();
402        b.path = "other.png".to_string();
403        assert_ne!(a, b);
404    }
405
406    #[test]
407    fn clone_independence() {
408        let img = sample_image();
409        let mut cloned = img.clone();
410        cloned.path = "modified.png".to_string();
411        assert_eq!(img.path, "BinData/image1.png");
412    }
413
414    #[test]
415    fn serde_roundtrip() {
416        let img = sample_image();
417        let json = serde_json::to_string(&img).unwrap();
418        let back: Image = serde_json::from_str(&json).unwrap();
419        assert_eq!(img, back);
420    }
421
422    #[test]
423    fn placement_roundtrip() {
424        use crate::placement::{ObjectPlacement, ObjectRelativeTo, ObjectTextFlow, ObjectTextWrap};
425        let img = sample_image().with_placement(ObjectPlacement {
426            text_wrap: ObjectTextWrap::Square,
427            text_flow: ObjectTextFlow::RightOnly,
428            treat_as_char: false,
429            flow_with_text: true,
430            allow_overlap: true,
431            vert_rel_to: ObjectRelativeTo::Paper,
432            horz_rel_to: ObjectRelativeTo::Page,
433            vert_offset: HwpUnit::new(1200).unwrap(),
434            horz_offset: HwpUnit::new(3400).unwrap(),
435        });
436        let json = serde_json::to_string(&img).unwrap();
437        let back: Image = serde_json::from_str(&json).unwrap();
438        assert_eq!(img, back);
439    }
440
441    #[test]
442    fn serde_unknown_format_roundtrip() {
443        let img = Image::new(
444            "test.svg",
445            HwpUnit::from_mm(10.0).unwrap(),
446            HwpUnit::from_mm(10.0).unwrap(),
447            ImageFormat::Unknown("SVG".to_string()),
448        );
449        let json = serde_json::to_string(&img).unwrap();
450        let back: Image = serde_json::from_str(&json).unwrap();
451        assert_eq!(img, back);
452    }
453
454    #[test]
455    fn image_format_hash() {
456        use std::collections::HashSet;
457        let mut set = HashSet::new();
458        set.insert(ImageFormat::Png);
459        set.insert(ImageFormat::Jpeg);
460        set.insert(ImageFormat::Png);
461        assert_eq!(set.len(), 2);
462    }
463
464    #[test]
465    fn from_string_path() {
466        let path = String::from("dynamic/path.bmp");
467        let img = Image::new(path, HwpUnit::ZERO, HwpUnit::ZERO, ImageFormat::Bmp);
468        assert_eq!(img.path, "dynamic/path.bmp");
469    }
470
471    // -----------------------------------------------------------------------
472    // ImageStore tests
473    // -----------------------------------------------------------------------
474
475    #[test]
476    fn image_store_new_is_empty() {
477        let store = ImageStore::new();
478        assert!(store.is_empty());
479        assert_eq!(store.len(), 0);
480    }
481
482    #[test]
483    fn image_store_insert_and_get() {
484        let mut store = ImageStore::new();
485        store.insert("logo.png", vec![0x89, 0x50, 0x4E, 0x47]);
486        assert_eq!(store.len(), 1);
487        assert!(!store.is_empty());
488        assert_eq!(store.get("logo.png"), Some(&[0x89, 0x50, 0x4E, 0x47][..]));
489    }
490
491    #[test]
492    fn image_store_get_missing() {
493        let store = ImageStore::new();
494        assert!(store.get("nonexistent.png").is_none());
495    }
496
497    #[test]
498    fn image_store_insert_replaces() {
499        let mut store = ImageStore::new();
500        store.insert("img.png", vec![1, 2, 3]);
501        store.insert("img.png", vec![4, 5, 6]);
502        assert_eq!(store.len(), 1);
503        assert_eq!(store.get("img.png"), Some(&[4, 5, 6][..]));
504    }
505
506    #[test]
507    fn image_store_multiple_images() {
508        let mut store = ImageStore::new();
509        store.insert("a.png", vec![1]);
510        store.insert("b.jpg", vec![2]);
511        store.insert("c.gif", vec![3]);
512        assert_eq!(store.len(), 3);
513    }
514
515    #[test]
516    fn image_store_iter() {
517        let mut store = ImageStore::new();
518        store.insert("a.png", vec![1]);
519        store.insert("b.jpg", vec![2]);
520        let pairs: Vec<_> = store.iter().collect();
521        assert_eq!(pairs.len(), 2);
522    }
523
524    #[test]
525    fn image_store_from_iterator() {
526        let items = vec![("a.png".to_string(), vec![1, 2]), ("b.jpg".to_string(), vec![3, 4])];
527        let store: ImageStore = items.into_iter().collect();
528        assert_eq!(store.len(), 2);
529        assert_eq!(store.get("a.png"), Some(&[1, 2][..]));
530    }
531
532    #[test]
533    fn image_store_default() {
534        let store = ImageStore::default();
535        assert!(store.is_empty());
536    }
537
538    #[test]
539    fn image_store_clone_independence() {
540        let mut store = ImageStore::new();
541        store.insert("img.png", vec![1, 2, 3]);
542        let mut cloned = store.clone();
543        cloned.insert("other.png", vec![4, 5]);
544        assert_eq!(store.len(), 1);
545        assert_eq!(cloned.len(), 2);
546    }
547
548    #[test]
549    fn image_store_equality() {
550        let mut a = ImageStore::new();
551        a.insert("img.png", vec![1, 2, 3]);
552        let mut b = ImageStore::new();
553        b.insert("img.png", vec![1, 2, 3]);
554        assert_eq!(a, b);
555    }
556
557    #[test]
558    fn image_store_serde_roundtrip() {
559        let mut store = ImageStore::new();
560        store.insert("logo.png", vec![0x89, 0x50]);
561        let json = serde_json::to_string(&store).unwrap();
562        let back: ImageStore = serde_json::from_str(&json).unwrap();
563        assert_eq!(store, back);
564    }
565
566    #[test]
567    fn image_store_string_key() {
568        let mut store = ImageStore::new();
569        let key = String::from("dynamic/path.png");
570        store.insert(key, vec![42]);
571        assert!(store.get("dynamic/path.png").is_some());
572    }
573
574    // -----------------------------------------------------------------------
575    // ImageFormat::from_extension tests
576    // -----------------------------------------------------------------------
577
578    #[test]
579    fn from_extension_png() {
580        assert_eq!(ImageFormat::from_extension("photo.png"), ImageFormat::Png);
581    }
582
583    #[test]
584    fn from_extension_jpg_uppercase() {
585        assert_eq!(ImageFormat::from_extension("image.JPG"), ImageFormat::Jpeg);
586    }
587
588    #[test]
589    fn from_extension_jpeg() {
590        assert_eq!(ImageFormat::from_extension("file.jpeg"), ImageFormat::Jpeg);
591    }
592
593    #[test]
594    fn from_extension_gif() {
595        assert_eq!(ImageFormat::from_extension("doc.gif"), ImageFormat::Gif);
596    }
597
598    #[test]
599    fn from_extension_bmp() {
600        assert_eq!(ImageFormat::from_extension("img.bmp"), ImageFormat::Bmp);
601    }
602
603    #[test]
604    fn from_extension_wmf() {
605        assert_eq!(ImageFormat::from_extension("chart.wmf"), ImageFormat::Wmf);
606    }
607
608    #[test]
609    fn from_extension_emf() {
610        assert_eq!(ImageFormat::from_extension("dia.emf"), ImageFormat::Emf);
611    }
612
613    #[test]
614    fn from_extension_unknown() {
615        assert_eq!(
616            ImageFormat::from_extension("file.xyz"),
617            ImageFormat::Unknown("xyz".to_string()),
618        );
619    }
620
621    #[test]
622    fn from_extension_no_extension() {
623        assert_eq!(ImageFormat::from_extension("noext"), ImageFormat::Unknown(String::new()));
624    }
625
626    #[test]
627    fn from_extension_multi_dot() {
628        assert_eq!(ImageFormat::from_extension("multi.dot.png"), ImageFormat::Png);
629    }
630
631    // -----------------------------------------------------------------------
632    // Image::from_path tests
633    // -----------------------------------------------------------------------
634
635    #[test]
636    fn from_path_infers_format() {
637        let w = HwpUnit::from_mm(100.0).unwrap();
638        let h = HwpUnit::from_mm(75.0).unwrap();
639
640        let img = Image::from_path("photos/hero.png", w, h);
641        assert_eq!(img.format, ImageFormat::Png);
642        assert_eq!(img.path, "photos/hero.png");
643        assert_eq!(img.width, w);
644        assert_eq!(img.height, h);
645        assert!(img.caption.is_none());
646    }
647
648    #[test]
649    fn from_path_jpeg_uppercase() {
650        let w = HwpUnit::ZERO;
651        let h = HwpUnit::ZERO;
652        let img = Image::from_path("scan.JPG", w, h);
653        assert_eq!(img.format, ImageFormat::Jpeg);
654    }
655
656    #[test]
657    fn from_path_unknown_extension() {
658        let w = HwpUnit::ZERO;
659        let h = HwpUnit::ZERO;
660        let img = Image::from_path("diagram.svg", w, h);
661        assert_eq!(img.format, ImageFormat::Unknown("svg".to_string()));
662    }
663
664    #[test]
665    fn from_path_string_owned() {
666        let w = HwpUnit::ZERO;
667        let h = HwpUnit::ZERO;
668        let path = String::from("owned/path.bmp");
669        let img = Image::from_path(path, w, h);
670        assert_eq!(img.format, ImageFormat::Bmp);
671        assert_eq!(img.path, "owned/path.bmp");
672    }
673
674    #[test]
675    fn unknown_format_display_normalizes_to_lowercase() {
676        assert_eq!(ImageFormat::Unknown("SVG".to_string()).to_string(), "svg");
677        assert_eq!(ImageFormat::Unknown("Tiff".to_string()).to_string(), "tiff");
678        assert_eq!(ImageFormat::Unknown("webp".to_string()).to_string(), "webp");
679    }
680
681    #[test]
682    fn unknown_format_casing_inequality() {
683        // Unknown preserves the stored string for equality, even though display normalizes
684        let upper = ImageFormat::Unknown("SVG".to_string());
685        let lower = ImageFormat::Unknown("svg".to_string());
686        assert_ne!(upper, lower, "Different casing in Unknown produces inequality");
687        // But display output is identical
688        assert_eq!(upper.to_string(), lower.to_string());
689    }
690}