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, scaling to available width if needed.
245fn compute_column_widths(
246    grid: Option<&CT_TblGrid>,
247    available_width: f64,
248    tbl: &CT_Tbl,
249) -> Vec<f64> {
250    match grid {
251        Some(g) if !g.columns.is_empty() => {
252            let widths: Vec<f64> = g.columns.iter().map(|c| c.width.to_pt()).collect();
253            let total: f64 = widths.iter().sum();
254            if total > 0.01 && (total - available_width).abs() > 1.0 {
255                // Scale to fit available width
256                let scale = available_width / total;
257                widths.iter().map(|w| w * scale).collect()
258            } else if total < 0.01 {
259                // All zero widths — distribute equally based on column count
260                let n = g.columns.len();
261                vec![available_width / n as f64; n]
262            } else {
263                widths
264            }
265        }
266        _ => {
267            // No grid defined — infer column count from the first row
268            let num_cols = tbl
269                .rows
270                .first()
271                .map(|r| {
272                    r.cells
273                        .iter()
274                        .map(|c| {
275                            c.properties.as_ref().and_then(|p| p.grid_span).unwrap_or(1) as usize
276                        })
277                        .sum::<usize>()
278                })
279                .unwrap_or(1)
280                .max(1);
281            vec![available_width / num_cols as f64; num_cols]
282        }
283    }
284}
285
286/// Layout content within a table cell (paragraphs and nested tables).
287///
288/// For nested tables, we lay out the table and flatten its cell paragraphs
289/// into the parent cell's paragraph blocks.
290fn layout_cell_content(
291    content: &[rdocx_oxml::table::CellContent],
292    available_width: f64,
293    styles: &CT_Styles,
294    input: &LayoutInput,
295    fm: &mut FontManager,
296    num_state: &mut NumberingState,
297) -> Result<Vec<ParagraphBlock>> {
298    use crate::engine;
299    use rdocx_oxml::table::CellContent;
300
301    let mut blocks = Vec::new();
302    for item in content {
303        match item {
304            CellContent::Paragraph(para) => {
305                let block =
306                    engine::layout_paragraph(para, available_width, styles, input, fm, num_state)?;
307                blocks.push(block);
308            }
309            CellContent::Table(tbl) => {
310                // Recursively lay out the nested table
311                let _nested = layout_table(tbl, available_width, styles, input, fm, num_state)?;
312                // For now, flatten: render nested table cell content as paragraph blocks
313                // (Full nested table rendering would require the paginator to handle tables within cells)
314                for row in &_nested.rows {
315                    for cell in &row.cells {
316                        if !cell.is_vmerge_continue {
317                            blocks.extend(cell.paragraphs.iter().cloned());
318                        }
319                    }
320                }
321            }
322        }
323    }
324    Ok(blocks)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use rdocx_oxml::table::{CT_TblGrid, CT_TblGridCol};
331    use rdocx_oxml::units::Twips;
332
333    #[test]
334    fn column_widths_from_grid() {
335        let tbl = CT_Tbl::new();
336        let grid = CT_TblGrid {
337            columns: vec![
338                CT_TblGridCol { width: Twips(2880) }, // 2 inches
339                CT_TblGridCol { width: Twips(2880) },
340            ],
341        };
342        let widths = compute_column_widths(Some(&grid), 468.0, &tbl);
343        assert_eq!(widths.len(), 2);
344        // 2880tw = 144pt, total = 288pt, scaled to 468pt
345        let total: f64 = widths.iter().sum();
346        assert!((total - 468.0).abs() < 1.0);
347    }
348
349    #[test]
350    fn column_widths_no_grid() {
351        let tbl = CT_Tbl::new();
352        let widths = compute_column_widths(None, 468.0, &tbl);
353        assert_eq!(widths.len(), 1);
354        assert!((widths[0] - 468.0).abs() < 0.01);
355    }
356
357    #[test]
358    fn column_widths_zero_grid() {
359        let tbl = CT_Tbl::new();
360        let grid = CT_TblGrid {
361            columns: vec![
362                CT_TblGridCol { width: Twips(0) },
363                CT_TblGridCol { width: Twips(0) },
364                CT_TblGridCol { width: Twips(0) },
365            ],
366        };
367        let widths = compute_column_widths(Some(&grid), 468.0, &tbl);
368        assert_eq!(widths.len(), 3);
369        for w in &widths {
370            assert!((w - 156.0).abs() < 0.01);
371        }
372    }
373
374    #[test]
375    fn column_widths_inferred_from_rows() {
376        use rdocx_oxml::table::{CT_Row, CT_Tc};
377        let mut tbl = CT_Tbl::new();
378        let mut row = CT_Row::new();
379        row.cells.push(CT_Tc::new());
380        row.cells.push(CT_Tc::new());
381        row.cells.push(CT_Tc::new());
382        tbl.rows.push(row);
383        let widths = compute_column_widths(None, 300.0, &tbl);
384        assert_eq!(widths.len(), 3);
385        for w in &widths {
386            assert!((w - 100.0).abs() < 0.01);
387        }
388    }
389
390    #[test]
391    fn nested_table_layout_dimensions() {
392        use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
393
394        // Build an outer table with one cell containing a nested table
395        let mut outer = CT_Tbl::new();
396        outer.grid = Some(CT_TblGrid {
397            columns: vec![CT_TblGridCol { width: Twips(4680) }], // 3.25"
398        });
399
400        let mut outer_row = CT_Row::new();
401        let mut outer_cell = CT_Tc::new();
402        outer_cell.paragraphs_mut()[0].add_run("Before nested");
403
404        // Nested table with 2 columns
405        let mut nested = CT_Tbl::new();
406        nested.grid = Some(CT_TblGrid {
407            columns: vec![
408                CT_TblGridCol { width: Twips(2000) },
409                CT_TblGridCol { width: Twips(2000) },
410            ],
411        });
412        let mut nr = CT_Row::new();
413        let mut nc1 = CT_Tc::new();
414        nc1.paragraphs_mut()[0].add_run("N1");
415        let mut nc2 = CT_Tc::new();
416        nc2.paragraphs_mut()[0].add_run("N2");
417        nr.cells.push(nc1);
418        nr.cells.push(nc2);
419        nested.rows.push(nr);
420
421        outer_cell.content.push(CellContent::Table(nested));
422        outer_row.cells.push(outer_cell);
423        outer.rows.push(outer_row);
424
425        // Layout with default styles
426        let styles = rdocx_oxml::styles::CT_Styles::default();
427        let input = crate::input::LayoutInput {
428            document: rdocx_oxml::document::CT_Document {
429                body: rdocx_oxml::document::CT_Body {
430                    content: Vec::new(),
431                    sect_pr: None,
432                },
433                extra_namespaces: Vec::new(),
434                background_xml: None,
435            },
436            styles: styles.clone(),
437            numbering: None,
438            headers: std::collections::HashMap::new(),
439            footers: std::collections::HashMap::new(),
440            images: std::collections::HashMap::new(),
441            hyperlink_urls: std::collections::HashMap::new(),
442            footnotes: None,
443            endnotes: None,
444            core_properties: None,
445            theme: None,
446            fonts: Vec::new(),
447        };
448
449        let mut fm = crate::font::FontManager::new();
450        let mut num_state = crate::style_resolver::NumberingState::new();
451
452        let result = layout_table(&outer, 234.0, &styles, &input, &mut fm, &mut num_state);
453        assert!(result.is_ok());
454        let block = result.unwrap();
455
456        // Outer table should have 1 row, 1 cell
457        assert_eq!(block.rows.len(), 1);
458        assert_eq!(block.rows[0].cells.len(), 1);
459
460        // Cell should have paragraphs from both the outer paragraph and flattened nested content
461        let cell = &block.rows[0].cells[0];
462        // At least: "Before nested" + "N1" + "N2" = 3 paragraph blocks
463        assert!(
464            cell.paragraphs.len() >= 3,
465            "Expected at least 3 paragraph blocks from outer + nested content, got {}",
466            cell.paragraphs.len()
467        );
468
469        // Table width should match available width
470        assert!((block.table_width - 234.0).abs() < 1.0);
471    }
472}