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    /// Sets an explicit table width.
230    #[must_use]
231    pub fn with_width(mut self, width: HwpUnit) -> Self {
232        self.width = Some(width);
233        self
234    }
235
236    /// Attaches a table caption.
237    #[must_use]
238    pub fn with_caption(mut self, caption: Caption) -> Self {
239        self.caption = Some(caption);
240        self
241    }
242
243    /// Sets the page-break policy for this table.
244    #[must_use]
245    pub fn with_page_break(mut self, page_break: TablePageBreak) -> Self {
246        self.page_break = page_break;
247        self
248    }
249
250    /// Controls whether the leading header block repeats across page breaks.
251    #[must_use]
252    pub fn with_repeat_header(mut self, repeat_header: bool) -> Self {
253        self.repeat_header = repeat_header;
254        self
255    }
256
257    /// Sets the explicit spacing between cells.
258    #[must_use]
259    pub fn with_cell_spacing(mut self, cell_spacing: HwpUnit) -> Self {
260        self.cell_spacing = Some(cell_spacing);
261        self
262    }
263
264    /// Sets the table-level border/fill reference.
265    #[must_use]
266    pub fn with_border_fill_id(mut self, border_fill_id: u32) -> Self {
267        self.border_fill_id = Some(border_fill_id);
268        self
269    }
270
271    /// Sets the outer margins around the table.
272    #[must_use]
273    pub fn with_out_margin(mut self, out_margin: TableMargin) -> Self {
274        self.out_margin = Some(out_margin);
275        self
276    }
277
278    /// Sets the table-level default cell padding.
279    #[must_use]
280    pub fn with_in_margin(mut self, in_margin: TableMargin) -> Self {
281        self.in_margin = Some(in_margin);
282        self
283    }
284
285    /// Attaches the decode-only layout cache.
286    #[must_use]
287    pub fn with_layout_cache(mut self, layout_cache: TableLayoutCache) -> Self {
288        self.layout_cache = Some(layout_cache);
289        self
290    }
291
292    /// Returns the number of rows.
293    pub fn row_count(&self) -> usize {
294        self.rows.len()
295    }
296
297    /// Returns the number of columns (from the first row).
298    ///
299    /// Returns 0 if the table has no rows.
300    pub fn col_count(&self) -> usize {
301        self.rows.first().map_or(0, |r| r.cells.len())
302    }
303
304    /// Returns `true` if the table has no rows.
305    pub fn is_empty(&self) -> bool {
306        self.rows.is_empty()
307    }
308}
309
310impl std::fmt::Display for Table {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        write!(f, "Table({}x{})", self.row_count(), self.col_count())
313    }
314}
315
316/// A single row of a table.
317///
318/// # Examples
319///
320/// ```
321/// use hwpforge_core::table::{TableRow, TableCell};
322/// use hwpforge_core::paragraph::Paragraph;
323/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
324///
325/// let row = TableRow::new(vec![
326///     TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
327///     TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
328/// ]);
329/// assert_eq!(row.cells.len(), 2);
330/// ```
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
332#[non_exhaustive]
333pub struct TableRow {
334    /// Cells in this row.
335    pub cells: Vec<TableCell>,
336    /// Optional fixed row height. `None` means auto-height.
337    pub height: Option<HwpUnit>,
338    /// Whether this row is part of the table's leading header-row block.
339    #[serde(default)]
340    pub is_header: bool,
341}
342
343impl TableRow {
344    /// Creates a new table row with the given cells and auto-calculated height.
345    ///
346    /// # Examples
347    ///
348    /// ```
349    /// use hwpforge_core::table::{TableRow, TableCell};
350    /// use hwpforge_core::paragraph::Paragraph;
351    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
352    ///
353    /// let cell = TableCell::new(
354    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
355    ///     HwpUnit::from_mm(40.0).unwrap(),
356    /// );
357    /// let row = TableRow::new(vec![cell]);
358    /// assert!(row.height.is_none());
359    /// ```
360    #[must_use]
361    pub fn new(cells: Vec<TableCell>) -> Self {
362        Self { cells, height: None, is_header: false }
363    }
364
365    /// Creates a new table row with an explicit fixed height.
366    ///
367    /// # Examples
368    ///
369    /// ```
370    /// use hwpforge_core::table::{TableRow, TableCell};
371    /// use hwpforge_core::paragraph::Paragraph;
372    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
373    ///
374    /// let cell = TableCell::new(
375    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
376    ///     HwpUnit::from_mm(40.0).unwrap(),
377    /// );
378    /// let row = TableRow::with_height(vec![cell], HwpUnit::from_mm(20.0).unwrap());
379    /// assert!(row.height.is_some());
380    /// ```
381    #[must_use]
382    pub fn with_height(cells: Vec<TableCell>, height: HwpUnit) -> Self {
383        Self { cells, height: Some(height), is_header: false }
384    }
385
386    /// Marks whether this row belongs to the table's leading header-row block.
387    #[must_use]
388    pub fn with_header(mut self, is_header: bool) -> Self {
389        self.is_header = is_header;
390        self
391    }
392}
393
394/// A single cell within a table row.
395///
396/// Each cell contains its own paragraphs (rich content). Spans
397/// default to 1 (no spanning).
398///
399/// # Examples
400///
401/// ```
402/// use hwpforge_core::table::TableCell;
403/// use hwpforge_core::paragraph::Paragraph;
404/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
405///
406/// let cell = TableCell::new(
407///     vec![Paragraph::new(ParaShapeIndex::new(0))],
408///     HwpUnit::from_mm(40.0).unwrap(),
409/// );
410/// assert_eq!(cell.col_span, 1);
411/// assert_eq!(cell.row_span, 1);
412/// ```
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
414#[non_exhaustive]
415pub struct TableCell {
416    /// Rich content within the cell.
417    pub paragraphs: Vec<Paragraph>,
418    /// Number of columns this cell spans. Must be >= 1.
419    pub col_span: u16,
420    /// Number of rows this cell spans. Must be >= 1.
421    pub row_span: u16,
422    /// Cell width.
423    pub width: HwpUnit,
424    /// Optional explicit cell height.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub height: Option<HwpUnit>,
427    /// Optional cell background color.
428    pub background: Option<Color>,
429    /// Optional border/fill reference for this cell.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub border_fill_id: Option<u32>,
432    /// Optional cell-local margin override.
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub margin: Option<TableMargin>,
435    /// Optional vertical alignment override for the cell content box.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub vertical_align: Option<TableVerticalAlign>,
438}
439
440impl TableCell {
441    /// Creates a cell with default spans (1x1) and no background.
442    ///
443    /// # Examples
444    ///
445    /// ```
446    /// use hwpforge_core::table::TableCell;
447    /// use hwpforge_core::paragraph::Paragraph;
448    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
449    ///
450    /// let cell = TableCell::new(
451    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
452    ///     HwpUnit::from_mm(50.0).unwrap(),
453    /// );
454    /// assert_eq!(cell.col_span, 1);
455    /// assert_eq!(cell.row_span, 1);
456    /// assert!(cell.background.is_none());
457    /// ```
458    #[must_use]
459    pub fn new(paragraphs: Vec<Paragraph>, width: HwpUnit) -> Self {
460        Self {
461            paragraphs,
462            col_span: 1,
463            row_span: 1,
464            width,
465            height: None,
466            background: None,
467            border_fill_id: None,
468            margin: None,
469            vertical_align: None,
470        }
471    }
472
473    /// Creates a cell with explicit span values.
474    ///
475    /// # Examples
476    ///
477    /// ```
478    /// use hwpforge_core::table::TableCell;
479    /// use hwpforge_core::paragraph::Paragraph;
480    /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
481    ///
482    /// let merged = TableCell::with_span(
483    ///     vec![Paragraph::new(ParaShapeIndex::new(0))],
484    ///     HwpUnit::from_mm(100.0).unwrap(),
485    ///     2, // col_span
486    ///     3, // row_span
487    /// );
488    /// assert_eq!(merged.col_span, 2);
489    /// assert_eq!(merged.row_span, 3);
490    /// ```
491    #[must_use]
492    pub fn with_span(
493        paragraphs: Vec<Paragraph>,
494        width: HwpUnit,
495        col_span: u16,
496        row_span: u16,
497    ) -> Self {
498        Self {
499            paragraphs,
500            col_span,
501            row_span,
502            width,
503            height: None,
504            background: None,
505            border_fill_id: None,
506            margin: None,
507            vertical_align: None,
508        }
509    }
510
511    /// Sets an explicit cell height.
512    #[must_use]
513    pub fn with_height(mut self, height: HwpUnit) -> Self {
514        self.height = Some(height);
515        self
516    }
517
518    /// Sets the cell background color.
519    #[must_use]
520    pub fn with_background(mut self, background: Color) -> Self {
521        self.background = Some(background);
522        self
523    }
524
525    /// Sets the cell border/fill reference.
526    #[must_use]
527    pub fn with_border_fill_id(mut self, border_fill_id: u32) -> Self {
528        self.border_fill_id = Some(border_fill_id);
529        self
530    }
531
532    /// Sets the cell-local margin override.
533    #[must_use]
534    pub fn with_margin(mut self, margin: TableMargin) -> Self {
535        self.margin = Some(margin);
536        self
537    }
538
539    /// Sets the vertical alignment override for the cell content box.
540    #[must_use]
541    pub fn with_vertical_align(mut self, vertical_align: TableVerticalAlign) -> Self {
542        self.vertical_align = Some(vertical_align);
543        self
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::run::Run;
551    use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
552
553    fn simple_paragraph() -> Paragraph {
554        Paragraph::with_runs(
555            vec![Run::text("cell", CharShapeIndex::new(0))],
556            ParaShapeIndex::new(0),
557        )
558    }
559
560    fn simple_cell() -> TableCell {
561        TableCell::new(vec![simple_paragraph()], HwpUnit::from_mm(50.0).unwrap())
562    }
563
564    fn simple_row() -> TableRow {
565        TableRow::new(vec![simple_cell(), simple_cell()])
566    }
567
568    fn simple_table() -> Table {
569        Table::new(vec![simple_row(), simple_row()])
570    }
571
572    #[test]
573    fn table_new() {
574        let t = simple_table();
575        assert_eq!(t.row_count(), 2);
576        assert_eq!(t.col_count(), 2);
577        assert!(!t.is_empty());
578        assert!(t.width.is_none());
579        assert!(t.caption.is_none());
580        assert_eq!(t.page_break, TablePageBreak::Cell);
581        assert!(t.repeat_header);
582        assert!(t.cell_spacing.is_none());
583        assert!(t.border_fill_id.is_none());
584    }
585
586    #[test]
587    fn empty_table() {
588        let t = Table::new(vec![]);
589        assert_eq!(t.row_count(), 0);
590        assert_eq!(t.col_count(), 0);
591        assert!(t.is_empty());
592    }
593
594    #[test]
595    fn table_with_caption() {
596        let t = simple_table().with_caption(crate::caption::Caption::default());
597        assert!(t.caption.is_some());
598    }
599
600    #[test]
601    fn table_with_width() {
602        let t = simple_table().with_width(HwpUnit::from_mm(150.0).unwrap());
603        assert!(t.width.is_some());
604    }
605
606    #[test]
607    fn table_with_page_break() {
608        let t = simple_table().with_page_break(TablePageBreak::Table);
609        assert_eq!(t.page_break, TablePageBreak::Table);
610    }
611
612    #[test]
613    fn table_with_repeat_header_disabled() {
614        let t = simple_table().with_repeat_header(false);
615        assert!(!t.repeat_header);
616    }
617
618    #[test]
619    fn cell_new_defaults() {
620        let cell = simple_cell();
621        assert_eq!(cell.col_span, 1);
622        assert_eq!(cell.row_span, 1);
623        assert!(cell.height.is_none());
624        assert!(cell.background.is_none());
625        assert!(cell.border_fill_id.is_none());
626        assert!(cell.margin.is_none());
627        assert!(cell.vertical_align.is_none());
628        assert_eq!(cell.paragraphs.len(), 1);
629    }
630
631    #[test]
632    fn cell_with_span() {
633        let cell =
634            TableCell::with_span(vec![simple_paragraph()], HwpUnit::from_mm(100.0).unwrap(), 3, 2);
635        assert_eq!(cell.col_span, 3);
636        assert_eq!(cell.row_span, 2);
637    }
638
639    #[test]
640    fn cell_with_background() {
641        let cell = simple_cell().with_background(Color::from_rgb(200, 200, 200));
642        assert!(cell.background.is_some());
643    }
644
645    #[test]
646    fn table_display() {
647        let t = simple_table();
648        assert_eq!(t.to_string(), "Table(2x2)");
649    }
650
651    #[test]
652    fn single_cell_table() {
653        let table = Table::new(vec![TableRow::with_height(
654            vec![simple_cell()],
655            HwpUnit::from_mm(10.0).unwrap(),
656        )]);
657        assert_eq!(table.row_count(), 1);
658        assert_eq!(table.col_count(), 1);
659    }
660
661    #[test]
662    fn row_with_fixed_height() {
663        let row = TableRow::with_height(vec![simple_cell()], HwpUnit::from_mm(25.0).unwrap());
664        assert!(row.height.is_some());
665    }
666
667    #[test]
668    fn row_new_auto_height() {
669        let row = TableRow::new(vec![simple_cell(), simple_cell()]);
670        assert_eq!(row.cells.len(), 2);
671        assert!(row.height.is_none());
672    }
673
674    #[test]
675    fn row_new_empty_cells() {
676        let row = TableRow::new(vec![]);
677        assert!(row.cells.is_empty());
678        assert!(row.height.is_none());
679    }
680
681    #[test]
682    fn row_with_height_constructor() {
683        let h = HwpUnit::from_mm(20.0).unwrap();
684        let row = TableRow::with_height(vec![simple_cell()], h);
685        assert_eq!(row.cells.len(), 1);
686        assert_eq!(row.height, Some(h));
687    }
688
689    #[test]
690    fn equality() {
691        let a = simple_table();
692        let b = simple_table();
693        assert_eq!(a, b);
694    }
695
696    #[test]
697    fn clone_independence() {
698        let t = simple_table();
699        let mut cloned = t.clone();
700        cloned.caption = Some(crate::caption::Caption::default());
701        assert!(t.caption.is_none());
702    }
703
704    #[test]
705    fn serde_roundtrip() {
706        let t = simple_table();
707        let json = serde_json::to_string(&t).unwrap();
708        let back: Table = serde_json::from_str(&json).unwrap();
709        assert_eq!(t, back);
710    }
711
712    #[test]
713    fn serde_with_all_optional_fields() {
714        let mut t = simple_table()
715            .with_width(HwpUnit::from_mm(150.0).unwrap())
716            .with_caption(crate::caption::Caption::default())
717            .with_page_break(TablePageBreak::None)
718            .with_repeat_header(false)
719            .with_cell_spacing(HwpUnit::from_mm(2.0).unwrap())
720            .with_border_fill_id(7);
721        t.rows[0].height = Some(HwpUnit::from_mm(20.0).unwrap());
722        t.rows[0].cells[0] = t.rows[0].cells[0]
723            .clone()
724            .with_background(Color::from_rgb(255, 0, 0))
725            .with_height(HwpUnit::from_mm(8.0).unwrap())
726            .with_border_fill_id(9)
727            .with_margin(TableMargin {
728                left: HwpUnit::from_mm(1.0).unwrap(),
729                right: HwpUnit::from_mm(2.0).unwrap(),
730                top: HwpUnit::from_mm(0.5).unwrap(),
731                bottom: HwpUnit::from_mm(0.25).unwrap(),
732            })
733            .with_vertical_align(TableVerticalAlign::Bottom);
734
735        let json = serde_json::to_string(&t).unwrap();
736        let back: Table = serde_json::from_str(&json).unwrap();
737        assert_eq!(t, back);
738    }
739
740    #[test]
741    fn serde_defaults_missing_new_fields() {
742        let json = r#"{"rows":[],"width":null,"caption":null}"#;
743        let back: Table = serde_json::from_str(json).unwrap();
744        assert_eq!(back.page_break, TablePageBreak::Cell);
745        assert!(back.repeat_header);
746        assert!(back.cell_spacing.is_none());
747        assert!(back.border_fill_id.is_none());
748    }
749
750    #[test]
751    fn table_margin_defaults_to_zero() {
752        let margin = TableMargin::default();
753        assert_eq!(margin.left, HwpUnit::ZERO);
754        assert_eq!(margin.right, HwpUnit::ZERO);
755        assert_eq!(margin.top, HwpUnit::ZERO);
756        assert_eq!(margin.bottom, HwpUnit::ZERO);
757    }
758
759    #[test]
760    fn cell_zero_span_allowed_at_construction() {
761        // Zero spans are allowed during construction; validation catches them
762        let cell = TableCell::with_span(
763            vec![simple_paragraph()],
764            HwpUnit::from_mm(50.0).unwrap(),
765            0, // invalid, but construction doesn't prevent it
766            0,
767        );
768        assert_eq!(cell.col_span, 0);
769        assert_eq!(cell.row_span, 0);
770    }
771
772    #[test]
773    fn row_new_sets_expected_defaults() {
774        let cells = vec![simple_cell()];
775        let row = TableRow::new(cells.clone());
776        assert_eq!(row.cells, cells);
777        assert!(row.height.is_none());
778        assert!(!row.is_header);
779    }
780}