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}