diff --git a/ROADMAP.md b/ROADMAP.md index a76148d85..b2bc4d3f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | |---|---| diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 6487e72ee..c71154880 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -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>>, diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index a8147bf6a..2f248b155 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -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" => { diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 2cfe983cb..8aa70b1c9 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -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. diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 8b9ab9431..3da1e6e8c 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -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#""#; + let hides = |d: &Document| -> Vec { + 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]); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 80b078f5f..567f99de8 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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, 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 diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index add09c6ec..6950c166b 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -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. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index cd891bb74..e75814490 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 653c8a72f..2ddc8baf3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index b94484989..7c4fcb64a 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -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) |