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