244: inherit omitted cell margins (#2434)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fin WarmanandBrandon Thomas authored and GitHub committed 2026-10-18 10:25:38 -04:00
1 parent a14948a23d
commit 4cb4b2d231
7 files changed
+105 -10

No files matched your search

+14
View File
@@ -1235,6 +1235,9 @@ pub struct CellProps {
pub valign: Option<VAlign>,
/// 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<f32>; 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<Box<PropChange<CellProps>>>,
}
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::*;
+13 -2
View File
@@ -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<f32>; 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");
+10 -2
View File
@@ -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<f32>; 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());
@@ -0,0 +1,30 @@
//! Original partial-cell-margin fixture (ECMA-376 §17.4.69).
use std::io::{Cursor, Read, Write};
fn fixture() -> Vec<u8> {
let document = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:tbl><w:tblPr><w:tblCellMar><w:top w:w="55" w:type="dxa"/><w:left w:w="45" w:type="dxa"/><w:bottom w:w="55" w:type="dxa"/><w:right w:w="55" w:type="dxa"/></w:tblCellMar></w:tblPr><w:tblGrid><w:gridCol w:w="4320"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcMar><w:left w:w="0" w:type="dxa"/></w:tcMar></w:tcPr><w:p><w:r><w:t>Point</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>"#;
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("<w:tcMar>").nth(1).unwrap().split("</w:tcMar>").next().unwrap();
assert!(margins.contains("<w:left w:w=\"0\""));
assert!(!margins.contains("<w:top"));
assert!(!margins.contains("<w:bottom"));
assert!(!margins.contains("<w:right"));
}
+7 -6
View File
@@ -111,7 +111,8 @@ fn default_margins(t: &Table, style: Option<&TableStyleProps>) -> [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)),
+30
View File
@@ -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);
}
+1
View File
@@ -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. |