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}