hwpforge_core/
column.rs

1//! Multi-column layout settings for document sections.
2//!
3//! A [`ColumnSettings`] describes how a section is divided into multiple
4//! columns (다단). In HWPX this maps to `<hp:ctrl><hp:colPr>` elements
5//! appearing after `</hp:secPr>` in the first run of the first paragraph.
6//!
7//! Single-column layout is represented as `None` on [`Section`](crate::section::Section),
8//! not as a `ColumnSettings` with one column. This keeps the common case
9//! (single column) zero-cost and matches HWPX conventions.
10//!
11//! # Examples
12//!
13//! ```
14//! use hwpforge_core::column::{ColumnSettings, ColumnType, ColumnLayoutMode, ColumnDef};
15//! use hwpforge_foundation::HwpUnit;
16//!
17//! // Equal-width 2-column layout with 4mm gap
18//! let cols = ColumnSettings::equal_columns(2, HwpUnit::from_mm(4.0).unwrap()).unwrap();
19//! assert_eq!(cols.columns.len(), 2);
20//! assert_eq!(cols.column_type, ColumnType::Newspaper);
21//! ```
22
23use hwpforge_foundation::HwpUnit;
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27// ---------------------------------------------------------------------------
28// ColumnType
29// ---------------------------------------------------------------------------
30
31/// Column flow type: how text flows between columns.
32///
33/// In HWPX this maps to the `type` attribute on `<hp:colPr>`.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35#[non_exhaustive]
36pub enum ColumnType {
37    /// Text flows from column 1 -> 2 -> 3 (newspaper style). Most common.
38    #[default]
39    Newspaper,
40    /// Each column is independent (side-by-side comparisons). Rare.
41    Parallel,
42}
43
44// ---------------------------------------------------------------------------
45// ColumnLayoutMode
46// ---------------------------------------------------------------------------
47
48/// Column balance strategy.
49///
50/// In HWPX this maps to the `layout` attribute on `<hp:colPr>`.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
52#[non_exhaustive]
53pub enum ColumnLayoutMode {
54    /// Balance towards left column. Most common.
55    #[default]
56    Left,
57    /// Balance towards right column.
58    Right,
59    /// Symmetric balance (mirrors on odd/even pages).
60    Mirror,
61}
62
63// ---------------------------------------------------------------------------
64// ColumnDef
65// ---------------------------------------------------------------------------
66
67/// Individual column dimensions.
68///
69/// Each column has a width and a gap (space after the column).
70/// The last column's gap should be [`HwpUnit::ZERO`].
71///
72/// # Examples
73///
74/// ```
75/// use hwpforge_core::column::ColumnDef;
76/// use hwpforge_foundation::HwpUnit;
77///
78/// let col = ColumnDef {
79///     width: HwpUnit::from_mm(80.0).unwrap(),
80///     gap: HwpUnit::from_mm(4.0).unwrap(),
81/// };
82/// assert!(col.width.as_i32() > 0);
83/// ```
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85pub struct ColumnDef {
86    /// Column width (HWPUNIT).
87    pub width: HwpUnit,
88    /// Gap after this column (HWPUNIT). Last column gap is always 0.
89    pub gap: HwpUnit,
90}
91
92// ---------------------------------------------------------------------------
93// ColumnSettings
94// ---------------------------------------------------------------------------
95
96/// Multi-column layout settings for a section.
97///
98/// Maps to HWPX `<hp:ctrl><hp:colPr>`. Single-column layout is
99/// represented as `None` on [`Section`](crate::section::Section)
100/// rather than a `ColumnSettings` with one column.
101///
102/// # Examples
103///
104/// ```
105/// use hwpforge_core::column::{ColumnSettings, ColumnType, ColumnLayoutMode};
106/// use hwpforge_foundation::HwpUnit;
107///
108/// let cs = ColumnSettings::equal_columns(3, HwpUnit::from_mm(4.0).unwrap()).unwrap();
109/// assert_eq!(cs.columns.len(), 3);
110/// assert_eq!(cs.column_type, ColumnType::Newspaper);
111/// assert_eq!(cs.layout_mode, ColumnLayoutMode::Left);
112/// ```
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
114pub struct ColumnSettings {
115    /// Column flow type.
116    pub column_type: ColumnType,
117    /// Column balance strategy.
118    pub layout_mode: ColumnLayoutMode,
119    /// Individual column definitions. Length = number of columns (>= 2).
120    pub columns: Vec<ColumnDef>,
121}
122
123impl ColumnSettings {
124    /// Creates an equal-width N-column layout with the given gap.
125    ///
126    /// All columns get the same gap value (last column gap is set to zero
127    /// by the encoder). Uses [`ColumnType::Newspaper`] and
128    /// [`ColumnLayoutMode::Left`] as defaults.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if `count < 2` (single-column should be `None`).
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use hwpforge_core::column::ColumnSettings;
138    /// use hwpforge_foundation::HwpUnit;
139    ///
140    /// let cs = ColumnSettings::equal_columns(2, HwpUnit::from_mm(4.0).unwrap()).unwrap();
141    /// assert_eq!(cs.columns.len(), 2);
142    /// ```
143    pub fn equal_columns(count: u32, gap: HwpUnit) -> Result<Self, &'static str> {
144        if count < 2 {
145            return Err("column count must be >= 2 (use None for single column)");
146        }
147        let columns: Vec<ColumnDef> = (0..count)
148            .map(|i| ColumnDef {
149                width: HwpUnit::ZERO, // widths calculated by 한글 when sameSz=1
150                gap: if i < count - 1 { gap } else { HwpUnit::ZERO },
151            })
152            .collect();
153        Ok(Self {
154            column_type: ColumnType::Newspaper,
155            layout_mode: ColumnLayoutMode::Left,
156            columns,
157        })
158    }
159
160    /// Creates a variable-width column layout from explicit definitions.
161    ///
162    /// Uses [`ColumnType::Newspaper`] and [`ColumnLayoutMode::Left`] as defaults.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if `columns.len() < 2` (single-column should be `None`).
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use hwpforge_core::column::{ColumnSettings, ColumnDef};
172    /// use hwpforge_foundation::HwpUnit;
173    ///
174    /// let cs = ColumnSettings::custom(vec![
175    ///     ColumnDef { width: HwpUnit::new(14000).unwrap(), gap: HwpUnit::new(1134).unwrap() },
176    ///     ColumnDef { width: HwpUnit::new(27000).unwrap(), gap: HwpUnit::ZERO },
177    /// ]).unwrap();
178    /// assert_eq!(cs.columns.len(), 2);
179    /// ```
180    pub fn custom(columns: Vec<ColumnDef>) -> Result<Self, &'static str> {
181        if columns.len() < 2 {
182            return Err("column count must be >= 2 (use None for single column)");
183        }
184        Ok(Self {
185            column_type: ColumnType::Newspaper,
186            layout_mode: ColumnLayoutMode::Left,
187            columns,
188        })
189    }
190
191    /// Returns the number of columns.
192    pub fn count(&self) -> usize {
193        self.columns.len()
194    }
195
196    /// Returns `true` if all columns have the same width (or width is zero,
197    /// meaning 한글 calculates equal widths).
198    pub fn is_equal_width(&self) -> bool {
199        if self.columns.is_empty() {
200            return true;
201        }
202        let first = self.columns[0].width;
203        self.columns.iter().all(|c| c.width == first)
204    }
205}
206
207impl std::fmt::Display for ColumnSettings {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        write!(f, "ColumnSettings({} columns, {:?})", self.columns.len(), self.column_type)
210    }
211}
212
213// ---------------------------------------------------------------------------
214// Tests
215// ---------------------------------------------------------------------------
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn equal_columns_2() {
223        let gap = HwpUnit::new(1134).unwrap();
224        let cs = ColumnSettings::equal_columns(2, gap).unwrap();
225        assert_eq!(cs.count(), 2);
226        assert_eq!(cs.column_type, ColumnType::Newspaper);
227        assert_eq!(cs.layout_mode, ColumnLayoutMode::Left);
228        assert_eq!(cs.columns[0].gap, gap);
229        assert_eq!(cs.columns[1].gap, HwpUnit::ZERO);
230        assert!(cs.is_equal_width());
231    }
232
233    #[test]
234    fn equal_columns_3() {
235        let gap = HwpUnit::new(1134).unwrap();
236        let cs = ColumnSettings::equal_columns(3, gap).unwrap();
237        assert_eq!(cs.count(), 3);
238        assert_eq!(cs.columns[0].gap, gap);
239        assert_eq!(cs.columns[1].gap, gap);
240        assert_eq!(cs.columns[2].gap, HwpUnit::ZERO);
241    }
242
243    #[test]
244    fn equal_columns_returns_error_on_1() {
245        let result = ColumnSettings::equal_columns(1, HwpUnit::ZERO);
246        assert!(result.is_err());
247        assert_eq!(result.unwrap_err(), "column count must be >= 2 (use None for single column)");
248    }
249
250    #[test]
251    fn equal_columns_returns_error_on_0() {
252        let result = ColumnSettings::equal_columns(0, HwpUnit::ZERO);
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn custom_columns() {
258        let cs = ColumnSettings::custom(vec![
259            ColumnDef { width: HwpUnit::new(14000).unwrap(), gap: HwpUnit::new(1134).unwrap() },
260            ColumnDef { width: HwpUnit::new(27000).unwrap(), gap: HwpUnit::ZERO },
261        ])
262        .unwrap();
263        assert_eq!(cs.count(), 2);
264        assert!(!cs.is_equal_width());
265        assert_eq!(cs.columns[0].width.as_i32(), 14000);
266        assert_eq!(cs.columns[1].width.as_i32(), 27000);
267    }
268
269    #[test]
270    fn custom_returns_error_on_1() {
271        let result =
272            ColumnSettings::custom(vec![ColumnDef { width: HwpUnit::ZERO, gap: HwpUnit::ZERO }]);
273        assert!(result.is_err());
274        assert_eq!(result.unwrap_err(), "column count must be >= 2 (use None for single column)");
275    }
276
277    #[test]
278    fn serde_roundtrip() {
279        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
280        let json = serde_json::to_string(&cs).unwrap();
281        let back: ColumnSettings = serde_json::from_str(&json).unwrap();
282        assert_eq!(cs, back);
283    }
284
285    #[test]
286    fn serde_roundtrip_custom() {
287        let cs = ColumnSettings::custom(vec![
288            ColumnDef { width: HwpUnit::new(14000).unwrap(), gap: HwpUnit::new(1134).unwrap() },
289            ColumnDef { width: HwpUnit::new(27000).unwrap(), gap: HwpUnit::ZERO },
290        ])
291        .unwrap();
292        let json = serde_json::to_string(&cs).unwrap();
293        let back: ColumnSettings = serde_json::from_str(&json).unwrap();
294        assert_eq!(cs, back);
295    }
296
297    #[test]
298    fn display() {
299        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
300        let s = cs.to_string();
301        assert!(s.contains("2 columns"), "display: {s}");
302        assert!(s.contains("Newspaper"), "display: {s}");
303    }
304
305    #[test]
306    fn default_types() {
307        assert_eq!(ColumnType::default(), ColumnType::Newspaper);
308        assert_eq!(ColumnLayoutMode::default(), ColumnLayoutMode::Left);
309    }
310
311    #[test]
312    fn parallel_type() {
313        let mut cs = ColumnSettings::equal_columns(2, HwpUnit::ZERO).unwrap();
314        cs.column_type = ColumnType::Parallel;
315        assert_eq!(cs.column_type, ColumnType::Parallel);
316    }
317
318    #[test]
319    fn mirror_layout() {
320        let mut cs = ColumnSettings::equal_columns(2, HwpUnit::ZERO).unwrap();
321        cs.layout_mode = ColumnLayoutMode::Mirror;
322        assert_eq!(cs.layout_mode, ColumnLayoutMode::Mirror);
323    }
324
325    #[test]
326    fn is_equal_width_with_zero_widths() {
327        let cs = ColumnSettings::equal_columns(3, HwpUnit::new(1134).unwrap()).unwrap();
328        // All widths are ZERO (sameSz mode), which counts as equal
329        assert!(cs.is_equal_width());
330    }
331
332    #[test]
333    fn clone_independence() {
334        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
335        let mut cloned = cs.clone();
336        cloned.column_type = ColumnType::Parallel;
337        assert_eq!(cs.column_type, ColumnType::Newspaper);
338        assert_eq!(cloned.column_type, ColumnType::Parallel);
339    }
340
341    #[test]
342    fn column_settings_serde_roundtrip() {
343        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
344        let json = serde_json::to_string(&cs).unwrap();
345        let back: ColumnSettings = serde_json::from_str(&json).unwrap();
346        assert_eq!(cs, back);
347    }
348}