Skip to main content

rdocx_layout/
table.rs

1//! Table layout: column widths, cell content, merge handling.
2
3use rdocx_oxml::content_control::{CT_Sdt, SdtContent};
4use rdocx_oxml::styles::CT_Styles;
5use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_TblBorders, CT_TblGrid, CT_Tc, ST_VerticalJc, VMerge};
6
7use crate::WordStory;
8use crate::block::{
9    CellBlockSemantics, CellSemantics, ParagraphBlock, ParagraphSemantics, RowSemantics,
10    TableSemantics,
11};
12use crate::engine::SourceRegistry;
13use crate::input::{LayoutInput, MediaRegistry};
14use crate::style_resolver::NumberingState;
15use oxml_layout::{Color, Diagnostic, FontManager, Result, StructureId};
16
17const CONTROL_PATH_COMPONENT: usize = usize::MAX;
18
19pub(crate) fn layout_table_rows<'a>(
20    table: &'a CT_Tbl,
21    path: &[usize],
22) -> Vec<(&'a CT_Row, Vec<usize>)> {
23    let mut rows = Vec::new();
24    for boundary in 0..=table.rows.len() {
25        for (control_index, (_, _, control)) in table
26            .content_controls
27            .iter()
28            .enumerate()
29            .filter(|(_, (position, _, _))| *position == boundary)
30        {
31            let mut control_path = path.to_vec();
32            control_path.extend([CONTROL_PATH_COMPONENT, boundary, control_index]);
33            collect_control_rows(control, &control_path, &mut rows);
34        }
35        if let Some(row) = table.rows.get(boundary) {
36            let mut row_path = path.to_vec();
37            row_path.push(boundary);
38            rows.push((row, row_path));
39        }
40    }
41    rows
42}
43
44fn collect_control_rows<'a>(
45    control: &'a CT_Sdt,
46    path: &[usize],
47    rows: &mut Vec<(&'a CT_Row, Vec<usize>)>,
48) {
49    for (content_index, content) in control.content.iter().enumerate() {
50        let mut content_path = path.to_vec();
51        content_path.push(content_index);
52        match content {
53            SdtContent::Row(row) => rows.push((row, content_path)),
54            SdtContent::ContentControl(control) => {
55                collect_control_rows(control, &content_path, rows)
56            }
57            _ => {}
58        }
59    }
60}
61
62pub(crate) fn layout_row_cells<'a>(
63    row: &'a CT_Row,
64    path: &[usize],
65) -> Vec<(&'a CT_Tc, Vec<usize>)> {
66    let mut cells = Vec::new();
67    for boundary in 0..=row.cells.len() {
68        for (control_index, (_, _, control)) in row
69            .content_controls
70            .iter()
71            .enumerate()
72            .filter(|(_, (position, _, _))| *position == boundary)
73        {
74            let mut control_path = path.to_vec();
75            control_path.extend([CONTROL_PATH_COMPONENT, boundary, control_index]);
76            collect_control_cells(control, &control_path, &mut cells);
77        }
78        if let Some(cell) = row.cells.get(boundary) {
79            let mut cell_path = path.to_vec();
80            cell_path.push(boundary);
81            cells.push((cell, cell_path));
82        }
83    }
84    cells
85}
86
87fn collect_control_cells<'a>(
88    control: &'a CT_Sdt,
89    path: &[usize],
90    cells: &mut Vec<(&'a CT_Tc, Vec<usize>)>,
91) {
92    for (content_index, content) in control.content.iter().enumerate() {
93        let mut content_path = path.to_vec();
94        content_path.push(content_index);
95        match content {
96            SdtContent::Cell(cell) => cells.push((cell, content_path)),
97            SdtContent::ContentControl(control) => {
98                collect_control_cells(control, &content_path, cells)
99            }
100            _ => {}
101        }
102    }
103}
104
105/// A laid-out table.
106#[derive(Debug, Clone)]
107pub struct TableBlock {
108    /// Logical table node allocated before pagination.
109    pub structure_id: Option<StructureId>,
110    /// Column widths in points.
111    pub col_widths: Vec<f64>,
112    /// Laid-out rows.
113    pub rows: Vec<TableRow>,
114    /// Indices of rows that are header rows (repeat on page break).
115    pub header_row_indices: Vec<usize>,
116    /// Total table width in points.
117    pub table_width: f64,
118    /// Table indent from left margin in points.
119    pub table_indent: f64,
120    /// Table-level borders (used as fallback for cell borders).
121    pub borders: Option<CT_TblBorders>,
122}
123
124impl TableBlock {
125    /// Total content height of all rows.
126    pub fn content_height(&self) -> f64 {
127        self.rows.iter().map(|r| r.height).sum()
128    }
129
130    /// Total height (same as content for tables, no before/after spacing).
131    pub fn total_height(&self) -> f64 {
132        self.content_height()
133    }
134}
135
136/// A laid-out table row.
137#[derive(Debug, Clone)]
138pub struct TableRow {
139    /// Logical row node allocated before pagination.
140    pub structure_id: Option<StructureId>,
141    /// Cells in this row.
142    pub cells: Vec<TableCell>,
143    /// Row height in points.
144    pub height: f64,
145    /// Whether this row is a header row.
146    pub is_header: bool,
147}
148
149/// One source-ordered block inside a table cell.
150#[derive(Debug, Clone)]
151pub enum CellBlock {
152    /// A laid-out paragraph.
153    Paragraph(ParagraphBlock),
154    /// A recursively laid-out nested table.
155    Table(TableBlock),
156}
157
158impl CellBlock {
159    /// Total block height in points.
160    pub fn total_height(&self) -> f64 {
161        match self {
162            Self::Paragraph(paragraph) => paragraph.total_height(),
163            Self::Table(table) => table.total_height(),
164        }
165    }
166}
167
168/// A laid-out table cell.
169#[derive(Debug, Clone)]
170pub struct TableCell {
171    /// Logical cell node allocated before pagination.
172    pub structure_id: Option<StructureId>,
173    /// Source-ordered paragraph and nested-table blocks.
174    pub blocks: Vec<CellBlock>,
175    /// Cell width in points (may span multiple grid columns).
176    pub width: f64,
177    /// Cell height in points (set to row height).
178    pub height: f64,
179    /// Number of grid columns this cell spans.
180    pub grid_span: u32,
181    /// Whether this cell is part of a vertical merge continuation (render no content).
182    pub is_vmerge_continue: bool,
183    /// Whether this cell begins a vertical merge.
184    pub starts_vmerge: bool,
185    /// Height of the complete vertical-merge span.
186    pub merged_height: f64,
187    /// Whether the same grid span continues in the next row.
188    pub merge_with_below: bool,
189    /// Whether overflowing cell content must be clipped to its painted span.
190    pub clip_content: bool,
191    /// Column index in the grid.
192    pub col_index: usize,
193    /// Cell-level borders.
194    pub borders: Option<CT_TblBorders>,
195    /// Cell background shading color.
196    pub shading: Option<Color>,
197    /// Cell margin left in points.
198    pub margin_left: f64,
199    /// Cell margin right in points.
200    pub margin_right: f64,
201    /// Cell margin top in points.
202    pub margin_top: f64,
203    /// Cell margin bottom in points.
204    pub margin_bottom: f64,
205    /// Whether this cell is in the first row.
206    pub is_first_row: bool,
207    /// Whether this cell is in the last row.
208    pub is_last_row: bool,
209    /// Vertical alignment of content within the cell.
210    pub v_align: Option<ST_VerticalJc>,
211}
212
213/// Lay out a table into a TableBlock.
214pub fn layout_table(
215    tbl: &CT_Tbl,
216    available_width: f64,
217    styles: &CT_Styles,
218    input: &LayoutInput,
219    media: &MediaRegistry,
220    fm: &mut FontManager,
221    num_state: &mut NumberingState,
222    diagnostics: &mut Vec<Diagnostic>,
223) -> Result<TableBlock> {
224    layout_table_inner(
225        tbl,
226        available_width,
227        styles,
228        input,
229        media,
230        fm,
231        num_state,
232        diagnostics,
233        None,
234        &WordStory::Document,
235        &[],
236    )
237    .map(|(block, _)| block)
238}
239
240pub(crate) fn layout_table_with_provenance(
241    tbl: &CT_Tbl,
242    available_width: f64,
243    styles: &CT_Styles,
244    input: &LayoutInput,
245    media: &MediaRegistry,
246    fm: &mut FontManager,
247    num_state: &mut NumberingState,
248    diagnostics: &mut Vec<Diagnostic>,
249    sources: Option<&SourceRegistry>,
250    story: &WordStory,
251    path: &[usize],
252) -> Result<(TableBlock, TableSemantics)> {
253    layout_table_inner(
254        tbl,
255        available_width,
256        styles,
257        input,
258        media,
259        fm,
260        num_state,
261        diagnostics,
262        sources,
263        story,
264        path,
265    )
266}
267
268fn layout_table_inner(
269    tbl: &CT_Tbl,
270    available_width: f64,
271    styles: &CT_Styles,
272    input: &LayoutInput,
273    media: &MediaRegistry,
274    fm: &mut FontManager,
275    num_state: &mut NumberingState,
276    diagnostics: &mut Vec<Diagnostic>,
277    sources: Option<&SourceRegistry>,
278    story: &WordStory,
279    path: &[usize],
280) -> Result<(TableBlock, TableSemantics)> {
281    let source_rows = layout_table_rows(tbl, path);
282    // 1. Compute column widths
283    let col_widths = compute_column_widths(tbl.grid.as_ref(), available_width, tbl, path);
284    let table_width: f64 = col_widths.iter().sum();
285
286    // Table indent
287    let table_indent = tbl
288        .properties
289        .as_ref()
290        .and_then(|p| p.indent.as_ref())
291        .map(|ind| {
292            if ind.width_type == "dxa" {
293                ind.w as f64 / 20.0 // twips to pt
294            } else {
295                0.0
296            }
297        })
298        .unwrap_or(0.0);
299
300    // Direct table borders win. Table-style borders are the fallback.
301    let table_borders = tbl
302        .properties
303        .as_ref()
304        .and_then(|properties| properties.borders.clone())
305        .or_else(|| {
306            let mut style_id = tbl.properties.as_ref()?.style_id.as_deref()?;
307            let mut visited = std::collections::HashSet::new();
308            while visited.insert(style_id) {
309                let style = styles.get_by_id(style_id)?;
310                if let Some(properties) = &style.table_properties
311                    && let Some(borders) = &properties.borders
312                {
313                    return Some(borders.clone());
314                }
315                style_id = style.based_on.as_deref()?;
316            }
317            None
318        });
319
320    // Default cell margins
321    let default_cell_margin = tbl.properties.as_ref().and_then(|p| p.cell_margin.as_ref());
322    let cell_margin_left = default_cell_margin
323        .and_then(|m| m.left)
324        .map(|t| t.to_pt())
325        .unwrap_or(5.4); // Word default ~108 twips
326    let cell_margin_right = default_cell_margin
327        .and_then(|m| m.right)
328        .map(|t| t.to_pt())
329        .unwrap_or(5.4);
330    let cell_margin_top = default_cell_margin
331        .and_then(|m| m.top)
332        .map(|t| t.to_pt())
333        .unwrap_or(0.0);
334    let cell_margin_bottom = default_cell_margin
335        .and_then(|m| m.bottom)
336        .map(|t| t.to_pt())
337        .unwrap_or(0.0);
338
339    let num_rows = source_rows.len();
340    let mut header_row_indices = Vec::new();
341    let mut rows = Vec::new();
342    let mut row_semantics = Vec::new();
343    let mut exact_rows = Vec::new();
344
345    for (row_idx, (row, row_path)) in source_rows.iter().enumerate() {
346        let is_header = row
347            .properties
348            .as_ref()
349            .and_then(|p| p.header)
350            .unwrap_or(false);
351        if is_header {
352            header_row_indices.push(row_idx);
353        }
354
355        let mut cells = Vec::new();
356        let mut cell_semantics = Vec::new();
357        let mut col_index = 0usize;
358
359        let source_cells = layout_row_cells(row, row_path);
360        for (cell, cell_path) in &source_cells {
361            let grid_span = cell
362                .properties
363                .as_ref()
364                .and_then(|p| p.grid_span)
365                .unwrap_or(1);
366
367            let is_vmerge_continue = cell
368                .properties
369                .as_ref()
370                .and_then(|p| p.v_merge)
371                .map(|vm| vm == VMerge::Continue)
372                .unwrap_or(false);
373            let starts_vmerge =
374                cell.properties.as_ref().and_then(|p| p.v_merge) == Some(VMerge::Restart);
375
376            let style_cell = resolve_table_style_cell(
377                tbl,
378                styles,
379                row_idx,
380                col_index,
381                num_rows,
382                col_widths.len(),
383                row.properties
384                    .as_ref()
385                    .and_then(|properties| properties.cnf_style.as_deref()),
386                cell.properties
387                    .as_ref()
388                    .and_then(|properties| properties.cnf_style.as_deref()),
389            );
390
391            // Direct cell borders overlay table-style region borders.
392            let mut cell_borders = style_cell.borders;
393            if let Some(direct) = cell.properties.as_ref().and_then(|p| p.borders.as_ref()) {
394                overlay_borders(&mut cell_borders, direct);
395            }
396            let cell_shading = cell
397                .properties
398                .as_ref()
399                .and_then(|p| p.shading.as_ref())
400                .or(style_cell.shading.as_ref())
401                .and_then(|shd| shd.fill.as_ref())
402                .filter(|f| f.as_str() != "auto")
403                .map(|f| Color::from_hex(f));
404
405            // Calculate cell width from spanned columns
406            let cell_width: f64 = (col_index..col_index + grid_span as usize)
407                .filter_map(|i| col_widths.get(i))
408                .sum();
409
410            let content_width = (cell_width - cell_margin_left - cell_margin_right).max(0.0);
411
412            // Layout cell content (paragraphs and nested tables)
413            let (blocks, block_semantics) = if is_vmerge_continue {
414                (Vec::new(), Vec::new())
415            } else {
416                layout_cell_content(
417                    &cell.content,
418                    content_width,
419                    styles,
420                    input,
421                    media,
422                    fm,
423                    num_state,
424                    diagnostics,
425                    sources,
426                    story,
427                    cell_path,
428                    style_cell.paragraph_properties.as_ref(),
429                )?
430            };
431
432            let content_height: f64 = blocks.iter().map(CellBlock::total_height).sum::<f64>()
433                + cell_margin_top
434                + cell_margin_bottom;
435
436            let v_align = cell.properties.as_ref().and_then(|p| p.v_align);
437
438            cells.push(TableCell {
439                structure_id: None,
440                blocks,
441                width: cell_width,
442                height: content_height,
443                grid_span,
444                is_vmerge_continue,
445                starts_vmerge,
446                merged_height: content_height,
447                merge_with_below: false,
448                clip_content: false,
449                col_index,
450                borders: cell_borders,
451                shading: cell_shading,
452                margin_left: cell_margin_left,
453                margin_right: cell_margin_right,
454                margin_top: cell_margin_top,
455                margin_bottom: cell_margin_bottom,
456                is_first_row: row_idx == 0,
457                is_last_row: row_idx == num_rows - 1,
458                v_align,
459            });
460            cell_semantics.push(CellSemantics {
461                blocks: block_semantics,
462            });
463
464            col_index += grid_span as usize;
465        }
466
467        let max_cell_height = cells
468            .iter()
469            .filter(|cell| !cell.starts_vmerge)
470            .map(|cell| cell.height)
471            .fold(0.0f64, f64::max);
472        let specified_height = row
473            .properties
474            .as_ref()
475            .and_then(|p| p.height)
476            .map(|h| h.to_pt())
477            .unwrap_or(0.0);
478        let exact = row
479            .properties
480            .as_ref()
481            .and_then(|properties| properties.height_rule.as_deref())
482            == Some("exact")
483            && specified_height > 0.0;
484        exact_rows.push(exact);
485        for cell in &mut cells {
486            cell.clip_content = exact && !cell.is_vmerge_continue;
487        }
488        let row_height = if exact {
489            specified_height
490        } else {
491            max_cell_height.max(specified_height)
492        };
493
494        rows.push(TableRow {
495            structure_id: None,
496            cells,
497            height: row_height,
498            is_header,
499        });
500        row_semantics.push(RowSemantics {
501            cells: cell_semantics,
502        });
503    }
504
505    // Resolve vertical merges over exact logical grid spans. Only the last
506    // non-exact row grows when the restart content exceeds the full span.
507    let mut spans = Vec::new();
508    for row_index in 0..rows.len() {
509        for cell_index in 0..rows[row_index].cells.len() {
510            let cell = &rows[row_index].cells[cell_index];
511            if !cell.starts_vmerge {
512                continue;
513            }
514            let start_col = cell.col_index;
515            let grid_span = cell.grid_span;
516            let mut last_row = row_index;
517            while let Some(next_row) = rows.get(last_row + 1) {
518                let continues = next_row.cells.iter().any(|next| {
519                    next.is_vmerge_continue
520                        && next.col_index == start_col
521                        && next.grid_span == grid_span
522                });
523                if !continues {
524                    break;
525                }
526                last_row += 1;
527            }
528            let required = cell.height;
529            let available = rows[row_index..=last_row]
530                .iter()
531                .map(|row| row.height)
532                .sum::<f64>();
533            if required > available
534                && let Some(grow_row) = (row_index..=last_row)
535                    .rev()
536                    .find(|candidate| !exact_rows[*candidate])
537            {
538                rows[grow_row].height += required - available;
539            }
540            spans.push((row_index, cell_index, last_row, required));
541        }
542    }
543
544    let row_heights = rows.iter().map(|row| row.height).collect::<Vec<_>>();
545    for row_index in 0..rows.len() {
546        let continuing_spans = rows
547            .get(row_index + 1)
548            .map(|next_row| {
549                next_row
550                    .cells
551                    .iter()
552                    .filter(|cell| cell.is_vmerge_continue)
553                    .map(|cell| (cell.col_index, cell.grid_span))
554                    .collect::<Vec<_>>()
555            })
556            .unwrap_or_default();
557        for cell in &mut rows[row_index].cells {
558            cell.height = row_heights[row_index];
559            cell.merged_height = row_heights[row_index];
560            cell.merge_with_below = continuing_spans.contains(&(cell.col_index, cell.grid_span));
561        }
562    }
563    for (row_index, cell_index, last_row, required) in spans {
564        let restart = &mut rows[row_index].cells[cell_index];
565        restart.merged_height = row_heights[row_index..=last_row].iter().sum();
566        restart.is_last_row = last_row + 1 == num_rows;
567        restart.clip_content = required > restart.merged_height;
568    }
569
570    Ok((
571        TableBlock {
572            structure_id: None,
573            col_widths,
574            rows,
575            header_row_indices,
576            table_width,
577            table_indent,
578            borders: table_borders,
579        },
580        TableSemantics {
581            rows: row_semantics,
582        },
583    ))
584}
585
586/// Compute column widths from CT_TblGrid, shrinking to the available width if
587/// the declared grid overflows it.
588///
589/// A grid narrower than the text column keeps its declared width: Word renders
590/// a deliberately narrow table at the size the author chose rather than
591/// stretching it to the margins, and so do we.
592fn compute_column_widths(
593    grid: Option<&CT_TblGrid>,
594    available_width: f64,
595    table: &CT_Tbl,
596    path: &[usize],
597) -> Vec<f64> {
598    match grid {
599        Some(g) if !g.columns.is_empty() => {
600            let widths: Vec<f64> = g.columns.iter().map(|c| c.width.to_pt()).collect();
601            let total: f64 = widths.iter().sum();
602            if total < 0.01 {
603                // All zero widths — distribute equally based on column count
604                let n = g.columns.len();
605                vec![available_width / n as f64; n]
606            } else if total > available_width + 1.0 {
607                // Overflows the text column: scale down so it fits the page.
608                let scale = available_width / total;
609                widths.iter().map(|w| w * scale).collect()
610            } else {
611                widths
612            }
613        }
614        _ => {
615            // No grid defined — infer column count from the first row
616            let num_cols = layout_table_rows(table, path)
617                .first()
618                .map(|(row, path)| {
619                    layout_row_cells(row, path)
620                        .iter()
621                        .map(|(cell, _)| {
622                            cell.properties
623                                .as_ref()
624                                .and_then(|p| p.grid_span)
625                                .unwrap_or(1) as usize
626                        })
627                        .sum::<usize>()
628                })
629                .unwrap_or(1)
630                .max(1);
631            vec![available_width / num_cols as f64; num_cols]
632        }
633    }
634}
635
636/// Layout content within a table cell (paragraphs and nested tables).
637///
638/// Nested tables remain recursive blocks in source order.
639fn layout_cell_content(
640    content: &[rdocx_oxml::table::CellContent],
641    available_width: f64,
642    styles: &CT_Styles,
643    input: &LayoutInput,
644    media: &MediaRegistry,
645    fm: &mut FontManager,
646    num_state: &mut NumberingState,
647    diagnostics: &mut Vec<Diagnostic>,
648    sources: Option<&SourceRegistry>,
649    story: &WordStory,
650    cell_path: &[usize],
651    table_style_ppr: Option<&rdocx_oxml::properties::CT_PPr>,
652) -> Result<(Vec<CellBlock>, Vec<CellBlockSemantics>)> {
653    use crate::engine;
654    use rdocx_oxml::table::CellContent;
655
656    let mut blocks = Vec::new();
657    let mut semantics = Vec::new();
658    for (content_index, item) in content.iter().enumerate() {
659        let mut source_path = cell_path.to_vec();
660        source_path.push(content_index);
661        match item {
662            CellContent::Paragraph(para) => {
663                let source = sources.and_then(|sources| sources.id(story, &source_path));
664                let (block, reflow_direction) = engine::layout_paragraph_with_source_in_table(
665                    para,
666                    available_width,
667                    styles,
668                    input,
669                    media,
670                    fm,
671                    num_state,
672                    diagnostics,
673                    source,
674                    table_style_ppr,
675                )?;
676                blocks.push(CellBlock::Paragraph(block));
677                semantics.push(CellBlockSemantics::Paragraph(ParagraphSemantics {
678                    source_node: source,
679                    structure_id: None,
680                    reflow_direction,
681                }));
682            }
683            CellContent::Table(tbl) => {
684                // Recursively lay out the nested table
685                let (nested, nested_semantics) = layout_table_inner(
686                    tbl,
687                    available_width,
688                    styles,
689                    input,
690                    media,
691                    fm,
692                    num_state,
693                    diagnostics,
694                    sources,
695                    story,
696                    &source_path,
697                )?;
698                blocks.push(CellBlock::Table(nested));
699                semantics.push(CellBlockSemantics::Table(nested_semantics));
700            }
701            CellContent::ContentControl(control) => layout_control_cell_content(
702                control,
703                available_width,
704                styles,
705                input,
706                media,
707                fm,
708                num_state,
709                diagnostics,
710                sources,
711                story,
712                &source_path,
713                table_style_ppr,
714                &mut blocks,
715                &mut semantics,
716            )?,
717        }
718    }
719    Ok((blocks, semantics))
720}
721
722#[allow(clippy::too_many_arguments)]
723fn layout_control_cell_content(
724    control: &CT_Sdt,
725    available_width: f64,
726    styles: &CT_Styles,
727    input: &LayoutInput,
728    media: &MediaRegistry,
729    fm: &mut FontManager,
730    num_state: &mut NumberingState,
731    diagnostics: &mut Vec<Diagnostic>,
732    sources: Option<&SourceRegistry>,
733    story: &WordStory,
734    path: &[usize],
735    table_style_ppr: Option<&rdocx_oxml::properties::CT_PPr>,
736    blocks: &mut Vec<CellBlock>,
737    semantics: &mut Vec<CellBlockSemantics>,
738) -> Result<()> {
739    use crate::engine;
740
741    for (content_index, content) in control.content.iter().enumerate() {
742        let mut source_path = path.to_vec();
743        source_path.push(content_index);
744        match content {
745            SdtContent::Paragraph(paragraph) => {
746                let source = sources.and_then(|sources| sources.id(story, &source_path));
747                let (block, reflow_direction) = engine::layout_paragraph_with_source_in_table(
748                    paragraph,
749                    available_width,
750                    styles,
751                    input,
752                    media,
753                    fm,
754                    num_state,
755                    diagnostics,
756                    source,
757                    table_style_ppr,
758                )?;
759                blocks.push(CellBlock::Paragraph(block));
760                semantics.push(CellBlockSemantics::Paragraph(ParagraphSemantics {
761                    source_node: source,
762                    structure_id: None,
763                    reflow_direction,
764                }));
765            }
766            SdtContent::Table(table) => {
767                let (nested, nested_semantics) = layout_table_inner(
768                    table,
769                    available_width,
770                    styles,
771                    input,
772                    media,
773                    fm,
774                    num_state,
775                    diagnostics,
776                    sources,
777                    story,
778                    &source_path,
779                )?;
780                blocks.push(CellBlock::Table(nested));
781                semantics.push(CellBlockSemantics::Table(nested_semantics));
782            }
783            SdtContent::ContentControl(control) => layout_control_cell_content(
784                control,
785                available_width,
786                styles,
787                input,
788                media,
789                fm,
790                num_state,
791                diagnostics,
792                sources,
793                story,
794                &source_path,
795                table_style_ppr,
796                blocks,
797                semantics,
798            )?,
799            SdtContent::Row(_)
800            | SdtContent::Cell(_)
801            | SdtContent::Run(_)
802            | SdtContent::RawXml(_) => {}
803        }
804    }
805    Ok(())
806}
807
808#[derive(Default)]
809struct ResolvedTableCellStyle {
810    paragraph_properties: Option<rdocx_oxml::properties::CT_PPr>,
811    borders: Option<CT_TblBorders>,
812    shading: Option<rdocx_oxml::properties::CT_Shd>,
813}
814
815fn resolve_table_style_cell(
816    table: &CT_Tbl,
817    styles: &CT_Styles,
818    row: usize,
819    column: usize,
820    row_count: usize,
821    column_count: usize,
822    row_cnf_style: Option<&str>,
823    cell_cnf_style: Option<&str>,
824) -> ResolvedTableCellStyle {
825    let Some(mut style_id) = table
826        .properties
827        .as_ref()
828        .and_then(|p| p.style_id.as_deref())
829    else {
830        return ResolvedTableCellStyle::default();
831    };
832    let mut chain = Vec::new();
833    let mut visited = std::collections::HashSet::new();
834    while visited.insert(style_id) {
835        let Some(style) = styles.get_by_id(style_id) else {
836            break;
837        };
838        chain.push(style);
839        let Some(base) = style.based_on.as_deref() else {
840            break;
841        };
842        style_id = base;
843    }
844    let mut resolved = ResolvedTableCellStyle::default();
845    for style in chain.into_iter().rev() {
846        if let Some(properties) = &style.ppr {
847            resolved
848                .paragraph_properties
849                .get_or_insert_with(rdocx_oxml::properties::CT_PPr::default)
850                .merge_from(properties);
851        }
852        if let Some(borders) = style
853            .table_properties
854            .as_ref()
855            .and_then(|properties| properties.borders.as_ref())
856        {
857            overlay_borders(&mut resolved.borders, borders);
858        }
859        if let Some(shading) = style
860            .table_properties
861            .as_ref()
862            .and_then(|properties| properties.shading.as_ref())
863        {
864            resolved.shading = Some(shading.clone());
865        }
866        for region in applicable_table_regions(
867            table,
868            row,
869            column,
870            row_count,
871            column_count,
872            row_cnf_style,
873            cell_cnf_style,
874        ) {
875            for conditional in style
876                .conditional_table_styles
877                .iter()
878                .filter(|conditional| conditional.region == region)
879            {
880                if let Some(properties) = &conditional.paragraph_properties {
881                    resolved
882                        .paragraph_properties
883                        .get_or_insert_with(rdocx_oxml::properties::CT_PPr::default)
884                        .merge_from(properties);
885                }
886                if let Some(borders) = conditional
887                    .cell_properties
888                    .as_ref()
889                    .and_then(|properties| properties.borders.as_ref())
890                    .or_else(|| {
891                        conditional
892                            .table_properties
893                            .as_ref()
894                            .and_then(|properties| properties.borders.as_ref())
895                    })
896                {
897                    overlay_borders(&mut resolved.borders, borders);
898                }
899                if let Some(shading) = conditional
900                    .cell_properties
901                    .as_ref()
902                    .and_then(|properties| properties.shading.as_ref())
903                    .or_else(|| {
904                        conditional
905                            .table_properties
906                            .as_ref()
907                            .and_then(|properties| properties.shading.as_ref())
908                    })
909                {
910                    resolved.shading = Some(shading.clone());
911                }
912            }
913        }
914    }
915    resolved
916}
917
918fn applicable_table_regions(
919    table: &CT_Tbl,
920    row: usize,
921    column: usize,
922    row_count: usize,
923    column_count: usize,
924    row_cnf_style: Option<&str>,
925    cell_cnf_style: Option<&str>,
926) -> Vec<&'static str> {
927    let cnf = |index: usize| {
928        [row_cnf_style, cell_cnf_style]
929            .into_iter()
930            .flatten()
931            .any(|value| value.as_bytes().get(index) == Some(&b'1'))
932    };
933    let look = table
934        .properties
935        .as_ref()
936        .and_then(|properties| properties.look.as_ref());
937    let enabled = |explicit: Option<bool>, mask: u16, default: bool| {
938        explicit.unwrap_or_else(|| {
939            look.and_then(|look| look.val.as_deref())
940                .and_then(|value| u16::from_str_radix(value, 16).ok())
941                .map_or(default, |value| value & mask != 0)
942        })
943    };
944    let first_row =
945        (enabled(look.and_then(|look| look.first_row), 0x20, false) && row == 0) || cnf(0);
946    let last_row = (enabled(look.and_then(|look| look.last_row), 0x40, false)
947        && row + 1 == row_count)
948        || cnf(1);
949    let first_column =
950        (enabled(look.and_then(|look| look.first_column), 0x80, false) && column == 0) || cnf(2);
951    let last_column = (enabled(look.and_then(|look| look.last_column), 0x100, false)
952        && column + 1 == column_count)
953        || cnf(3);
954    let no_h_band = enabled(look.and_then(|look| look.no_h_band), 0x200, false);
955    let no_v_band = enabled(look.and_then(|look| look.no_v_band), 0x400, false);
956
957    let mut regions = vec!["wholeTable"];
958    if cnf(6) {
959        regions.push("band1Horz");
960    } else if cnf(7) {
961        regions.push("band2Horz");
962    } else if !no_h_band {
963        regions.push(if row.is_multiple_of(2) {
964            "band1Horz"
965        } else {
966            "band2Horz"
967        });
968    }
969    if cnf(4) {
970        regions.push("band1Vert");
971    } else if cnf(5) {
972        regions.push("band2Vert");
973    } else if !no_v_band {
974        regions.push(if column.is_multiple_of(2) {
975            "band1Vert"
976        } else {
977            "band2Vert"
978        });
979    }
980    if first_column {
981        regions.push("firstCol");
982    }
983    if last_column {
984        regions.push("lastCol");
985    }
986    if first_row {
987        regions.push("firstRow");
988    }
989    if last_row {
990        regions.push("lastRow");
991    }
992    if cnf(9) {
993        regions.push("nwCell");
994    } else if cnf(8) {
995        regions.push("neCell");
996    } else if cnf(11) {
997        regions.push("swCell");
998    } else if cnf(10) {
999        regions.push("seCell");
1000    } else {
1001        match (first_row, last_row, first_column, last_column) {
1002            (true, _, true, _) => regions.push("nwCell"),
1003            (true, _, _, true) => regions.push("neCell"),
1004            (_, true, true, _) => regions.push("swCell"),
1005            (_, true, _, true) => regions.push("seCell"),
1006            _ => {}
1007        }
1008    }
1009    regions
1010}
1011
1012fn overlay_borders(target: &mut Option<CT_TblBorders>, source: &CT_TblBorders) {
1013    let target = target.get_or_insert_with(CT_TblBorders::default);
1014    if source.top.is_some() {
1015        target.top = source.top.clone();
1016    }
1017    if source.bottom.is_some() {
1018        target.bottom = source.bottom.clone();
1019    }
1020    if source.left.is_some() {
1021        target.left = source.left.clone();
1022    }
1023    if source.right.is_some() {
1024        target.right = source.right.clone();
1025    }
1026    if source.inside_h.is_some() {
1027        target.inside_h = source.inside_h.clone();
1028    }
1029    if source.inside_v.is_some() {
1030        target.inside_v = source.inside_v.clone();
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use rdocx_oxml::table::{
1038        CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblLook, CT_TblPr, CT_Tc, CT_TcPr, CT_TrPr,
1039    };
1040    use rdocx_oxml::units::Twips;
1041
1042    fn layout_with_defaults(table: &CT_Tbl, width: f64) -> TableBlock {
1043        let styles = CT_Styles::default();
1044        layout_with_styles(table, width, &styles)
1045    }
1046
1047    fn layout_with_styles(table: &CT_Tbl, width: f64, styles: &CT_Styles) -> TableBlock {
1048        let input = LayoutInput {
1049            revision_view: crate::input::RevisionView::Accepted,
1050            automatic_hyphenation: false,
1051            math_properties: None,
1052            document: rdocx_oxml::document::CT_Document {
1053                body: rdocx_oxml::document::CT_Body {
1054                    content: Vec::new(),
1055                    sect_pr: None,
1056                },
1057                extra_namespaces: Vec::new(),
1058                background_xml: None,
1059                background_extra_xml: Vec::new(),
1060            },
1061            styles: styles.clone(),
1062            numbering: None,
1063            headers: std::collections::HashMap::new(),
1064            footers: std::collections::HashMap::new(),
1065            images: std::collections::HashMap::new(),
1066            charts: std::collections::HashMap::new(),
1067            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
1068            chart_color_map: oxml_drawing::color::ColorMap::default(),
1069            hyperlink_urls: std::collections::HashMap::new(),
1070            footnotes: None,
1071            endnotes: None,
1072            core_properties: None,
1073            theme: None,
1074            fonts: Vec::new(),
1075        };
1076        let media = MediaRegistry::new(&input.images);
1077        let mut font_manager = FontManager::new();
1078        let mut numbering = NumberingState::new();
1079        layout_table(
1080            table,
1081            width,
1082            styles,
1083            &input,
1084            &media,
1085            &mut font_manager,
1086            &mut numbering,
1087            &mut Vec::new(),
1088        )
1089        .unwrap()
1090    }
1091
1092    #[test]
1093    fn narrow_grid_keeps_its_declared_width() {
1094        let tbl = CT_Tbl::new();
1095        let grid = CT_TblGrid {
1096            columns: vec![
1097                CT_TblGridCol { width: Twips(2880) }, // 2 inches = 144pt
1098                CT_TblGridCol { width: Twips(2880) },
1099            ],
1100            ..Default::default()
1101        };
1102
1103        // 288pt total in a 468pt text column: the author asked for a narrow
1104        // table, so it must not be stretched to the margins.
1105        let widths = compute_column_widths(Some(&grid), 468.0, &tbl, &[]);
1106
1107        assert_eq!(widths.len(), 2);
1108        let total: f64 = widths.iter().sum();
1109        assert!((total - 288.0).abs() < 1.0, "got {total}");
1110    }
1111
1112    #[test]
1113    fn historical_table_grid_never_changes_active_column_widths() {
1114        let table = CT_Tbl::new();
1115        let grid = CT_TblGrid {
1116            columns: vec![
1117                CT_TblGridCol { width: Twips(1440) },
1118                CT_TblGridCol { width: Twips(2880) },
1119            ],
1120            grid_change_xml: Some(
1121                br#"<w:tblGridChange w:id="4"><w:tblGrid><w:gridCol w:w="9000"/><w:gridCol w:w="9000"/></w:tblGrid></w:tblGridChange>"#
1122                    .to_vec(),
1123            ),
1124            ..CT_TblGrid::default()
1125        };
1126
1127        assert_eq!(
1128            compute_column_widths(Some(&grid), 468.0, &table, &[]),
1129            vec![72.0, 144.0]
1130        );
1131    }
1132
1133    #[test]
1134    fn overflowing_grid_is_scaled_down_to_fit() {
1135        let tbl = CT_Tbl::new();
1136        let grid = CT_TblGrid {
1137            columns: vec![
1138                CT_TblGridCol { width: Twips(7200) }, // 5 inches = 360pt
1139                CT_TblGridCol { width: Twips(7200) },
1140            ],
1141            ..Default::default()
1142        };
1143
1144        // 720pt total will not fit a 468pt column, so scale it down.
1145        let widths = compute_column_widths(Some(&grid), 468.0, &tbl, &[]);
1146
1147        let total: f64 = widths.iter().sum();
1148        assert!((total - 468.0).abs() < 1.0, "got {total}");
1149        // Proportions are preserved.
1150        assert!((widths[0] - widths[1]).abs() < 0.01);
1151    }
1152
1153    #[test]
1154    fn column_widths_no_grid() {
1155        let tbl = CT_Tbl::new();
1156        let widths = compute_column_widths(None, 468.0, &tbl, &[]);
1157        assert_eq!(widths.len(), 1);
1158        assert!((widths[0] - 468.0).abs() < 0.01);
1159    }
1160
1161    #[test]
1162    fn column_widths_zero_grid() {
1163        let tbl = CT_Tbl::new();
1164        let grid = CT_TblGrid {
1165            columns: vec![
1166                CT_TblGridCol { width: Twips(0) },
1167                CT_TblGridCol { width: Twips(0) },
1168                CT_TblGridCol { width: Twips(0) },
1169            ],
1170            ..Default::default()
1171        };
1172        let widths = compute_column_widths(Some(&grid), 468.0, &tbl, &[]);
1173        assert_eq!(widths.len(), 3);
1174        for w in &widths {
1175            assert!((w - 156.0).abs() < 0.01);
1176        }
1177    }
1178
1179    #[test]
1180    fn column_widths_inferred_from_rows() {
1181        use rdocx_oxml::table::{CT_Row, CT_Tc};
1182        let mut tbl = CT_Tbl::new();
1183        let mut row = CT_Row::new();
1184        row.cells.push(CT_Tc::new());
1185        row.cells.push(CT_Tc::new());
1186        row.cells.push(CT_Tc::new());
1187        tbl.rows.push(row);
1188        let widths = compute_column_widths(None, 300.0, &tbl, &[]);
1189        assert_eq!(widths.len(), 3);
1190        for w in &widths {
1191            assert!((w - 100.0).abs() < 0.01);
1192        }
1193    }
1194
1195    #[test]
1196    fn nested_tables_remain_recursive_cell_blocks() {
1197        use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
1198
1199        // Build an outer table with one cell containing a nested table
1200        let mut outer = CT_Tbl::new();
1201        outer.grid = Some(CT_TblGrid {
1202            columns: vec![CT_TblGridCol { width: Twips(4680) }], // 3.25"
1203            ..Default::default()
1204        });
1205
1206        let mut outer_row = CT_Row::new();
1207        let mut outer_cell = CT_Tc::new();
1208        outer_cell.paragraphs_mut()[0].add_run("Before nested");
1209
1210        // Nested table with 2 columns
1211        let mut nested = CT_Tbl::new();
1212        nested.grid = Some(CT_TblGrid {
1213            columns: vec![
1214                CT_TblGridCol { width: Twips(2000) },
1215                CT_TblGridCol { width: Twips(2000) },
1216            ],
1217            ..Default::default()
1218        });
1219        let mut nr = CT_Row::new();
1220        let mut nc1 = CT_Tc::new();
1221        nc1.paragraphs_mut()[0].add_run("N1");
1222        let mut nc2 = CT_Tc::new();
1223        nc2.paragraphs_mut()[0].add_run("N2");
1224        nr.cells.push(nc1);
1225        nr.cells.push(nc2);
1226        nested.rows.push(nr);
1227
1228        outer_cell.content.push(CellContent::Table(nested));
1229        outer_row.cells.push(outer_cell);
1230        outer.rows.push(outer_row);
1231
1232        // Layout with default styles
1233        let styles = rdocx_oxml::styles::CT_Styles::default();
1234        let input = crate::input::LayoutInput {
1235            revision_view: crate::input::RevisionView::Accepted,
1236            automatic_hyphenation: false,
1237            math_properties: None,
1238            document: rdocx_oxml::document::CT_Document {
1239                body: rdocx_oxml::document::CT_Body {
1240                    content: Vec::new(),
1241                    sect_pr: None,
1242                },
1243                extra_namespaces: Vec::new(),
1244                background_xml: None,
1245                background_extra_xml: Vec::new(),
1246            },
1247            styles: styles.clone(),
1248            numbering: None,
1249            headers: std::collections::HashMap::new(),
1250            footers: std::collections::HashMap::new(),
1251            images: std::collections::HashMap::new(),
1252            charts: std::collections::HashMap::new(),
1253            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
1254            chart_color_map: oxml_drawing::color::ColorMap::default(),
1255            hyperlink_urls: std::collections::HashMap::new(),
1256            footnotes: None,
1257            endnotes: None,
1258            core_properties: None,
1259            theme: None,
1260            fonts: Vec::new(),
1261        };
1262
1263        let mut fm = FontManager::new();
1264        let mut num_state = crate::style_resolver::NumberingState::new();
1265        let mut diagnostics = Vec::new();
1266        let media = MediaRegistry::new(&input.images);
1267
1268        let result = layout_table(
1269            &outer,
1270            234.0,
1271            &styles,
1272            &input,
1273            &media,
1274            &mut fm,
1275            &mut num_state,
1276            &mut diagnostics,
1277        );
1278        assert!(result.is_ok());
1279        let block = result.unwrap();
1280
1281        // Outer table should have 1 row, 1 cell
1282        assert_eq!(block.rows.len(), 1);
1283        assert_eq!(block.rows[0].cells.len(), 1);
1284
1285        // The nested table remains a distinct block at its source-order slot.
1286        let cell = &block.rows[0].cells[0];
1287        assert_eq!(cell.blocks.len(), 2);
1288        assert!(matches!(cell.blocks[0], CellBlock::Paragraph(_)));
1289        assert!(matches!(cell.blocks[1], CellBlock::Table(_)));
1290
1291        // Table width should match available width
1292        assert!((block.table_width - 234.0).abs() < 1.0);
1293    }
1294
1295    #[test]
1296    fn vertical_merges_and_row_height_rules_share_the_exact_grid_span() {
1297        let mut table = CT_Tbl::new();
1298        table.grid = Some(CT_TblGrid {
1299            columns: vec![
1300                CT_TblGridCol { width: Twips(600) },
1301                CT_TblGridCol { width: Twips(600) },
1302            ],
1303            ..Default::default()
1304        });
1305
1306        let mut exact_row = CT_Row::new();
1307        exact_row.properties = Some(CT_TrPr {
1308            height: Some(Twips(200)),
1309            height_rule: Some("exact".to_owned()),
1310            ..Default::default()
1311        });
1312        let mut restart = CT_Tc::new();
1313        restart.properties = Some(CT_TcPr {
1314            grid_span: Some(2),
1315            v_merge: Some(VMerge::Restart),
1316            ..Default::default()
1317        });
1318        restart.paragraphs_mut()[0].add_run(
1319            "merged content wraps across enough words to require both rows and grow only a minimum row",
1320        );
1321        exact_row.cells.push(restart);
1322
1323        let mut minimum_row = CT_Row::new();
1324        minimum_row.properties = Some(CT_TrPr {
1325            height: Some(Twips(200)),
1326            height_rule: Some("atLeast".to_owned()),
1327            ..Default::default()
1328        });
1329        let mut continuation = CT_Tc::new();
1330        continuation.properties = Some(CT_TcPr {
1331            grid_span: Some(2),
1332            v_merge: Some(VMerge::Continue),
1333            ..Default::default()
1334        });
1335        minimum_row.cells.push(continuation);
1336        table.rows = vec![exact_row, minimum_row];
1337
1338        let block = layout_with_defaults(&table, 60.0);
1339        assert_eq!(block.rows[0].height, 10.0, "exact row must stay pinned");
1340        assert!(block.rows[1].height >= 10.0);
1341        let restart = &block.rows[0].cells[0];
1342        assert_eq!(restart.grid_span, 2);
1343        assert!(restart.merge_with_below);
1344        assert_eq!(
1345            restart.merged_height,
1346            block.rows[0].height + block.rows[1].height
1347        );
1348        assert!(
1349            restart.is_last_row,
1350            "merge ends on the table's outer bottom"
1351        );
1352        assert!(block.rows[1].cells[0].is_vmerge_continue);
1353
1354        let mut minimum_merge = CT_Tbl::new();
1355        minimum_merge.grid = Some(CT_TblGrid {
1356            columns: vec![CT_TblGridCol { width: Twips(600) }],
1357            ..Default::default()
1358        });
1359        let mut restart_row = CT_Row::new();
1360        let mut restart = CT_Tc::new();
1361        restart.properties = Some(CT_TcPr {
1362            v_merge: Some(VMerge::Restart),
1363            ..Default::default()
1364        });
1365        restart.paragraphs_mut()[0]
1366            .add_run("merged content grows the final eligible row in this span");
1367        restart_row.cells.push(restart);
1368        let mut final_row = CT_Row::new();
1369        final_row.properties = Some(CT_TrPr {
1370            height: Some(Twips(200)),
1371            height_rule: Some("atLeast".to_owned()),
1372            ..Default::default()
1373        });
1374        let mut continuation = CT_Tc::new();
1375        continuation.properties = Some(CT_TcPr {
1376            v_merge: Some(VMerge::Continue),
1377            ..Default::default()
1378        });
1379        final_row.cells.push(continuation);
1380        minimum_merge.rows = vec![restart_row, final_row];
1381
1382        let minimum_block = layout_with_defaults(&minimum_merge, 30.0);
1383        assert_eq!(minimum_block.rows[0].height, 0.0);
1384        assert!(
1385            minimum_block.rows[1].height > 10.0,
1386            "restart content grows the final non-exact row"
1387        );
1388    }
1389
1390    #[test]
1391    fn table_style_cascade_resolves_borders_and_paragraph_spacing() {
1392        let styles = CT_Styles::from_xml(
1393            format!(
1394                r#"<w:styles xmlns:w="{}"><w:style w:type="table" w:styleId="Base"><w:pPr><w:spacing w:after="80"/></w:pPr><w:tblPr><w:tblBorders><w:left w:val="single" w:sz="8" w:color="AA0000"/></w:tblBorders></w:tblPr></w:style><w:style w:type="table" w:styleId="Dense"><w:basedOn w:val="Base"/><w:pPr><w:spacing w:after="40"/></w:pPr><w:tblStylePr w:type="firstRow"><w:pPr><w:spacing w:after="0"/></w:pPr><w:tcPr><w:tcBorders><w:top w:val="double" w:sz="12" w:color="0000AA"/></w:tcBorders><w:shd w:val="clear" w:fill="DDEEFF"/></w:tcPr></w:tblStylePr><w:tblStylePr w:type="firstCol"><w:tcPr><w:shd w:val="clear" w:fill="CCFFCC"/></w:tcPr></w:tblStylePr></w:style></w:styles>"#,
1395                rdocx_oxml::namespace::W_NS
1396            )
1397            .as_bytes(),
1398        )
1399        .unwrap();
1400        let mut table = CT_Tbl::new();
1401        table.properties = Some(CT_TblPr {
1402            style_id: Some("Dense".to_owned()),
1403            look: Some(CT_TblLook {
1404                first_row: Some(false),
1405                first_column: Some(false),
1406                no_h_band: Some(true),
1407                no_v_band: Some(true),
1408                ..Default::default()
1409            }),
1410            ..Default::default()
1411        });
1412        table.grid = Some(CT_TblGrid {
1413            columns: vec![CT_TblGridCol { width: Twips(1200) }],
1414            ..Default::default()
1415        });
1416        for (index, text) in ["header", "body"].into_iter().enumerate() {
1417            let mut row = CT_Row::new();
1418            let mut cell = CT_Tc::new();
1419            if index == 0 {
1420                row.properties = Some(CT_TrPr {
1421                    cnf_style: Some("100000000000".to_owned()),
1422                    ..Default::default()
1423                });
1424            } else {
1425                cell.properties = Some(CT_TcPr {
1426                    cnf_style: Some("001000000000".to_owned()),
1427                    ..Default::default()
1428                });
1429            }
1430            cell.paragraphs_mut()[0].add_run(text);
1431            row.cells.push(cell);
1432            table.rows.push(row);
1433        }
1434
1435        let block = layout_with_styles(&table, 60.0, &styles);
1436        let CellBlock::Paragraph(header) = &block.rows[0].cells[0].blocks[0] else {
1437            panic!("header paragraph");
1438        };
1439        let CellBlock::Paragraph(body) = &block.rows[1].cells[0].blocks[0] else {
1440            panic!("body paragraph");
1441        };
1442        assert_eq!(header.space_after, 0.0);
1443        assert_eq!(body.space_after, 2.0);
1444        assert_eq!(
1445            block.rows[0].cells[0].shading,
1446            Some(Color::from_hex("DDEEFF"))
1447        );
1448        assert_eq!(
1449            block.rows[1].cells[0].shading,
1450            Some(Color::from_hex("CCFFCC"))
1451        );
1452        let header_borders = block.rows[0].cells[0].borders.as_ref().unwrap();
1453        assert_eq!(header_borders.top.as_ref().unwrap().sz, Some(12));
1454        assert_eq!(
1455            header_borders.top.as_ref().unwrap().color.as_deref(),
1456            Some("0000AA")
1457        );
1458        assert_eq!(
1459            header_borders.left.as_ref().unwrap().color.as_deref(),
1460            Some("AA0000")
1461        );
1462    }
1463}