1use 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#[derive(Debug, Clone)]
15pub struct TableBlock {
16 pub col_widths: Vec<f64>,
18 pub rows: Vec<TableRow>,
20 pub header_row_indices: Vec<usize>,
22 pub table_width: f64,
24 pub table_indent: f64,
26 pub borders: Option<CT_TblBorders>,
28}
29
30impl TableBlock {
31 pub fn content_height(&self) -> f64 {
33 self.rows.iter().map(|r| r.height).sum()
34 }
35
36 pub fn total_height(&self) -> f64 {
38 self.content_height()
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct TableRow {
45 pub cells: Vec<TableCell>,
47 pub height: f64,
49 pub is_header: bool,
51}
52
53#[derive(Debug, Clone)]
55pub struct TableCell {
56 pub paragraphs: Vec<ParagraphBlock>,
58 pub width: f64,
60 pub height: f64,
62 pub grid_span: u32,
64 pub is_vmerge_continue: bool,
66 pub col_index: usize,
68 pub borders: Option<CT_TblBorders>,
70 pub shading: Option<Color>,
72 pub margin_left: f64,
74 pub margin_top: f64,
76 pub is_first_row: bool,
78 pub is_last_row: bool,
80 pub v_align: Option<ST_VerticalJc>,
82}
83
84pub 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 let col_widths = compute_column_widths(tbl.grid.as_ref(), available_width, tbl);
153 let table_width: f64 = col_widths.iter().sum();
154
155 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 } else {
164 0.0
165 }
166 })
167 .unwrap_or(0.0);
168
169 let table_borders = tbl.properties.as_ref().and_then(|p| p.borders.clone());
171
172 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); 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 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 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 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 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 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
317fn 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 let n = g.columns.len();
335 vec![available_width / n as f64; n]
336 } else if total > available_width + 1.0 {
337 let scale = available_width / total;
339 widths.iter().map(|w| w * scale).collect()
340 } else {
341 widths
342 }
343 }
344 _ => {
345 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
364fn 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 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 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) }, CT_TblGridCol { width: Twips(2880) },
450 ],
451 };
452
453 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) }, CT_TblGridCol { width: Twips(7200) },
469 ],
470 };
471
472 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 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 let mut outer = CT_Tbl::new();
528 outer.grid = Some(CT_TblGrid {
529 columns: vec![CT_TblGridCol { width: Twips(4680) }], });
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 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 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 assert_eq!(block.rows.len(), 1);
605 assert_eq!(block.rows[0].cells.len(), 1);
606
607 let cell = &block.rows[0].cells[0];
609 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 assert!((block.table_width - 234.0).abs() < 1.0);
618 }
619}