diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index d8e03395c..98c0a2d19 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1235,6 +1235,9 @@ pub struct CellProps { pub valign: Option, /// Cell margins override (top, left, bottom, right), points. pub margins: Option<[f32; 4]>, + /// Per-side overrides when `margins` is absent; omitted sides inherit table defaults. + #[serde(skip_serializing_if = "Option::is_none")] + pub partial_margins: Option<[Option; 4]>, /// Which way the cell's text runs. pub text_direction: TextDirection, /// The file states the horizontal direction (`lrTb`) outright; saving writes it back. @@ -1250,6 +1253,17 @@ pub struct CellProps { pub fmt_change: Option>>, } +impl CellProps { + /// Resolve top, left, bottom and right margins against the current table defaults. + /// A complete override takes precedence over per-side overrides. + pub fn resolved_margins(&self, defaults: [f32; 4]) -> [f32; 4] { + if let Some(margins) = self.margins { + return margins; + } + self.partial_margins.map_or(defaults, |sides| std::array::from_fn(|i| sides[i].unwrap_or(defaults[i]))) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index d4f059d7e..930f6afbe 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -430,7 +430,11 @@ pub fn borders(e: &El) -> Borders { /// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right]. pub fn margins(e: &El) -> [f32; 4] { - let get = |names: &[&str]| names.iter().find_map(|n| e.child(n)).and_then(|c| tw(c, "w:w")).unwrap_or(0.0).clamp(0.0, 1584.0); + margin_sides(e).map(|side| side.unwrap_or(0.0)) +} + +fn margin_sides(e: &El) -> [Option; 4] { + let get = |names: &[&str]| names.iter().find_map(|n| e.child(n)).and_then(|c| tw(c, "w:w")).map(|v| v.clamp(0.0, 1584.0)); [get(&["w:top"]), get(&["w:left", "w:start"]), get(&["w:bottom"]), get(&["w:right", "w:end"])] } @@ -565,7 +569,14 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) { "w:shd" => c.shading = pc.shd_fill(k), "w:noWrap" => c.no_wrap = on_off(k), "w:hideMark" => c.hide_mark = on_off(k), - "w:tcMar" => c.margins = Some(margins(k)), + "w:tcMar" => { + let sides = margin_sides(k); + if sides.iter().all(Option::is_some) { + c.margins = Some(sides.map(|side| side.unwrap_or(0.0))); + } else { + c.partial_margins = Some(sides); + } + } "w:textDirection" => { c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")); c.text_direction_set = k.attr("w:val") == Some("lrTb"); diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index d9b3a9237..72d1c801b 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -401,9 +401,15 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } pub fn margins(w: &mut W, tag: &str, m: &[f32; 4]) { + margin_sides(w, tag, &m.map(Some)); +} + +fn margin_sides(w: &mut W, tag: &str, m: &[Option; 4]) { w.open(tag, &[]); - for (name, v) in ["w:top", "w:left", "w:bottom", "w:right"].iter().zip(m.iter()) { - w.empty(name, &[("w:w", &twips(v.clamp(0.0, 1584.0))), ("w:type", "dxa")]); + for (name, value) in ["w:top", "w:left", "w:bottom", "w:right"].iter().zip(m.iter()) { + if let Some(v) = value { + w.empty(name, &[("w:w", &twips(v.clamp(0.0, 1584.0))), ("w:type", "dxa")]); + } } w.close(tag); } @@ -607,6 +613,8 @@ fn tcpr_inner(w: &mut W, c: &CellProps) { } if let Some(m) = &c.margins { margins(w, "w:tcMar", m); + } else if let Some(m) = &c.partial_margins { + margin_sides(w, "w:tcMar", m); } if c.text_direction.is_turned() || c.text_direction_set { w.val("w:textDirection", c.text_direction.ooxml()); diff --git a/crates/docx/tests/cell_margin_inheritance.rs b/crates/docx/tests/cell_margin_inheritance.rs new file mode 100644 index 000000000..8a726d81e --- /dev/null +++ b/crates/docx/tests/cell_margin_inheritance.rs @@ -0,0 +1,30 @@ +//! Original partial-cell-margin fixture (ECMA-376 §17.4.69). +use std::io::{Cursor, Read, Write}; + +fn fixture() -> Vec { + let document = r#"Point"#; + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file("word/document.xml", zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(document.as_bytes()).unwrap(); + zip.finish().unwrap().into_inner() +} + +#[test] +fn partial_margin_stays_partial() { + let doc = wordcraft_docx::read(&fixture()).unwrap(); + let table = doc.body[0].as_table().unwrap(); + assert_eq!(table.rows[0].cells[0].props.margins, None, "a left-only margin is not a complete override"); + assert_eq!(table.rows[0].cells[0].props.resolved_margins([6.0, 7.0, 8.0, 9.0]), [6.0, 0.0, 8.0, 9.0]); + let bytes = wordcraft_docx::write(&doc).unwrap(); + let reopened = wordcraft_docx::read(&bytes).unwrap(); + let cell = &reopened.body[0].as_table().unwrap().rows[0].cells[0]; + assert_eq!(cell.props.resolved_margins([6.0, 7.0, 8.0, 9.0]), [6.0, 0.0, 8.0, 9.0]); + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + let margins = xml.split("").nth(1).unwrap().split("").next().unwrap(); + assert!(margins.contains(") -> [f32; 4] { /// bottom margins. pub fn cell_margins(doc: &wordcraft_doc::Document, t: &Table, row: usize, cell: usize) -> [f32; 4] { let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); - t.rows.get(row).and_then(|r| r.cells.get(cell)).and_then(|c| c.props.margins).unwrap_or_else(|| default_margins(t, style.as_ref())) + let defaults = default_margins(t, style.as_ref()); + t.rows.get(row).and_then(|r| r.cells.get(cell)).map_or(defaults, |cell| cell.props.resolved_margins(defaults)) } /// The most paragraphs [`measure_table_columns`] lays out: a huge table is measured by its first @@ -183,7 +184,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f let mut g = 0usize; for cell in &row.cells { let span = cell.span(); - let margins = cell.props.margins.unwrap_or(margins_def); + let margins = cell.props.resolved_margins(margins_def); let side = margins[1].max(0.0) + margins[3].max(0.0); let text = style.as_ref().map(|st| region_text(st, region_of(t, style.as_ref(), ri, g, span, ncols))); let env = crate::para::ParaEnv { @@ -286,7 +287,7 @@ pub(crate) fn first_cell_left_margin(ctx: &Ctx, t: &Table) -> f32 { } fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 { - t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1] + t.rows.first().and_then(|r| r.cells.first()).map_or(def, |cell| cell.props.resolved_margins(def))[1] } /// What a table style gives the cells in one set of parts: shading, vertical alignment and each @@ -470,7 +471,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let (row_top, row_bottom) = row .cells .iter() - .map(|c| c.props.margins.unwrap_or(margins_def)) + .map(|c| c.props.resolved_margins(margins_def)) .fold((0.0f32, 0.0f32), |(top, bottom), m| (top.max(m[0]), bottom.max(m[2]))); for (ci, cell) in row.cells.iter().enumerate() { let span = cell.span(); @@ -479,7 +480,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // Right to left: grid columns run from the right edge, and the start (left) margin // is on the cell's right. let (x0, x1) = if rtl { ((acc - x1).max(0.0), (acc - x0).max(0.0)) } else { (x0, x1) }; - let m = cell.props.margins.unwrap_or(margins_def); + let m = cell.props.resolved_margins(margins_def); let margins = if rtl { [row_top, m[3], row_bottom, m[1]] } else { [row_top, m[1], row_bottom, m[3]] }; let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); let region = region_of(t, style.as_ref(), ri, g, span, ncols); @@ -1075,7 +1076,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep let text = style.map(|st| region_text(st, region_of(t, style, ri, g, cell.span(), cols.len()))); let (lo, hi) = blocks_content(ctx, &cell.blocks, text.as_ref(), depth); any |= hi > 0.0; - let m = cell.props.margins.unwrap_or(margins_def); + let m = cell.props.resolved_margins(margins_def); let need = (lo + m[1] + m[3], hi + m[1] + m[3]); match cols.get_mut(g) { Some(col) if span == 1 => *col = (col.0.max(need.0), col.1.max(need.1)), diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 6e13b0312..0edbcbb56 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8140,3 +8140,33 @@ fn section_start_summed_spacing() { assert!((top - d.last_section.margin_top - gap).abs() < 0.01, "summed={summed}: {top}"); } } + +#[test] +fn partial_margins_inherit() { + let height = |top, bottom| { + let mut table = Table::new(1, 1, 200.0); + table.props.style = None; + table.props.cell_margins = Some([top, 2.25, bottom, 2.75]); + let cell = &mut table.rows[0].cells[0]; + cell.props.margins = None; + cell.props.partial_margins = Some([None, Some(0.0), None, None]); + let mut para = Paragraph::with_text("Point", Default::default()); + para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(11.0)); + para.props.space_before = Some(0.0); + para.props.space_after = Some(0.0); + cell.blocks = vec![wordcraft_doc::para_block(para)]; + let mut doc = Document::new(); + doc.body = vec![std::sync::Arc::new(Block::Table(table))]; + let layout = lay(&doc); + layout.pages[0] + .items + .iter() + .find_map(|item| match item { + Placed::Cell { rect, .. } => Some(rect.h), + _ => None, + }) + .unwrap() + }; + assert!((height(2.75, 2.75) - 16.5).abs() < 0.01); + assert!((height(6.0, 8.0) - 25.0).abs() < 0.01); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index aadb9983f..18073655f 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -213,6 +213,7 @@ Behind-text pictures in table cells participate in centre and bottom vertical al | 2026-10-15 | trivial | Absent or zero DrawingML effect extents remain zero on rotated pictures; contracted axes no longer reserve invented effects room. Synthetic quarter-turn import regression (`zero_effects_stay_zero_rotated`). | | 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. | | 2026-10-15 | trivial | Removed the 12 pt line-width floor in narrow cells and float spans; synthetic wrapping and oversized-glyph controls | +| 2026-10-15 | trivial | Partial cell margins retain omitted edges through DOCX save and inherit current table defaults during layout. Original import/write and row-height regressions. | | 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |