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