Skip to main content

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