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
87/// Decode-only layout cache for a table — Hancom-saved wire values that are
88/// replayed or used as checksums, never re-typeset.
89///
90/// Encoders MUST NOT emit these values back to the wire (the HWPX encoder
91/// keeps its own `sz` policy). Edit surfaces drop this cache together with
92/// paragraph caches via [`crate::document::Document::strip_layout_caches`].
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
94#[non_exhaustive]
95pub struct TableLayoutCache {
96    /// Hancom-saved `<hp:sz height>` ([`None`] when the wire stored `0` or
97    /// omitted it — natively authored split tables do).
98    ///
99    /// ⚠️ For a page-spanning (split) table this is the **first fragment's
100    /// height**, not the total table height (W0 measurement).
101    pub saved_sz_height: Option<HwpUnit>,
102    /// Whether the wire `<hp:pos>` was the default inline-flow combination
103    /// (`treatAsChar=0`, `flowWithText=1`, PARA/COLUMN relative, zero
104    /// offsets). Renderers only support default-flow tables — a `false`
105    /// here must fail closed (verified profile admission).
106    pub default_flow_pos: bool,
107}
108
109impl TableLayoutCache {
110    /// Creates a table layout cache from decoded wire facts.
111    #[must_use]
112    pub fn new(saved_sz_height: Option<HwpUnit>, default_flow_pos: bool) -> Self {
113        Self { saved_sz_height, default_flow_pos }
114    }
115}
116
117fn default_repeat_header() -> bool {
118    true
119}
120
121/// A table: a sequence of rows, with optional width and caption.
122///
123/// # Design Decision
124///
125/// No `border: Option<BorderStyle>` in Phase 1. Border styling is a
126/// Blueprint concern (Phase 2). Core tables are purely structural.
127///
128/// # Examples
129///
130/// ```
131/// use hwpforge_core::table::{Table, TableCell, TablePageBreak, TableRow};
132/// use hwpforge_core::paragraph::Paragraph;
133/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
134///
135/// let table = Table::new(vec![TableRow::new(vec![TableCell::new(
136///     vec![Paragraph::new(ParaShapeIndex::new(0))],
137///     HwpUnit::from_mm(100.0).unwrap(),
138/// )])])
139/// .with_page_break(TablePageBreak::Cell);
140/// assert_eq!(table.row_count(), 1);
141/// ```
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
143#[non_exhaustive]
144pub struct Table {
145    /// Rows of the table.
146    pub rows: Vec<TableRow>,
147    /// Optional explicit table width. `None` means auto-width.
148    pub width: Option<HwpUnit>,
149    /// Optional table caption.
150    pub caption: Option<Caption>,
151    /// Page-break policy for this table.
152    #[serde(default)]
153    pub page_break: TablePageBreak,
154    /// Whether the leading header-row block (consecutive rows marked
155    /// [`TableRow::is_header`]) repeats across page breaks.
156    #[serde(default = "default_repeat_header")]
157    pub repeat_header: bool,
158    /// Optional explicit spacing between table cells.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub cell_spacing: Option<HwpUnit>,
161    /// Optional table-level border/fill reference.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub border_fill_id: Option<u32>,
164    /// Wave 12p Step 2b: instance ID for cross-ref target lookup. HWP5
165    /// 변환 시 Table CtrlHeader trailer 의 instance ID 가 채워지고,
166    /// HWPX encoder 가 `<hp:tbl id="...">` attribute 로 emit. `None`
167    /// 이면 encoder 가 fallback 값 (예: sequential counter) 사용 허용.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub inst_id: Option<ObjectId>,
170    /// Outer margins around the table (`<hp:outMargin>`) — the wire source
171    /// for table placement (PDF replay rule R5).
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub out_margin: Option<TableMargin>,
174    /// Table-level default cell padding (`<hp:inMargin>`) — the fallback when
175    /// a cell has no explicit [`TableCell::margin`].
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub in_margin: Option<TableMargin>,
178    /// Decode-only layout cache (saved wire geometry) — see
179    /// [`TableLayoutCache`]. Encoders must not emit it.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub layout_cache: Option<TableLayoutCache>,
182}
183
184impl Table {
185    /// Creates a table from rows.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use hwpforge_core::table::{Table, TableRow};
191    ///
192    /// let table = Table::new(vec![TableRow::new(vec![])]);
193    /// assert_eq!(table.row_count(), 1);
194    /// ```
195    #[must_use]
196    pub fn new(rows: Vec<TableRow>) -> Self {
197        Self {
198            rows,
199            width: None,
200            caption: None,
201            page_break: TablePageBreak::Cell,
202            repeat_header: true,
203            cell_spacing: None,
204            border_fill_id: None,
205            inst_id: None,
206            out_margin: None,
207            in_margin: None,
208            layout_cache: None,
209        }
210    }
211
212    /// 표의 모든 셀 문단과 캡션 문단을 재귀 방문한다 (중첩 표 포함).
213    pub(crate) fn walk_paragraphs_mut(
214        &mut self,
215        f: &mut dyn FnMut(&mut crate::paragraph::Paragraph),
216    ) {
217        for row in &mut self.rows {
218            for cell in &mut row.cells {
219                for p in &mut cell.paragraphs {
220                    p.walk_paragraphs_mut(f);
221                }
222            }
223        }
224        if let Some(caption) = &mut self.caption {
225            caption.walk_paragraphs_mut(f);
226        }
227    }
228
229    /// [`Self::walk_paragraphs_mut`] 의 불변 쌍둥이 — 방문 순서 동일
230    /// (행 → 셀 → 셀 문단, 그다음 캡션).
231    pub(crate) fn walk_paragraphs(&self, f: &mut dyn FnMut(&crate::paragraph::Paragraph)) {
232        for row in &self.rows {
233            for cell in &row.cells {
234                for p in &cell.paragraphs {
235                    p.walk_paragraphs(f);
236                }
237            }
238        }
239        if let Some(caption) = &self.caption {
240            caption.walk_paragraphs(f);
241        }
242    }
243
244    /// Sets an explicit table width.
245    #[must_use]
246    pub fn with_width(mut self, width: HwpUnit) -> Self {
247        self.width = Some(width);
248        self
249    }
250
251    /// Attaches a table caption.
252    #[must_use]
253    pub fn with_caption(mut self, caption: Caption) -> Self {
254        self.caption = Some(caption);
255        self
256    }
257
258    /// Sets the page-break policy for this table.
259    #[must_use]
260    pub fn with_page_break(mut self, page_break: TablePageBreak) -> Self {
261        self.page_break = page_break;
262        self
263    }
264
265    /// Controls whether the leading header block repeats across page breaks.
266    #[must_use]
267    pub fn with_repeat_header(mut self, repeat_header: bool) -> Self {
268        self.repeat_header = repeat_header;
269        self
270    }
271
272    /// Sets the explicit spacing between cells.
273    #[must_use]
274    pub fn with_cell_spacing(mut self, cell_spacing: HwpUnit) -> Self {
275        self.cell_spacing = Some(cell_spacing);
276        self
277    }
278
279    /// Sets the table-level border/fill reference.
280    #[must_use]
281    pub fn with_border_fill_id(mut self, border_fill_id: u32) -> Self {
282        self.border_fill_id = Some(border_fill_id);
283        self
284    }
285
286    /// Sets the outer margins around the table.
287    #[must_use]
288    pub fn with_out_margin(mut self, out_margin: TableMargin) -> Self {
289        self.out_margin = Some(out_margin);
290        self
291    }
292
293    /// Sets the table-level default cell padding.
294    #[must_use]
295    pub fn with_in_margin(mut self, in_margin: TableMargin) -> Self {
296        self.in_margin = Some(in_margin);
297        self
298    }
299
300    /// Attaches the decode-only layout cache.
301    #[must_use]
302    pub fn with_layout_cache(mut self, layout_cache: TableLayoutCache) -> Self {
303        self.layout_cache = Some(layout_cache);
304        self
305    }
306
307    /// Returns the number of rows.
308    pub fn row_count(&self) -> usize {
309        self.rows.len()
310    }
311
312    /// Returns the number of columns (from the first row).
313    ///
314    /// Returns 0 if the table has no rows.
315    pub fn col_count(&self) -> usize {
316        self.rows.first().map_or(0, |r| r.cells.len())
317    }
318
319    /// Returns `true` if the table has no rows.
320    pub fn is_empty(&self) -> bool {
321        self.rows.is_empty()
322    }
323}
324
325impl std::fmt::Display for Table {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        write!(f, "Table({}x{})", self.row_count(), self.col_count())
328    }
329}
330
331/// A single row of a table.
332///
333/// # Examples
334///
335/// ```
336/// use hwpforge_core::table::{TableRow, TableCell};
337/// use hwpforge_core::paragraph::Paragraph;
338/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
339///
340/// let row = TableRow::new(vec![
341///     TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
342///     TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
343/// ]);
344/// assert_eq!(row.cells.len(), 2);
345/// ```
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
347#[non_exhaustive]
348pub struct TableRow {
349    /// Cells in this row.
350    pub cells: Vec<TableCell>,
351    /// Optional fixed row height. `None` means auto-height.
352    pub height: Option<HwpUnit>,
353    /// Whether this row is part of the table's leading header-row block.
354    #[serde(default)]
355    pub is_header: bool,
356}
357
358impl TableRow {
359    /// Creates a new table row with the given cells and auto-calculated height.
360    ///
361    /// # Examples
362    ///
363    /// ```
364    /// use hwpforge_core::table::{TableRow, TableCell};
365    /// use hwpforge_core::paragraph::Paragraph;
366    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
367    ///
368    /// let cell = TableCell::new(
369    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
370    ///     HwpUnit::from_mm(40.0).unwrap(),
371    /// );
372    /// let row = TableRow::new(vec![cell]);
373    /// assert!(row.height.is_none());
374    /// ```
375    #[must_use]
376    pub fn new(cells: Vec<TableCell>) -> Self {
377        Self { cells, height: None, is_header: false }
378    }
379
380    /// Creates a new table row with an explicit fixed height.
381    ///
382    /// # Examples
383    ///
384    /// ```
385    /// use hwpforge_core::table::{TableRow, TableCell};
386    /// use hwpforge_core::paragraph::Paragraph;
387    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
388    ///
389    /// let cell = TableCell::new(
390    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
391    ///     HwpUnit::from_mm(40.0).unwrap(),
392    /// );
393    /// let row = TableRow::with_height(vec![cell], HwpUnit::from_mm(20.0).unwrap());
394    /// assert!(row.height.is_some());
395    /// ```
396    #[must_use]
397    pub fn with_height(cells: Vec<TableCell>, height: HwpUnit) -> Self {
398        Self { cells, height: Some(height), is_header: false }
399    }
400
401    /// Marks whether this row belongs to the table's leading header-row block.
402    #[must_use]
403    pub fn with_header(mut self, is_header: bool) -> Self {
404        self.is_header = is_header;
405        self
406    }
407}
408
409/// A single cell within a table row.
410///
411/// Each cell contains its own paragraphs (rich content). Spans
412/// default to 1 (no spanning).
413///
414/// # Examples
415///
416/// ```
417/// use hwpforge_core::table::TableCell;
418/// use hwpforge_core::paragraph::Paragraph;
419/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
420///
421/// let cell = TableCell::new(
422///     vec![Paragraph::new(ParaShapeIndex::new(0))],
423///     HwpUnit::from_mm(40.0).unwrap(),
424/// );
425/// assert_eq!(cell.col_span, 1);
426/// assert_eq!(cell.row_span, 1);
427/// ```
428#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
429#[non_exhaustive]
430pub struct TableCell {
431    /// Rich content within the cell.
432    pub paragraphs: Vec<Paragraph>,
433    /// Number of columns this cell spans. Must be >= 1.
434    pub col_span: u16,
435    /// Number of rows this cell spans. Must be >= 1.
436    pub row_span: u16,
437    /// Cell width.
438    pub width: HwpUnit,
439    /// Optional explicit cell height.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub height: Option<HwpUnit>,
442    /// Optional cell background color.
443    pub background: Option<Color>,
444    /// Optional border/fill reference for this cell.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub border_fill_id: Option<u32>,
447    /// Optional cell-local margin override.
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub margin: Option<TableMargin>,
450    /// Optional vertical alignment override for the cell content box.
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub vertical_align: Option<TableVerticalAlign>,
453}
454
455impl TableCell {
456    /// Creates a cell with default spans (1x1) and no background.
457    ///
458    /// # Examples
459    ///
460    /// ```
461    /// use hwpforge_core::table::TableCell;
462    /// use hwpforge_core::paragraph::Paragraph;
463    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
464    ///
465    /// let cell = TableCell::new(
466    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
467    ///     HwpUnit::from_mm(50.0).unwrap(),
468    /// );
469    /// assert_eq!(cell.col_span, 1);
470    /// assert_eq!(cell.row_span, 1);
471    /// assert!(cell.background.is_none());
472    /// ```
473    #[must_use]
474    pub fn new(paragraphs: Vec<Paragraph>, width: HwpUnit) -> Self {
475        Self {
476            paragraphs,
477            col_span: 1,
478            row_span: 1,
479            width,
480            height: None,
481            background: None,
482            border_fill_id: None,
483            margin: None,
484            vertical_align: None,
485        }
486    }
487
488    /// Creates a cell with explicit span values.
489    ///
490    /// # Examples
491    ///
492    /// ```
493    /// use hwpforge_core::table::TableCell;
494    /// use hwpforge_core::paragraph::Paragraph;
495    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
496    ///
497    /// let merged = TableCell::with_span(
498    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
499    ///     HwpUnit::from_mm(100.0).unwrap(),
500    ///     2, // col_span
501    ///     3, // row_span
502    /// );
503    /// assert_eq!(merged.col_span, 2);
504    /// assert_eq!(merged.row_span, 3);
505    /// ```
506    #[must_use]
507    pub fn with_span(
508        paragraphs: Vec<Paragraph>,
509        width: HwpUnit,
510        col_span: u16,
511        row_span: u16,
512    ) -> Self {
513        Self {
514            paragraphs,
515            col_span,
516            row_span,
517            width,
518            height: None,
519            background: None,
520            border_fill_id: None,
521            margin: None,
522            vertical_align: None,
523        }
524    }
525
526    /// Sets an explicit cell height.
527    #[must_use]
528    pub fn with_height(mut self, height: HwpUnit) -> Self {
529        self.height = Some(height);
530        self
531    }
532
533    /// Sets the cell background color.
534    #[must_use]
535    pub fn with_background(mut self, background: Color) -> Self {
536        self.background = Some(background);
537        self
538    }
539
540    /// Sets the cell border/fill reference.
541    #[must_use]
542    pub fn with_border_fill_id(mut self, border_fill_id: u32) -> Self {
543        self.border_fill_id = Some(border_fill_id);
544        self
545    }
546
547    /// Sets the cell-local margin override.
548    #[must_use]
549    pub fn with_margin(mut self, margin: TableMargin) -> Self {
550        self.margin = Some(margin);
551        self
552    }
553
554    /// Sets the vertical alignment override for the cell content box.
555    #[must_use]
556    pub fn with_vertical_align(mut self, vertical_align: TableVerticalAlign) -> Self {
557        self.vertical_align = Some(vertical_align);
558        self
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use crate::run::Run;
566    use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
567
568    fn simple_paragraph() -> Paragraph {
569        Paragraph::with_runs(
570            vec![Run::text("cell", CharShapeIndex::new(0))],
571            ParaShapeIndex::new(0),
572        )
573    }
574
575    fn simple_cell() -> TableCell {
576        TableCell::new(vec![simple_paragraph()], HwpUnit::from_mm(50.0).unwrap())
577    }
578
579    fn simple_row() -> TableRow {
580        TableRow::new(vec![simple_cell(), simple_cell()])
581    }
582
583    fn simple_table() -> Table {
584        Table::new(vec![simple_row(), simple_row()])
585    }
586
587    #[test]
588    fn table_new() {
589        let t = simple_table();
590        assert_eq!(t.row_count(), 2);
591        assert_eq!(t.col_count(), 2);
592        assert!(!t.is_empty());
593        assert!(t.width.is_none());
594        assert!(t.caption.is_none());
595        assert_eq!(t.page_break, TablePageBreak::Cell);
596        assert!(t.repeat_header);
597        assert!(t.cell_spacing.is_none());
598        assert!(t.border_fill_id.is_none());
599    }
600
601    #[test]
602    fn empty_table() {
603        let t = Table::new(vec![]);
604        assert_eq!(t.row_count(), 0);
605        assert_eq!(t.col_count(), 0);
606        assert!(t.is_empty());
607    }
608
609    #[test]
610    fn table_with_caption() {
611        let t = simple_table().with_caption(crate::caption::Caption::default());
612        assert!(t.caption.is_some());
613    }
614
615    #[test]
616    fn table_with_width() {
617        let t = simple_table().with_width(HwpUnit::from_mm(150.0).unwrap());
618        assert!(t.width.is_some());
619    }
620
621    #[test]
622    fn table_with_page_break() {
623        let t = simple_table().with_page_break(TablePageBreak::Table);
624        assert_eq!(t.page_break, TablePageBreak::Table);
625    }
626
627    #[test]
628    fn table_with_repeat_header_disabled() {
629        let t = simple_table().with_repeat_header(false);
630        assert!(!t.repeat_header);
631    }
632
633    #[test]
634    fn cell_new_defaults() {
635        let cell = simple_cell();
636        assert_eq!(cell.col_span, 1);
637        assert_eq!(cell.row_span, 1);
638        assert!(cell.height.is_none());
639        assert!(cell.background.is_none());
640        assert!(cell.border_fill_id.is_none());
641        assert!(cell.margin.is_none());
642        assert!(cell.vertical_align.is_none());
643        assert_eq!(cell.paragraphs.len(), 1);
644    }
645
646    #[test]
647    fn cell_with_span() {
648        let cell =
649            TableCell::with_span(vec![simple_paragraph()], HwpUnit::from_mm(100.0).unwrap(), 3, 2);
650        assert_eq!(cell.col_span, 3);
651        assert_eq!(cell.row_span, 2);
652    }
653
654    #[test]
655    fn cell_with_background() {
656        let cell = simple_cell().with_background(Color::from_rgb(200, 200, 200));
657        assert!(cell.background.is_some());
658    }
659
660    #[test]
661    fn table_display() {
662        let t = simple_table();
663        assert_eq!(t.to_string(), "Table(2x2)");
664    }
665
666    #[test]
667    fn single_cell_table() {
668        let table = Table::new(vec![TableRow::with_height(
669            vec![simple_cell()],
670            HwpUnit::from_mm(10.0).unwrap(),
671        )]);
672        assert_eq!(table.row_count(), 1);
673        assert_eq!(table.col_count(), 1);
674    }
675
676    #[test]
677    fn row_with_fixed_height() {
678        let row = TableRow::with_height(vec![simple_cell()], HwpUnit::from_mm(25.0).unwrap());
679        assert!(row.height.is_some());
680    }
681
682    #[test]
683    fn row_new_auto_height() {
684        let row = TableRow::new(vec![simple_cell(), simple_cell()]);
685        assert_eq!(row.cells.len(), 2);
686        assert!(row.height.is_none());
687    }
688
689    #[test]
690    fn row_new_empty_cells() {
691        let row = TableRow::new(vec![]);
692        assert!(row.cells.is_empty());
693        assert!(row.height.is_none());
694    }
695
696    #[test]
697    fn row_with_height_constructor() {
698        let h = HwpUnit::from_mm(20.0).unwrap();
699        let row = TableRow::with_height(vec![simple_cell()], h);
700        assert_eq!(row.cells.len(), 1);
701        assert_eq!(row.height, Some(h));
702    }
703
704    #[test]
705    fn equality() {
706        let a = simple_table();
707        let b = simple_table();
708        assert_eq!(a, b);
709    }
710
711    #[test]
712    fn clone_independence() {
713        let t = simple_table();
714        let mut cloned = t.clone();
715        cloned.caption = Some(crate::caption::Caption::default());
716        assert!(t.caption.is_none());
717    }
718
719    #[test]
720    fn serde_roundtrip() {
721        let t = simple_table();
722        let json = serde_json::to_string(&t).unwrap();
723        let back: Table = serde_json::from_str(&json).unwrap();
724        assert_eq!(t, back);
725    }
726
727    #[test]
728    fn serde_with_all_optional_fields() {
729        let mut t = simple_table()
730            .with_width(HwpUnit::from_mm(150.0).unwrap())
731            .with_caption(crate::caption::Caption::default())
732            .with_page_break(TablePageBreak::None)
733            .with_repeat_header(false)
734            .with_cell_spacing(HwpUnit::from_mm(2.0).unwrap())
735            .with_border_fill_id(7);
736        t.rows[0].height = Some(HwpUnit::from_mm(20.0).unwrap());
737        t.rows[0].cells[0] = t.rows[0].cells[0]
738            .clone()
739            .with_background(Color::from_rgb(255, 0, 0))
740            .with_height(HwpUnit::from_mm(8.0).unwrap())
741            .with_border_fill_id(9)
742            .with_margin(TableMargin {
743                left: HwpUnit::from_mm(1.0).unwrap(),
744                right: HwpUnit::from_mm(2.0).unwrap(),
745                top: HwpUnit::from_mm(0.5).unwrap(),
746                bottom: HwpUnit::from_mm(0.25).unwrap(),
747            })
748            .with_vertical_align(TableVerticalAlign::Bottom);
749
750        let json = serde_json::to_string(&t).unwrap();
751        let back: Table = serde_json::from_str(&json).unwrap();
752        assert_eq!(t, back);
753    }
754
755    #[test]
756    fn serde_defaults_missing_new_fields() {
757        let json = r#"{"rows":[],"width":null,"caption":null}"#;
758        let back: Table = serde_json::from_str(json).unwrap();
759        assert_eq!(back.page_break, TablePageBreak::Cell);
760        assert!(back.repeat_header);
761        assert!(back.cell_spacing.is_none());
762        assert!(back.border_fill_id.is_none());
763    }
764
765    #[test]
766    fn table_margin_defaults_to_zero() {
767        let margin = TableMargin::default();
768        assert_eq!(margin.left, HwpUnit::ZERO);
769        assert_eq!(margin.right, HwpUnit::ZERO);
770        assert_eq!(margin.top, HwpUnit::ZERO);
771        assert_eq!(margin.bottom, HwpUnit::ZERO);
772    }
773
774    #[test]
775    fn cell_zero_span_allowed_at_construction() {
776        // Zero spans are allowed during construction; validation catches them
777        let cell = TableCell::with_span(
778            vec![simple_paragraph()],
779            HwpUnit::from_mm(50.0).unwrap(),
780            0, // invalid, but construction doesn't prevent it
781            0,
782        );
783        assert_eq!(cell.col_span, 0);
784        assert_eq!(cell.row_span, 0);
785    }
786
787    #[test]
788    fn row_new_sets_expected_defaults() {
789        let cells = vec![simple_cell()];
790        let row = TableRow::new(cells.clone());
791        assert_eq!(row.cells, cells);
792        assert!(row.height.is_none());
793        assert!(!row.is_header);
794    }
795}