Skip to main content

extend_docx/
table_grid.rs

1use crate::model::{
2    DocModel, DocNode, ParagraphChildNode, ParagraphNode, TableCellContentNode, TableLayout,
3    TableNode,
4};
5
6struct PhysicalGrid {
7    widths: Vec<i64>,
8    spans: Vec<i64>,
9}
10
11fn physical_grid(table: &TableNode) -> Result<Option<PhysicalGrid>, String> {
12    let Some(style) = table.style.as_ref() else {
13        return Ok(None);
14    };
15    let Some(collapsed) = style.collapsed_grid_columns.as_ref() else {
16        return Ok(None);
17    };
18    if collapsed.is_empty() {
19        return Ok(None);
20    }
21    if style.layout != Some(TableLayout::Fixed) || style.cell_spacing_twips.unwrap_or(0) != 0 {
22        return Err("Collapsed columns require fixed layout without cell spacing".into());
23    }
24    let widths = style
25        .column_widths_twips
26        .as_ref()
27        .ok_or("Collapsed columns require a logical grid")?;
28    let mut marked = vec![false; widths.len()];
29    for &column in collapsed {
30        if column >= widths.len() || marked[column] || widths[column] != 0 {
31            return Err("Collapsed column indices must be unique zero-width grid entries".into());
32        }
33        marked[column] = true;
34    }
35    let mut boundaries = Vec::with_capacity(widths.len() + 1);
36    let mut physical_widths = Vec::with_capacity(widths.len() - collapsed.len());
37    boundaries.push(0usize);
38    for (column, &width) in widths.iter().enumerate() {
39        if !marked[column] {
40            if width <= 0 {
41                return Err("Unmarked physical columns must have positive widths".into());
42            }
43            physical_widths.push(width);
44        }
45        boundaries.push(physical_widths.len());
46    }
47    if physical_widths.is_empty() {
48        return Err("Collapsed grid has no physical columns".into());
49    }
50    let mut spans = Vec::new();
51    for row in &table.rows {
52        if row.style.as_ref().is_some_and(|value| {
53            value.grid_before.unwrap_or(0) != 0 || value.grid_after.unwrap_or(0) != 0
54        }) {
55            return Err("Collapsed columns do not support omitted row columns".into());
56        }
57        let mut cursor = 0usize;
58        for cell in &row.cells {
59            let span = cell
60                .style
61                .as_ref()
62                .and_then(|value| value.grid_span)
63                .unwrap_or(1);
64            if span < 1 {
65                return Err("Cell spans must be positive".into());
66            }
67            let span = usize::try_from(span).map_err(|_| "Cell span exceeds the logical grid")?;
68            let end = cursor
69                .checked_add(span)
70                .ok_or("Cell span exceeds the logical grid")?;
71            if end >= boundaries.len() {
72                return Err("Cell span exceeds the logical grid".into());
73            }
74            let physical_span = boundaries[end] - boundaries[cursor];
75            if physical_span == 0 {
76                return Err("Cell occupies only collapsed columns".into());
77            }
78            spans.push(physical_span as i64);
79            cursor = end;
80        }
81    }
82    Ok(Some(PhysicalGrid {
83        widths: physical_widths,
84        spans,
85    }))
86}
87
88/// Materialize a validated physical grid before committing a structural edit.
89pub fn materialize_collapsed_table_grid(table: &TableNode) -> Result<Option<TableNode>, String> {
90    let Some(physical) = physical_grid(table)? else {
91        return Ok(None);
92    };
93    let mut result = table.clone();
94    let result_style = result.style.as_mut().expect("Style checked before cloning");
95    result_style.column_widths_twips = Some(physical.widths);
96    result_style.collapsed_grid_columns = None;
97    let mut spans = physical.spans.into_iter();
98    for row in &mut result.rows {
99        for cell in &mut row.cells {
100            let span = spans.next().expect("Every cell span was validated");
101            if let Some(style) = cell.style.as_mut() {
102                style.grid_span = (span > 1).then_some(span);
103            } else if span > 1 {
104                return Err("A spanning cell is missing its style".into());
105            }
106        }
107    }
108    result.source_xml = None;
109    result.source_text_patches = None;
110    Ok(Some(result))
111}
112
113fn validate_table(table: &TableNode) -> Result<(), String> {
114    if physical_grid(table)?.is_some() {
115        let style = table.style.as_ref().expect("Validated table has a style");
116        if style.style_id != style.source_style_id {
117            return Err(
118                "Collapsed table grid must be materialized before changing table style".into(),
119            );
120        }
121        if style.borders.as_ref().is_some_and(|borders| {
122            [
123                &borders.top,
124                &borders.right,
125                &borders.bottom,
126                &borders.left,
127                &borders.inside_h,
128                &borders.inside_v,
129                &borders.tl2br,
130                &borders.tr2bl,
131            ]
132            .into_iter()
133            .flatten()
134            .any(|border| border.border_type != "none" && border.border_type != "nil")
135        }) {
136            return Err("Collapsed table grid requires zero border spacing before export".into());
137        }
138    }
139    for row in &table.rows {
140        for cell in &row.cells {
141            for node in &cell.nodes {
142                match node {
143                    TableCellContentNode::Table(table) => validate_table(table)?,
144                    TableCellContentNode::Paragraph(paragraph) => validate_paragraph(paragraph)?,
145                }
146            }
147        }
148    }
149    if let Some(patches) = &table.source_text_patches {
150        for patch in patches {
151            validate_paragraph(&patch.paragraph)?;
152        }
153    }
154    Ok(())
155}
156
157fn validate_paragraph(paragraph: &ParagraphNode) -> Result<(), String> {
158    for child in &paragraph.children {
159        if let ParagraphChildNode::Image(image) = child {
160            if let Some(nodes) = &image.content_nodes {
161                validate_nodes(nodes)?;
162            }
163        }
164    }
165    Ok(())
166}
167
168fn validate_nodes(nodes: &[DocNode]) -> Result<(), String> {
169    for node in nodes {
170        match node {
171            DocNode::Table(table) => validate_table(table)?,
172            DocNode::Paragraph(paragraph) => validate_paragraph(paragraph)?,
173        }
174    }
175    Ok(())
176}
177
178/// Reject unsupported marked grids before a fallible serialization entry point.
179pub fn validate_collapsed_table_grids(model: &DocModel) -> Result<(), String> {
180    validate_nodes(&model.nodes)?;
181    for header in &model.metadata.header_sections {
182        validate_nodes(&header.nodes)?;
183    }
184    for footer in &model.metadata.footer_sections {
185        validate_nodes(&footer.nodes)?;
186    }
187    if let Some(sections) = &model.metadata.sections {
188        for section in sections {
189            for header in &section.header_sections {
190                validate_nodes(&header.nodes)?;
191            }
192            for footer in &section.footer_sections {
193                validate_nodes(&footer.nodes)?;
194            }
195        }
196    }
197    for notes in [&model.metadata.footnotes, &model.metadata.endnotes]
198        .into_iter()
199        .flatten()
200    {
201        for note in notes {
202            if let Some(nodes) = &note.nodes {
203                validate_nodes(nodes)?;
204            }
205        }
206    }
207    if let Some(comments) = &model.metadata.comments {
208        for comment in comments {
209            if let Some(nodes) = &comment.nodes {
210                validate_nodes(nodes)?;
211            }
212        }
213    }
214    Ok(())
215}