Skip to main content

hwpforge_core/
table.rs

1//! Table types: [`Table`], [`TableRow`], [`TableCell`].
2//!
3//! Tables in HWP documents are structural containers. Each cell holds
4//! its own paragraphs (rich content, not just text). Cells can span
5//! multiple columns or rows via `col_span` / `row_span`.
6//!
7//! # Validation
8//!
9//! Table validation is performed at the Document level (not by Table
10//! constructors) so that tables can be built incrementally. The
11//! validation rules are:
12//!
13//! - At least 1 row
14//! - Each row has at least 1 cell
15//! - Each cell has at least 1 paragraph
16//! - `col_span >= 1`, `row_span >= 1`
17//!
18//! # Examples
19//!
20//! ```
21//! use hwpforge_core::table::{Table, TableRow, TableCell};
22//! use hwpforge_core::paragraph::Paragraph;
23//! use hwpforge_foundation::{HwpUnit, ParaShapeIndex, CharShapeIndex};
24//! use hwpforge_core::run::Run;
25//!
26//! let cell = TableCell::new(
27//!     vec![Paragraph::with_runs(
28//!         vec![Run::text("Hello", CharShapeIndex::new(0))],
29//!         ParaShapeIndex::new(0),
30//!     )],
31//!     HwpUnit::from_mm(50.0).unwrap(),
32//! );
33//! let row = TableRow::new(vec![cell]);
34//! let table = Table::new(vec![row]);
35//! assert_eq!(table.row_count(), 1);
36//! ```
37
38use hwpforge_foundation::{Color, HwpUnit};
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41
42use crate::caption::Caption;
43use crate::paragraph::Paragraph;
44
45/// Page-break policy for a table.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
47#[serde(rename_all = "snake_case")]
48pub enum TablePageBreak {
49    /// Split the table at cell boundaries.
50    #[default]
51    Cell,
52    /// Split the table as a whole unit.
53    Table,
54    /// Do not split the table across pages.
55    None,
56}
57
58/// Vertical alignment for content inside a table cell.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
60#[serde(rename_all = "snake_case")]
61pub enum TableVerticalAlign {
62    /// Align cell content to the top edge.
63    Top,
64    /// Center cell content vertically.
65    #[default]
66    Center,
67    /// Align cell content to the bottom edge.
68    Bottom,
69}
70
71/// Explicit margins inside a table cell.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
73pub struct TableMargin {
74    /// Left margin in HWP units.
75    pub left: HwpUnit,
76    /// Right margin in HWP units.
77    pub right: HwpUnit,
78    /// Top margin in HWP units.
79    pub top: HwpUnit,
80    /// Bottom margin in HWP units.
81    pub bottom: HwpUnit,
82}
83
84fn default_repeat_header() -> bool {
85    true
86}
87
88/// A table: a sequence of rows, with optional width and caption.
89///
90/// # Design Decision
91///
92/// No `border: Option<BorderStyle>` in Phase 1. Border styling is a
93/// Blueprint concern (Phase 2). Core tables are purely structural.
94///
95/// # Examples
96///
97/// ```
98/// use hwpforge_core::table::{Table, TableCell, TablePageBreak, TableRow};
99/// use hwpforge_core::paragraph::Paragraph;
100/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
101///
102/// let table = Table::new(vec![TableRow::new(vec![TableCell::new(
103///     vec![Paragraph::new(ParaShapeIndex::new(0))],
104///     HwpUnit::from_mm(100.0).unwrap(),
105/// )])])
106/// .with_page_break(TablePageBreak::Cell);
107/// assert_eq!(table.row_count(), 1);
108/// ```
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
110#[non_exhaustive]
111pub struct Table {
112    /// Rows of the table.
113    pub rows: Vec<TableRow>,
114    /// Optional explicit table width. `None` means auto-width.
115    pub width: Option<HwpUnit>,
116    /// Optional table caption.
117    pub caption: Option<Caption>,
118    /// Page-break policy for this table.
119    #[serde(default)]
120    pub page_break: TablePageBreak,
121    /// Whether the first row repeats across page breaks.
122    #[serde(default = "default_repeat_header")]
123    pub repeat_header: bool,
124    /// Optional explicit spacing between table cells.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub cell_spacing: Option<HwpUnit>,
127    /// Optional table-level border/fill reference.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub border_fill_id: Option<u32>,
130    /// Wave 12p Step 2b: instance ID for cross-ref target lookup. HWP5
131    /// 변환 시 Table CtrlHeader trailer 의 instance ID 가 채워지고,
132    /// HWPX encoder 가 `<hp:tbl id="...">` attribute 로 emit. `None`
133    /// 이면 encoder 가 fallback 값 (예: sequential counter) 사용 허용.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub inst_id: Option<u64>,
136}
137
138impl Table {
139    /// Creates a table from rows.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use hwpforge_core::table::{Table, TableRow};
145    ///
146    /// let table = Table::new(vec![TableRow::new(vec![])]);
147    /// assert_eq!(table.row_count(), 1);
148    /// ```
149    #[must_use]
150    pub fn new(rows: Vec<TableRow>) -> Self {
151        Self {
152            rows,
153            width: None,
154            caption: None,
155            page_break: TablePageBreak::Cell,
156            repeat_header: true,
157            cell_spacing: None,
158            border_fill_id: None,
159            inst_id: None,
160        }
161    }
162
163    /// Sets an explicit table width.
164    #[must_use]
165    pub fn with_width(mut self, width: HwpUnit) -> Self {
166        self.width = Some(width);
167        self
168    }
169
170    /// Attaches a table caption.
171    #[must_use]
172    pub fn with_caption(mut self, caption: Caption) -> Self {
173        self.caption = Some(caption);
174        self
175    }
176
177    /// Sets the page-break policy for this table.
178    #[must_use]
179    pub fn with_page_break(mut self, page_break: TablePageBreak) -> Self {
180        self.page_break = page_break;
181        self
182    }
183
184    /// Controls whether the leading header block repeats across page breaks.
185    #[must_use]
186    pub fn with_repeat_header(mut self, repeat_header: bool) -> Self {
187        self.repeat_header = repeat_header;
188        self
189    }
190
191    /// Sets the explicit spacing between cells.
192    #[must_use]
193    pub fn with_cell_spacing(mut self, cell_spacing: HwpUnit) -> Self {
194        self.cell_spacing = Some(cell_spacing);
195        self
196    }
197
198    /// Sets the table-level border/fill reference.
199    #[must_use]
200    pub fn with_border_fill_id(mut self, border_fill_id: u32) -> Self {
201        self.border_fill_id = Some(border_fill_id);
202        self
203    }
204
205    /// Returns the number of rows.
206    pub fn row_count(&self) -> usize {
207        self.rows.len()
208    }
209
210    /// Returns the number of columns (from the first row).
211    ///
212    /// Returns 0 if the table has no rows.
213    pub fn col_count(&self) -> usize {
214        self.rows.first().map_or(0, |r| r.cells.len())
215    }
216
217    /// Returns `true` if the table has no rows.
218    pub fn is_empty(&self) -> bool {
219        self.rows.is_empty()
220    }
221}
222
223impl std::fmt::Display for Table {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        write!(f, "Table({}x{})", self.row_count(), self.col_count())
226    }
227}
228
229/// A single row of a table.
230///
231/// # Examples
232///
233/// ```
234/// use hwpforge_core::table::{TableRow, TableCell};
235/// use hwpforge_core::paragraph::Paragraph;
236/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
237///
238/// let row = TableRow::new(vec![
239///     TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
240///     TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
241/// ]);
242/// assert_eq!(row.cells.len(), 2);
243/// ```
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
245#[non_exhaustive]
246pub struct TableRow {
247    /// Cells in this row.
248    pub cells: Vec<TableCell>,
249    /// Optional fixed row height. `None` means auto-height.
250    pub height: Option<HwpUnit>,
251    /// Whether this row is part of the table's leading header-row block.
252    #[serde(default)]
253    pub is_header: bool,
254}
255
256impl TableRow {
257    /// Creates a new table row with the given cells and auto-calculated height.
258    ///
259    /// # Examples
260    ///
261    /// ```
262    /// use hwpforge_core::table::{TableRow, TableCell};
263    /// use hwpforge_core::paragraph::Paragraph;
264    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
265    ///
266    /// let cell = TableCell::new(
267    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
268    ///     HwpUnit::from_mm(40.0).unwrap(),
269    /// );
270    /// let row = TableRow::new(vec![cell]);
271    /// assert!(row.height.is_none());
272    /// ```
273    #[must_use]
274    pub fn new(cells: Vec<TableCell>) -> Self {
275        Self { cells, height: None, is_header: false }
276    }
277
278    /// Creates a new table row with an explicit fixed height.
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use hwpforge_core::table::{TableRow, TableCell};
284    /// use hwpforge_core::paragraph::Paragraph;
285    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
286    ///
287    /// let cell = TableCell::new(
288    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
289    ///     HwpUnit::from_mm(40.0).unwrap(),
290    /// );
291    /// let row = TableRow::with_height(vec![cell], HwpUnit::from_mm(20.0).unwrap());
292    /// assert!(row.height.is_some());
293    /// ```
294    #[must_use]
295    pub fn with_height(cells: Vec<TableCell>, height: HwpUnit) -> Self {
296        Self { cells, height: Some(height), is_header: false }
297    }
298
299    /// Marks whether this row belongs to the table's leading header-row block.
300    #[must_use]
301    pub fn with_header(mut self, is_header: bool) -> Self {
302        self.is_header = is_header;
303        self
304    }
305}
306
307/// A single cell within a table row.
308///
309/// Each cell contains its own paragraphs (rich content). Spans
310/// default to 1 (no spanning).
311///
312/// # Examples
313///
314/// ```
315/// use hwpforge_core::table::TableCell;
316/// use hwpforge_core::paragraph::Paragraph;
317/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
318///
319/// let cell = TableCell::new(
320///     vec![Paragraph::new(ParaShapeIndex::new(0))],
321///     HwpUnit::from_mm(40.0).unwrap(),
322/// );
323/// assert_eq!(cell.col_span, 1);
324/// assert_eq!(cell.row_span, 1);
325/// ```
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
327#[non_exhaustive]
328pub struct TableCell {
329    /// Rich content within the cell.
330    pub paragraphs: Vec<Paragraph>,
331    /// Number of columns this cell spans. Must be >= 1.
332    pub col_span: u16,
333    /// Number of rows this cell spans. Must be >= 1.
334    pub row_span: u16,
335    /// Cell width.
336    pub width: HwpUnit,
337    /// Optional explicit cell height.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub height: Option<HwpUnit>,
340    /// Optional cell background color.
341    pub background: Option<Color>,
342    /// Optional border/fill reference for this cell.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub border_fill_id: Option<u32>,
345    /// Optional cell-local margin override.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub margin: Option<TableMargin>,
348    /// Optional vertical alignment override for the cell content box.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub vertical_align: Option<TableVerticalAlign>,
351}
352
353impl TableCell {
354    /// Creates a cell with default spans (1x1) and no background.
355    ///
356    /// # Examples
357    ///
358    /// ```
359    /// use hwpforge_core::table::TableCell;
360    /// use hwpforge_core::paragraph::Paragraph;
361    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
362    ///
363    /// let cell = TableCell::new(
364    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
365    ///     HwpUnit::from_mm(50.0).unwrap(),
366    /// );
367    /// assert_eq!(cell.col_span, 1);
368    /// assert_eq!(cell.row_span, 1);
369    /// assert!(cell.background.is_none());
370    /// ```
371    #[must_use]
372    pub fn new(paragraphs: Vec<Paragraph>, width: HwpUnit) -> Self {
373        Self {
374            paragraphs,
375            col_span: 1,
376            row_span: 1,
377            width,
378            height: None,
379            background: None,
380            border_fill_id: None,
381            margin: None,
382            vertical_align: None,
383        }
384    }
385
386    /// Creates a cell with explicit span values.
387    ///
388    /// # Examples
389    ///
390    /// ```
391    /// use hwpforge_core::table::TableCell;
392    /// use hwpforge_core::paragraph::Paragraph;
393    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
394    ///
395    /// let merged = TableCell::with_span(
396    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
397    ///     HwpUnit::from_mm(100.0).unwrap(),
398    ///     2, // col_span
399    ///     3, // row_span
400    /// );
401    /// assert_eq!(merged.col_span, 2);
402    /// assert_eq!(merged.row_span, 3);
403    /// ```
404    #[must_use]
405    pub fn with_span(
406        paragraphs: Vec<Paragraph>,
407        width: HwpUnit,
408        col_span: u16,
409        row_span: u16,
410    ) -> Self {
411        Self {
412            paragraphs,
413            col_span,
414            row_span,
415            width,
416            height: None,
417            background: None,
418            border_fill_id: None,
419            margin: None,
420            vertical_align: None,
421        }
422    }
423
424    /// Sets an explicit cell height.
425    #[must_use]
426    pub fn with_height(mut self, height: HwpUnit) -> Self {
427        self.height = Some(height);
428        self
429    }
430
431    /// Sets the cell background color.
432    #[must_use]
433    pub fn with_background(mut self, background: Color) -> Self {
434        self.background = Some(background);
435        self
436    }
437
438    /// Sets the cell border/fill reference.
439    #[must_use]
440    pub fn with_border_fill_id(mut self, border_fill_id: u32) -> Self {
441        self.border_fill_id = Some(border_fill_id);
442        self
443    }
444
445    /// Sets the cell-local margin override.
446    #[must_use]
447    pub fn with_margin(mut self, margin: TableMargin) -> Self {
448        self.margin = Some(margin);
449        self
450    }
451
452    /// Sets the vertical alignment override for the cell content box.
453    #[must_use]
454    pub fn with_vertical_align(mut self, vertical_align: TableVerticalAlign) -> Self {
455        self.vertical_align = Some(vertical_align);
456        self
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::run::Run;
464    use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
465
466    fn simple_paragraph() -> Paragraph {
467        Paragraph::with_runs(
468            vec![Run::text("cell", CharShapeIndex::new(0))],
469            ParaShapeIndex::new(0),
470        )
471    }
472
473    fn simple_cell() -> TableCell {
474        TableCell::new(vec![simple_paragraph()], HwpUnit::from_mm(50.0).unwrap())
475    }
476
477    fn simple_row() -> TableRow {
478        TableRow::new(vec![simple_cell(), simple_cell()])
479    }
480
481    fn simple_table() -> Table {
482        Table::new(vec![simple_row(), simple_row()])
483    }
484
485    #[test]
486    fn table_new() {
487        let t = simple_table();
488        assert_eq!(t.row_count(), 2);
489        assert_eq!(t.col_count(), 2);
490        assert!(!t.is_empty());
491        assert!(t.width.is_none());
492        assert!(t.caption.is_none());
493        assert_eq!(t.page_break, TablePageBreak::Cell);
494        assert!(t.repeat_header);
495        assert!(t.cell_spacing.is_none());
496        assert!(t.border_fill_id.is_none());
497    }
498
499    #[test]
500    fn empty_table() {
501        let t = Table::new(vec![]);
502        assert_eq!(t.row_count(), 0);
503        assert_eq!(t.col_count(), 0);
504        assert!(t.is_empty());
505    }
506
507    #[test]
508    fn table_with_caption() {
509        let t = simple_table().with_caption(crate::caption::Caption::default());
510        assert!(t.caption.is_some());
511    }
512
513    #[test]
514    fn table_with_width() {
515        let t = simple_table().with_width(HwpUnit::from_mm(150.0).unwrap());
516        assert!(t.width.is_some());
517    }
518
519    #[test]
520    fn table_with_page_break() {
521        let t = simple_table().with_page_break(TablePageBreak::Table);
522        assert_eq!(t.page_break, TablePageBreak::Table);
523    }
524
525    #[test]
526    fn table_with_repeat_header_disabled() {
527        let t = simple_table().with_repeat_header(false);
528        assert!(!t.repeat_header);
529    }
530
531    #[test]
532    fn cell_new_defaults() {
533        let cell = simple_cell();
534        assert_eq!(cell.col_span, 1);
535        assert_eq!(cell.row_span, 1);
536        assert!(cell.height.is_none());
537        assert!(cell.background.is_none());
538        assert!(cell.border_fill_id.is_none());
539        assert!(cell.margin.is_none());
540        assert!(cell.vertical_align.is_none());
541        assert_eq!(cell.paragraphs.len(), 1);
542    }
543
544    #[test]
545    fn cell_with_span() {
546        let cell =
547            TableCell::with_span(vec![simple_paragraph()], HwpUnit::from_mm(100.0).unwrap(), 3, 2);
548        assert_eq!(cell.col_span, 3);
549        assert_eq!(cell.row_span, 2);
550    }
551
552    #[test]
553    fn cell_with_background() {
554        let cell = simple_cell().with_background(Color::from_rgb(200, 200, 200));
555        assert!(cell.background.is_some());
556    }
557
558    #[test]
559    fn table_display() {
560        let t = simple_table();
561        assert_eq!(t.to_string(), "Table(2x2)");
562    }
563
564    #[test]
565    fn single_cell_table() {
566        let table = Table::new(vec![TableRow::with_height(
567            vec![simple_cell()],
568            HwpUnit::from_mm(10.0).unwrap(),
569        )]);
570        assert_eq!(table.row_count(), 1);
571        assert_eq!(table.col_count(), 1);
572    }
573
574    #[test]
575    fn row_with_fixed_height() {
576        let row = TableRow::with_height(vec![simple_cell()], HwpUnit::from_mm(25.0).unwrap());
577        assert!(row.height.is_some());
578    }
579
580    #[test]
581    fn row_new_auto_height() {
582        let row = TableRow::new(vec![simple_cell(), simple_cell()]);
583        assert_eq!(row.cells.len(), 2);
584        assert!(row.height.is_none());
585    }
586
587    #[test]
588    fn row_new_empty_cells() {
589        let row = TableRow::new(vec![]);
590        assert!(row.cells.is_empty());
591        assert!(row.height.is_none());
592    }
593
594    #[test]
595    fn row_with_height_constructor() {
596        let h = HwpUnit::from_mm(20.0).unwrap();
597        let row = TableRow::with_height(vec![simple_cell()], h);
598        assert_eq!(row.cells.len(), 1);
599        assert_eq!(row.height, Some(h));
600    }
601
602    #[test]
603    fn equality() {
604        let a = simple_table();
605        let b = simple_table();
606        assert_eq!(a, b);
607    }
608
609    #[test]
610    fn clone_independence() {
611        let t = simple_table();
612        let mut cloned = t.clone();
613        cloned.caption = Some(crate::caption::Caption::default());
614        assert!(t.caption.is_none());
615    }
616
617    #[test]
618    fn serde_roundtrip() {
619        let t = simple_table();
620        let json = serde_json::to_string(&t).unwrap();
621        let back: Table = serde_json::from_str(&json).unwrap();
622        assert_eq!(t, back);
623    }
624
625    #[test]
626    fn serde_with_all_optional_fields() {
627        let mut t = simple_table()
628            .with_width(HwpUnit::from_mm(150.0).unwrap())
629            .with_caption(crate::caption::Caption::default())
630            .with_page_break(TablePageBreak::None)
631            .with_repeat_header(false)
632            .with_cell_spacing(HwpUnit::from_mm(2.0).unwrap())
633            .with_border_fill_id(7);
634        t.rows[0].height = Some(HwpUnit::from_mm(20.0).unwrap());
635        t.rows[0].cells[0] = t.rows[0].cells[0]
636            .clone()
637            .with_background(Color::from_rgb(255, 0, 0))
638            .with_height(HwpUnit::from_mm(8.0).unwrap())
639            .with_border_fill_id(9)
640            .with_margin(TableMargin {
641                left: HwpUnit::from_mm(1.0).unwrap(),
642                right: HwpUnit::from_mm(2.0).unwrap(),
643                top: HwpUnit::from_mm(0.5).unwrap(),
644                bottom: HwpUnit::from_mm(0.25).unwrap(),
645            })
646            .with_vertical_align(TableVerticalAlign::Bottom);
647
648        let json = serde_json::to_string(&t).unwrap();
649        let back: Table = serde_json::from_str(&json).unwrap();
650        assert_eq!(t, back);
651    }
652
653    #[test]
654    fn serde_defaults_missing_new_fields() {
655        let json = r#"{"rows":[],"width":null,"caption":null}"#;
656        let back: Table = serde_json::from_str(json).unwrap();
657        assert_eq!(back.page_break, TablePageBreak::Cell);
658        assert!(back.repeat_header);
659        assert!(back.cell_spacing.is_none());
660        assert!(back.border_fill_id.is_none());
661    }
662
663    #[test]
664    fn table_margin_defaults_to_zero() {
665        let margin = TableMargin::default();
666        assert_eq!(margin.left, HwpUnit::ZERO);
667        assert_eq!(margin.right, HwpUnit::ZERO);
668        assert_eq!(margin.top, HwpUnit::ZERO);
669        assert_eq!(margin.bottom, HwpUnit::ZERO);
670    }
671
672    #[test]
673    fn cell_zero_span_allowed_at_construction() {
674        // Zero spans are allowed during construction; validation catches them
675        let cell = TableCell::with_span(
676            vec![simple_paragraph()],
677            HwpUnit::from_mm(50.0).unwrap(),
678            0, // invalid, but construction doesn't prevent it
679            0,
680        );
681        assert_eq!(cell.col_span, 0);
682        assert_eq!(cell.row_span, 0);
683    }
684
685    #[test]
686    fn row_new_sets_expected_defaults() {
687        let cells = vec![simple_cell()];
688        let row = TableRow::new(cells.clone());
689        assert_eq!(row.cells, cells);
690        assert!(row.height.is_none());
691        assert!(!row.is_header);
692    }
693}