R20: hidden end-of-cell marks (w:hideMark) (#939)

Read and write w:hideMark on table cells, and lay it out like Word: a cell's
empty last paragraph takes no room, spacing and all, so a row of only such
empty cells is just its borders; an ordinary empty cell keeps the row a line
tall. Measured in Word for Windows: 9 synthetic cases. NIH's Table A no
longer shows a blank row.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-12 09:45:40 -04:00
1 parent 2f833b6acf
commit 541e10e23a
10 files changed
+106 -6

No files matched your search

+1
View File
@@ -147,6 +147,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | `.doc` import: tab stops (centred titles, right-aligned dates, leaders) and sections ending at their section marks (continuous breaks, footers); EPA .doc templates now paginate like Word |
| 2026-10-12 | DOCX headings numbered like Word when a heading style gives only a list level (6.4.5, not 6.15) |
| 2026-10-12 | A list label makes its line taller above the baseline only, as in Word (6 cases measured in Word for Windows): Symbol bullets no longer add their deeper descent, so bulleted lists keep Word's height and page breaks |
| 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders |
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
+4
View File
@@ -1013,6 +1013,10 @@ pub struct CellProps {
/// Which way the cell's text runs.
pub text_direction: TextDirection,
pub no_wrap: bool,
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
/// empty cells shrinks to its borders.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub hide_mark: bool,
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
+1
View File
@@ -506,6 +506,7 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
"w:tcBorders" => c.borders = Some(borders(k)),
"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:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:vAlign" => {
+3
View File
@@ -497,6 +497,9 @@ fn tcpr_inner(w: &mut W, c: &CellProps) {
VAlign::Center => w.val("w:vAlign", "center"),
VAlign::Bottom => w.val("w:vAlign", "bottom"),
}
if c.hide_mark {
w.empty("w:hideMark", &[]);
}
}
/// `w:tblpPr` and `w:tblOverlap` for a floating table.
+19
View File
@@ -1882,3 +1882,22 @@ fn a_list_level_without_a_list_takes_the_style_s_list() {
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(again.styles.get("Heading3").and_then(|s| s.para.numbering).map(|n| (n.num, n.level)), Some((15, 2)));
}
/// `w:hideMark` on a cell is read and written back (it was dropped, and the empty rows NIH's
/// tables collapse with it showed as blank lines).
#[test]
fn cell_hide_mark_reads_and_round_trips() {
let body = r#"<w:tbl><w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:hideMark/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl><w:p/>"#;
let hides = |d: &Document| -> Vec<bool> {
d.body
.iter()
.find_map(|b| {
if let wordcraft_doc::Block::Table(t) = &**b { Some(t.rows[0].cells.iter().map(|c| c.props.hide_mark).collect()) } else { None }
})
.unwrap()
};
let d = read_body(body);
assert_eq!(hides(&d), [true, false]);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(hides(&again), [true, false]);
}
+7 -3
View File
@@ -873,7 +873,9 @@ fn layout_box(
let mut y = 0.0f32;
let mut prev_after = 0.0f32;
let mut prev_style: Option<(String, bool)> = None;
let mut last_top = 0.0f32;
for (i, b) in blocks.iter().enumerate() {
last_top = y;
let mut path = prefix.to_vec();
path.push(i as u32);
match &**b {
@@ -958,15 +960,17 @@ fn layout_box(
}
let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max);
ctx.hide_deleted = outer_hide;
BoxLayout { items, height: y.max(0.0), floats_bottom }
BoxLayout { items, height: y.max(0.0), floats_bottom, last_top: last_top.max(0.0) }
}
/// A laid-out box: items relative to its top-left, the height of its text flow, and where its
/// floating tables end (a table cell grows to hold them).
/// A laid-out box: items relative to its top-left, the height of its text flow, where its
/// floating tables end (a table cell grows to hold them) and where its last block starts (the
/// height without it).
struct BoxLayout {
items: Vec<Placed>,
height: f32,
floats_bottom: f32,
last_top: f32,
}
/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and
+14 -3
View File
@@ -411,6 +411,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let mut rh = 0.0f32;
// The tallest cell margins plus border bands in the row.
let mut row_insets = 0.0f32;
// Every cell an empty one with a hidden end-of-cell mark: the row is only its borders.
let mut collapsed = true;
let is_header = header_rows && ri == 0;
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
let band = banded(t, style.as_ref(), ri);
@@ -497,6 +499,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let (items, h) = if cell.props.vmerge == VMerge::Continue {
(Vec::new(), 0.0)
} else if turn.is_turned() {
collapsed = false;
// Turned text: its lines run along the cell's height. An exact row height sets
// their length; otherwise they get the length the text needs unwrapped and the row
// grows to fit (the second pass lays them out again if the row ends up taller).
@@ -509,12 +512,20 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
natural_length(&probe).clamp(4.0, TURNED_MAX)
}
};
let BoxLayout { items, height, floats_bottom } = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None);
let BoxLayout { items, height, floats_bottom, .. } = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None);
let across = height.max(floats_bottom);
turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
(items, len)
} else {
let BoxLayout { mut items, height, floats_bottom } = layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None);
let BoxLayout { mut items, mut height, floats_bottom, last_top } =
layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None);
// A hidden end-of-cell mark: an empty last paragraph takes no room (Word for
// Windows: a row of empty such cells is only its borders).
if cell.props.hide_mark && cell.blocks.last().and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()) {
height = last_top;
} else {
collapsed = false;
}
for it in &mut items {
it.translate(x0 + margins[1], margins[0] + band_t);
}
@@ -549,7 +560,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
(Some(h), _) if h > 0.0 => rh.max(h + row_insets),
_ => rh,
};
heights.push(rh.max(4.0));
heights.push(if collapsed && !cells.is_empty() { rh.max(0.0) } else { rh.max(4.0) });
rows.push(cells);
}
// Vertically merged cells: grow the last row of the merge if the content is taller.
+55
View File
@@ -4208,3 +4208,58 @@ fn a_list_label_adds_ascent_but_not_descent() {
assert!(big.baseline - big.top > plain.baseline - plain.top + 5.0, "the big label raises the baseline");
assert!((below(&big) - below(&plain)).abs() < 0.01, "the text's descent: {} vs {}", below(&big), below(&plain));
}
/// A cell's hidden end-of-cell mark (`w:hideMark`), measured in Word for Windows (`plan/word`
/// R20, 9 cases): its empty last paragraph takes no room, spacing and all, so a row of such empty
/// cells is only its borders; one ordinary empty cell keeps the row a line tall, and a paragraph
/// with text is never hidden. NIH's Table A showed a blank row Word doesn't.
#[test]
fn a_hidden_cell_mark_takes_no_room() {
let row3_top = |hide: [bool; 2], second: Option<&str>| {
let mut d = Document::from_text("Above");
let mut t = Table::new(3, 2, 300.0);
for (r, text) in [(0, "R1"), (2, "R3")] {
for c in 0..2 {
t.rows[r].cells[c].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
}
}
for c in 0..2 {
let cell = &mut t.rows[1].cells[c];
cell.props.hide_mark = hide[c];
let mut blocks = Vec::new();
if let Some(text) = second {
blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default())));
}
let mut mark = wordcraft_doc::Paragraph::with_text("", Default::default());
mark.props.space_before = Some(10.0);
blocks.push(wordcraft_doc::para_block(mark));
cell.blocks = blocks;
}
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let at = |r: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, r, 0, 0]), off: 0 }).unwrap().top;
// Rows 1 and 2 together.
at(2) - at(0)
};
// Row 1 is one line of text; row 2's height is what's left.
let line = {
let both = row3_top([false, false], Some("Tx"));
let mut d = Document::from_text("Above");
let mut t = Table::new(2, 1, 300.0);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("R1", Default::default()))];
t.rows[1].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("R3", Default::default()))];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let at = |r: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, r, 0, 0]), off: 0 }).unwrap().top;
assert!(both > at(1) - at(0));
at(1) - at(0)
};
let row2 = |hide: [bool; 2], second: Option<&str>| row3_top(hide, second) - line;
let hidden = row2([true, true], None);
assert!(hidden < 1.5, "a row of hidden marks is only its borders: {hidden} vs a line of {line}");
let mixed = row2([true, false], None);
let plain = row2([false, false], None);
assert!(plain > 5.0 && (mixed - plain).abs() < 0.01, "an ordinary empty cell keeps the row: {mixed} vs {plain}");
let text = row2([true, true], Some("Tx"));
assert!((text - line).abs() < 0.5, "text stays, the empty mark after it goes: {text} vs {line}");
}
+1
View File
@@ -169,6 +169,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| 2026-10-12 | trivial | DOCX: a table of contents' entry links (Hyperlink-styled runs in hyperlinks inside a TOC field) import without the Hyperlink style, as Word draws them; measured in Word for Windows |
| 2026-10-12 | trivial | `.doc`: custom tab stops read from styles and paragraphs (`sprmPChgTabsPapx`, `sprmPChgTabs`, with the alignment and leader of each stop; `sprmPChgTabs`' operand size fixed), and a section mark ends its section's last paragraph instead of becoming a page break, so the sections (continuous breaks, footers) are kept. Found by comparing EPA templates with Word for Windows; two of them now paginate like Word |
| 2026-10-12 | trivial | DOCX: a style or paragraph `w:numPr` with a level and no list takes the list of its style chain (Heading 3 based on a numbered Heading 2 numbers 6.4.5, not 6.15), found in NIH's monitoring-plan template against Word for Windows |
| 2026-10-12 | trivial | DOCX: `w:hideMark` on table cells read and written |
| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% |
| 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) |
| 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly |
+1
View File
@@ -77,6 +77,7 @@ font can't match Word's metrics.
|---|---|---|
| 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) |
| 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) |
| 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders |
| 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page |
| 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) |
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |