hwpforge_core/
caption.rs

1//! Caption types for shape objects (tables, images, textboxes, etc.).
2//!
3//! A [`Caption`] attaches descriptive text (typically numbered) below, above,
4//! or beside a shape object. In HWPX, this maps to the `<hp:caption>` element
5//! nested inside drawing objects like `<hp:tbl>`, `<hp:pic>`, `<hp:rect>`, etc.
6//!
7//! # Design
8//!
9//! Caption is a Core-level structural type. It holds position, gap, optional
10//! width, and paragraph content. HWPX-specific attributes (`fullSz`, `lastWidth`)
11//! belong in the Schema layer, not here.
12//!
13//! # Examples
14//!
15//! ```
16//! use hwpforge_core::caption::{Caption, CaptionSide};
17//! use hwpforge_core::paragraph::Paragraph;
18//! use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
19//!
20//! let caption = Caption {
21//!     side: CaptionSide::Bottom,
22//!     width: None,
23//!     gap: HwpUnit::new(850).unwrap(),
24//!     paragraphs: vec![Paragraph::new(ParaShapeIndex::new(0))],
25//! };
26//! assert_eq!(caption.side, CaptionSide::Bottom);
27//! ```
28
29use hwpforge_foundation::HwpUnit;
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32
33use crate::paragraph::Paragraph;
34
35/// Default caption gap in HWPUNIT (~3mm). Used by [`Caption::default`] and [`Caption::new`].
36pub const DEFAULT_CAPTION_GAP: i32 = 850;
37
38/// Caption attached to a shape object (table, image, textbox, etc.).
39///
40/// Contains position, gap distance, optional explicit width, and the
41/// caption's paragraph content. Empty paragraphs are valid (한글 allows it).
42///
43/// # Default
44///
45/// Default caption: side = Bottom, width = None, gap = 850 HWPUNIT (~3mm),
46/// paragraphs = empty.
47///
48/// # Examples
49///
50/// ```
51/// use hwpforge_core::caption::{Caption, CaptionSide};
52/// use hwpforge_foundation::HwpUnit;
53///
54/// let cap = Caption::default();
55/// assert_eq!(cap.side, CaptionSide::Bottom);
56/// assert_eq!(cap.gap.as_i32(), 850);
57/// assert!(cap.width.is_none());
58/// assert!(cap.paragraphs.is_empty());
59/// ```
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
61pub struct Caption {
62    /// Position of the caption relative to the object.
63    pub side: CaptionSide,
64    /// Caption width in HwpUnit. `None` = auto (same as object width).
65    pub width: Option<HwpUnit>,
66    /// Gap between caption and object. Default: `HwpUnit(850)` (~3mm).
67    pub gap: HwpUnit,
68    /// Caption content paragraphs.
69    pub paragraphs: Vec<Paragraph>,
70}
71
72impl Default for Caption {
73    fn default() -> Self {
74        Self {
75            side: CaptionSide::default(),
76            width: None,
77            gap: HwpUnit::new(DEFAULT_CAPTION_GAP).unwrap(),
78            paragraphs: Vec::new(),
79        }
80    }
81}
82
83impl Caption {
84    /// Creates a caption with the given paragraphs and side placement.
85    ///
86    /// Uses default gap (850 HWPUNIT ≈ 3mm) and auto width (`None`).
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use hwpforge_core::caption::{Caption, CaptionSide};
92    /// use hwpforge_core::paragraph::Paragraph;
93    /// use hwpforge_foundation::ParaShapeIndex;
94    ///
95    /// let cap = Caption::new(
96    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
97    ///     CaptionSide::Bottom,
98    /// );
99    /// assert_eq!(cap.side, CaptionSide::Bottom);
100    /// assert_eq!(cap.gap.as_i32(), 850);
101    /// assert!(cap.width.is_none());
102    /// assert_eq!(cap.paragraphs.len(), 1);
103    /// ```
104    pub fn new(paragraphs: Vec<Paragraph>, side: CaptionSide) -> Self {
105        Self {
106            side,
107            width: None,
108            gap: HwpUnit::new(DEFAULT_CAPTION_GAP).expect("DEFAULT_CAPTION_GAP is valid"),
109            paragraphs,
110        }
111    }
112}
113
114/// Position of caption relative to its parent object.
115///
116/// # Default
117///
118/// Defaults to [`CaptionSide::Bottom`], the most common position in
119/// Korean government documents.
120///
121/// # Examples
122///
123/// ```
124/// use hwpforge_core::caption::CaptionSide;
125///
126/// assert_eq!(CaptionSide::default(), CaptionSide::Bottom);
127/// ```
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
129pub enum CaptionSide {
130    /// Caption appears to the left of the object.
131    Left,
132    /// Caption appears to the right of the object.
133    Right,
134    /// Caption appears above the object.
135    Top,
136    /// Caption appears below the object (most common).
137    #[default]
138    Bottom,
139}
140
141impl std::fmt::Display for CaptionSide {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            Self::Left => write!(f, "Left"),
145            Self::Right => write!(f, "Right"),
146            Self::Top => write!(f, "Top"),
147            Self::Bottom => write!(f, "Bottom"),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::run::Run;
156    use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
157
158    fn simple_paragraph() -> Paragraph {
159        Paragraph::with_runs(
160            vec![Run::text("Figure 1: Example", CharShapeIndex::new(0))],
161            ParaShapeIndex::new(0),
162        )
163    }
164
165    #[test]
166    fn caption_new_bottom() {
167        let cap = Caption::new(vec![simple_paragraph()], CaptionSide::Bottom);
168        assert_eq!(cap.side, CaptionSide::Bottom);
169        assert_eq!(cap.gap.as_i32(), 850);
170        assert!(cap.width.is_none());
171        assert_eq!(cap.paragraphs.len(), 1);
172    }
173
174    #[test]
175    fn caption_new_top() {
176        let cap = Caption::new(vec![simple_paragraph(), simple_paragraph()], CaptionSide::Top);
177        assert_eq!(cap.side, CaptionSide::Top);
178        assert_eq!(cap.paragraphs.len(), 2);
179    }
180
181    #[test]
182    fn caption_new_empty_paragraphs() {
183        let cap = Caption::new(vec![], CaptionSide::Left);
184        assert!(cap.paragraphs.is_empty());
185        assert_eq!(cap.side, CaptionSide::Left);
186    }
187
188    #[test]
189    fn caption_default_values() {
190        let cap = Caption::default();
191        assert_eq!(cap.side, CaptionSide::Bottom);
192        assert!(cap.width.is_none());
193        assert_eq!(cap.gap.as_i32(), 850);
194        assert!(cap.paragraphs.is_empty());
195    }
196
197    #[test]
198    fn caption_side_default_is_bottom() {
199        assert_eq!(CaptionSide::default(), CaptionSide::Bottom);
200    }
201
202    #[test]
203    fn caption_side_all_variants() {
204        let sides = [CaptionSide::Left, CaptionSide::Right, CaptionSide::Top, CaptionSide::Bottom];
205        assert_eq!(sides.len(), 4);
206
207        // Display
208        assert_eq!(CaptionSide::Left.to_string(), "Left");
209        assert_eq!(CaptionSide::Right.to_string(), "Right");
210        assert_eq!(CaptionSide::Top.to_string(), "Top");
211        assert_eq!(CaptionSide::Bottom.to_string(), "Bottom");
212    }
213
214    #[test]
215    fn caption_serde_roundtrip() {
216        let cap = Caption {
217            side: CaptionSide::Top,
218            width: Some(HwpUnit::from_mm(80.0).unwrap()),
219            gap: HwpUnit::new(1000).unwrap(),
220            paragraphs: vec![simple_paragraph()],
221        };
222        let json = serde_json::to_string(&cap).unwrap();
223        let back: Caption = serde_json::from_str(&json).unwrap();
224        assert_eq!(cap, back);
225    }
226
227    #[test]
228    fn caption_serde_roundtrip_default() {
229        let cap = Caption::default();
230        let json = serde_json::to_string(&cap).unwrap();
231        let back: Caption = serde_json::from_str(&json).unwrap();
232        assert_eq!(cap, back);
233    }
234
235    #[test]
236    fn caption_side_serde_roundtrip() {
237        for side in [CaptionSide::Left, CaptionSide::Right, CaptionSide::Top, CaptionSide::Bottom] {
238            let json = serde_json::to_string(&side).unwrap();
239            let back: CaptionSide = serde_json::from_str(&json).unwrap();
240            assert_eq!(side, back);
241        }
242    }
243
244    #[test]
245    fn caption_with_paragraphs() {
246        let cap = Caption {
247            side: CaptionSide::Bottom,
248            width: None,
249            gap: HwpUnit::new(850).unwrap(),
250            paragraphs: vec![simple_paragraph(), simple_paragraph()],
251        };
252        assert_eq!(cap.paragraphs.len(), 2);
253    }
254
255    #[test]
256    fn caption_empty_paragraphs() {
257        // Empty paragraphs are valid (한글 allows it)
258        let cap = Caption { paragraphs: vec![], ..Caption::default() };
259        assert!(cap.paragraphs.is_empty());
260        // Should still serialize/deserialize fine
261        let json = serde_json::to_string(&cap).unwrap();
262        let back: Caption = serde_json::from_str(&json).unwrap();
263        assert_eq!(cap, back);
264    }
265
266    #[test]
267    fn caption_clone_independence() {
268        let cap = Caption {
269            side: CaptionSide::Left,
270            width: Some(HwpUnit::from_mm(50.0).unwrap()),
271            gap: HwpUnit::new(500).unwrap(),
272            paragraphs: vec![simple_paragraph()],
273        };
274        let mut cloned = cap.clone();
275        cloned.side = CaptionSide::Right;
276        assert_eq!(cap.side, CaptionSide::Left);
277    }
278
279    #[test]
280    fn caption_equality() {
281        let a = Caption::default();
282        let b = Caption::default();
283        assert_eq!(a, b);
284
285        let c = Caption { side: CaptionSide::Top, ..Caption::default() };
286        assert_ne!(a, c);
287    }
288
289    #[test]
290    fn caption_side_hash() {
291        use std::collections::HashSet;
292        let mut set = HashSet::new();
293        set.insert(CaptionSide::Left);
294        set.insert(CaptionSide::Right);
295        set.insert(CaptionSide::Left);
296        assert_eq!(set.len(), 2);
297    }
298
299    #[test]
300    fn caption_side_copy() {
301        let side = CaptionSide::Top;
302        let copied = side;
303        assert_eq!(side, copied);
304    }
305
306    #[test]
307    fn caption_custom_gap() {
308        let cap = Caption { gap: HwpUnit::from_mm(5.0).unwrap(), ..Caption::default() };
309        assert!(cap.gap.as_i32() > 850);
310    }
311
312    #[test]
313    fn caption_new_empty_bottom_equals_default() {
314        let from_new = Caption::new(vec![], CaptionSide::Bottom);
315        let from_default = Caption::default();
316        assert_eq!(from_new, from_default);
317    }
318
319    #[test]
320    fn default_caption_gap_constant() {
321        assert_eq!(super::DEFAULT_CAPTION_GAP, 850);
322        let cap = Caption::default();
323        assert_eq!(cap.gap.as_i32(), super::DEFAULT_CAPTION_GAP);
324    }
325}