From a35d17abbbb87d74d43b2e51d79f23e5a37a0878 Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Sat, 24 Oct 2026 20:30:06 +1300 Subject: [PATCH 1/2] fix(fragmentation): end a split table row's first fragment at the page end A forced break inside a table cell splits the row, and the first fragment's cells are stretched to fill the rest of the fragmentainer. The row's block extent also starts with its internal spacing (on a first row, the table's top border, padding and border-spacing), which the stretch didn't subtract, so the fragment ran past the page end by that amount. Subtract the row's internal start from the space before splitting the cells. --- src/layout/fragmentation/forced_break.rs | 4 ++ .../fragmentation/forced_break/tests.rs | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/layout/fragmentation/forced_break.rs b/src/layout/fragmentation/forced_break.rs index 86b743dd3..30ea12988 100644 --- a/src/layout/fragmentation/forced_break.rs +++ b/src/layout/fragmentation/forced_break.rs @@ -869,6 +869,10 @@ fn split_table_row( element: &TableRow, space: FragmentainerSpace, ) -> Option> { + // The row's block extent starts with its internal table spacing (the + // table's top border, padding and `border-spacing` on a first row), so the + // cells start, and the first fragment's cells end, that much further down. + let space = space.after(element.flow.internal.start); for (index, cell) in element.content.cells.iter().enumerate() { if cell.table.clips { continue; diff --git a/src/layout/fragmentation/forced_break/tests.rs b/src/layout/fragmentation/forced_break/tests.rs index 310172626..bb9f3effd 100644 --- a/src/layout/fragmentation/forced_break/tests.rs +++ b/src/layout/fragmentation/forced_break/tests.rs @@ -293,6 +293,45 @@ fn table_row_break_slices_its_wrapper_decoration() { assert_eq!(continuation.box_model.margins.end, -20.0); } +/// A first table row starts with the table's top border, padding and +/// `border-spacing`. Its open fragment fills the rest of the fragmentainer, +/// so the cells must stop that much earlier, or the fragment overflows the +/// page and pagination splits it again onto an extra page. +#[test] +fn table_row_break_fragment_ends_at_the_fragmentainer_end_after_its_internal_start() { + let table = TableRow { + content: TableCells { + cells: vec![TableCell { + layout: CellBox { + content: CellContent { + children: vec![ + container(Vec::new(), Some(10.0)), + PageBreak { + side: PageBreakSide::Any, + page_name: None, + } + .boxed(), + container(Vec::new(), Some(20.0)), + ], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }], + column_widths: vec![40.0], + }, + flow: crate::layout::flow_metrics::BlockFlowSpacing::from_internal_start(9.0), + ..Default::default() + } + .boxed(); + + let split = split_sequence(&[table], FragmentainerSpace::new(100.0)) + .expect("forced break inside the table cell"); + + assert_eq!(estimate_element_height(split.before[0].as_ref()), 100.0); +} + #[test] fn fixed_container_descendant_break_becomes_overflow_fragment() { let nested = container( From d81c9c7095416503e03a594d2d6bb27cf4d1d9f7 Mon Sep 17 00:00:00 2001 From: Nathan Broadbent Date: Sat, 24 Oct 2026 19:23:29 +1300 Subject: [PATCH 2/2] fix(paginate): fragment a box that overflows the page before a forced break --- CHANGELOG.md | 6 ++++ src/layout/paginate.rs | 4 --- src/layout/tests/paged_breaks.rs | 57 +++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ba1e921..cdf0e0157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ - Ruby binding: magnus 0.9 drops Ruby 3.0 (end of life since April 2024). The gem requires Ruby 3.1 or later. +### Fixed + +- A box taller than the space left on the page that is followed by a forced + break, such as a long table before `break-before: page`, continues on the + next page instead of losing everything past the page edge. + ## [1.7.0] โ€” 2026-09-15 ### Added diff --git a/src/layout/paginate.rs b/src/layout/paginate.rs index 8f1b7cafe..53e0f91a0 100644 --- a/src/layout/paginate.rs +++ b/src/layout/paginate.rs @@ -3364,9 +3364,6 @@ pub(crate) fn paginate_with_context( // resolve (i.e. taller than a full empty page, or a too-tall box already // at the page top). Every box that fits โ€” the entire existing corpus โ€” // skips this block and takes the unchanged whole-placement path below. - let followed_by_forced_break = work - .front() - .is_some_and(|pending| page_break_data(pending.element.as_ref()).is_some()); let avail_below_box_top = effective_content_height - (y + effective_margin_top); // A forced break retained inside this box occurs before any top-level // break marker that follows the box. The following marker must not make @@ -3390,7 +3387,6 @@ pub(crate) fn paginate_with_context( continue; } if elem_position == Position::Static - && !followed_by_forced_break && may_fragment_internally && exceeds_with_roundoff(y + effective_element_height, effective_content_height) { diff --git a/src/layout/tests/paged_breaks.rs b/src/layout/tests/paged_breaks.rs index 7439e8ef0..7e3ab1eaa 100644 --- a/src/layout/tests/paged_breaks.rs +++ b/src/layout/tests/paged_breaks.rs @@ -1,7 +1,7 @@ use super::support::layout_pages_at; use crate::layout::cells::GridInset; use crate::layout::elements::{ - ColumnRule, Container, FlexRow, GridRow, LayoutVisitor, MulticolContainer, TextBlock, + ColumnRule, Container, FlexRow, GridRow, LayoutVisitor, MulticolContainer, TableRow, TextBlock, }; use crate::layout::engine::{FlexFragmentRole, visit_layout_tree}; use crate::types::PageSize; @@ -383,3 +383,58 @@ fn descendant_break_fills_each_auto_sized_ancestor_fragment() { "page 1: {first_page:#?}" ); } + +#[derive(Debug, Default)] +struct TableRowTexts(Vec); + +impl LayoutVisitor for TableRowTexts { + fn visit_table_row(&mut self, row: &TableRow) { + let words = row + .content + .cells + .iter() + .flat_map(|cell| &cell.layout.content.lines) + .flat_map(|line| &line.runs) + .map(|run| run.text.trim()) + .filter(|text| !text.is_empty()) + .collect::>(); + self.0.push(words.join(" ")); + } +} + +/// CSS Fragmentation 3 ยง4.4: unforced breaks keep content from overflowing the +/// fragmentainer, and a forced break after a box doesn't exempt that box. A table +/// taller than the page that is followed by a forced break continues on the next +/// page instead of overflowing the first. +#[test] +fn box_taller_than_the_page_fragments_before_a_forced_break() { + let rows = (1..=40) + .map(|row| format!("Row {row}")) + .collect::(); + let pages = layout_pages_at( + &format!( + r#" + + {rows}
+

After

+ "# + ), + PageSize::new(200.0, 150.0), + ); + + let mut rows_laid_out = TableRowTexts::default(); + for (page_index, page) in pages.iter().enumerate() { + for (y, element) in &page.elements { + let bottom = y + crate::layout::paginate::estimate_element_height(element.as_ref()); + assert!( + bottom <= 150.0 + 0.01, + "page {} has an element ending at {bottom}pt", + page_index + 1 + ); + visit_layout_tree(element.as_ref(), &mut rows_laid_out); + } + } + let expected = (1..=40).map(|row| format!("Row {row}")).collect::>(); + assert_eq!(rows_laid_out.0, expected); + assert!(pages.len() >= 3, "{} pages", pages.len()); +}