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}
131
132impl Table {
133    /// Creates a table from rows.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// use hwpforge_core::table::{Table, TableRow};
139    ///
140    /// let table = Table::new(vec![TableRow::new(vec![])]);
141    /// assert_eq!(table.row_count(), 1);
142    /// ```
143    #[must_use]
144    pub fn new(rows: Vec<TableRow>) -> Self {
145        Self {
146            rows,
147            width: None,
148            caption: None,
149            page_break: TablePageBreak::Cell,
150            repeat_header: true,
151            cell_spacing: None,
152            border_fill_id: None,
153        }
154    }
155
156    /// Sets an explicit table width.
157    #[must_use]
158    pub fn with_width(mut self, width: HwpUnit) -> Self {
159        self.width = Some(width);
160        self
161    }
162
163    /// Attaches a table caption.
164    #[must_use]
165    pub fn with_caption(mut self, caption: Caption) -> Self {
166        self.caption = Some(caption);
167        self
168    }
169
170    /// Sets the page-break policy for this table.
171    #[must_use]
172    pub fn with_page_break(mut self, page_break: TablePageBreak) -> Self {
173        self.page_break = page_break;
174        self
175    }
176
177    /// Controls whether the leading header block repeats across page breaks.
178    #[must_use]
179    pub fn with_repeat_header(mut self, repeat_header: bool) -> Self {
180        self.repeat_header = repeat_header;
181        self
182    }
183
184    /// Sets the explicit spacing between cells.
185    #[must_use]
186    pub fn with_cell_spacing(mut self, cell_spacing: HwpUnit) -> Self {
187        self.cell_spacing = Some(cell_spacing);
188        self
189    }
190
191    /// Sets the table-level border/fill reference.
192    #[must_use]
193    pub fn with_border_fill_id(mut self, border_fill_id: u32) -> Self {
194        self.border_fill_id = Some(border_fill_id);
195        self
196    }
197
198    /// Returns the number of rows.
199    pub fn row_count(&self) -> usize {
200        self.rows.len()
201    }
202
203    /// Returns the number of columns (from the first row).
204    ///
205    /// Returns 0 if the table has no rows.
206    pub fn col_count(&self) -> usize {
207        self.rows.first().map_or(0, |r| r.cells.len())
208    }
209
210    /// Returns `true` if the table has no rows.
211    pub fn is_empty(&self) -> bool {
212        self.rows.is_empty()
213    }
214}
215
216impl std::fmt::Display for Table {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        write!(f, "Table({}x{})", self.row_count(), self.col_count())
219    }
220}
221
222/// A single row of a table.
223///
224/// # Examples
225///
226/// ```
227/// use hwpforge_core::table::{TableRow, TableCell};
228/// use hwpforge_core::paragraph::Paragraph;
229/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
230///
231/// let row = TableRow::new(vec![
232///     TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
233///     TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
234/// ]);
235/// assert_eq!(row.cells.len(), 2);
236/// ```
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
238#[non_exhaustive]
239pub struct TableRow {
240    /// Cells in this row.
241    pub cells: Vec<TableCell>,
242    /// Optional fixed row height. `None` means auto-height.
243    pub height: Option<HwpUnit>,
244    /// Whether this row is part of the table's leading header-row block.
245    #[serde(default)]
246    pub is_header: bool,
247}
248
249impl TableRow {
250    /// Creates a new table row with the given cells and auto-calculated height.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use hwpforge_core::table::{TableRow, TableCell};
256    /// use hwpforge_core::paragraph::Paragraph;
257    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
258    ///
259    /// let cell = TableCell::new(
260    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
261    ///     HwpUnit::from_mm(40.0).unwrap(),
262    /// );
263    /// let row = TableRow::new(vec![cell]);
264    /// assert!(row.height.is_none());
265    /// ```
266    #[must_use]
267    pub fn new(cells: Vec<TableCell>) -> Self {
268        Self { cells, height: None, is_header: false }
269    }
270
271    /// Creates a new table row with an explicit fixed height.
272    ///
273    /// # Examples
274    ///
275    /// ```
276    /// use hwpforge_core::table::{TableRow, TableCell};
277    /// use hwpforge_core::paragraph::Paragraph;
278    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
279    ///
280    /// let cell = TableCell::new(
281    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
282    ///     HwpUnit::from_mm(40.0).unwrap(),
283    /// );
284    /// let row = TableRow::with_height(vec![cell], HwpUnit::from_mm(20.0).unwrap());
285    /// assert!(row.height.is_some());
286    /// ```
287    #[must_use]
288    pub fn with_height(cells: Vec<TableCell>, height: HwpUnit) -> Self {
289        Self { cells, height: Some(height), is_header: false }
290    }
291
292    /// Marks whether this row belongs to the table's leading header-row block.
293    #[must_use]
294    pub fn with_header(mut self, is_header: bool) -> Self {
295        self.is_header = is_header;
296        self
297    }
298}
299
300/// A single cell within a table row.
301///
302/// Each cell contains its own paragraphs (rich content). Spans
303/// default to 1 (no spanning).
304///
305/// # Examples
306///
307/// ```
308/// use hwpforge_core::table::TableCell;
309/// use hwpforge_core::paragraph::Paragraph;
310/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
311///
312/// let cell = TableCell::new(
313///     vec![Paragraph::new(ParaShapeIndex::new(0))],
314///     HwpUnit::from_mm(40.0).unwrap(),
315/// );
316/// assert_eq!(cell.col_span, 1);
317/// assert_eq!(cell.row_span, 1);
318/// ```
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
320#[non_exhaustive]
321pub struct TableCell {
322    /// Rich content within the cell.
323    pub paragraphs: Vec<Paragraph>,
324    /// Number of columns this cell spans. Must be >= 1.
325    pub col_span: u16,
326    /// Number of rows this cell spans. Must be >= 1.
327    pub row_span: u16,
328    /// Cell width.
329    pub width: HwpUnit,
330    /// Optional explicit cell height.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub height: Option<HwpUnit>,
333    /// Optional cell background color.
334    pub background: Option<Color>,
335    /// Optional border/fill reference for this cell.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub border_fill_id: Option<u32>,
338    /// Optional cell-local margin override.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub margin: Option<TableMargin>,
341    /// Optional vertical alignment override for the cell content box.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub vertical_align: Option<TableVerticalAlign>,
344}
345
346impl TableCell {
347    /// Creates a cell with default spans (1x1) and no background.
348    ///
349    /// # Examples
350    ///
351    /// ```
352    /// use hwpforge_core::table::TableCell;
353    /// use hwpforge_core::paragraph::Paragraph;
354    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
355    ///
356    /// let cell = TableCell::new(
357    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
358    ///     HwpUnit::from_mm(50.0).unwrap(),
359    /// );
360    /// assert_eq!(cell.col_span, 1);
361    /// assert_eq!(cell.row_span, 1);
362    /// assert!(cell.background.is_none());
363    /// ```
364    #[must_use]
365    pub fn new(paragraphs: Vec<Paragraph>, width: HwpUnit) -> Self {
366        Self {
367            paragraphs,
368            col_span: 1,
369            row_span: 1,
370            width,
371            height: None,
372            background: None,
373            border_fill_id: None,
374            margin: None,
375            vertical_align: None,
376        }
377    }
378
379    /// Creates a cell with explicit span values.
380    ///
381    /// # Examples
382    ///
383    /// ```
384    /// use hwpforge_core::table::TableCell;
385    /// use hwpforge_core::paragraph::Paragraph;
386    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
387    ///
388    /// let merged = TableCell::with_span(
389    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
390    ///     HwpUnit::from_mm(100.0).unwrap(),
391    ///     2, // col_span
392    ///     3, // row_span
393    /// );
394    /// assert_eq!(merged.col_span, 2);
395    /// assert_eq!(merged.row_span, 3);
396    /// ```
397    #[must_use]
398    pub fn with_span(
399        paragraphs: Vec<Paragraph>,
400        width: HwpUnit,
401        col_span: u16,
402        row_span: u16,
403    ) -> Self {
404        Self {
405            paragraphs,
406            col_span,
407            row_span,
408            width,
409            height: None,
410            background: None,
411            border_fill_id: None,
412            margin: None,
413            vertical_align: None,
414        }
415    }
416
417    /// Sets an explicit cell height.
418    #[must_use]
419    pub fn with_height(mut self, height: HwpUnit) -> Self {
420        self.height = Some(height);
421        self
422    }
423
424    /// Sets the cell background color.
425    #[must_use]
426    pub fn with_background(mut self, background: Color) -> Self {
427        self.background = Some(background);
428        self
429    }
430
431    /// Sets the cell border/fill reference.
432    #[must_use]
433    pub fn with_border_fill_id(mut self, border_fill_id: u32) -> Self {
434        self.border_fill_id = Some(border_fill_id);
435        self
436    }
437
438    /// Sets the cell-local margin override.
439    #[must_use]
440    pub fn with_margin(mut self, margin: TableMargin) -> Self {
441        self.margin = Some(margin);
442        self
443    }
444
445    /// Sets the vertical alignment override for the cell content box.
446    #[must_use]
447    pub fn with_vertical_align(mut self, vertical_align: TableVerticalAlign) -> Self {
448        self.vertical_align = Some(vertical_align);
449        self
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::run::Run;
457    use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
458
459    fn simple_paragraph() -> Paragraph {
460        Paragraph::with_runs(
461            vec![Run::text("cell", CharShapeIndex::new(0))],
462            ParaShapeIndex::new(0),
463        )
464    }
465
466    fn simple_cell() -> TableCell {
467        TableCell::new(vec![simple_paragraph()], HwpUnit::from_mm(50.0).unwrap())
468    }
469
470    fn simple_row() -> TableRow {
471        TableRow::new(vec![simple_cell(), simple_cell()])
472    }
473
474    fn simple_table() -> Table {
475        Table::new(vec![simple_row(), simple_row()])
476    }
477
478    #[test]
479    fn table_new() {
480        let t = simple_table();
481        assert_eq!(t.row_count(), 2);
482        assert_eq!(t.col_count(), 2);
483        assert!(!t.is_empty());
484        assert!(t.width.is_none());
485        assert!(t.caption.is_none());
486        assert_eq!(t.page_break, TablePageBreak::Cell);
487        assert!(t.repeat_header);
488        assert!(t.cell_spacing.is_none());
489        assert!(t.border_fill_id.is_none());
490    }
491
492    #[test]
493    fn empty_table() {
494        let t = Table::new(vec![]);
495        assert_eq!(t.row_count(), 0);
496        assert_eq!(t.col_count(), 0);
497        assert!(t.is_empty());
498    }
499
500    #[test]
501    fn table_with_caption() {
502        let t = simple_table().with_caption(crate::caption::Caption::default());
503        assert!(t.caption.is_some());
504    }
505
506    #[test]
507    fn table_with_width() {
508        let t = simple_table().with_width(HwpUnit::from_mm(150.0).unwrap());
509        assert!(t.width.is_some());
510    }
511
512    #[test]
513    fn table_with_page_break() {
514        let t = simple_table().with_page_break(TablePageBreak::Table);
515        assert_eq!(t.page_break, TablePageBreak::Table);
516    }
517
518    #[test]
519    fn table_with_repeat_header_disabled() {
520        let t = simple_table().with_repeat_header(false);
521        assert!(!t.repeat_header);
522    }
523
524    #[test]
525    fn cell_new_defaults() {
526        let cell = simple_cell();
527        assert_eq!(cell.col_span, 1);
528        assert_eq!(cell.row_span, 1);
529        assert!(cell.height.is_none());
530        assert!(cell.background.is_none());
531        assert!(cell.border_fill_id.is_none());
532        assert!(cell.margin.is_none());
533        assert!(cell.vertical_align.is_none());
534        assert_eq!(cell.paragraphs.len(), 1);
535    }
536
537    #[test]
538    fn cell_with_span() {
539        let cell =
540            TableCell::with_span(vec![simple_paragraph()], HwpUnit::from_mm(100.0).unwrap(), 3, 2);
541        assert_eq!(cell.col_span, 3);
542        assert_eq!(cell.row_span, 2);
543    }
544
545    #[test]
546    fn cell_with_background() {
547        let cell = simple_cell().with_background(Color::from_rgb(200, 200, 200));
548        assert!(cell.background.is_some());
549    }
550
551    #[test]
552    fn table_display() {
553        let t = simple_table();
554        assert_eq!(t.to_string(), "Table(2x2)");
555    }
556
557    #[test]
558    fn single_cell_table() {
559        let table = Table::new(vec![TableRow::with_height(
560            vec![simple_cell()],
561            HwpUnit::from_mm(10.0).unwrap(),
562        )]);
563        assert_eq!(table.row_count(), 1);
564        assert_eq!(table.col_count(), 1);
565    }
566
567    #[test]
568    fn row_with_fixed_height() {
569        let row = TableRow::with_height(vec![simple_cell()], HwpUnit::from_mm(25.0).unwrap());
570        assert!(row.height.is_some());
571    }
572
573    #[test]
574    fn row_new_auto_height() {
575        let row = TableRow::new(vec![simple_cell(), simple_cell()]);
576        assert_eq!(row.cells.len(), 2);
577        assert!(row.height.is_none());
578    }
579
580    #[test]
581    fn row_new_empty_cells() {
582        let row = TableRow::new(vec![]);
583        assert!(row.cells.is_empty());
584        assert!(row.height.is_none());
585    }
586
587    #[test]
588    fn row_with_height_constructor() {
589        let h = HwpUnit::from_mm(20.0).unwrap();
590        let row = TableRow::with_height(vec![simple_cell()], h);
591        assert_eq!(row.cells.len(), 1);
592        assert_eq!(row.height, Some(h));
593    }
594
595    #[test]
596    fn equality() {
597        let a = simple_table();
598        let b = simple_table();
599        assert_eq!(a, b);
600    }
601
602    #[test]
603    fn clone_independence() {
604        let t = simple_table();
605        let mut cloned = t.clone();
606        cloned.caption = Some(crate::caption::Caption::default());
607        assert!(t.caption.is_none());
608    }
609
610    #[test]
611    fn serde_roundtrip() {
612        let t = simple_table();
613        let json = serde_json::to_string(&t).unwrap();
614        let back: Table = serde_json::from_str(&json).unwrap();
615        assert_eq!(t, back);
616    }
617
618    #[test]
619    fn serde_with_all_optional_fields() {
620        let mut t = simple_table()
621            .with_width(HwpUnit::from_mm(150.0).unwrap())
622            .with_caption(crate::caption::Caption::default())
623            .with_page_break(TablePageBreak::None)
624            .with_repeat_header(false)
625            .with_cell_spacing(HwpUnit::from_mm(2.0).unwrap())
626            .with_border_fill_id(7);
627        t.rows[0].height = Some(HwpUnit::from_mm(20.0).unwrap());
628        t.rows[0].cells[0] = t.rows[0].cells[0]
629            .clone()
630            .with_background(Color::from_rgb(255, 0, 0))
631            .with_height(HwpUnit::from_mm(8.0).unwrap())
632            .with_border_fill_id(9)
633            .with_margin(TableMargin {
634                left: HwpUnit::from_mm(1.0).unwrap(),
635                right: HwpUnit::from_mm(2.0).unwrap(),
636                top: HwpUnit::from_mm(0.5).unwrap(),
637                bottom: HwpUnit::from_mm(0.25).unwrap(),
638            })
639            .with_vertical_align(TableVerticalAlign::Bottom);
640
641        let json = serde_json::to_string(&t).unwrap();
642        let back: Table = serde_json::from_str(&json).unwrap();
643        assert_eq!(t, back);
644    }
645
646    #[test]
647    fn serde_defaults_missing_new_fields() {
648        let json = r#"{"rows":[],"width":null,"caption":null}"#;
649        let back: Table = serde_json::from_str(json).unwrap();
650        assert_eq!(back.page_break, TablePageBreak::Cell);
651        assert!(back.repeat_header);
652        assert!(back.cell_spacing.is_none());
653        assert!(back.border_fill_id.is_none());
654    }
655
656    #[test]
657    fn table_margin_defaults_to_zero() {
658        let margin = TableMargin::default();
659        assert_eq!(margin.left, HwpUnit::ZERO);
660        assert_eq!(margin.right, HwpUnit::ZERO);
661        assert_eq!(margin.top, HwpUnit::ZERO);
662        assert_eq!(margin.bottom, HwpUnit::ZERO);
663    }
664
665    #[test]
666    fn cell_zero_span_allowed_at_construction() {
667        // Zero spans are allowed during construction; validation catches them
668        let cell = TableCell::with_span(
669            vec![simple_paragraph()],
670            HwpUnit::from_mm(50.0).unwrap(),
671            0, // invalid, but construction doesn't prevent it
672            0,
673        );
674        assert_eq!(cell.col_span, 0);
675        assert_eq!(cell.row_span, 0);
676    }
677
678    #[test]
679    fn row_new_sets_expected_defaults() {
680        let cells = vec![simple_cell()];
681        let row = TableRow::new(cells.clone());
682        assert_eq!(row.cells, cells);
683        assert!(row.height.is_none());
684        assert!(!row.is_header);
685    }
686}