collective

Browse / github.com/storytold/wordcraft / patch

#3406: .doc tables keep their cell widths, and rows with their own cells

#3406: .doc tables keep their cell widths, and rows with their own cells …ll edges share one grid A table in a Word 97-2003 file was read with a column grid taken from its first row's cell edges (sprmTDefTable), but with no width on any cell. A table that asks for no width anywhere is laid out by its content alone, so its columns shrank to their text and the table stopped short of the margin. A row with its own, coarser edges (two wide cells under a row of four narrow ones, as LibreOffice writes a merged header cell) also put its cells in the first columns of the grid. Give each cell the width between its row's edges (a merged cell, the sum of the cells it sp

Status
open
Score
0.00
Adopters
0 other operators
Author
PasqualeIannace (imported from pull request #4434, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+283 −6 in 2 files
Review
pending
Shared
2026-10-28
Id
e2d5749b4f1c2552b7ba6a11

How it went

No other operator has reported using it yet.

Diff

raw · crates/docbin/src/table.rs crates/docbin/src/tests.rs

diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs--- a/crates/docbin/src/table.rs+++ b/crates/docbin/src/table.rs@@ -258,10 +258,13 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {     let mut cells: Vec<Cell> = Vec::new();     let mut cell: Blocks = Vec::new();     let mut grid: Vec<f32> = Vec::new();+    // Each row's own cell edges, parallel to `rows` (a row without a `sprmTDefTable` has none).+    let mut row_edges: Vec<Vec<f32>> = Vec::new();     let mut props = TableProps::default();     let mut open = false;     let mut depth_seen = 0u32; +    /// Close the open cell: its blocks (an empty paragraph when it has none) go onto the row's cells.     fn flush_cell(cells: &mut Vec<Cell>, cell: &mut Blocks) {         let mut blocks = std::mem::take(cell);         if blocks.is_empty() {@@ -269,6 +272,8 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {         }         cells.push(Cell { props: CellProps { span: 1, ..Default::default() }, blocks, controls: Default::default() });     }+    /// Turn a row's collected cells into a [`Row`]: the per-cell sprm overrides, a width from the+    /// row's cell edges for cells that have none, then the horizontal merges.     fn build_row(mut cells: Vec<Cell>, info: &RowInfo) -> Row {         // Per-cell overrides first (sprm cell indices count pre-merge cells), then merges.         for (i, c) in cells.iter_mut().enumerate() {@@ -282,6 +287,12 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {             if let Some((_, pct)) = info.width_pcts.iter().rfind(|(k, _)| *k == i) {                 c.props.width_pct = Some(*pct);             }+            // A cell with no width of its own is as wide as the row's `sprmTDefTable` edges make it,+            // the width Word's own docx export writes on every cell. Without one, a table that asks+            // for no width anywhere is laid out by its content alone and shrinks.+            if c.props.width.is_none() && c.props.width_pct.is_none() {+                c.props.width = edge_width(&info.edges, usize::from(i));+            }             if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) {                 c.props.shading = *s;             }@@ -289,13 +300,24 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {                 c.props.valign = Some(*v);             }         }-        for &(a, b) in info.merges.iter().take(MAX_COLS) {+        // Rightmost merge first: a merge drains only the cells after its first one, so the original+        // indices of the merges to its left stay valid.+        let mut merges: Vec<(u8, u8)> = info.merges.iter().take(MAX_COLS).copied().collect();+        merges.sort_by_key(|m| std::cmp::Reverse(m.0));+        for &(a, b) in &merges {             let a = a as usize;             let b = (b as usize).min(cells.len());             if a < b && b <= cells.len() && b - a > 1 {+                // A merged cell is as wide as the cells it spans together.+                // When one of them has only a percentage width, the row's outer edges give the sum.+                let merged_width =+                    cells.get(a..b).and_then(|s| s.iter().map(|c| c.props.width).sum::<Option<f32>>()).or_else(|| edge_span(&info.edges, a, b));                 let removed: Vec<Cell> = cells.drain(a + 1..b).collect();                 if let Some(first) = cells.get_mut(a) {                     first.props.span = ((b - a) as u32).min(63);+                    if merged_width.is_some() {+                        first.props.width = merged_width;+                    }                     for c in removed {                         first.blocks.extend(c.blocks.into_iter().filter(|x| x.as_para().is_none_or(|p| !p.is_empty())));                     }@@ -317,7 +339,18 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {             controls: Default::default(),         }     }-    fn flush_table(out: &mut Blocks, rows: &mut Vec<Row>, cells: &mut Vec<Cell>, cell: &mut Blocks, grid: &[f32], props: &TableProps, nested: bool) {+    /// Close the open table: the pending cell and row first, then the rows go out as a [`Table`]+    /// block (or as plain paragraphs when the table is nested deeper than the model goes).+    fn flush_table(+        out: &mut Blocks,+        rows: &mut Vec<Row>,+        row_edges: &mut Vec<Vec<f32>>,+        cells: &mut Vec<Cell>,+        cell: &mut Blocks,+        grid: &[f32],+        props: &TableProps,+        nested: bool,+    ) {         if !cells.is_empty() || !cell.is_empty() {             flush_cell(cells, cell);             // A stray trailing row (no TTP of its own): size its cells from the grid.@@ -328,6 +361,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {                 }             }             rows.push(stray);+            row_edges.push(Vec::new());         }         if rows.is_empty() {             return;@@ -340,14 +374,16 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {                 }             }         } else {+            let aligned = align_rows(rows, row_edges);             out.push(Arc::new(Block::Table(Table {                 props: props.clone(),-                grid: grid.to_vec(),+                grid: aligned.unwrap_or_else(|| grid.to_vec()),                 rows: std::mem::take(rows),                 controls: Default::default(),             })));         }         rows.clear();+        row_edges.clear();     }      for p in paras {@@ -355,7 +391,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {         let is_table = info.in_table || info.ttp || info.cell_mark;         if !is_table {             if open {-                flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);+                flush_table(&mut out, &mut rows, &mut row_edges, &mut cells, &mut cell, &grid, &props, depth_seen > 1);                 grid.clear();                 // Table props are per-table (first row carrying the sprm wins within the                 // table): reset the accumulator so one table's borders, align, indent and@@ -409,13 +445,14 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {             if info.ttp {                 let row = build_row(std::mem::take(&mut cells), &info);                 rows.push(row);+                row_edges.push(info.edges.clone());             }         } else {             cell.push(para_block(p.para));         }     }     if open {-        flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);+        flush_table(&mut out, &mut rows, &mut row_edges, &mut cells, &mut cell, &grid, &props, depth_seen > 1);     }     out }@@ -439,6 +476,81 @@ fn def_table_edges(operand: &[u8]) -> Vec<f32> {     edges } +/// The width between `sprmTDefTable` edges `a` and `b`, when they give a usable one.+fn edge_span(edges: &[f32], a: usize, b: usize) -> Option<f32> {+    let w = edges.get(b)? - edges.get(a)?;+    (w.is_finite() && w > 0.0).then_some(w)+}++/// The width of cell `i` between its `sprmTDefTable` edges, when they give a usable one.+fn edge_width(edges: &[f32], i: usize) -> Option<f32> {+    edge_span(edges, i, i + 1)+}++/// How far (points) two rows' cell edges may differ and still be the same column boundary.+const EDGE_TOL: f32 = 0.5;++/// A row of a .doc table carries its own cell edges, while the model's rows share one grid. The+/// grid is every row's edges together (two wide cells under a row of four narrow ones, or rows+/// whose edges only partly coincide), and each cell spans the grid columns its own edges cover.+/// `None` when the rows agree (the first row's grid then stands), or when a row has no usable edges+/// or its cells can't be mapped onto the grid: the grid and spans then stay as they were.+fn align_rows(rows: &mut [Row], row_edges: &[Vec<f32>]) -> Option<Vec<f32>> {+    if rows.is_empty() || rows.len() != row_edges.len() {+        return None;+    }+    if row_edges.iter().any(|e| e.len() < 2 || e.iter().any(|x| !x.is_finite()) || e.windows(2).any(|w| w[0] >= w[1])) {+        return None;+    }+    // The grid's boundaries: every row's edges, those within `EDGE_TOL` of each other as one.+    let mut all: Vec<f32> = row_edges.iter().flatten().copied().collect();+    all.sort_by(f32::total_cmp);+    let mut bounds: Vec<f32> = Vec::new();+    for e in all {+        if bounds.last().is_none_or(|&l| e - l > EDGE_TOL) {+            bounds.push(e);+        }+    }+    if bounds.len() > MAX_COLS + 1 {+        return None;+    }+    let col_of = |e: f32| bounds.iter().position(|f| (f - e).abs() <= EDGE_TOL);+    let mut cols_of_row: Vec<Vec<usize>> = Vec::with_capacity(row_edges.len());+    for edges in row_edges {+        let cols = edges.iter().map(|&e| col_of(e)).collect::<Option<Vec<usize>>>()?;+        if !cols.windows(2).all(|w| w[0] < w[1]) {+            return None;+        }+        cols_of_row.push(cols);+    }+    if cols_of_row.iter().all(|c| c.len() == bounds.len()) {+        return None;+    }+    // Work out every row's spans before changing any, so a row that can't be mapped leaves all as they were.+    let mut plans: Vec<(Vec<u32>, u32, u32)> = Vec::with_capacity(rows.len());+    for (row, cols) in rows.iter().zip(&cols_of_row) {+        let mut at = 0usize;+        let mut spans = Vec::with_capacity(row.cells.len());+        for c in &row.cells {+            let span = c.props.span.max(1) as usize;+            let (Some(&l), Some(&r)) = (cols.get(at), cols.get(at + span)) else { return None };+            spans.push(((r - l) as u32).min(63));+            at += span;+        }+        let first = cols.first().copied().unwrap_or(0);+        let last = cols.get(at).copied().unwrap_or(first);+        plans.push((spans, first as u32, (bounds.len() - 1).saturating_sub(last) as u32));+    }+    for (row, (spans, before, after)) in rows.iter_mut().zip(plans) {+        for (c, s) in row.cells.iter_mut().zip(spans) {+            c.props.span = s;+        }+        row.props.grid_before = before;+        row.props.grid_after = after;+    }+    Some(bounds.windows(2).map(|w| w[1] - w[0]).collect())+}+ /// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top, /// left, bottom, right, inside-horizontal, inside-vertical. fn table_borders(operand: &[u8]) -> Option<Borders> {diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs--- a/crates/docbin/src/tests.rs+++ b/crates/docbin/src/tests.rs@@ -985,6 +985,7 @@ fn t_def_table(edges: &[i16]) -> Vec<u8> {     v } +/// Cell and row-end marks assemble into a 2x2 table whose grid and widths come from the row's edges. #[test] fn table_assembled_from_marks() {     // A 2×2 table: "a\x07 b\x07|ttp  c\x07 d\x07|ttp" then a normal paragraph.@@ -1026,12 +1027,176 @@ fn table_assembled_from_marks() {     assert_eq!(last.text, "tail"); } +/// A .doc row has its own cell edges: a cell takes the width between them, and a row of two wide+/// cells under a row of four narrow ones spans two grid columns per cell instead of landing in+/// the first two columns.+#[test]+fn rows_with_their_own_edges_share_one_grid() {+    // Row 1: four cells, 1000 twips (50 pt) each. Row 2: two cells, 2000 twips (100 pt) each.+    let wide = t_def_table(&[0, 1000, 2000, 3000, 4000]);+    let narrow = t_def_table(&[0, 2000, 4000]);+    let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}f\u{7}tail\r";+    let in_tbl = grpprl(&[(0x2416, &[0x01])]);+    let ttp = |def: Vec<u8>| -> Vec<u8> { [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), def].concat() };+    let spec = StructSpec {+        text,+        header_stories: vec![],+        sections: vec![(text.chars().count() as u32, Vec::new())],+        chpx_runs: vec![(0, Vec::new())],+        papx_entries: vec![+            (0, 0, in_tbl.clone()),+            (2, 0, in_tbl.clone()),+            (4, 0, in_tbl.clone()),+            (6, 0, ttp(wide)),+            (8, 0, in_tbl),+            (10, 0, ttp(narrow)),+            (12, 0, Vec::new()),+        ],+        plf_lst: Vec::new(),+        plf_lfo: Vec::new(),+        note_stories: vec![],+        endnote_stories: vec![],+        extra_pairs: Vec::new(),+        data_stream: Vec::new(),+    };+    let doc = read(&struct_doc(&spec)).expect("opens");+    let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");+    assert_eq!(tbl.grid, vec![50.0; 4]);+    assert_eq!(tbl.rows.len(), 2);+    let widths = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.width).collect::<Vec<_>>();+    let spans = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.span).collect::<Vec<_>>();+    assert_eq!(widths(0), vec![Some(50.0); 4]);+    assert_eq!(spans(0), vec![1; 4]);+    assert_eq!(widths(1), vec![Some(100.0); 2]);+    assert_eq!(spans(1), vec![2; 2]);+    assert_eq!((tbl.rows[1].props.grid_before, tbl.rows[1].props.grid_after), (0, 0));+}++/// A merged cell is as wide as the cells it spans. When one of them has only a percentage width the+/// sum of point widths is unavailable, so the width comes from the row's outer cell edges.+#[test]+fn merged_cell_with_a_percentage_neighbour_takes_the_edge_width() {+    let text = "a\u{7}b\u{7}tail\r";+    let in_tbl = grpprl(&[(0x2416, &[0x01])]);+    // Cells 0..2 merged; cell 1 has only a 50% width. Edges 0, 50 pt, 100 pt.+    let pct = [1u8, 2, 2, 0xC4, 0x09];+    let ttp = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x5624, &[0, 2])]), t_def_edges(&[0, 1000, 2000]), sprm_cb(0xD635, &pct)].concat();+    let spec = StructSpec {+        text,+        papx_entries: vec![(0, 0, in_tbl), (2, 0, ttp), (4, 0, Vec::new())],+        ..structured_spec(text, text.chars().count() as u32)+    };+    let doc = read(&struct_doc(&spec)).expect("opens");+    let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");+    let cells = &tbl.rows[0].cells;+    assert_eq!(cells.len(), 1, "the two cells are merged");+    assert_eq!(cells[0].props.span, 2);+    assert_eq!(cells[0].props.width, Some(100.0), "the merged cell is not left at its first cell's 50 pt");+}++/// A row's merges name the cells before any of them is merged. A merge drains only the cells after+/// its first one, so applying them rightmost first keeps the earlier ones' indices valid: with+/// merges (0, 2) and (3, 5), the second still joins the fourth and fifth cell.+#[test]+fn merges_in_one_row_use_the_original_cell_indices() {+    let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}f\u{7}tail\r";+    let in_tbl = grpprl(&[(0x2416, &[0x01])]);+    // Six cells of 50, 50, 50, 50, 100 and 25 pt; the fifth has only a 50% width.+    let pct = [4u8, 5, 2, 0xC4, 0x09];+    let ttp = [+        grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x5624, &[0, 2]), (0x5624, &[3, 5])]),+        t_def_edges(&[0, 1000, 2000, 3000, 4000, 6000, 6500]),+        sprm_cb(0xD635, &pct),+    ]+    .concat();+    let spec = StructSpec {+        text,+        papx_entries: vec![+            (0, 0, in_tbl.clone()),+            (2, 0, in_tbl.clone()),+            (4, 0, in_tbl.clone()),+            (6, 0, in_tbl.clone()),+            (8, 0, in_tbl),+            (10, 0, ttp),+            (12, 0, Vec::new()),+        ],+        ..structured_spec(text, text.chars().count() as u32)+    };+    let doc = read(&struct_doc(&spec)).expect("opens");+    let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");+    let cells = &tbl.rows[0].cells;+    assert_eq!(cells.iter().map(|c| c.props.span).collect::<Vec<_>>(), [2, 1, 2, 1]);+    assert_eq!(cells.iter().map(|c| c.props.width).collect::<Vec<_>>(), [Some(100.0), Some(50.0), Some(150.0), Some(25.0)]);+}++/// Rows whose edges only partly coincide (a boundary at 100 pt in one and not in the other) share+/// a grid of every row's boundaries, and each cell spans the columns between its own edges.+#[test]+fn rows_with_partly_matching_edges_share_a_union_grid() {+    let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}tail\r";+    let in_tbl = grpprl(&[(0x2416, &[0x01])]);+    let end_row = grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]);+    // Row 1: 50, 100 and 50 pt cells. Row 2: two 100 pt cells; its middle boundary isn't row 1's.+    let three = [end_row.clone(), t_def_edges(&[0, 1000, 3000, 4000])].concat();+    let two = [end_row, t_def_edges(&[0, 2000, 4000])].concat();+    let spec = StructSpec {+        text,+        papx_entries: vec![(0, 0, in_tbl.clone()), (2, 0, in_tbl.clone()), (4, 0, three), (6, 0, in_tbl), (8, 0, two), (10, 0, Vec::new())],+        ..structured_spec(text, text.chars().count() as u32)+    };+    let doc = read(&struct_doc(&spec)).expect("opens");+    let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");+    assert_eq!(tbl.grid, vec![50.0, 50.0, 50.0, 50.0]);+    let spans = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.span).collect::<Vec<_>>();+    let widths = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.width).collect::<Vec<_>>();+    assert_eq!(spans(0), [1, 2, 1]);+    assert_eq!(spans(1), [2, 2]);+    assert_eq!(widths(0), [Some(50.0), Some(100.0), Some(50.0)]);+    assert_eq!(widths(1), [Some(100.0), Some(100.0)]);+}++/// A row with no `sprmTDefTable` of its own can't be mapped onto a finer grid, so the grid and every+/// row's spans stay as they were rather than leaving that row in the first columns of a new grid.+#[test]+fn a_row_without_edges_keeps_the_previous_grid() {+    let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}f\u{7}g\u{7}h\u{7}tail\r";+    let in_tbl = grpprl(&[(0x2416, &[0x01])]);+    let end_row = grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]);+    let two = [end_row.clone(), t_def_edges(&[0, 2000, 4000])].concat();+    let four = [end_row.clone(), t_def_edges(&[0, 1000, 2000, 3000, 4000])].concat();+    let spec = StructSpec {+        text,+        papx_entries: vec![+            (0, 0, in_tbl.clone()),+            (2, 0, two),+            (4, 0, in_tbl.clone()),+            (6, 0, in_tbl.clone()),+            (8, 0, in_tbl.clone()),+            (10, 0, four),+            (12, 0, in_tbl),+            (14, 0, end_row), // no edges+            (16, 0, Vec::new()),+        ],+        ..structured_spec(text, text.chars().count() as u32)+    };+    let doc = read(&struct_doc(&spec)).expect("opens");+    let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");+    assert_eq!(tbl.rows.len(), 3);+    assert_eq!(tbl.grid, vec![100.0; 2], "the grid the first row defined");+    for row in &tbl.rows {+        assert!(row.cells.iter().all(|c| c.props.span == 1), "no row was remapped");+        assert_eq!((row.props.grid_before, row.props.grid_after), (0, 0));+    }+}++/// A horizontal merge, table borders, cell shading and a vertical-merge restart are all read from+/// the row properties. #[test] fn table_merges_borders_shading() {     // Two rows: the first "a\x07 b\x07 c\x07" with cells 0-1 horizontally merged; the second     // "d\x07 e\x07" with table borders, a shaded first cell and a vertical-merge restart.     let def3 = t_def_table(&[0, 1920, 3840, 5760]);-    let def2 = t_def_table(&[0, 2880, 5760]);+    let def2 = t_def_table(&[0, 3840, 5760]); // its boundary 3840 is one of row 1's, so the rows share a grid     let borders = {         // sprmTTableBorders80: cb = 0x18, then 6 × Brc80 (width 4/8pt, single, ico 1, space 0).         let brc = [4u8, 1, 1, 0];