diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index ff10f0f8f..19b456e38 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1055,9 +1055,10 @@ fn layout_box( // Deletions in balloons leave the body's text only: a header, note or text box keeps its own // inline (restored for the story around this box when it's done). let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story)); + let empty_cell = para::CellText::default(); let (table, in_cell) = match context { BoxContext::Other => (None, false), - BoxContext::Cell(table) => (table, true), + BoxContext::Cell(table) => (Some(table.unwrap_or(&empty_cell)), true), }; // Word 16.113.3: the override applies in modes 11/12/14, but not mode 15. let allow_same_style_space = diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index a4b99da0f..88ebe4518 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1517,6 +1517,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { match c { '\t' => push(&mut b, ClKind::Tab, 0.0, 0.0), LINE_BREAK | '\r' => push(&mut b, ClKind::LineBreak, 0.0, 0.0), + // Word ignores explicit page breaks in cells, preserving their source offsets. + PAGE_BREAK if env.table.is_some() => push(&mut b, ClKind::Marker, 0.0, 0.0), PAGE_BREAK => push(&mut b, ClKind::PageBreak, 0.0, 0.0), COLUMN_BREAK => push(&mut b, ClKind::ColumnBreak, 0.0, 0.0), _ => { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b8bb13173..4f9602c2d 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8416,3 +8416,115 @@ fn the_word_being_typed_has_no_spelling_squiggle() { assert_eq!(marks(&left, false), ["helo", "wrld"]); assert_eq!(c.misses, misses); } + +fn cell_break_doc(text: &str) -> Document { + use wordcraft_doc::props::{HeightRule, LineSpacing, VAlign}; + let mut d = Document::new(); + d.last_section.page_w = 344.0; + d.last_section.margin_left = 72.0; + d.last_section.margin_right = 72.0; + let mut p = Paragraph::with_text(text, CharProps { size: Some(11.0), ..Default::default() }); + p.props.line_spacing = Some(LineSpacing::Exactly(12.0)); + p.props.space_before = Some(3.0); + p.props.space_after = Some(3.0); + let prefix = text.bytes().take_while(|&c| c == wordcraft_doc::para::PAGE_BREAK as u8 || c == b'\n').count(); + p.format(0, prefix, &|props| props.size = Some(3.0)).unwrap(); + let mut t = Table::new(1, 1, 200.0); + t.props.cell_margins = Some([0.0; 4]); + t.rows[0].props.height = Some(20.0); + t.rows[0].props.height_rule = HeightRule::AtLeast; + t.rows[0].cells[0].props.valign = Some(VAlign::Center); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)]; + d.body = vec![Arc::new(Block::Table(t))]; + d +} + +#[test] +fn cell_breaks_take_no_height() { + let height = |d: &Document| { + lay(d).pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Cell { rect, .. } => Some(rect.h), + _ => None, + }) + .unwrap() + }; + let plain = height(&cell_break_doc("Heading")); + for text in ["\u{000c}Heading", "\u{000c}\u{000c}\u{000c}Heading", "Head\u{000c}ing", "Heading\u{000c}\u{000c}\u{000c}", "é\u{000c}界"] { + let d = cell_break_doc(text); + assert_eq!(height(&d), plain, "cell page breaks take no height: {text:?}"); + let l = lay(&d); + let para = l.pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Lines { para, .. } => Some(para), + _ => None, + }) + .unwrap(); + assert_eq!(para.lines.len(), 1); + assert!(!para.clusters.iter().any(|c| c.kind == para::ClKind::PageBreak)); + for c in ¶.clusters { + assert!(text.is_char_boundary(c.start) && text.is_char_boundary(c.end)); + if text.get(c.start..c.end) == Some("\u{000c}") { + assert_eq!(c.kind, para::ClKind::Marker); + } + } + let starts: Vec<_> = para.clusters.iter().filter(|c| c.kind == para::ClKind::Text).map(|c| c.start).collect(); + let expected: Vec<_> = text.char_indices().filter_map(|(i, c)| (c != '\u{000c}').then_some(i)).collect(); + assert_eq!(starts, expected, "visible clusters retain source offsets"); + } + assert!(height(&cell_break_doc("\n\n\nHeading")) > plain, "ordinary line breaks retain their lines"); +} + +#[test] +fn cell_break_cache_is_scoped() { + let mut d = cell_break_doc("\u{000c}Heading"); + let Block::Table(t) = &*d.body[0] else { panic!("table") }; + let p = t.rows[0].cells[0].blocks[0].clone(); + d.body.push(p); + assert_eq!(lay(&d).pages.len(), 2, "a reused paragraph's body page break still starts a page"); +} + +#[test] +fn cell_breaks_with_bookmarks() { + for offset in [0, 1, 3] { + let mut d = cell_break_doc("\u{000c}\u{000c}\u{000c}Heading"); + let Block::Table(t) = Arc::make_mut(&mut d.body[0]) else { panic!("table") }; + let Block::Para(p) = Arc::make_mut(&mut t.rows[0].cells[0].blocks[0]) else { panic!("paragraph") }; + p.insert_object(offset, InlineObject::BookmarkStart { name: "control".into() }, &Default::default()).unwrap(); + let original = p.clone(); + let l = lay(&d); + let para = l.pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Lines { para, .. } => Some(para), + _ => None, + }) + .unwrap(); + assert!(!para.clusters.iter().any(|c| c.kind == para::ClKind::PageBreak)); + assert_eq!(para.lines.len(), 1); + assert_eq!(para.clusters.iter().find(|c| c.kind == para::ClKind::Text).unwrap().start, 6); + let Block::Table(t) = &*d.body[0] else { panic!("table") }; + let Block::Para(p) = &*t.rows[0].cells[0].blocks[0] else { panic!("paragraph") }; + assert_eq!(p, &original, "layout preserves bookmark objects and source bytes"); + } +} + +#[test] +fn cell_break_only_is_marker() { + let d = cell_break_doc("\u{000c}"); + let l = lay(&d); + let para = l.pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Lines { para, .. } => Some(para), + _ => None, + }) + .unwrap(); + assert!(para.clusters.iter().all(|c| c.kind == para::ClKind::Marker)); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 871cbb50f..01734173d 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -193,6 +193,8 @@ Behind-text pictures in table cells participate in centre and bottom vertical al TOC refresh reuses direct tab formatting from the first cached entry with a number-separator stop at each level for numbered entries, including imported range fields. Page-number tabs still use the TOC section width. No new default number-tab position is inferred. +Explicit cell page breaks (Word for Mac 16.113.2, compatibility modes 14 and 15): leading, interior and trailing breaks take no line height. Ordinary line breaks retain their lines; body page breaks still paginate. Synthetic regressions preserve UTF-8 cluster boundaries, bookmark objects and source offsets, including body/cell cache reuse without table-style formatting. Width, border strokes and residual row metrics remain outside this change. + ## Revision history | Date | Change | Summary | @@ -221,6 +223,7 @@ TOC refresh reuses direct tab formatting from the first cached entry with a numb | 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. | | 2026-10-16 | trivial | Zero-extent drawing anchors contribute no inline font height. Synthetic regressions. | | 2026-10-16 | trivial | Preserve direct TOC number tabs during regeneration | +| 2026-10-16 | trivial | Explicit cell page breaks take no line height; original native Word controls in modes 14 and 15 and synthetic cluster/cache 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. |