mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-19 02:10:15 -05:00
244: inherit omitted cell margins (#2434)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
1 parent
a14948a23d
commit
4cb4b2d231
7 files changed
+105
-10
No files matched your search
@@ -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::*;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user