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