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 { cells: vec![cell], height: None };
34//! let table = Table::new(vec![row]);
35//! assert_eq!(table.row_count(), 1);
36//! ```
37
38use hwpforge_foundation::{Color, HwpUnit};
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41
42use crate::caption::Caption;
43use crate::paragraph::Paragraph;
44
45/// A table: a sequence of rows, with optional width and caption.
46///
47/// # Design Decision
48///
49/// No `border: Option<BorderStyle>` in Phase 1. Border styling is a
50/// Blueprint concern (Phase 2). Core tables are purely structural.
51///
52/// # Examples
53///
54/// ```
55/// use hwpforge_core::table::{Table, TableRow, TableCell};
56/// use hwpforge_core::paragraph::Paragraph;
57/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
58///
59/// let table = Table {
60/// rows: vec![TableRow {
61/// cells: vec![TableCell::new(
62/// vec![Paragraph::new(ParaShapeIndex::new(0))],
63/// HwpUnit::from_mm(100.0).unwrap(),
64/// )],
65/// height: None,
66/// }],
67/// width: None,
68/// caption: None,
69/// };
70/// assert_eq!(table.row_count(), 1);
71/// ```
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
73pub struct Table {
74 /// Rows of the table.
75 pub rows: Vec<TableRow>,
76 /// Optional explicit table width. `None` means auto-width.
77 pub width: Option<HwpUnit>,
78 /// Optional table caption.
79 pub caption: Option<Caption>,
80}
81
82impl Table {
83 /// Creates a table from rows.
84 ///
85 /// # Examples
86 ///
87 /// ```
88 /// use hwpforge_core::table::{Table, TableRow};
89 ///
90 /// let table = Table::new(vec![TableRow { cells: vec![], height: None }]);
91 /// assert_eq!(table.row_count(), 1);
92 /// ```
93 pub fn new(rows: Vec<TableRow>) -> Self {
94 Self { rows, width: None, caption: None }
95 }
96
97 /// Returns the number of rows.
98 pub fn row_count(&self) -> usize {
99 self.rows.len()
100 }
101
102 /// Returns the number of columns (from the first row).
103 ///
104 /// Returns 0 if the table has no rows.
105 pub fn col_count(&self) -> usize {
106 self.rows.first().map_or(0, |r| r.cells.len())
107 }
108
109 /// Returns `true` if the table has no rows.
110 pub fn is_empty(&self) -> bool {
111 self.rows.is_empty()
112 }
113}
114
115impl std::fmt::Display for Table {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 write!(f, "Table({}x{})", self.row_count(), self.col_count())
118 }
119}
120
121/// A single row of a table.
122///
123/// # Examples
124///
125/// ```
126/// use hwpforge_core::table::{TableRow, TableCell};
127/// use hwpforge_core::paragraph::Paragraph;
128/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
129///
130/// let row = TableRow {
131/// cells: vec![
132/// TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
133/// TableCell::new(vec![Paragraph::new(ParaShapeIndex::new(0))], HwpUnit::from_mm(50.0).unwrap()),
134/// ],
135/// height: None,
136/// };
137/// assert_eq!(row.cells.len(), 2);
138/// ```
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
140pub struct TableRow {
141 /// Cells in this row.
142 pub cells: Vec<TableCell>,
143 /// Optional fixed row height. `None` means auto-height.
144 pub height: Option<HwpUnit>,
145}
146
147impl TableRow {
148 /// Creates a new table row with the given cells and auto-calculated height.
149 ///
150 /// # Examples
151 ///
152 /// ```
153 /// use hwpforge_core::table::{TableRow, TableCell};
154 /// use hwpforge_core::paragraph::Paragraph;
155 /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
156 ///
157 /// let cell = TableCell::new(
158 /// vec![Paragraph::new(ParaShapeIndex::new(0))],
159 /// HwpUnit::from_mm(40.0).unwrap(),
160 /// );
161 /// let row = TableRow::new(vec![cell]);
162 /// assert!(row.height.is_none());
163 /// ```
164 pub fn new(cells: Vec<TableCell>) -> Self {
165 Self { cells, height: None }
166 }
167
168 /// Creates a new table row with an explicit fixed height.
169 ///
170 /// # Examples
171 ///
172 /// ```
173 /// use hwpforge_core::table::{TableRow, TableCell};
174 /// use hwpforge_core::paragraph::Paragraph;
175 /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
176 ///
177 /// let cell = TableCell::new(
178 /// vec![Paragraph::new(ParaShapeIndex::new(0))],
179 /// HwpUnit::from_mm(40.0).unwrap(),
180 /// );
181 /// let row = TableRow::with_height(vec![cell], HwpUnit::from_mm(20.0).unwrap());
182 /// assert!(row.height.is_some());
183 /// ```
184 pub fn with_height(cells: Vec<TableCell>, height: HwpUnit) -> Self {
185 Self { cells, height: Some(height) }
186 }
187}
188
189/// A single cell within a table row.
190///
191/// Each cell contains its own paragraphs (rich content). Spans
192/// default to 1 (no spanning).
193///
194/// # Examples
195///
196/// ```
197/// use hwpforge_core::table::TableCell;
198/// use hwpforge_core::paragraph::Paragraph;
199/// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
200///
201/// let cell = TableCell::new(
202/// vec![Paragraph::new(ParaShapeIndex::new(0))],
203/// HwpUnit::from_mm(40.0).unwrap(),
204/// );
205/// assert_eq!(cell.col_span, 1);
206/// assert_eq!(cell.row_span, 1);
207/// ```
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
209pub struct TableCell {
210 /// Rich content within the cell.
211 pub paragraphs: Vec<Paragraph>,
212 /// Number of columns this cell spans. Must be >= 1.
213 pub col_span: u16,
214 /// Number of rows this cell spans. Must be >= 1.
215 pub row_span: u16,
216 /// Cell width.
217 pub width: HwpUnit,
218 /// Optional cell background color.
219 pub background: Option<Color>,
220}
221
222impl TableCell {
223 /// Creates a cell with default spans (1x1) and no background.
224 ///
225 /// # Examples
226 ///
227 /// ```
228 /// use hwpforge_core::table::TableCell;
229 /// use hwpforge_core::paragraph::Paragraph;
230 /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
231 ///
232 /// let cell = TableCell::new(
233 /// vec![Paragraph::new(ParaShapeIndex::new(0))],
234 /// HwpUnit::from_mm(50.0).unwrap(),
235 /// );
236 /// assert_eq!(cell.col_span, 1);
237 /// assert_eq!(cell.row_span, 1);
238 /// assert!(cell.background.is_none());
239 /// ```
240 pub fn new(paragraphs: Vec<Paragraph>, width: HwpUnit) -> Self {
241 Self { paragraphs, col_span: 1, row_span: 1, width, background: None }
242 }
243
244 /// Creates a cell with explicit span values.
245 ///
246 /// # Examples
247 ///
248 /// ```
249 /// use hwpforge_core::table::TableCell;
250 /// use hwpforge_core::paragraph::Paragraph;
251 /// use hwpforge_foundation::{HwpUnit, ParaShapeIndex};
252 ///
253 /// let merged = TableCell::with_span(
254 /// vec![Paragraph::new(ParaShapeIndex::new(0))],
255 /// HwpUnit::from_mm(100.0).unwrap(),
256 /// 2, // col_span
257 /// 3, // row_span
258 /// );
259 /// assert_eq!(merged.col_span, 2);
260 /// assert_eq!(merged.row_span, 3);
261 /// ```
262 pub fn with_span(
263 paragraphs: Vec<Paragraph>,
264 width: HwpUnit,
265 col_span: u16,
266 row_span: u16,
267 ) -> Self {
268 Self { paragraphs, col_span, row_span, width, background: None }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::run::Run;
276 use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
277
278 fn simple_paragraph() -> Paragraph {
279 Paragraph::with_runs(
280 vec![Run::text("cell", CharShapeIndex::new(0))],
281 ParaShapeIndex::new(0),
282 )
283 }
284
285 fn simple_cell() -> TableCell {
286 TableCell::new(vec![simple_paragraph()], HwpUnit::from_mm(50.0).unwrap())
287 }
288
289 fn simple_row() -> TableRow {
290 TableRow { cells: vec![simple_cell(), simple_cell()], height: None }
291 }
292
293 fn simple_table() -> Table {
294 Table::new(vec![simple_row(), simple_row()])
295 }
296
297 #[test]
298 fn table_new() {
299 let t = simple_table();
300 assert_eq!(t.row_count(), 2);
301 assert_eq!(t.col_count(), 2);
302 assert!(!t.is_empty());
303 assert!(t.width.is_none());
304 assert!(t.caption.is_none());
305 }
306
307 #[test]
308 fn empty_table() {
309 let t = Table::new(vec![]);
310 assert_eq!(t.row_count(), 0);
311 assert_eq!(t.col_count(), 0);
312 assert!(t.is_empty());
313 }
314
315 #[test]
316 fn table_with_caption() {
317 let mut t = simple_table();
318 t.caption = Some(crate::caption::Caption::default());
319 assert!(t.caption.is_some());
320 }
321
322 #[test]
323 fn table_with_width() {
324 let mut t = simple_table();
325 t.width = Some(HwpUnit::from_mm(150.0).unwrap());
326 assert!(t.width.is_some());
327 }
328
329 #[test]
330 fn cell_new_defaults() {
331 let cell = simple_cell();
332 assert_eq!(cell.col_span, 1);
333 assert_eq!(cell.row_span, 1);
334 assert!(cell.background.is_none());
335 assert_eq!(cell.paragraphs.len(), 1);
336 }
337
338 #[test]
339 fn cell_with_span() {
340 let cell =
341 TableCell::with_span(vec![simple_paragraph()], HwpUnit::from_mm(100.0).unwrap(), 3, 2);
342 assert_eq!(cell.col_span, 3);
343 assert_eq!(cell.row_span, 2);
344 }
345
346 #[test]
347 fn cell_with_background() {
348 let mut cell = simple_cell();
349 cell.background = Some(Color::from_rgb(200, 200, 200));
350 assert!(cell.background.is_some());
351 }
352
353 #[test]
354 fn table_display() {
355 let t = simple_table();
356 assert_eq!(t.to_string(), "Table(2x2)");
357 }
358
359 #[test]
360 fn single_cell_table() {
361 let table = Table::new(vec![TableRow {
362 cells: vec![simple_cell()],
363 height: Some(HwpUnit::from_mm(10.0).unwrap()),
364 }]);
365 assert_eq!(table.row_count(), 1);
366 assert_eq!(table.col_count(), 1);
367 }
368
369 #[test]
370 fn row_with_fixed_height() {
371 let row =
372 TableRow { cells: vec![simple_cell()], height: Some(HwpUnit::from_mm(25.0).unwrap()) };
373 assert!(row.height.is_some());
374 }
375
376 #[test]
377 fn row_new_auto_height() {
378 let row = TableRow::new(vec![simple_cell(), simple_cell()]);
379 assert_eq!(row.cells.len(), 2);
380 assert!(row.height.is_none());
381 }
382
383 #[test]
384 fn row_new_empty_cells() {
385 let row = TableRow::new(vec![]);
386 assert!(row.cells.is_empty());
387 assert!(row.height.is_none());
388 }
389
390 #[test]
391 fn row_with_height_constructor() {
392 let h = HwpUnit::from_mm(20.0).unwrap();
393 let row = TableRow::with_height(vec![simple_cell()], h);
394 assert_eq!(row.cells.len(), 1);
395 assert_eq!(row.height, Some(h));
396 }
397
398 #[test]
399 fn equality() {
400 let a = simple_table();
401 let b = simple_table();
402 assert_eq!(a, b);
403 }
404
405 #[test]
406 fn clone_independence() {
407 let t = simple_table();
408 let mut cloned = t.clone();
409 cloned.caption = Some(crate::caption::Caption::default());
410 assert!(t.caption.is_none());
411 }
412
413 #[test]
414 fn serde_roundtrip() {
415 let t = simple_table();
416 let json = serde_json::to_string(&t).unwrap();
417 let back: Table = serde_json::from_str(&json).unwrap();
418 assert_eq!(t, back);
419 }
420
421 #[test]
422 fn serde_with_all_optional_fields() {
423 let mut t = simple_table();
424 t.width = Some(HwpUnit::from_mm(150.0).unwrap());
425 t.caption = Some(crate::caption::Caption::default());
426 t.rows[0].height = Some(HwpUnit::from_mm(20.0).unwrap());
427 t.rows[0].cells[0].background = Some(Color::from_rgb(255, 0, 0));
428
429 let json = serde_json::to_string(&t).unwrap();
430 let back: Table = serde_json::from_str(&json).unwrap();
431 assert_eq!(t, back);
432 }
433
434 #[test]
435 fn cell_zero_span_allowed_at_construction() {
436 // Zero spans are allowed during construction; validation catches them
437 let cell = TableCell::with_span(
438 vec![simple_paragraph()],
439 HwpUnit::from_mm(50.0).unwrap(),
440 0, // invalid, but construction doesn't prevent it
441 0,
442 );
443 assert_eq!(cell.col_span, 0);
444 assert_eq!(cell.row_span, 0);
445 }
446
447 #[test]
448 fn row_new_equals_struct_literal() {
449 let cells = vec![simple_cell()];
450 let from_new = TableRow::new(cells.clone());
451 let from_literal = TableRow { cells, height: None };
452 assert_eq!(from_new, from_literal);
453 }
454}