Skip to main content

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::{BorderLineType, Color, 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// ColumnLine
94// ---------------------------------------------------------------------------
95
96/// Separator line drawn between columns.
97///
98/// Maps to the HWPX `<hp:colLine>` child of `<hp:colPr>` (OWPML
99/// KS X 6101 §10.7.1.2). Present only when the document draws a visible
100/// divider between columns; [`ColumnSettings::col_line`] is `None` for the
101/// common no-separator case, which keeps `<hp:colPr>` self-closing.
102///
103/// OWPML defaults: `SOLID`, `0.12 mm`, `#000000` (see [`Default`]).
104///
105/// # Examples
106///
107/// ```
108/// use hwpforge_core::column::ColumnLine;
109/// use hwpforge_foundation::{BorderLineType, Color, HwpUnit};
110///
111/// let line = ColumnLine {
112///     line_type: BorderLineType::DoubleSlim,
113///     width: HwpUnit::from_mm(0.7).unwrap(),
114///     color: Color::from_rgb(0x3A, 0x3C, 0x84),
115/// };
116/// assert_eq!(line.line_type, BorderLineType::DoubleSlim);
117/// ```
118#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
119pub struct ColumnLine {
120    /// Separator line style. HWPX `type` attribute (e.g. `SOLID`, `DOUBLE_SLIM`).
121    pub line_type: BorderLineType,
122    /// Separator line thickness. HWPX `width` attribute, emitted in millimetres
123    /// (e.g. `"0.7 mm"`).
124    pub width: HwpUnit,
125    /// Separator line color. HWPX `color` attribute (e.g. `#3A3C84`).
126    pub color: Color,
127}
128
129impl Default for ColumnLine {
130    /// OWPML defaults: `SOLID`, `0.12 mm`, black.
131    fn default() -> Self {
132        Self {
133            line_type: BorderLineType::Solid,
134            width: HwpUnit::from_mm(0.12).unwrap_or(HwpUnit::ZERO),
135            color: Color::from_rgb(0, 0, 0),
136        }
137    }
138}
139
140// ---------------------------------------------------------------------------
141// ColumnSettings
142// ---------------------------------------------------------------------------
143
144/// Multi-column layout settings for a section.
145///
146/// Maps to HWPX `<hp:ctrl><hp:colPr>`. Single-column layout is
147/// represented as `None` on [`Section`](crate::section::Section)
148/// rather than a `ColumnSettings` with one column.
149///
150/// # Examples
151///
152/// ```
153/// use hwpforge_core::column::{ColumnSettings, ColumnType, ColumnLayoutMode};
154/// use hwpforge_foundation::HwpUnit;
155///
156/// let cs = ColumnSettings::equal_columns(3, HwpUnit::from_mm(4.0).unwrap()).unwrap();
157/// assert_eq!(cs.columns.len(), 3);
158/// assert_eq!(cs.column_type, ColumnType::Newspaper);
159/// assert_eq!(cs.layout_mode, ColumnLayoutMode::Left);
160/// ```
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
162pub struct ColumnSettings {
163    /// Column flow type.
164    pub column_type: ColumnType,
165    /// Column balance strategy.
166    pub layout_mode: ColumnLayoutMode,
167    /// Individual column definitions. Length = number of columns (>= 2).
168    pub columns: Vec<ColumnDef>,
169    /// Optional separator line drawn between columns (`<hp:colLine>`).
170    /// `None` = no divider (the common case; keeps `<hp:colPr>` self-closing).
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub col_line: Option<ColumnLine>,
173}
174
175impl ColumnSettings {
176    /// Creates an equal-width N-column layout with the given gap.
177    ///
178    /// All columns get the same gap value (last column gap is set to zero
179    /// by the encoder). Uses [`ColumnType::Newspaper`] and
180    /// [`ColumnLayoutMode::Left`] as defaults.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if `count < 2` (single-column should be `None`).
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use hwpforge_core::column::ColumnSettings;
190    /// use hwpforge_foundation::HwpUnit;
191    ///
192    /// let cs = ColumnSettings::equal_columns(2, HwpUnit::from_mm(4.0).unwrap()).unwrap();
193    /// assert_eq!(cs.columns.len(), 2);
194    /// ```
195    pub fn equal_columns(count: u32, gap: HwpUnit) -> Result<Self, &'static str> {
196        if count < 2 {
197            return Err("column count must be >= 2 (use None for single column)");
198        }
199        let columns: Vec<ColumnDef> = (0..count)
200            .map(|i| ColumnDef {
201                width: HwpUnit::ZERO, // widths calculated by 한글 when sameSz=1
202                gap: if i < count - 1 { gap } else { HwpUnit::ZERO },
203            })
204            .collect();
205        Ok(Self {
206            column_type: ColumnType::Newspaper,
207            layout_mode: ColumnLayoutMode::Left,
208            columns,
209            col_line: None,
210        })
211    }
212
213    /// Creates a variable-width column layout from explicit definitions.
214    ///
215    /// Uses [`ColumnType::Newspaper`] and [`ColumnLayoutMode::Left`] as defaults.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if `columns.len() < 2` (single-column should be `None`).
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use hwpforge_core::column::{ColumnSettings, ColumnDef};
225    /// use hwpforge_foundation::HwpUnit;
226    ///
227    /// let cs = ColumnSettings::custom(vec![
228    ///     ColumnDef { width: HwpUnit::new(14000).unwrap(), gap: HwpUnit::new(1134).unwrap() },
229    ///     ColumnDef { width: HwpUnit::new(27000).unwrap(), gap: HwpUnit::ZERO },
230    /// ]).unwrap();
231    /// assert_eq!(cs.columns.len(), 2);
232    /// ```
233    pub fn custom(columns: Vec<ColumnDef>) -> Result<Self, &'static str> {
234        if columns.len() < 2 {
235            return Err("column count must be >= 2 (use None for single column)");
236        }
237        Ok(Self {
238            column_type: ColumnType::Newspaper,
239            layout_mode: ColumnLayoutMode::Left,
240            columns,
241            col_line: None,
242        })
243    }
244
245    /// Attaches a separator line drawn between columns (builder style).
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// use hwpforge_core::column::{ColumnSettings, ColumnLine};
251    /// use hwpforge_foundation::HwpUnit;
252    ///
253    /// let cs = ColumnSettings::equal_columns(2, HwpUnit::from_mm(4.0).unwrap())
254    ///     .unwrap()
255    ///     .with_separator(ColumnLine::default());
256    /// assert!(cs.col_line.is_some());
257    /// ```
258    #[must_use]
259    pub fn with_separator(mut self, line: ColumnLine) -> Self {
260        self.col_line = Some(line);
261        self
262    }
263
264    /// Returns the number of columns.
265    pub fn count(&self) -> usize {
266        self.columns.len()
267    }
268
269    /// Returns `true` if all columns have the same width (or width is zero,
270    /// meaning 한글 calculates equal widths).
271    pub fn is_equal_width(&self) -> bool {
272        if self.columns.is_empty() {
273            return true;
274        }
275        let first = self.columns[0].width;
276        self.columns.iter().all(|c| c.width == first)
277    }
278}
279
280impl std::fmt::Display for ColumnSettings {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        write!(f, "ColumnSettings({} columns, {:?})", self.columns.len(), self.column_type)
283    }
284}
285
286// ---------------------------------------------------------------------------
287// Tests
288// ---------------------------------------------------------------------------
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn equal_columns_2() {
296        let gap = HwpUnit::new(1134).unwrap();
297        let cs = ColumnSettings::equal_columns(2, gap).unwrap();
298        assert_eq!(cs.count(), 2);
299        assert_eq!(cs.column_type, ColumnType::Newspaper);
300        assert_eq!(cs.layout_mode, ColumnLayoutMode::Left);
301        assert_eq!(cs.columns[0].gap, gap);
302        assert_eq!(cs.columns[1].gap, HwpUnit::ZERO);
303        assert!(cs.is_equal_width());
304    }
305
306    #[test]
307    fn equal_columns_3() {
308        let gap = HwpUnit::new(1134).unwrap();
309        let cs = ColumnSettings::equal_columns(3, gap).unwrap();
310        assert_eq!(cs.count(), 3);
311        assert_eq!(cs.columns[0].gap, gap);
312        assert_eq!(cs.columns[1].gap, gap);
313        assert_eq!(cs.columns[2].gap, HwpUnit::ZERO);
314    }
315
316    #[test]
317    fn default_columns_have_no_separator() {
318        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
319        assert!(cs.col_line.is_none(), "default columns must have no separator line");
320    }
321
322    #[test]
323    fn column_line_default_is_owpml_default() {
324        let line = ColumnLine::default();
325        assert_eq!(line.line_type, BorderLineType::Solid);
326        assert_eq!(line.color, Color::from_rgb(0, 0, 0));
327        // 0.12 mm rounds to ~34 HWPUNIT (1 mm = 283.46 HWPUNIT).
328        assert!((line.width.to_mm() - 0.12).abs() < 0.01);
329    }
330
331    #[test]
332    fn with_separator_attaches_col_line() {
333        let line = ColumnLine {
334            line_type: BorderLineType::DoubleSlim,
335            width: HwpUnit::from_mm(0.7).unwrap(),
336            color: Color::from_rgb(0x3A, 0x3C, 0x84),
337        };
338        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap())
339            .unwrap()
340            .with_separator(line);
341        assert_eq!(cs.col_line, Some(line));
342    }
343
344    #[test]
345    fn serde_round_trip_with_and_without_separator() {
346        // Without separator: col_line is skipped entirely (byte-neutral for old JSON).
347        let plain = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
348        let json = serde_json::to_string(&plain).unwrap();
349        assert!(!json.contains("col_line"), "absent separator must not serialize a key");
350        assert_eq!(serde_json::from_str::<ColumnSettings>(&json).unwrap(), plain);
351
352        // With separator: round-trips intact.
353        let withed = plain.clone().with_separator(ColumnLine::default());
354        let json2 = serde_json::to_string(&withed).unwrap();
355        assert!(json2.contains("col_line"));
356        assert_eq!(serde_json::from_str::<ColumnSettings>(&json2).unwrap(), withed);
357    }
358
359    #[test]
360    fn equal_columns_returns_error_on_1() {
361        let result = ColumnSettings::equal_columns(1, HwpUnit::ZERO);
362        assert!(result.is_err());
363        assert_eq!(result.unwrap_err(), "column count must be >= 2 (use None for single column)");
364    }
365
366    #[test]
367    fn equal_columns_returns_error_on_0() {
368        let result = ColumnSettings::equal_columns(0, HwpUnit::ZERO);
369        assert!(result.is_err());
370    }
371
372    #[test]
373    fn custom_columns() {
374        let cs = ColumnSettings::custom(vec![
375            ColumnDef { width: HwpUnit::new(14000).unwrap(), gap: HwpUnit::new(1134).unwrap() },
376            ColumnDef { width: HwpUnit::new(27000).unwrap(), gap: HwpUnit::ZERO },
377        ])
378        .unwrap();
379        assert_eq!(cs.count(), 2);
380        assert!(!cs.is_equal_width());
381        assert_eq!(cs.columns[0].width.as_i32(), 14000);
382        assert_eq!(cs.columns[1].width.as_i32(), 27000);
383    }
384
385    #[test]
386    fn custom_returns_error_on_1() {
387        let result =
388            ColumnSettings::custom(vec![ColumnDef { width: HwpUnit::ZERO, gap: HwpUnit::ZERO }]);
389        assert!(result.is_err());
390        assert_eq!(result.unwrap_err(), "column count must be >= 2 (use None for single column)");
391    }
392
393    #[test]
394    fn serde_roundtrip() {
395        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
396        let json = serde_json::to_string(&cs).unwrap();
397        let back: ColumnSettings = serde_json::from_str(&json).unwrap();
398        assert_eq!(cs, back);
399    }
400
401    #[test]
402    fn serde_roundtrip_custom() {
403        let cs = ColumnSettings::custom(vec![
404            ColumnDef { width: HwpUnit::new(14000).unwrap(), gap: HwpUnit::new(1134).unwrap() },
405            ColumnDef { width: HwpUnit::new(27000).unwrap(), gap: HwpUnit::ZERO },
406        ])
407        .unwrap();
408        let json = serde_json::to_string(&cs).unwrap();
409        let back: ColumnSettings = serde_json::from_str(&json).unwrap();
410        assert_eq!(cs, back);
411    }
412
413    #[test]
414    fn display() {
415        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
416        let s = cs.to_string();
417        assert!(s.contains("2 columns"), "display: {s}");
418        assert!(s.contains("Newspaper"), "display: {s}");
419    }
420
421    #[test]
422    fn default_types() {
423        assert_eq!(ColumnType::default(), ColumnType::Newspaper);
424        assert_eq!(ColumnLayoutMode::default(), ColumnLayoutMode::Left);
425    }
426
427    #[test]
428    fn parallel_type() {
429        let mut cs = ColumnSettings::equal_columns(2, HwpUnit::ZERO).unwrap();
430        cs.column_type = ColumnType::Parallel;
431        assert_eq!(cs.column_type, ColumnType::Parallel);
432    }
433
434    #[test]
435    fn mirror_layout() {
436        let mut cs = ColumnSettings::equal_columns(2, HwpUnit::ZERO).unwrap();
437        cs.layout_mode = ColumnLayoutMode::Mirror;
438        assert_eq!(cs.layout_mode, ColumnLayoutMode::Mirror);
439    }
440
441    #[test]
442    fn is_equal_width_with_zero_widths() {
443        let cs = ColumnSettings::equal_columns(3, HwpUnit::new(1134).unwrap()).unwrap();
444        // All widths are ZERO (sameSz mode), which counts as equal
445        assert!(cs.is_equal_width());
446    }
447
448    #[test]
449    fn clone_independence() {
450        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
451        let mut cloned = cs.clone();
452        cloned.column_type = ColumnType::Parallel;
453        assert_eq!(cs.column_type, ColumnType::Newspaper);
454        assert_eq!(cloned.column_type, ColumnType::Parallel);
455    }
456
457    #[test]
458    fn column_settings_serde_roundtrip() {
459        let cs = ColumnSettings::equal_columns(2, HwpUnit::new(1134).unwrap()).unwrap();
460        let json = serde_json::to_string(&cs).unwrap();
461        let back: ColumnSettings = serde_json::from_str(&json).unwrap();
462        assert_eq!(cs, back);
463    }
464}