Skip to main content

rdocx_layout/
table.rs

1//! Table layout: column widths, cell content, merge handling.
2
3use rdocx_oxml::styles::CT_Styles;
4use rdocx_oxml::table::{CT_Tbl, CT_TblBorders, CT_TblGrid, ST_VerticalJc, VMerge};
5
6use crate::block::ParagraphBlock;
7use crate::error::Result;
8use crate::font::FontManager;
9use crate::input::LayoutInput;
10use crate::style_resolver::NumberingState;
11
12/// A laid-out table.
13#[derive(Debug, Clone)]
14pub struct TableBlock {
15    /// Column widths in points.
16    pub col_widths: Vec<f64>,
17    /// Laid-out rows.
18    pub rows: Vec<TableRow>,
19    /// Indices of rows that are header rows (repeat on page break).
20    pub header_row_indices: Vec<usize>,
21    /// Total table width in points.
22    pub table_width: f64,
23    /// Table indent from left margin in points.
24    pub table_indent: f64,
25    /// Table-level borders (used as fallback for cell borders).
26    pub borders: Option<CT_TblBorders>,
27}
28
29impl TableBlock {
30    /// Total content height of all rows.
31    pub fn content_height(&self) -> f64 {
32        self.rows.iter().map(|r| r.height).sum()
33    }
34
35    /// Total height (same as content for tables, no before/after spacing).
36    pub fn total_height(&self) -> f64 {
37        self.content_height()
38    }
39}
40
41/// A laid-out table row.
42#[derive(Debug, Clone)]
43pub struct TableRow {
44    /// Cells in this row.
45    pub cells: Vec<TableCell>,
46    /// Row height in points.
47    pub height: f64,
48    /// Whether this row is a header row.
49    pub is_header: bool,
50}
51
52/// A laid-out table cell.
53#[derive(Debug, Clone)]
54pub struct TableCell {
55    /// Cell content (paragraph blocks).
56    pub paragraphs: Vec<ParagraphBlock>,
57    /// Cell width in points (may span multiple grid columns).
58    pub width: f64,
59    /// Cell height in points (set to row height).
60    pub height: f64,
61    /// Number of grid columns this cell spans.
62    pub grid_span: u32,
63    /// Whether this cell is part of a vertical merge continuation (render no content).
64    pub is_vmerge_continue: bool,
65    /// Column index in the grid.
66    pub col_index: usize,
67    /// Cell-level borders.
68    pub borders: Option<CT_TblBorders>,
69    /// Cell background shading color.
70    pub shading: Option<crate::output::Color>,
71    /// Cell margin left in points.
72    pub margin_left: f64,
73    /// Cell margin top in points.
74    pub margin_top: f64,
75    /// Whether this cell is in the first row.
76    pub is_first_row: bool,
77    /// Whether this cell is in the last row.
78    pub is_last_row: bool,
79    /// Vertical alignment of content within the cell.
80    pub v_align: Option<ST_VerticalJc>,
81}
82
83/// Lay out a table into a TableBlock.
84pub fn layout_table(
85    tbl: &CT_Tbl,
86    available_width: f64,
87    styles: &CT_Styles,
88    input: &LayoutInput,
89    fm: &mut FontManager,
90    num_state: &mut NumberingState,
91) -> Result<TableBlock> {
92    // 1. Compute column widths
93    let col_widths = compute_column_widths(tbl.grid.as_ref(), available_width, tbl);
94    let table_width: f64 = col_widths.iter().sum();
95
96    // Table indent
97    let table_indent = tbl
98        .properties
99        .as_ref()
100        .and_then(|p| p.indent.as_ref())
101        .map(|ind| {
102            if ind.width_type == "dxa" {
103                ind.w as f64 / 20.0 // twips to pt
104            } else {
105                0.0
106            }
107        })
108        .unwrap_or(0.0);
109
110    // Table-level borders
111    let table_borders = tbl.properties.as_ref().and_then(|p| p.borders.clone());
112
113    // Default cell margins
114    let default_cell_margin = tbl.properties.as_ref().and_then(|p| p.cell_margin.as_ref());
115    let cell_margin_left = default_cell_margin
116        .and_then(|m| m.left)
117        .map(|t| t.to_pt())
118        .unwrap_or(5.4); // Word default ~108 twips
119    let cell_margin_right = default_cell_margin
120        .and_then(|m| m.right)
121        .map(|t| t.to_pt())
122        .unwrap_or(5.4);
123    let cell_margin_top = default_cell_margin
124        .and_then(|m| m.top)
125        .map(|t| t.to_pt())
126        .unwrap_or(0.0);
127    let cell_margin_bottom = default_cell_margin
128        .and_then(|m| m.bottom)
129        .map(|t| t.to_pt())
130        .unwrap_or(0.0);
131
132    let num_rows = tbl.rows.len();
133    let mut header_row_indices = Vec::new();
134    let mut rows = Vec::new();
135
136    for (row_idx, row) in tbl.rows.iter().enumerate() {
137        let is_header = row
138            .properties
139            .as_ref()
140            .and_then(|p| p.header)
141            .unwrap_or(false);
142        if is_header {
143            header_row_indices.push(row_idx);
144        }
145
146        let mut cells = Vec::new();
147        let mut col_index = 0usize;
148
149        for cell in &row.cells {
150            let grid_span = cell
151                .properties
152                .as_ref()
153                .and_then(|p| p.grid_span)
154                .unwrap_or(1);
155
156            let is_vmerge_continue = cell
157                .properties
158                .as_ref()
159                .and_then(|p| p.v_merge)
160                .map(|vm| vm == VMerge::Continue)
161                .unwrap_or(false);
162
163            // Cell-level borders and shading
164            let cell_borders = cell.properties.as_ref().and_then(|p| p.borders.clone());
165            let cell_shading = cell
166                .properties
167                .as_ref()
168                .and_then(|p| p.shading.as_ref())
169                .and_then(|shd| shd.fill.as_ref())
170                .filter(|f| f.as_str() != "auto")
171                .map(|f| crate::output::Color::from_hex(f));
172
173            // Calculate cell width from spanned columns
174            let cell_width: f64 = (col_index..col_index + grid_span as usize)
175                .filter_map(|i| col_widths.get(i))
176                .sum();
177
178            let content_width = (cell_width - cell_margin_left - cell_margin_right).max(0.0);
179
180            // Layout cell content (paragraphs and nested tables)
181            let paragraphs = if is_vmerge_continue {
182                Vec::new()
183            } else {
184                layout_cell_content(&cell.content, content_width, styles, input, fm, num_state)?
185            };
186
187            let content_height: f64 = paragraphs.iter().map(|p| p.total_height()).sum::<f64>()
188                + cell_margin_top
189                + cell_margin_bottom;
190
191            let v_align = cell.properties.as_ref().and_then(|p| p.v_align);
192
193            cells.push(TableCell {
194                paragraphs,
195                width: cell_width,
196                height: content_height,
197                grid_span,
198                is_vmerge_continue,
199                col_index,
200                borders: cell_borders,
201                shading: cell_shading,
202                margin_left: cell_margin_left,
203                margin_top: cell_margin_top,
204                is_first_row: row_idx == 0,
205                is_last_row: row_idx == num_rows - 1,
206                v_align,
207            });
208
209            col_index += grid_span as usize;
210        }
211
212        // Row height is max of all cell heights and any specified height
213        let max_cell_height = cells.iter().map(|c| c.height).fold(0.0f64, f64::max);
214        let specified_height = row
215            .properties
216            .as_ref()
217            .and_then(|p| p.height)
218            .map(|h| h.to_pt())
219            .unwrap_or(0.0);
220        let row_height = max_cell_height.max(specified_height);
221
222        // Set all cell heights to match row height
223        for cell in &mut cells {
224            cell.height = row_height;
225        }
226
227        rows.push(TableRow {
228            cells,
229            height: row_height,
230            is_header,
231        });
232    }
233
234    Ok(TableBlock {
235        col_widths,
236        rows,
237        header_row_indices,
238        table_width,
239        table_indent,
240        borders: table_borders,
241    })
242}
243
244/// Compute column widths from CT_TblGrid, shrinking to the available width if
245/// the declared grid overflows it.
246///
247/// A grid narrower than the text column keeps its declared width: Word renders
248/// a deliberately narrow table at the size the author chose rather than
249/// stretching it to the margins, and so do we.
250fn compute_column_widths(
251    grid: Option<&CT_TblGrid>,
252    available_width: f64,
253    tbl: &CT_Tbl,
254) -> Vec<f64> {
255    match grid {
256        Some(g) if !g.columns.is_empty() => {
257            let widths: Vec<f64> = g.columns.iter().map(|c| c.width.to_pt()).collect();
258            let total: f64 = widths.iter().sum();
259            if total < 0.01 {
260                // All zero widths — distribute equally based on column count
261                let n = g.columns.len();
262                vec![available_width / n as f64; n]
263            } else if total > available_width + 1.0 {
264                // Overflows the text column: scale down so it fits the page.
265                let scale = available_width / total;
266                widths.iter().map(|w| w * scale).collect()
267            } else {
268                widths
269            }
270        }
271        _ => {
272            // No grid defined — infer column count from the first row
273            let num_cols = tbl
274                .rows
275                .first()
276                .map(|r| {
277                    r.cells
278                        .iter()
279                        .map(|c| {
280                            c.properties.as_ref().and_then(|p| p.grid_span).unwrap_or(1) as usize
281                        })
282                        .sum::<usize>()
283                })
284                .unwrap_or(1)
285                .max(1);
286            vec![available_width / num_cols as f64; num_cols]
287        }
288    }
289}
290
291/// Layout content within a table cell (paragraphs and nested tables).
292///
293/// For nested tables, we lay out the table and flatten its cell paragraphs
294/// into the parent cell's paragraph blocks.
295fn layout_cell_content(
296    content: &[rdocx_oxml::table::CellContent],
297    available_width: f64,
298    styles: &CT_Styles,
299    input: &LayoutInput,
300    fm: &mut FontManager,
301    num_state: &mut NumberingState,
302) -> Result<Vec<ParagraphBlock>> {
303    use crate::engine;
304    use rdocx_oxml::table::CellContent;
305
306    let mut blocks = Vec::new();
307    for item in content {
308        match item {
309            CellContent::Paragraph(para) => {
310                let block =
311                    engine::layout_paragraph(para, available_width, styles, input, fm, num_state)?;
312                blocks.push(block);
313            }
314            CellContent::Table(tbl) => {
315                // Recursively lay out the nested table
316                let _nested = layout_table(tbl, available_width, styles, input, fm, num_state)?;
317                // For now, flatten: render nested table cell content as paragraph blocks
318                // (Full nested table rendering would require the paginator to handle tables within cells)
319                for row in &_nested.rows {
320                    for cell in &row.cells {
321                        if !cell.is_vmerge_continue {
322                            blocks.extend(cell.paragraphs.iter().cloned());
323                        }
324                    }
325                }
326            }
327        }
328    }
329    Ok(blocks)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use rdocx_oxml::table::{CT_TblGrid, CT_TblGridCol};
336    use rdocx_oxml::units::Twips;
337
338    #[test]
339    fn narrow_grid_keeps_its_declared_width() {
340        let tbl = CT_Tbl::new();
341        let grid = CT_TblGrid {
342            columns: vec![
343                CT_TblGridCol { width: Twips(2880) }, // 2 inches = 144pt
344                CT_TblGridCol { width: Twips(2880) },
345            ],
346        };
347
348        // 288pt total in a 468pt text column: the author asked for a narrow
349        // table, so it must not be stretched to the margins.
350        let widths = compute_column_widths(Some(&grid), 468.0, &tbl);
351
352        assert_eq!(widths.len(), 2);
353        let total: f64 = widths.iter().sum();
354        assert!((total - 288.0).abs() < 1.0, "got {total}");
355    }
356
357    #[test]
358    fn overflowing_grid_is_scaled_down_to_fit() {
359        let tbl = CT_Tbl::new();
360        let grid = CT_TblGrid {
361            columns: vec![
362                CT_TblGridCol { width: Twips(7200) }, // 5 inches = 360pt
363                CT_TblGridCol { width: Twips(7200) },
364            ],
365        };
366
367        // 720pt total will not fit a 468pt column, so scale it down.
368        let widths = compute_column_widths(Some(&grid), 468.0, &tbl);
369
370        let total: f64 = widths.iter().sum();
371        assert!((total - 468.0).abs() < 1.0, "got {total}");
372        // Proportions are preserved.
373        assert!((widths[0] - widths[1]).abs() < 0.01);
374    }
375
376    #[test]
377    fn column_widths_no_grid() {
378        let tbl = CT_Tbl::new();
379        let widths = compute_column_widths(None, 468.0, &tbl);
380        assert_eq!(widths.len(), 1);
381        assert!((widths[0] - 468.0).abs() < 0.01);
382    }
383
384    #[test]
385    fn column_widths_zero_grid() {
386        let tbl = CT_Tbl::new();
387        let grid = CT_TblGrid {
388            columns: vec![
389                CT_TblGridCol { width: Twips(0) },
390                CT_TblGridCol { width: Twips(0) },
391                CT_TblGridCol { width: Twips(0) },
392            ],
393        };
394        let widths = compute_column_widths(Some(&grid), 468.0, &tbl);
395        assert_eq!(widths.len(), 3);
396        for w in &widths {
397            assert!((w - 156.0).abs() < 0.01);
398        }
399    }
400
401    #[test]
402    fn column_widths_inferred_from_rows() {
403        use rdocx_oxml::table::{CT_Row, CT_Tc};
404        let mut tbl = CT_Tbl::new();
405        let mut row = CT_Row::new();
406        row.cells.push(CT_Tc::new());
407        row.cells.push(CT_Tc::new());
408        row.cells.push(CT_Tc::new());
409        tbl.rows.push(row);
410        let widths = compute_column_widths(None, 300.0, &tbl);
411        assert_eq!(widths.len(), 3);
412        for w in &widths {
413            assert!((w - 100.0).abs() < 0.01);
414        }
415    }
416
417    #[test]
418    fn nested_table_layout_dimensions() {
419        use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
420
421        // Build an outer table with one cell containing a nested table
422        let mut outer = CT_Tbl::new();
423        outer.grid = Some(CT_TblGrid {
424            columns: vec![CT_TblGridCol { width: Twips(4680) }], // 3.25"
425        });
426
427        let mut outer_row = CT_Row::new();
428        let mut outer_cell = CT_Tc::new();
429        outer_cell.paragraphs_mut()[0].add_run("Before nested");
430
431        // Nested table with 2 columns
432        let mut nested = CT_Tbl::new();
433        nested.grid = Some(CT_TblGrid {
434            columns: vec![
435                CT_TblGridCol { width: Twips(2000) },
436                CT_TblGridCol { width: Twips(2000) },
437            ],
438        });
439        let mut nr = CT_Row::new();
440        let mut nc1 = CT_Tc::new();
441        nc1.paragraphs_mut()[0].add_run("N1");
442        let mut nc2 = CT_Tc::new();
443        nc2.paragraphs_mut()[0].add_run("N2");
444        nr.cells.push(nc1);
445        nr.cells.push(nc2);
446        nested.rows.push(nr);
447
448        outer_cell.content.push(CellContent::Table(nested));
449        outer_row.cells.push(outer_cell);
450        outer.rows.push(outer_row);
451
452        // Layout with default styles
453        let styles = rdocx_oxml::styles::CT_Styles::default();
454        let input = crate::input::LayoutInput {
455            document: rdocx_oxml::document::CT_Document {
456                body: rdocx_oxml::document::CT_Body {
457                    content: Vec::new(),
458                    sect_pr: None,
459                },
460                extra_namespaces: Vec::new(),
461                background_xml: None,
462            },
463            styles: styles.clone(),
464            numbering: None,
465            headers: std::collections::HashMap::new(),
466            footers: std::collections::HashMap::new(),
467            images: std::collections::HashMap::new(),
468            hyperlink_urls: std::collections::HashMap::new(),
469            footnotes: None,
470            endnotes: None,
471            core_properties: None,
472            theme: None,
473            fonts: Vec::new(),
474        };
475
476        let mut fm = crate::font::FontManager::new();
477        let mut num_state = crate::style_resolver::NumberingState::new();
478
479        let result = layout_table(&outer, 234.0, &styles, &input, &mut fm, &mut num_state);
480        assert!(result.is_ok());
481        let block = result.unwrap();
482
483        // Outer table should have 1 row, 1 cell
484        assert_eq!(block.rows.len(), 1);
485        assert_eq!(block.rows[0].cells.len(), 1);
486
487        // Cell should have paragraphs from both the outer paragraph and flattened nested content
488        let cell = &block.rows[0].cells[0];
489        // At least: "Before nested" + "N1" + "N2" = 3 paragraph blocks
490        assert!(
491            cell.paragraphs.len() >= 3,
492            "Expected at least 3 paragraph blocks from outer + nested content, got {}",
493            cell.paragraphs.len()
494        );
495
496        // Table width should match available width
497        assert!((block.table_width - 234.0).abs() < 1.0);
498    }
499}