Skip to main content

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    /// 캡션 문단 전부를 재귀 방문한다 (문단 안 중첩 포함).
105    pub(crate) fn walk_paragraphs_mut(&mut self, f: &mut dyn FnMut(&mut Paragraph)) {
106        for p in &mut self.paragraphs {
107            p.walk_paragraphs_mut(f);
108        }
109    }
110
111    /// Creates a caption with the given paragraphs and side.
112    pub fn new(paragraphs: Vec<Paragraph>, side: CaptionSide) -> Self {
113        Self {
114            side,
115            width: None,
116            gap: HwpUnit::new(DEFAULT_CAPTION_GAP).expect("DEFAULT_CAPTION_GAP is valid"),
117            paragraphs,
118        }
119    }
120}
121
122/// Position of caption relative to its parent object.
123///
124/// # Default
125///
126/// Defaults to [`CaptionSide::Bottom`], the most common position in
127/// Korean government documents.
128///
129/// # Examples
130///
131/// ```
132/// use hwpforge_core::caption::CaptionSide;
133///
134/// assert_eq!(CaptionSide::default(), CaptionSide::Bottom);
135/// ```
136#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
137pub enum CaptionSide {
138    /// Caption appears to the left of the object.
139    Left,
140    /// Caption appears to the right of the object.
141    Right,
142    /// Caption appears above the object.
143    Top,
144    /// Caption appears below the object (most common).
145    #[default]
146    Bottom,
147}
148
149impl std::fmt::Display for CaptionSide {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            Self::Left => write!(f, "Left"),
153            Self::Right => write!(f, "Right"),
154            Self::Top => write!(f, "Top"),
155            Self::Bottom => write!(f, "Bottom"),
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::run::Run;
164    use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
165
166    fn simple_paragraph() -> Paragraph {
167        Paragraph::with_runs(
168            vec![Run::text("Figure 1: Example", CharShapeIndex::new(0))],
169            ParaShapeIndex::new(0),
170        )
171    }
172
173    #[test]
174    fn caption_new_bottom() {
175        let cap = Caption::new(vec![simple_paragraph()], CaptionSide::Bottom);
176        assert_eq!(cap.side, CaptionSide::Bottom);
177        assert_eq!(cap.gap.as_i32(), 850);
178        assert!(cap.width.is_none());
179        assert_eq!(cap.paragraphs.len(), 1);
180    }
181
182    #[test]
183    fn caption_new_top() {
184        let cap = Caption::new(vec![simple_paragraph(), simple_paragraph()], CaptionSide::Top);
185        assert_eq!(cap.side, CaptionSide::Top);
186        assert_eq!(cap.paragraphs.len(), 2);
187    }
188
189    #[test]
190    fn caption_new_empty_paragraphs() {
191        let cap = Caption::new(vec![], CaptionSide::Left);
192        assert!(cap.paragraphs.is_empty());
193        assert_eq!(cap.side, CaptionSide::Left);
194    }
195
196    #[test]
197    fn caption_default_values() {
198        let cap = Caption::default();
199        assert_eq!(cap.side, CaptionSide::Bottom);
200        assert!(cap.width.is_none());
201        assert_eq!(cap.gap.as_i32(), 850);
202        assert!(cap.paragraphs.is_empty());
203    }
204
205    #[test]
206    fn caption_side_default_is_bottom() {
207        assert_eq!(CaptionSide::default(), CaptionSide::Bottom);
208    }
209
210    #[test]
211    fn caption_side_all_variants() {
212        let sides = [CaptionSide::Left, CaptionSide::Right, CaptionSide::Top, CaptionSide::Bottom];
213        assert_eq!(sides.len(), 4);
214
215        // Display
216        assert_eq!(CaptionSide::Left.to_string(), "Left");
217        assert_eq!(CaptionSide::Right.to_string(), "Right");
218        assert_eq!(CaptionSide::Top.to_string(), "Top");
219        assert_eq!(CaptionSide::Bottom.to_string(), "Bottom");
220    }
221
222    #[test]
223    fn caption_serde_roundtrip() {
224        let cap = Caption {
225            side: CaptionSide::Top,
226            width: Some(HwpUnit::from_mm(80.0).unwrap()),
227            gap: HwpUnit::new(1000).unwrap(),
228            paragraphs: vec![simple_paragraph()],
229        };
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_serde_roundtrip_default() {
237        let cap = Caption::default();
238        let json = serde_json::to_string(&cap).unwrap();
239        let back: Caption = serde_json::from_str(&json).unwrap();
240        assert_eq!(cap, back);
241    }
242
243    #[test]
244    fn caption_side_serde_roundtrip() {
245        for side in [CaptionSide::Left, CaptionSide::Right, CaptionSide::Top, CaptionSide::Bottom] {
246            let json = serde_json::to_string(&side).unwrap();
247            let back: CaptionSide = serde_json::from_str(&json).unwrap();
248            assert_eq!(side, back);
249        }
250    }
251
252    #[test]
253    fn caption_with_paragraphs() {
254        let cap = Caption {
255            side: CaptionSide::Bottom,
256            width: None,
257            gap: HwpUnit::new(850).unwrap(),
258            paragraphs: vec![simple_paragraph(), simple_paragraph()],
259        };
260        assert_eq!(cap.paragraphs.len(), 2);
261    }
262
263    #[test]
264    fn caption_empty_paragraphs() {
265        // Empty paragraphs are valid (한글 allows it)
266        let cap = Caption { paragraphs: vec![], ..Caption::default() };
267        assert!(cap.paragraphs.is_empty());
268        // Should still serialize/deserialize fine
269        let json = serde_json::to_string(&cap).unwrap();
270        let back: Caption = serde_json::from_str(&json).unwrap();
271        assert_eq!(cap, back);
272    }
273
274    #[test]
275    fn caption_clone_independence() {
276        let cap = Caption {
277            side: CaptionSide::Left,
278            width: Some(HwpUnit::from_mm(50.0).unwrap()),
279            gap: HwpUnit::new(500).unwrap(),
280            paragraphs: vec![simple_paragraph()],
281        };
282        let mut cloned = cap.clone();
283        cloned.side = CaptionSide::Right;
284        assert_eq!(cap.side, CaptionSide::Left);
285    }
286
287    #[test]
288    fn caption_equality() {
289        let a = Caption::default();
290        let b = Caption::default();
291        assert_eq!(a, b);
292
293        let c = Caption { side: CaptionSide::Top, ..Caption::default() };
294        assert_ne!(a, c);
295    }
296
297    #[test]
298    fn caption_side_hash() {
299        use std::collections::HashSet;
300        let mut set = HashSet::new();
301        set.insert(CaptionSide::Left);
302        set.insert(CaptionSide::Right);
303        set.insert(CaptionSide::Left);
304        assert_eq!(set.len(), 2);
305    }
306
307    #[test]
308    fn caption_side_copy() {
309        let side = CaptionSide::Top;
310        let copied = side;
311        assert_eq!(side, copied);
312    }
313
314    #[test]
315    fn caption_custom_gap() {
316        let cap = Caption { gap: HwpUnit::from_mm(5.0).unwrap(), ..Caption::default() };
317        assert!(cap.gap.as_i32() > 850);
318    }
319
320    #[test]
321    fn caption_new_empty_bottom_equals_default() {
322        let from_new = Caption::new(vec![], CaptionSide::Bottom);
323        let from_default = Caption::default();
324        assert_eq!(from_new, from_default);
325    }
326
327    #[test]
328    fn default_caption_gap_constant() {
329        assert_eq!(super::DEFAULT_CAPTION_GAP, 850);
330        let cap = Caption::default();
331        assert_eq!(cap.gap.as_i32(), super::DEFAULT_CAPTION_GAP);
332    }
333}