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];
