Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/layout/fragmentation/forced_break.rs
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,10 @@ fn split_table_row(
element: &TableRow,
space: FragmentainerSpace,
) -> Option<ForcedBreak<LayoutNode>> {
// 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;
Expand Down
39 changes: 39 additions & 0 deletions src/layout/fragmentation/forced_break/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 0 additions & 4 deletions src/layout/paginate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
{
Expand Down
57 changes: 56 additions & 1 deletion src/layout/tests/paged_breaks.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -383,3 +383,58 @@ fn descendant_break_fills_each_auto_sized_ancestor_fragment() {
"page 1: {first_page:#?}"
);
}

#[derive(Debug, Default)]
struct TableRowTexts(Vec<String>);

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::<Vec<_>>();
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!("<tr><td>Row {row}</td></tr>"))
.collect::<String>();
let pages = layout_pages_at(
&format!(
r#"
<style>@page {{ size: 200pt 150pt; margin: 0 }} * {{ margin: 0 }}</style>
<table>{rows}</table>
<p style="break-before: page">After</p>
"#
),
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::<Vec<_>>();
assert_eq!(rows_laid_out.0, expected);
assert!(pages.len() >= 3, "{} pages", pages.len());
}
Loading