extend_docx/
table_grid.rs1use 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
88pub 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 ¶graph.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
178pub 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 §ion.header_sections {
190 validate_nodes(&header.nodes)?;
191 }
192 for footer in §ion.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) = ¬e.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}