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