use std::collections::HashMap;
use std::rc::Rc;
use crate::fonts::FontCollection;
use crate::html::{Dom, NodeId};
use crate::style::{BreakBetween, BreakInside, ComputedStyle};
use crate::pdf::ImageAssetCache;
use super::block::{
layout_document, layout_document_positioned, shift_box_x, shift_box_y, shift_box_y_in_place,
FragmentationHints, LaidOutBox, LaidOutContent, LaidOutTable, LaidOutTableRow, PositionedBox,
PositionedKind,
};
use super::box_tree::TableSection;
use super::box_tree::{build_box_tree, resolve_images};
use super::geometry::{EdgeSizes, FragmentPosition, Layout, Rect};
use super::grid::{LaidOutGrid, LaidOutGridRow};
use super::inline::LineBox;
use super::page::PageSettings;
use crate::style::CaptionSide;
#[derive(Debug, Clone, Default)]
pub struct Page {
pub boxes: Vec<LaidOutBox>,
}
#[derive(Default)]
struct PaginationBuffer {
buffer: Vec<Page>,
flushed: usize,
active_min_page: Vec<usize>,
}
impl PaginationBuffer {
fn new() -> Self {
Self {
buffer: vec![Page::default()],
flushed: 0,
active_min_page: Vec::new(),
}
}
}
struct PaginationState<'a> {
inner: &'a mut PaginationBuffer,
on_flush: &'a mut dyn FnMut(Page),
}
impl<'a> PaginationState<'a> {
fn new(inner: &'a mut PaginationBuffer, on_flush: &'a mut dyn FnMut(Page)) -> Self {
Self { inner, on_flush }
}
fn len(&self) -> usize {
self.inner.flushed + self.inner.buffer.len()
}
fn current_index(&self) -> usize {
self.len() - 1
}
fn last_mut(&mut self) -> &mut Page {
self.inner
.buffer
.last_mut()
.expect("the buffer always holds at least one page")
}
fn last(&self) -> &Page {
self.inner
.buffer
.last()
.expect("the buffer always holds at least one page")
}
fn get(&self, absolute: usize) -> &Page {
&self.inner.buffer[absolute - self.inner.flushed]
}
fn get_mut(&mut self, absolute: usize) -> &mut Page {
&mut self.inner.buffer[absolute - self.inner.flushed]
}
fn push_new_page(&mut self) {
self.inner.buffer.push(Page::default());
}
fn enter_split(&mut self) {
let idx = self.current_index();
self.inner.active_min_page.push(idx);
}
fn exit_split(&mut self) {
self.inner.active_min_page.pop();
self.try_flush();
}
fn try_flush(&mut self) {
let safe_until = self
.inner
.active_min_page
.iter()
.copied()
.min()
.unwrap_or_else(|| self.current_index());
while self.inner.flushed < safe_until {
let page = self.inner.buffer.remove(0);
(self.on_flush)(page);
self.inner.flushed += 1;
}
}
}
pub fn paginate(root: &mut LaidOutBox, page_content_height: f32) -> Vec<Page> {
let mut result = Vec::new();
paginate_streaming(root, page_content_height, &mut |page| result.push(page));
result
}
pub fn paginate_streaming(
root: &mut LaidOutBox,
page_content_height: f32,
on_page: &mut dyn FnMut(Page),
) {
let mut paginator = StreamingPaginator::new(page_content_height);
for page in paginator.push_item(root) {
on_page(page);
}
for page in paginator.finish() {
on_page(page);
}
}
pub struct StreamingPaginator {
buffer: PaginationBuffer,
cursor: f32,
page_height: f32,
pending_break_after: bool,
}
impl StreamingPaginator {
pub fn new(page_height: f32) -> Self {
Self {
buffer: PaginationBuffer::new(),
cursor: 0.0,
page_height,
pending_break_after: false,
}
}
pub fn push_item(&mut self, item: &mut LaidOutBox) -> Vec<Page> {
let break_before =
self.pending_break_after || item.fragmentation.break_before == BreakBetween::Always;
self.pending_break_after = item.fragmentation.break_after == BreakBetween::Always;
let mut flushed = Vec::new();
{
let mut on_flush = |page: Page| flushed.push(page);
let mut state = PaginationState::new(&mut self.buffer, &mut on_flush);
if break_before && current_page_has_content(&state) {
new_page(&mut state, &mut self.cursor);
}
place_box(item, self.page_height, &mut state, &mut self.cursor);
}
flushed
}
pub fn finish(self) -> Vec<Page> {
debug_assert!(
self.buffer.active_min_page.is_empty(),
"finish should be called after leaving every place_split call"
);
self.buffer.buffer
}
}
pub fn paginate_document(
dom: &Dom,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
settings: &PageSettings,
) -> Vec<Page> {
let tree = build_box_tree(dom, styles);
let (laid_out, positioned) = layout_document_positioned(
&tree,
styles,
fonts,
(settings.content_width(), settings.content_height()),
);
drop(tree);
let mut laid_out = laid_out;
let mut pages = paginate(&mut laid_out, settings.content_height());
apply_positioned_overlays(&mut pages, &positioned);
pages
}
pub(crate) fn apply_positioned_overlays(pages: &mut [Page], positioned: &[PositionedBox]) {
for pb in positioned {
match pb.kind {
PositionedKind::Fixed => {
for page in pages.iter_mut() {
page.boxes.push(pb.laid.clone());
}
}
PositionedKind::AbsoluteInitial => {
if let Some(first) = pages.first_mut() {
first.boxes.push(pb.laid.clone());
}
}
PositionedKind::AbsoluteAncestor {
node,
padding_box_origin,
} => {
if let Some((idx, (px, py))) = find_ancestor_padding_box_origin(pages, node) {
let dx = px - padding_box_origin.0;
let dy = py - padding_box_origin.1;
let shifted = shift_box_x(&shift_box_y(&pb.laid, -dy), dx);
pages[idx].boxes.push(shifted);
}
}
}
}
}
fn find_ancestor_padding_box_origin(pages: &[Page], node: NodeId) -> Option<(usize, (f32, f32))> {
for (i, page) in pages.iter().enumerate() {
for b in &page.boxes {
if let Some(origin) = find_node_padding_origin(b, node) {
return Some((i, origin));
}
}
}
None
}
fn find_node_padding_origin(b: &LaidOutBox, node: NodeId) -> Option<(f32, f32)> {
if b.node == Some(node) {
return Some((
b.layout.content.x - b.layout.padding.left,
b.layout.content.y - b.layout.padding.top,
));
}
match &b.content {
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => children
.iter()
.find_map(|c| find_node_padding_origin(c, node)),
LaidOutContent::Grid(grid) => grid
.rows
.iter()
.flat_map(|row| &row.items)
.find_map(|item| find_node_padding_origin(item, node)),
LaidOutContent::Table(table) => table
.caption
.as_deref()
.and_then(|c| find_node_padding_origin(c, node))
.or_else(|| {
table
.rows
.iter()
.flat_map(|r| &r.cells)
.find_map(|c| find_node_padding_origin(c, node))
}),
LaidOutContent::Inline(lines) => lines
.iter()
.flat_map(|l| &l.atomics)
.find_map(|a| find_node_padding_origin(&a.content, node)),
LaidOutContent::Image(_) => None,
}
}
pub fn paginate_document_with_absolutes(
dom: &mut Dom,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
settings: &PageSettings,
image_cache: &ImageAssetCache,
) -> Vec<Page> {
let mut tree = build_box_tree(dom, styles);
resolve_images(&mut tree, dom, styles, image_cache);
let (laid_out, positioned) = layout_document_positioned(
&tree,
styles,
fonts,
(settings.content_width(), settings.content_height()),
);
let mut laid_out = laid_out;
let mut pages = paginate(&mut laid_out, settings.content_height());
apply_positioned_overlays(&mut pages, &positioned);
pages
}
#[allow(clippy::too_many_arguments)]
pub fn paginate_document_streaming(
dom: &mut Dom,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
settings: &PageSettings,
image_cache: &ImageAssetCache,
on_page: &mut dyn FnMut(Page),
) {
let mut tree = build_box_tree(dom, styles);
resolve_images(&mut tree, dom, styles, image_cache);
let mut laid_out = layout_document(&tree, styles, fonts, settings.content_width());
paginate_streaming(&mut laid_out, settings.content_height(), &mut |page| {
release_completed_subtrees(dom, &page);
on_page(page);
});
}
fn release_completed_subtrees(dom: &mut Dom, page: &Page) {
for root in collect_completed_subtree_roots(page) {
dom.release_subtree(root);
}
}
pub(crate) fn collect_completed_subtree_roots(page: &Page) -> Vec<NodeId> {
let mut roots = Vec::new();
for b in &page.boxes {
collect_completed_subtree_roots_in_box(b, &mut roots);
}
roots
}
fn collect_completed_subtree_roots_in_box(b: &LaidOutBox, roots: &mut Vec<NodeId>) {
if let Some(node) = b.node {
if matches!(
b.layout.fragment,
FragmentPosition::Whole | FragmentPosition::Last
) {
roots.push(node);
return;
}
}
match &b.content {
LaidOutContent::Blocks(children) => {
for child in children {
collect_completed_subtree_roots_in_box(child, roots);
}
}
LaidOutContent::Inline(_)
| LaidOutContent::Table(_)
| LaidOutContent::Flex(_)
| LaidOutContent::Grid(_)
| LaidOutContent::Image(_) => {}
}
}
fn place_box(
b: &mut LaidOutBox,
page_height: f32,
state: &mut PaginationState<'_>,
cursor: &mut f32,
) {
let height = b.layout.margin_box_height();
let has_forced_break_inside = subtree_requires_child_walk(b);
let orphans = b.fragmentation.orphans as usize;
let widows = b.fragmentation.widows as usize;
let break_inside_avoid = b.fragmentation.break_inside == BreakInside::Avoid;
if *cursor + height <= page_height && !has_forced_break_inside {
place_leaf(b, state, cursor);
return;
}
if break_inside_avoid
&& current_page_has_content(state)
&& height <= page_height
&& !has_forced_break_inside
{
new_page(state, cursor);
place_leaf(b, state, cursor);
return;
}
let mut container = SplitContainer::take_from(b);
match &mut b.content {
LaidOutContent::Blocks(children) if !children.is_empty() => {
place_split(
&mut container,
children,
page_height,
state,
cursor,
|_i, child: &LaidOutBox| {
(
child.fragmentation.break_before == BreakBetween::Always,
child.fragmentation.break_after == BreakBetween::Always,
)
},
|children: &[LaidOutBox], i| keep_with_next_bottom(children, i, page_height),
|child: &LaidOutBox| child.is_float,
|child: &LaidOutBox| {
let top = margin_box_top(child);
(top, top + child.layout.margin_box_height())
},
|child, ph, ps, c| {
place_box(child, ph, ps, c);
},
);
return;
}
LaidOutContent::Table(table) if !table.rows.is_empty() => {
place_table(&container, table, page_height, state, cursor);
return;
}
LaidOutContent::Grid(grid) if grid.rows.len() > 1 => {
place_grid(&container, grid, page_height, state, cursor);
return;
}
LaidOutContent::Inline(lines) if lines.len() > 1 => {
let initial_cursor = *cursor + container.top_extra();
let forced_breaks =
compute_orphans_widows_breaks(lines, orphans, widows, page_height, initial_cursor);
place_split(
&mut container,
lines,
page_height,
state,
cursor,
move |i, _line| (forced_breaks[i], false),
|_lines: &[LineBox], _i| None,
|_line: &LineBox| false,
|line: &LineBox| (line.rect.y, line.rect.y + line.rect.height),
|line, ph, ps, c| {
place_line(line, ph, ps, c);
},
);
return;
}
LaidOutContent::Flex(children) if !children.is_empty() && height > page_height => {
let mut bands = group_flex_items_into_bands(std::mem::take(children));
place_split(
&mut container,
&mut bands,
page_height,
state,
cursor,
|_i, _band: &FlexBand| (false, false),
|_bands: &[FlexBand], _i| None,
|_band: &FlexBand| false,
|band: &FlexBand| (band.top, band.bottom),
|band, ph, ps, c| {
place_flex_band(band, ph, ps, c);
},
);
return;
}
_ => {}
}
b.marker = container.marker.take();
if *cursor > 0.0 {
new_page(state, cursor);
}
place_leaf(b, state, cursor);
}
fn avoids_break_between(before: &LaidOutBox, after: &LaidOutBox) -> bool {
before.fragmentation.break_after == BreakBetween::Avoid
|| after.fragmentation.break_before == BreakBetween::Avoid
}
fn keep_with_next_bottom(children: &[LaidOutBox], i: usize, page_height: f32) -> Option<f32> {
let next = (i + 1..children.len()).find(|&j| !children[j].is_float)?;
if !avoids_break_between(&children[i], &children[next]) {
return None;
}
Some(
keep_with_next_bottom(children, next, page_height)
.unwrap_or_else(|| first_piece_bottom(&children[next], page_height)),
)
}
fn first_piece_bottom(b: &LaidOutBox, page_height: f32) -> f32 {
let bottom = margin_box_top(b) + b.layout.margin_box_height();
if b.fragmentation.break_inside == BreakInside::Avoid
&& b.layout.margin_box_height() <= page_height
{
return bottom;
}
match &b.content {
LaidOutContent::Blocks(children) => match children.iter().position(|c| !c.is_float) {
Some(first) => keep_with_next_bottom(children, first, page_height)
.unwrap_or_else(|| first_piece_bottom(&children[first], page_height)),
None => bottom,
},
LaidOutContent::Table(table) => table
.rows
.iter()
.find(|row| row.section != TableSection::Head)
.or(table.rows.first())
.map(table_row_bottom)
.unwrap_or(bottom),
LaidOutContent::Grid(grid) if grid.rows.len() > 1 => grid.rows[0].bottom,
LaidOutContent::Inline(lines)
if lines.len() > 1
&& lines.len() >= (b.fragmentation.orphans + b.fragmentation.widows) as usize =>
{
let line = &lines[(b.fragmentation.orphans as usize).max(1) - 1];
line.rect.y + line.rect.height
}
LaidOutContent::Grid(_)
| LaidOutContent::Inline(_)
| LaidOutContent::Flex(_)
| LaidOutContent::Image(_) => bottom,
}
}
fn margin_box_top(b: &LaidOutBox) -> f32 {
b.layout.content.y - b.layout.padding.top - b.layout.border.top - b.layout.margin.top
}
fn subtree_requires_child_walk(b: &LaidOutBox) -> bool {
match &b.content {
LaidOutContent::Blocks(children) => children.iter().any(|child| {
child.fragmentation.break_before == BreakBetween::Always
|| child.fragmentation.break_after == BreakBetween::Always
|| subtree_requires_child_walk(child)
}),
LaidOutContent::Inline(_)
| LaidOutContent::Table(_)
| LaidOutContent::Flex(_)
| LaidOutContent::Grid(_)
| LaidOutContent::Image(_) => false,
}
}
fn compute_orphans_widows_breaks(
lines: &[LineBox],
orphans: usize,
widows: usize,
page_height: f32,
initial_cursor: f32,
) -> Vec<bool> {
let n = lines.len();
let mut force_break_before = vec![false; n];
let mut cursor = initial_cursor;
let mut page_start = 0usize;
let mut i = 0usize;
while i < n {
let height = lines[i].rect.height;
if !(cursor > 0.0 && cursor + height > page_height) {
cursor += height;
i += 1;
continue;
}
let fit_count = i - page_start;
let remaining = n - i;
let orphans_ok = fit_count >= orphans;
let widows_ok = remaining >= widows;
if orphans_ok && widows_ok {
page_start = i;
cursor = 0.0;
continue;
}
if force_break_before[page_start] {
page_start = i;
cursor = 0.0;
continue;
}
if !orphans_ok {
force_break_before[page_start] = true;
cursor = 0.0;
i = page_start;
continue;
}
let candidate = n.saturating_sub(widows);
if candidate >= page_start + orphans && candidate < i {
force_break_before[candidate] = true;
page_start = candidate;
cursor = 0.0;
i = candidate;
} else {
force_break_before[page_start] = true;
cursor = 0.0;
i = page_start;
}
}
force_break_before
}
#[allow(clippy::too_many_arguments)]
struct SplitContainer {
node: Option<NodeId>,
layout: Layout,
has_visible_decoration: bool,
marker: Option<Box<LineBox>>,
link: Option<Rc<str>>,
}
impl SplitContainer {
fn take_from(b: &mut LaidOutBox) -> Self {
Self {
node: b.node,
layout: b.layout,
has_visible_decoration: b.has_visible_decoration,
marker: b.marker.take(),
link: b.link.take(),
}
}
fn top_extra(&self) -> f32 {
self.layout.margin.top + self.layout.border.top + self.layout.padding.top
}
}
#[allow(clippy::too_many_arguments)]
fn place_split<T>(
container: &mut SplitContainer,
items: &mut [T],
page_height: f32,
state: &mut PaginationState<'_>,
cursor: &mut f32,
break_hints: impl Fn(usize, &T) -> (bool, bool),
keep_with_next: impl Fn(&[T], usize) -> Option<f32>,
is_float: impl Fn(&T) -> bool,
item_extent: impl Fn(&T) -> (f32, f32),
place_one: impl Fn(&mut T, f32, &mut PaginationState<'_>, &mut f32),
) {
let top_extra = container.top_extra();
*cursor += top_extra;
let mut shift_reference = container.layout.content.y - *cursor;
let needs_decoration = container.has_visible_decoration;
let needs_fragments =
needs_decoration || container.marker.is_some() || container.link.is_some();
if needs_fragments {
state.enter_split();
}
struct Segment {
page_index: usize,
start_index: usize,
}
let mut current_page = state.current_index();
let mut segments: Vec<Segment> = if needs_fragments {
vec![Segment {
page_index: current_page,
start_index: state.get(current_page).boxes.len(),
}]
} else {
Vec::new()
};
let force_new_page = |state: &mut PaginationState<'_>,
cursor: &mut f32,
current_page: &mut usize,
segments: &mut Vec<Segment>| {
new_page(state, cursor);
*current_page = state.current_index();
if needs_fragments {
segments.push(Segment {
page_index: *current_page,
start_index: 0,
});
}
};
let item_count = items.len();
let mut forced_page_start = false;
for i in 0..item_count {
let keep_bottom = keep_with_next(items, i);
let item = &mut items[i];
let (breaks_before, breaks_after) = break_hints(i, item);
if breaks_before && current_page_has_content(state) {
force_new_page(state, cursor, &mut current_page, &mut segments);
forced_page_start = true;
}
if is_float(item) {
let mut local_cursor = item_extent(item).0 - shift_reference;
place_one(item, page_height, state, &mut local_cursor);
} else {
if forced_page_start {
shift_reference = item_extent(item).0 - *cursor;
forced_page_start = false;
}
if let Some(keep_bottom) = keep_bottom {
let (top, bottom) = item_extent(item);
if bottom - shift_reference <= page_height
&& keep_bottom - shift_reference > page_height
&& keep_bottom - top <= page_height
&& current_page_has_content(state)
{
force_new_page(state, cursor, &mut current_page, &mut segments);
shift_reference = top - *cursor;
}
}
*cursor = (item_extent(item).0 - shift_reference).max(0.0);
place_one(item, page_height, state, cursor);
shift_reference = item_extent(item).1 - *cursor;
let now_page = state.current_index();
if now_page != current_page {
if needs_fragments {
for p in (current_page + 1)..=now_page {
segments.push(Segment {
page_index: p,
start_index: 0,
});
}
}
current_page = now_page;
}
}
if breaks_after && i + 1 < item_count {
force_new_page(state, cursor, &mut current_page, &mut segments);
forced_page_start = true;
}
}
let container_bottom = container.layout.content.y
+ container.layout.content.height
+ container.layout.padding.bottom
+ container.layout.border.bottom
+ container.layout.margin.bottom;
*cursor = (container_bottom - shift_reference).max(*cursor);
if !needs_fragments {
return;
}
let valid: Vec<&Segment> = segments
.iter()
.filter(|s| state.get(s.page_index).boxes.len() > s.start_index)
.collect();
let fragments: Vec<(usize, usize, LaidOutBox)> = valid
.iter()
.enumerate()
.filter_map(|(i, seg)| {
let is_first = i == 0;
let is_last = i == valid.len() - 1;
if !needs_decoration && container.link.is_none() && !is_first {
return None;
}
let end_index = state.get(seg.page_index).boxes.len();
let (top, bottom) =
extent_of(&state.get(seg.page_index).boxes[seg.start_index..end_index]);
let layout = fragment_layout(&container.layout, top, bottom, is_first, is_last);
let marker = if is_first {
container.marker.take().map(|mut marker| {
marker.rect.y -= container.layout.content.y - layout.content.y;
marker
})
} else {
None
};
let decoration = LaidOutBox {
node: container.node,
layout,
fragmentation: FragmentationHints::default(),
has_visible_decoration: needs_decoration,
is_float: false,
content: LaidOutContent::Blocks(Vec::new()),
marker,
link: container.link.clone(),
};
Some((seg.page_index, seg.start_index, decoration))
})
.collect();
for (page_index, insert_index, decoration) in fragments {
state
.get_mut(page_index)
.boxes
.insert(insert_index, decoration);
}
state.exit_split();
}
fn extent_of(boxes: &[LaidOutBox]) -> (f32, f32) {
let mut top = f32::INFINITY;
let mut bottom = f32::NEG_INFINITY;
for b in boxes {
let box_top = margin_box_top(b);
let box_bottom = box_top + b.layout.margin_box_height();
top = top.min(box_top);
bottom = bottom.max(box_bottom);
}
(top, bottom)
}
fn fragment_layout(
original: &Layout,
content_y: f32,
content_bottom: f32,
is_first: bool,
is_last: bool,
) -> Layout {
let top_border = if is_first { original.border.top } else { 0.0 };
let bottom_border = if is_last { original.border.bottom } else { 0.0 };
let top_padding = if is_first { original.padding.top } else { 0.0 };
let bottom_padding = if is_last {
original.padding.bottom
} else {
0.0
};
let fragment = match (is_first, is_last) {
(true, true) => FragmentPosition::Whole,
(true, false) => FragmentPosition::First,
(false, true) => FragmentPosition::Last,
(false, false) => FragmentPosition::Middle,
};
Layout {
content: Rect {
x: original.content.x,
y: content_y,
width: original.content.width,
height: (content_bottom - content_y).max(0.0),
},
padding: EdgeSizes {
top: top_padding,
right: original.padding.right,
bottom: bottom_padding,
left: original.padding.left,
},
border: EdgeSizes {
top: top_border,
right: original.border.right,
bottom: bottom_border,
left: original.border.left,
},
margin: EdgeSizes::default(),
fragment,
}
}
fn place_line(line: &LineBox, page_height: f32, state: &mut PaginationState<'_>, cursor: &mut f32) {
if *cursor > 0.0 && *cursor + line.rect.height > page_height {
new_page(state, cursor);
}
let shift = line.rect.y - *cursor;
let mut translated = line.clone();
translated.rect.y -= shift;
for atomic in translated.atomics.iter_mut() {
atomic.content = shift_box_y(&atomic.content, shift);
}
let fragment = LaidOutBox {
node: None,
layout: Layout {
content: translated.rect,
..Layout::default()
},
fragmentation: FragmentationHints::default(),
has_visible_decoration: false,
is_float: false,
content: LaidOutContent::Inline(vec![translated]),
marker: None,
link: None,
};
*cursor += line.rect.height;
state.last_mut().boxes.push(fragment);
}
fn place_grid(
container: &SplitContainer,
grid: &LaidOutGrid,
page_height: f32,
state: &mut PaginationState<'_>,
cursor: &mut f32,
) {
let top_extra = container.top_extra();
let bottom_extra = container.layout.padding.bottom
+ container.layout.border.bottom
+ container.layout.margin.bottom;
let mut pending: Vec<LaidOutGridRow> = Vec::new();
let mut shift = 0.0f32;
let mut fragment_top = 0.0f32;
let mut is_first_fragment = true;
*cursor += top_extra;
for (index, row) in grid.rows.iter().enumerate() {
if pending.is_empty() {
fragment_top = *cursor;
shift = row.top - *cursor;
if row.bottom - shift > page_height && current_page_has_content(state) {
new_page(state, cursor);
if is_first_fragment {
*cursor += top_extra;
}
fragment_top = *cursor;
shift = row.top - *cursor;
}
}
let can_break_before = index > 0 && !grid.rows[index - 1].spans_bottom;
let row_bottom_on_page = row.bottom - shift;
if row_bottom_on_page > page_height && !pending.is_empty() && can_break_before {
flush_grid_fragment(
container,
&mut pending,
fragment_top,
*cursor,
is_first_fragment,
false,
state,
);
is_first_fragment = false;
new_page(state, cursor);
fragment_top = *cursor;
shift = row.top - *cursor;
}
pending.push(shift_grid_row_y(row, shift));
*cursor = row.bottom - shift;
}
flush_grid_fragment(
container,
&mut pending,
fragment_top,
*cursor,
is_first_fragment,
true,
state,
);
*cursor += bottom_extra;
}
fn shift_grid_row_y(row: &LaidOutGridRow, delta: f32) -> LaidOutGridRow {
LaidOutGridRow {
items: row
.items
.iter()
.map(|item| shift_box_y(item, delta))
.collect(),
top: row.top - delta,
bottom: row.bottom - delta,
spans_bottom: row.spans_bottom,
}
}
fn flush_grid_fragment(
container: &SplitContainer,
rows: &mut Vec<LaidOutGridRow>,
fragment_top: f32,
fragment_bottom: f32,
is_first: bool,
is_last: bool,
state: &mut PaginationState<'_>,
) {
if rows.is_empty() {
return;
}
let layout = fragment_layout(
&container.layout,
fragment_top,
fragment_bottom,
is_first,
is_last,
);
let fragment = LaidOutBox {
node: container.node,
layout,
fragmentation: FragmentationHints::default(),
is_float: false,
marker: None,
has_visible_decoration: container.has_visible_decoration,
content: LaidOutContent::Grid(LaidOutGrid {
rows: std::mem::take(rows),
}),
link: container.link.clone(),
};
state.last_mut().boxes.push(fragment);
}
struct FlexBand {
items: Vec<LaidOutBox>,
top: f32,
bottom: f32,
}
const FLEX_BAND_EPSILON: f32 = 0.01;
fn group_flex_items_into_bands(mut items: Vec<LaidOutBox>) -> Vec<FlexBand> {
items.sort_by(|a, b| margin_box_top(a).total_cmp(&margin_box_top(b)));
let mut bands: Vec<FlexBand> = Vec::new();
for item in items {
let top = margin_box_top(&item);
let bottom = top + item.layout.margin_box_height();
match bands.last_mut() {
Some(band) if top < band.bottom - FLEX_BAND_EPSILON => {
band.items.push(item);
band.bottom = band.bottom.max(bottom);
}
_ => bands.push(FlexBand {
items: vec![item],
top,
bottom,
}),
}
}
bands
}
fn place_flex_band(
band: &mut FlexBand,
page_height: f32,
state: &mut PaginationState<'_>,
cursor: &mut f32,
) {
if let [item] = band.items.as_mut_slice() {
place_box(item, page_height, state, cursor);
return;
}
let height = band.bottom - band.top;
if *cursor > 0.0 && *cursor + height > page_height {
new_page(state, cursor);
}
let base = *cursor;
for item in band.items.iter_mut() {
let mut local_cursor = base + (margin_box_top(item) - band.top);
place_leaf(item, state, &mut local_cursor);
}
*cursor = base + height;
}
fn place_table(
container: &SplitContainer,
table: &mut LaidOutTable,
page_height: f32,
state: &mut PaginationState<'_>,
cursor: &mut f32,
) {
let top_extra = container.top_extra();
let bottom_extra = container.layout.padding.bottom
+ container.layout.border.bottom
+ container.layout.margin.bottom;
let mut pending: Vec<LaidOutTableRow> = Vec::new();
let mut shift = 0.0f32;
let mut fragment_top = 0.0f32;
let mut is_first_fragment = true;
let head_rows: Vec<LaidOutTableRow> = table
.rows
.iter()
.filter(|row| row.section == TableSection::Head)
.cloned()
.collect();
let head_top = head_rows.iter().map(table_row_top).fold(f32::MAX, f32::min);
let head_bottom = head_rows
.iter()
.map(table_row_bottom)
.fold(f32::MIN, f32::max);
let head_height = if head_rows.is_empty() {
0.0
} else {
head_bottom - head_top
};
let repeat_head = !head_rows.is_empty() && head_height < page_height;
let caption_is_top = table.caption_side == CaptionSide::Top;
let mut pending_caption = table.caption.as_deref().filter(|_| caption_is_top).cloned();
*cursor += top_extra;
let caption_height = pending_caption
.as_ref()
.map(|c| c.layout.margin_box_height())
.unwrap_or(0.0);
let start_new_fragment = |cursor: &f32, first_row_top: f32, extra_above: f32| {
let top = *cursor;
(top, first_row_top - extra_above - *cursor)
};
for (index, mut row) in std::mem::take(&mut table.rows).into_iter().enumerate() {
let row_top = table_row_top(&row);
let row_bottom = table_row_bottom(&row);
let extra_above = if index == 0 { caption_height } else { 0.0 };
if pending.is_empty() {
let (top, s) = start_new_fragment(cursor, row_top, extra_above);
fragment_top = top;
shift = s;
if row_bottom - shift > page_height && current_page_has_content(state) {
new_page(state, cursor);
if is_first_fragment {
*cursor += top_extra;
}
let (top, s) = start_new_fragment(cursor, row_top, extra_above);
fragment_top = top;
shift = s;
}
}
let row_bottom_on_page = row_bottom - shift;
if row_bottom_on_page > page_height && !pending.is_empty() {
flush_table_fragment(
container,
&mut pending,
&mut pending_caption,
fragment_top,
*cursor,
is_first_fragment,
false,
state,
);
is_first_fragment = false;
new_page(state, cursor);
fragment_top = *cursor;
if repeat_head {
let head_shift = head_top - *cursor;
for head_row in &head_rows {
pending.push(shift_table_row_y(head_row, head_shift));
}
*cursor += head_height;
}
shift = row_top - *cursor;
}
shift_table_row_y_in_place(&mut row, shift);
pending.push(row);
*cursor = row_bottom - shift;
}
if !caption_is_top {
if let Some(caption) = table.caption.as_deref() {
let translated = shift_box_y(caption, shift);
*cursor += caption.layout.margin_box_height();
pending_caption = Some(translated);
}
}
flush_table_fragment(
container,
&mut pending,
&mut pending_caption,
fragment_top,
*cursor,
is_first_fragment,
true,
state,
);
*cursor += bottom_extra;
}
#[allow(clippy::too_many_arguments)]
fn flush_table_fragment(
container: &SplitContainer,
rows: &mut Vec<LaidOutTableRow>,
caption: &mut Option<LaidOutBox>,
fragment_top: f32,
fragment_bottom: f32,
is_first: bool,
is_last: bool,
state: &mut PaginationState<'_>,
) {
if rows.is_empty() && caption.is_none() {
return;
}
let layout = fragment_layout(
&container.layout,
fragment_top,
fragment_bottom,
is_first,
is_last,
);
let fragment = LaidOutBox {
node: container.node,
layout,
fragmentation: FragmentationHints::default(),
has_visible_decoration: container.has_visible_decoration,
is_float: false,
content: LaidOutContent::Table(LaidOutTable {
caption: caption.take().map(Box::new),
caption_side: CaptionSide::Top,
rows: std::mem::take(rows),
}),
marker: None,
link: container.link.clone(),
};
state.last_mut().boxes.push(fragment);
}
fn table_row_top(row: &LaidOutTableRow) -> f32 {
row.cells
.iter()
.map(margin_box_top)
.fold(f32::MAX, f32::min)
}
fn table_row_bottom(row: &LaidOutTableRow) -> f32 {
row.cells
.iter()
.map(|cell| margin_box_top(cell) + cell.layout.margin_box_height())
.fold(f32::MIN, f32::max)
}
fn shift_table_row_y_in_place(row: &mut LaidOutTableRow, shift: f32) {
for cell in &mut row.cells {
shift_box_y_in_place(cell, shift);
}
}
fn shift_table_row_y(row: &LaidOutTableRow, shift: f32) -> LaidOutTableRow {
LaidOutTableRow {
node: row.node,
cells: row.cells.iter().map(|c| shift_box_y(c, shift)).collect(),
section: row.section,
group: row.group,
}
}
fn place_leaf(b: &mut LaidOutBox, state: &mut PaginationState<'_>, cursor: &mut f32) {
let shift = margin_box_top(b) - *cursor;
let height = b.layout.margin_box_height();
let mut translated = LaidOutBox {
node: b.node,
layout: b.layout,
fragmentation: b.fragmentation,
has_visible_decoration: b.has_visible_decoration,
is_float: false,
content: std::mem::replace(&mut b.content, LaidOutContent::Blocks(Vec::new())),
marker: b.marker.take(),
link: b.link.take(),
};
shift_box_y_in_place(&mut translated, shift);
*cursor += height;
state.last_mut().boxes.push(translated);
}
fn new_page(state: &mut PaginationState<'_>, cursor: &mut f32) {
state.push_new_page();
state.try_flush();
*cursor = 0.0;
}
fn current_page_has_content(state: &PaginationState<'_>) -> bool {
!state.last().boxes.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fonts::Font;
use crate::html::{self, Dom, NodeData};
use crate::layout::block::layout_document_from;
use crate::layout::box_tree::build_box_for_element;
use crate::style::{compute_styles, parse_stylesheet, user_agent_stylesheet, Stylesheet};
const TEST_FONT_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fonts/DejaVuSans.ttf");
fn test_fonts() -> FontCollection {
FontCollection::new(vec![
Font::load(TEST_FONT_PATH).expect("should load bundled test font")
])
}
fn find_all(dom: &Dom, id: NodeId, tag: &str, out: &mut Vec<NodeId>) {
if let NodeData::Element { name, .. } = &dom.node(id).data {
if &*name.local == tag {
out.push(id);
}
}
for child in dom.children(id) {
find_all(dom, child, tag, out);
}
}
fn box_contains_node(b: &LaidOutBox, target: NodeId) -> bool {
if b.node == Some(target) {
return true;
}
if let LaidOutContent::Blocks(children) = &b.content {
return children
.iter()
.any(|child| box_contains_node(child, target));
}
false
}
fn assert_within_page(b: &LaidOutBox, page_height: f32) {
let top = margin_box_top(b);
assert!(top >= -0.01, "box top {top} should not be negative");
assert!(
top + b.layout.margin_box_height() <= page_height + 0.01,
"box bottom should not exceed page height {page_height}"
);
if let LaidOutContent::Blocks(children) = &b.content {
for child in children {
assert_within_page(child, page_height);
}
}
}
#[test]
fn page_settings_computes_content_area() {
let settings = PageSettings::default();
assert_eq!(
settings.content_width(),
settings.size.width - settings.margin.left - settings.margin.right
);
assert_eq!(
settings.content_height(),
settings.size.height - settings.margin.top - settings.margin.bottom
);
}
#[test]
fn short_document_fits_on_a_single_page_and_keeps_structure() {
let dom = html::parse(br#"<p>hello</p>"#);
let ua = user_agent_stylesheet();
let author = Stylesheet::default();
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(pages.len(), 1);
let mut htmls = Vec::new();
find_all(&dom, dom.document(), "html", &mut htmls);
assert_eq!(pages[0].boxes.len(), 1);
assert_eq!(pages[0].boxes[0].node, None);
assert!(box_contains_node(&pages[0].boxes[0], htmls[0]));
assert_eq!(pages[0].boxes[0].layout.fragment, FragmentPosition::Whole);
}
#[test]
fn tall_content_distributes_across_multiple_pages_without_losing_items() {
let mut html_src = String::from("<div>");
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(".item { height: 100px; margin: 0; }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert!(
pages.len() > 1,
"20 items of 100px should overflow a single page"
);
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
assert_eq!(ps.len(), 20);
for &p_id in &ps {
let found_on_some_page = pages
.iter()
.any(|page| page.boxes.iter().any(|b| box_contains_node(b, p_id)));
assert!(
found_on_some_page,
"p {p_id:?} should be placed on some page"
);
}
for page in &pages {
for b in &page.boxes {
assert_within_page(b, settings.content_height());
}
}
}
#[test]
fn float_taller_than_a_page_splits_across_pages_without_losing_items() {
let mut html_src = String::from(r#"<div><div class="f">"#);
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div></div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
"body { margin: 0; } \
.f { float: left; width: 100px; } \
.item { height: 100px; margin: 0; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert!(
pages.len() > 1,
"a float containing 20 items of 100px should overflow a single page \
(a float is allowed to cross a page boundary)"
);
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
assert_eq!(ps.len(), 20);
for &p_id in &ps {
let found_on_some_page = pages
.iter()
.any(|page| page.boxes.iter().any(|b| box_contains_node(b, p_id)));
assert!(
found_on_some_page,
"p {p_id:?} inside the float should be placed on some page"
);
}
for page in &pages {
for b in &page.boxes {
assert_within_page(b, settings.content_height());
}
}
}
#[test]
fn float_is_translated_to_page_relative_coordinates_consistently_with_siblings() {
let dom = html::parse(br#"<div><div class="a">a</div><div class="f">F</div></div>"#);
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
"body { margin: 0; } \
.a { height: 50px; margin: 0; } \
.f { float: left; width: 30px; height: 20px; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(pages.len(), 1);
let mut divs = Vec::new();
find_all(&dom, dom.document(), "div", &mut divs);
fn find_box(b: &LaidOutBox, target: NodeId) -> Option<&LaidOutBox> {
if b.node == Some(target) {
return Some(b);
}
if let LaidOutContent::Blocks(children) = &b.content {
for child in children {
if let Some(found) = find_box(child, target) {
return Some(found);
}
}
}
None
}
let float_box = pages[0]
.boxes
.iter()
.find_map(|b| find_box(b, divs[2]))
.expect("float box not found on the page");
assert_eq!(float_box.layout.content.y, 50.0);
}
#[test]
fn long_paragraph_splits_across_pages_by_line() {
let words: Vec<String> = (0..1000).map(|i| format!("word{i}")).collect();
let html_src = format!("<p>{}</p>", words.join(" "));
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = Stylesheet::default();
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert!(
pages.len() > 1,
"1000 words should wrap into more lines than fit on one page"
);
let total_lines: usize = pages
.iter()
.flat_map(|page| &page.boxes)
.filter_map(|b| match &b.content {
LaidOutContent::Inline(lines) => Some(lines.len()),
_ => None,
})
.sum();
assert!(
total_lines > 20,
"1000 words should wrap into many lines total, got {total_lines}"
);
assert!(
pages[0].boxes.len() > 1,
"first page should hold multiple line fragments, got {}",
pages[0].boxes.len()
);
}
fn find_decoration_fragment(page: &Page, target: NodeId) -> Option<&LaidOutBox> {
page.boxes.iter().find(|b| {
b.node == Some(target)
&& matches!(&b.content, LaidOutContent::Blocks(c) if c.is_empty())
})
}
#[test]
fn split_container_gets_a_decoration_fragment_on_every_page_it_spans() {
let mut html_src = String::from(r#"<div class="wrapper">"#);
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".wrapper { border: 2px solid black; padding: 5px; margin: 0; } \
.item { height: 100px; margin: 0; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert!(
pages.len() >= 3,
"expected the wrapper to span at least 3 pages, got {}",
pages.len()
);
let mut divs = Vec::new();
find_all(&dom, dom.document(), "div", &mut divs);
let wrapper = divs[0];
let decorations: Vec<&LaidOutBox> = pages
.iter()
.map(|page| {
find_decoration_fragment(page, wrapper)
.expect("every page the wrapper spans should carry a decoration fragment")
})
.collect();
assert_eq!(decorations[0].layout.border.top, 2.0);
assert_eq!(decorations[0].layout.padding.top, 5.0);
assert_eq!(decorations[0].layout.fragment, FragmentPosition::First);
let last = decorations.last().unwrap();
assert_eq!(last.layout.border.bottom, 2.0);
assert_eq!(last.layout.padding.bottom, 5.0);
assert_eq!(last.layout.fragment, FragmentPosition::Last);
for decoration in &decorations[1..decorations.len() - 1] {
assert_eq!(decoration.layout.fragment, FragmentPosition::Middle);
}
for decoration in &decorations {
assert_eq!(decoration.layout.border.left, 2.0);
assert_eq!(decoration.layout.padding.left, 5.0);
assert!(decoration.layout.content.height > 0.0);
}
for decoration in &decorations[1..decorations.len() - 1] {
assert_eq!(decoration.layout.border.top, 0.0);
assert_eq!(decoration.layout.border.bottom, 0.0);
assert_eq!(decoration.layout.padding.top, 0.0);
assert_eq!(decoration.layout.padding.bottom, 0.0);
}
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
for &p_id in &ps {
let found = pages
.iter()
.any(|page| page.boxes.iter().any(|b| box_contains_node(b, p_id)));
assert!(found, "p {p_id:?} should still be placed on some page");
}
}
#[test]
fn split_container_without_visible_decoration_gets_no_decoration_fragment() {
let mut html_src = String::from("<div>");
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(".item { height: 100px; margin: 0; }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert!(
pages.len() > 1,
"expected the wrapper to span multiple pages"
);
let mut divs = Vec::new();
find_all(&dom, dom.document(), "div", &mut divs);
let wrapper = divs[0];
for page in &pages {
assert!(
find_decoration_fragment(page, wrapper).is_none(),
"a wrapper without background/border should not get a decoration fragment"
);
}
}
fn page_contains_content(page: &Page, target: NodeId) -> bool {
page.boxes.iter().any(|b| box_contains_node(b, target))
}
#[test]
fn break_before_always_forces_a_new_page_even_though_both_fit_on_one_page() {
let dom = html::parse(br#"<div><p class="a">A</p><p class="b">B</p></div>"#);
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".a { height: 50px; margin: 0; } \
.b { height: 50px; margin: 0; break-before: always; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(
pages.len(),
2,
"break-before: always should force a new page even though both \
paragraphs easily fit on a single page"
);
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
let (a, b) = (ps[0], ps[1]);
assert!(page_contains_content(&pages[0], a));
assert!(!page_contains_content(&pages[0], b));
assert!(page_contains_content(&pages[1], b));
assert!(!page_contains_content(&pages[1], a));
}
#[test]
fn break_before_always_on_the_first_element_does_not_create_a_blank_leading_page() {
let dom = html::parse(br#"<p class="a">A</p>"#);
let ua = user_agent_stylesheet();
let author = parse_stylesheet(".a { height: 50px; margin: 0; break-before: always; }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(
pages.len(),
1,
"break-before: always on the very first element of the document \
should not produce a blank leading page"
);
}
#[test]
fn break_after_always_forces_a_new_page_before_the_next_sibling() {
let dom = html::parse(br#"<div><p class="a">A</p><p class="b">B</p></div>"#);
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".a { height: 50px; margin: 0; break-after: always; } \
.b { height: 50px; margin: 0; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(pages.len(), 2);
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
let (a, b) = (ps[0], ps[1]);
assert!(page_contains_content(&pages[0], a));
assert!(page_contains_content(&pages[1], b));
assert!(!page_contains_content(&pages[0], b));
}
#[test]
fn break_after_always_on_the_last_element_does_not_create_a_trailing_blank_page() {
let dom = html::parse(br#"<p class="a">A</p>"#);
let ua = user_agent_stylesheet();
let author = parse_stylesheet(".a { height: 50px; margin: 0; break-after: always; }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(
pages.len(),
1,
"break-after: always on the very last element should not produce \
a trailing blank page"
);
}
#[test]
fn nested_break_before_is_honored_even_when_the_whole_subtree_fits_on_one_page() {
let dom = html::parse(br#"<div><p class="a">A</p><p class="b">B</p></div>"#);
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".a { height: 10px; margin: 0; } \
.b { height: 10px; margin: 0; break-before: always; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(pages.len(), 2);
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
assert!(page_contains_content(&pages[0], ps[0]));
assert!(page_contains_content(&pages[1], ps[1]));
}
#[test]
fn break_inside_avoid_moves_the_whole_block_to_the_next_page_instead_of_splitting() {
let settings = PageSettings::default();
let filler_height = settings.content_height() - 200.0;
let html_src = r#"<div class="filler"></div>
<div class="wrapper">
<p class="a">A</p><p class="b">B</p><p class="c">C</p><p class="d">D</p>
</div>"#;
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(&format!(
".filler {{ height: {filler_height}px; margin: 0; }} \
.wrapper {{ break-inside: avoid; margin: 0; }} \
.a, .b, .c, .d {{ height: 100px; margin: 0; }}"
));
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(
pages.len(),
2,
"the wrapper should move to a fresh second page instead of splitting"
);
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
for &p in &ps {
assert!(
page_contains_content(&pages[1], p),
"all paragraphs of the avoid-split wrapper should land on page 2"
);
assert!(!page_contains_content(&pages[0], p));
}
}
fn paginate_after_filler(room: f32, body: &str, css: &str) -> (Dom, Vec<Page>) {
let settings = PageSettings::default();
let filler_height = settings.content_height() - room;
let dom = html::parse(format!(r#"<div class="filler"></div>{body}"#).as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(&format!(
".filler {{ height: {filler_height}px; margin: 0; }} \
h2, p, table {{ margin: 0; }} h2 {{ height: 40px; }} p {{ height: 100px; }} {css}"
));
let styles = compute_styles(&dom, &ua, &author);
let pages = paginate_document(&dom, &styles, &test_fonts(), &settings);
(dom, pages)
}
fn pages_of(dom: &Dom, pages: &[Page], tag: &str) -> Vec<usize> {
let mut nodes = Vec::new();
find_all(dom, dom.document(), tag, &mut nodes);
nodes
.iter()
.map(|&node| {
pages
.iter()
.position(|page| page_contains_content(page, node))
.expect("every element is placed on a page")
})
.collect()
}
#[test]
fn break_after_avoid_moves_a_heading_the_next_block_does_not_fit_under() {
let (dom, pages) = paginate_after_filler(
60.0,
"<section><h2>Heading</h2><p>Body</p></section>",
"h2 { break-after: avoid; }",
);
assert_eq!(pages_of(&dom, &pages, "h2"), vec![1]);
assert_eq!(pages_of(&dom, &pages, "p"), vec![1]);
}
#[test]
fn a_heading_without_break_after_avoid_stays_at_the_foot_of_the_page() {
let (dom, pages) =
paginate_after_filler(60.0, "<section><h2>Heading</h2><p>Body</p></section>", "");
assert_eq!(pages_of(&dom, &pages, "h2"), vec![0]);
assert_eq!(pages_of(&dom, &pages, "p"), vec![1]);
}
#[test]
fn break_after_avoid_keeps_a_heading_in_place_when_the_next_block_fits_under_it() {
let (dom, pages) = paginate_after_filler(
200.0,
"<section><h2>Heading</h2><p>Body</p><p>More</p></section>",
"h2 { break-after: avoid; }",
);
assert_eq!(pages_of(&dom, &pages, "h2"), vec![0]);
assert_eq!(pages_of(&dom, &pages, "p"), vec![0, 1]);
}
#[test]
fn break_before_avoid_on_the_next_block_moves_the_heading_too() {
let (dom, pages) = paginate_after_filler(
60.0,
"<section><h2>Heading</h2><p>Body</p></section>",
"p { break-before: avoid; }",
);
assert_eq!(pages_of(&dom, &pages, "h2"), vec![1]);
}
#[test]
fn break_after_avoid_moves_stacked_headings_together() {
let (dom, pages) = paginate_after_filler(
100.0,
"<section><h2>List</h2><div><div><h2>Item</h2><p>Body</p></div></div></section>",
"h2 { break-after: avoid; } div { margin: 0; }",
);
assert_eq!(pages_of(&dom, &pages, "h2"), vec![1, 1]);
assert_eq!(pages_of(&dom, &pages, "p"), vec![1]);
}
#[test]
fn break_after_avoid_keeps_a_heading_with_the_first_row_of_a_table() {
let mut rows = String::new();
for i in 0..20 {
rows.push_str(&format!("<tr><td>row {i}</td></tr>"));
}
let (dom, pages) = paginate_after_filler(
60.0,
&format!("<section><h2>Heading</h2><table>{rows}</table></section>"),
"h2 { break-after: avoid; } td { height: 30px; padding: 0; }",
);
assert_eq!(pages_of(&dom, &pages, "h2"), vec![1]);
assert_eq!(pages_of(&dom, &pages, "table"), vec![1]);
}
#[test]
fn break_after_avoid_gives_up_when_the_heading_and_the_next_block_do_not_fit_a_page() {
let settings = PageSettings::default();
let tall = settings.content_height();
let (dom, pages) = paginate_after_filler(
60.0,
"<section><h2>Heading</h2><p>Body</p></section>",
&format!("h2 {{ break-after: avoid; }} p {{ height: {tall}px; }}"),
);
assert_eq!(pages_of(&dom, &pages, "h2"), vec![0]);
}
#[test]
fn break_inside_avoid_still_splits_when_the_element_is_taller_than_a_full_page() {
let settings = PageSettings::default();
let mut html_src = String::from(r#"<div class="wrapper">"#);
for i in 0..30 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".wrapper { break-inside: avoid; margin: 0; } \
.item { height: 100px; margin: 0; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert!(
pages.len() > 2,
"a wrapper taller than a full page must still be split across pages \
despite break-inside: avoid, got {} pages",
pages.len()
);
}
fn measure_paragraph_lines(word_count: usize, width: f32) -> (usize, f32) {
let words: Vec<String> = (0..word_count).map(|i| format!("word{i}")).collect();
let html_src = format!(r#"<p class="target">{}</p>"#, words.join(" "));
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(&format!(".target {{ width: {width}px; margin: 0; }}"));
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let tree = build_box_tree(&dom, &styles);
let laid = layout_document(
&tree,
&styles,
&fonts,
PageSettings::default().content_width(),
);
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
let lines = find_inline_lines(&laid, ps[0]).expect("expected inline content");
let height = lines[0].rect.height;
assert!(
lines.iter().all(|l| (l.rect.height - height).abs() < 0.01),
"this test relies on every wrapped line having the same height"
);
(lines.len(), height)
}
fn find_inline_lines(b: &LaidOutBox, target: NodeId) -> Option<&Vec<LineBox>> {
if b.node == Some(target) {
if let LaidOutContent::Inline(lines) = &b.content {
return Some(lines);
}
}
match &b.content {
LaidOutContent::Blocks(children) => {
children.iter().find_map(|c| find_inline_lines(c, target))
}
_ => None,
}
}
fn count_inline_lines(b: &LaidOutBox) -> usize {
match &b.content {
LaidOutContent::Inline(lines) => lines.len(),
LaidOutContent::Blocks(children) => children.iter().map(count_inline_lines).sum(),
LaidOutContent::Table(_)
| LaidOutContent::Flex(_)
| LaidOutContent::Grid(_)
| LaidOutContent::Image(_) => 0,
}
}
fn lines_on_page(page: &Page) -> usize {
page.boxes.iter().map(count_inline_lines).sum()
}
#[test]
fn orphans_defers_the_whole_paragraph_when_too_few_lines_would_fit() {
let word_count = 60;
let width = 200.0;
let (n, line_height) = measure_paragraph_lines(word_count, width);
assert!(n >= 4, "expected several wrapped lines, got {n}");
let settings = PageSettings::default();
let orphans = 3usize;
let widows = 1usize;
let target_fit = 1usize;
let desired_remaining = (target_fit as f32 + 0.5) * line_height;
let filler_height = settings.content_height() - 8.0 - desired_remaining;
let words: Vec<String> = (0..word_count).map(|i| format!("word{i}")).collect();
let full_html = format!(
r#"<div class="filler"></div><p class="target">{}</p>"#,
words.join(" ")
);
let dom = html::parse(full_html.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(&format!(
".filler {{ height: {filler_height}px; margin: 0; }} \
.target {{ width: {width}px; margin: 0; orphans: {orphans}; widows: {widows}; }}"
));
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(
pages.len(),
2,
"the paragraph should move entirely to a second page"
);
assert_eq!(
lines_on_page(&pages[0]),
0,
"orphans: {orphans} should prevent leaving only {target_fit} line(s) behind"
);
assert_eq!(lines_on_page(&pages[1]), n);
}
#[test]
fn widows_pulls_lines_forward_to_avoid_stranding_too_few_on_the_next_page() {
let word_count = 60;
let width = 200.0;
let (n, line_height) = measure_paragraph_lines(word_count, width);
assert!(n >= 8, "expected several wrapped lines, got {n}");
let settings = PageSettings::default();
let orphans = 1usize;
let widows = 3usize;
let target_fit = n - 1;
let desired_remaining = (target_fit as f32 + 0.5) * line_height;
let filler_height = settings.content_height() - 8.0 - desired_remaining;
let words: Vec<String> = (0..word_count).map(|i| format!("word{i}")).collect();
let full_html = format!(
r#"<div class="filler"></div><p class="target">{}</p>"#,
words.join(" ")
);
let dom = html::parse(full_html.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(&format!(
".filler {{ height: {filler_height}px; margin: 0; }} \
.target {{ width: {width}px; margin: 0; orphans: {orphans}; widows: {widows}; }}"
));
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(pages.len(), 2);
let on_page_2 = lines_on_page(&pages[1]);
assert!(
on_page_2 >= widows,
"widows: {widows} should keep at least that many lines together on page 2, got {on_page_2}"
);
assert_eq!(lines_on_page(&pages[0]) + on_page_2, n);
}
#[test]
fn paragraph_shorter_than_orphans_plus_widows_is_never_split() {
let word_count = 3;
let width = 10.0;
let (n, line_height) = measure_paragraph_lines(word_count, width);
assert_eq!(n, 3, "expected each word to wrap onto its own line");
let settings = PageSettings::default();
let orphans = 2usize;
let widows = 2usize;
let target_fit = 2usize;
let desired_remaining = (target_fit as f32 + 0.5) * line_height;
let filler_height = settings.content_height() - 8.0 - desired_remaining;
let words: Vec<String> = (0..word_count).map(|i| format!("word{i}")).collect();
let full_html = format!(
r#"<div class="filler"></div><p class="target">{}</p>"#,
words.join(" ")
);
let dom = html::parse(full_html.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(&format!(
".filler {{ height: {filler_height}px; margin: 0; }} \
.target {{ width: {width}px; margin: 0; orphans: {orphans}; widows: {widows}; }}"
));
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
assert_eq!(pages.len(), 2);
assert_eq!(
lines_on_page(&pages[0]),
0,
"a paragraph shorter than orphans + widows should never be split"
);
assert_eq!(lines_on_page(&pages[1]), n);
}
fn unflushed_buffer_len_after_place_box(laid_out: &LaidOutBox, page_height: f32) -> usize {
let mut buffer = PaginationBuffer::new();
let mut on_page = |_page: Page| {};
let mut state = PaginationState::new(&mut buffer, &mut on_page);
let mut cursor = 0.0f32;
place_box(&mut laid_out.clone(), page_height, &mut state, &mut cursor);
buffer.buffer.len()
}
#[test]
fn streaming_flushes_undecorated_content_incrementally_not_all_at_finish() {
let mut html_src = String::from("<div>");
for i in 0..60 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(".item { height: 100px; margin: 0; }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let tree = build_box_tree(&dom, &styles);
let mut laid_out = layout_document(&tree, &styles, &fonts, settings.content_width());
let page_height = settings.content_height();
let total_pages = paginate(&mut laid_out, page_height).len();
assert!(
total_pages >= 5,
"expected several pages, got {total_pages}"
);
assert_eq!(
unflushed_buffer_len_after_place_box(&laid_out, page_height),
1,
"pages should already be flushed incrementally before finish() is even called"
);
}
#[test]
fn streaming_still_flushes_down_to_one_page_when_a_decorated_wrapper_spans_many_pages() {
let mut html_src = String::from(r#"<div class="wrapper">"#);
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".wrapper { border: 2px solid black; padding: 5px; margin: 0; } \
.item { height: 100px; margin: 0; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let tree = build_box_tree(&dom, &styles);
let mut laid_out = layout_document(&tree, &styles, &fonts, settings.content_width());
let page_height = settings.content_height();
let total_pages = paginate(&mut laid_out, page_height).len();
assert!(
total_pages >= 3,
"expected the wrapper to span multiple pages, got {total_pages}"
);
assert_eq!(
unflushed_buffer_len_after_place_box(&laid_out, page_height),
1,
"even a decorated wrapper should be fully resolved (and its earlier pages \
flushed) by the time the top-level place_box call returns"
);
}
#[test]
fn paginate_streaming_matches_the_batched_version_for_a_decorated_spanning_wrapper() {
let mut html_src = String::from(r#"<div class="wrapper">"#);
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".wrapper { border: 2px solid black; padding: 5px; margin: 0; } \
.item { height: 100px; margin: 0; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let tree = build_box_tree(&dom, &styles);
let mut laid_out = layout_document(&tree, &styles, &fonts, settings.content_width());
let page_height = settings.content_height();
let batched = paginate(&mut laid_out, page_height);
let mut streamed = Vec::new();
paginate_streaming(&mut laid_out, page_height, &mut |page| streamed.push(page));
assert_eq!(batched.len(), streamed.len());
assert!(batched.len() >= 3);
let mut divs = Vec::new();
find_all(&dom, dom.document(), "div", &mut divs);
let wrapper = divs[0];
for (b_page, s_page) in batched.iter().zip(streamed.iter()) {
assert_eq!(b_page.boxes.len(), s_page.boxes.len());
let b_dec = find_decoration_fragment(b_page, wrapper);
let s_dec = find_decoration_fragment(s_page, wrapper);
assert_eq!(
b_dec.map(|d| d.layout.fragment),
s_dec.map(|d| d.layout.fragment)
);
assert_eq!(
b_dec.map(|d| d.layout.border.top),
s_dec.map(|d| d.layout.border.top)
);
assert_eq!(
b_dec.map(|d| d.layout.border.bottom),
s_dec.map(|d| d.layout.border.bottom)
);
assert_eq!(
b_dec.map(|d| d.layout.padding.top),
s_dec.map(|d| d.layout.padding.top)
);
}
}
#[test]
fn paginate_document_streaming_releases_paragraphs_as_their_page_flushes() {
let mut html_src = String::from("<div>");
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let mut dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(".item { height: 100px; margin: 0; }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
assert_eq!(ps.len(), 20);
let mut flushed_pages = 0usize;
paginate_document_streaming(
&mut dom,
&styles,
&fonts,
&settings,
&ImageAssetCache::new(std::path::PathBuf::from("."), false),
&mut |_page| {
flushed_pages += 1;
},
);
assert!(flushed_pages > 1, "expected multiple pages");
for &p in &ps {
assert!(
dom.is_released(p),
"paragraph {p:?} should be released once its page has flushed"
);
}
}
#[test]
fn paginate_document_streaming_eventually_releases_a_spanning_wrapper() {
let mut html_src = String::from(r#"<div class="wrapper">"#);
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let mut dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".wrapper { border: 2px solid black; padding: 5px; margin: 0; } \
.item { height: 100px; margin: 0; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let mut divs = Vec::new();
find_all(&dom, dom.document(), "div", &mut divs);
let wrapper = divs[0];
let mut flushed_pages = 0usize;
paginate_document_streaming(
&mut dom,
&styles,
&fonts,
&settings,
&ImageAssetCache::new(std::path::PathBuf::from("."), false),
&mut |_page| {
flushed_pages += 1;
},
);
assert!(
flushed_pages >= 3,
"expected the wrapper to span at least 3 pages, got {flushed_pages}"
);
assert!(
dom.is_released(wrapper),
"the wrapper should be released once its final page has flushed"
);
}
#[test]
fn wrapper_node_is_not_released_before_its_last_fragment_flushes() {
let mut html_src = String::from(r#"<div class="wrapper">"#);
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let mut dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(
".wrapper { border: 2px solid black; padding: 5px; margin: 0; } \
.item { height: 100px; margin: 0; }",
);
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let mut divs = Vec::new();
find_all(&dom, dom.document(), "div", &mut divs);
let wrapper = divs[0];
let tree = build_box_tree(&dom, &styles);
let mut laid_out = layout_document(&tree, &styles, &fonts, settings.content_width());
let page_height = settings.content_height();
let total_pages = paginate(&mut laid_out, page_height).len();
assert!(total_pages >= 3);
let mut flushed_pages = 0usize;
let mut observed_mid_release = Vec::new();
paginate_streaming(&mut laid_out, page_height, &mut |page| {
flushed_pages += 1;
release_completed_subtrees(&mut dom, &page);
if flushed_pages < total_pages {
observed_mid_release.push(dom.is_released(wrapper));
}
});
assert!(
observed_mid_release.iter().all(|&released| !released),
"the wrapper must stay alive until its last fragment flushes, observed: \
{observed_mid_release:?}"
);
assert!(dom.is_released(wrapper));
}
#[test]
fn paragraphs_on_a_later_page_are_not_released_before_their_own_page_flushes() {
let mut html_src = String::from("<div>");
for i in 0..20 {
html_src.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
html_src.push_str("</div>");
let mut dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(".item { height: 100px; margin: 0; }");
let styles = compute_styles(&dom, &ua, &author);
let fonts = test_fonts();
let settings = PageSettings::default();
let mut ps = Vec::new();
find_all(&dom, dom.document(), "p", &mut ps);
assert_eq!(ps.len(), 20);
let tree = build_box_tree(&dom, &styles);
let mut laid_out = layout_document(&tree, &styles, &fonts, settings.content_width());
let page_height = settings.content_height();
let batched = paginate(&mut laid_out, page_height);
assert!(batched.len() > 1, "expected multiple pages");
let page_of: HashMap<NodeId, usize> = ps
.iter()
.map(|&p| {
let idx = batched
.iter()
.position(|page| page.boxes.iter().any(|b| box_contains_node(b, p)))
.expect("every paragraph should land on some page");
(p, idx)
})
.collect();
let mut current_page_index = 0usize;
paginate_streaming(&mut laid_out, page_height, &mut |page| {
release_completed_subtrees(&mut dom, &page);
for (&p, &expected_page) in &page_of {
if expected_page > current_page_index {
assert!(
!dom.is_released(p),
"paragraph destined for page {expected_page} must not be released \
while only page {current_page_index} has flushed"
);
}
}
current_page_index += 1;
});
}
#[test]
fn streaming_paginator_multiple_push_item_calls_match_a_single_combined_tree() {
let author = parse_stylesheet(".item { height: 100px; margin: 0; }");
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let mut combined_html = String::from("<div>");
for i in 0..20 {
combined_html.push_str(&format!(r#"<p class="item">item {i}</p>"#));
}
combined_html.push_str("</div>");
let combined_dom = html::parse(combined_html.as_bytes());
let combined_styles = compute_styles(&combined_dom, &ua, &author);
let combined_tree = build_box_tree(&combined_dom, &combined_styles);
let mut combined_laid_out = layout_document(
&combined_tree,
&combined_styles,
&fonts,
settings.content_width(),
);
let batched_pages = paginate(&mut combined_laid_out, settings.content_height());
assert!(batched_pages.len() > 1, "expected multiple pages");
let mut ps = Vec::new();
find_all(&combined_dom, combined_dom.document(), "p", &mut ps);
assert_eq!(ps.len(), 20);
let mut streamed_pages: Vec<Page> = Vec::new();
let mut paginator = StreamingPaginator::new(settings.content_height());
let mut start_y = 0.0f32;
for &p_node in &ps {
let item_box = build_box_for_element(&combined_dom, &combined_styles, p_node)
.expect("p element should produce a LayoutBox");
let item_laid_out = layout_document_from(
&item_box,
&combined_styles,
&fonts,
settings.content_width(),
0.0,
start_y,
);
start_y += item_laid_out.layout.margin_box_height();
streamed_pages.extend(paginator.push_item(&mut item_laid_out.clone()));
}
streamed_pages.extend(paginator.finish());
assert_eq!(
batched_pages.len(),
streamed_pages.len(),
"pushing items one at a time should yield the same page count as a single combined tree"
);
for (batched, streamed) in batched_pages.iter().zip(streamed_pages.iter()) {
assert_eq!(batched.boxes.len(), streamed.boxes.len());
}
}
fn page_counts_both_ways(author_css: &str, items_html: &str) -> (usize, usize) {
let author = parse_stylesheet(author_css);
let ua = user_agent_stylesheet();
let fonts = test_fonts();
let settings = PageSettings::default();
let dom = html::parse(format!("<div>{items_html}</div>").as_bytes());
let styles = compute_styles(&dom, &ua, &author);
let tree = build_box_tree(&dom, &styles);
let mut laid_out = layout_document(&tree, &styles, &fonts, settings.content_width());
let batched = paginate(&mut laid_out, settings.content_height()).len();
let mut items = Vec::new();
find_all(&dom, dom.document(), "p", &mut items);
let mut paginator = StreamingPaginator::new(settings.content_height());
let mut streamed = 0;
let mut start_y = 0.0f32;
for &node in &items {
let item_box = build_box_for_element(&dom, &styles, node)
.expect("p element should produce a LayoutBox");
let mut item_laid_out = layout_document_from(
&item_box,
&styles,
&fonts,
settings.content_width(),
0.0,
start_y,
);
start_y += item_laid_out.layout.margin_box_height();
streamed += paginator.push_item(&mut item_laid_out).len();
}
streamed += paginator.finish().len();
(batched, streamed)
}
#[test]
fn streaming_paginator_honors_break_after_between_items() {
let (batched, streamed) = page_counts_both_ways(
".brk { height: 50px; margin: 0; break-after: always; }",
r#"<p class="brk">A</p><p class="brk">B</p><p class="brk">C</p>"#,
);
assert_eq!(
batched, 3,
"break-after: always on each of three short items should give three pages"
);
assert_eq!(
streamed, batched,
"pushing the same items one at a time must honor break-after too"
);
}
#[test]
fn streaming_paginator_honors_break_before_between_items() {
let (batched, streamed) = page_counts_both_ways(
".a { height: 50px; margin: 0; } \
.brk { height: 50px; margin: 0; break-before: always; }",
r#"<p class="a">A</p><p class="brk">B</p><p class="brk">C</p>"#,
);
assert_eq!(batched, 3);
assert_eq!(
streamed, batched,
"pushing the same items one at a time must honor break-before too"
);
}
#[test]
fn streaming_paginator_does_not_create_blank_pages_at_the_document_edges() {
let (batched, streamed) = page_counts_both_ways(
".first { height: 50px; margin: 0; break-before: always; } \
.last { height: 50px; margin: 0; break-after: always; }",
r#"<p class="first">A</p><p class="last">B</p>"#,
);
assert_eq!(
batched, 1,
"a break-before on the first item and a break-after on the last one \
should not create blank pages"
);
assert_eq!(streamed, batched);
}
fn table_rows_per_page(html_src: &str) -> Vec<usize> {
let dom = html::parse(html_src.as_bytes());
let styles = compute_styles(&dom, &user_agent_stylesheet(), &Stylesheet::default());
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
fn count(b: &LaidOutBox) -> usize {
match &b.content {
LaidOutContent::Table(table) => table.rows.len(),
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
children.iter().map(count).sum()
}
_ => 0,
}
}
pages
.iter()
.map(|page| page.boxes.iter().map(count).sum())
.collect()
}
fn rows_html(n: usize) -> String {
let rows: String = (0..n).map(|i| format!("<tr><td>{i}</td></tr>")).collect();
format!("<table>{rows}</table>")
}
#[test]
fn a_table_is_split_row_by_row_instead_of_being_treated_as_atomic() {
let counts = table_rows_per_page(&rows_html(120));
assert!(counts.len() >= 2, "got {counts:?}");
assert_eq!(counts.iter().sum::<usize>(), 120, "got {counts:?}");
assert!(counts.iter().all(|&c| c > 0), "got {counts:?}");
}
#[test]
fn table_fragments_are_marked_first_middle_last() {
let dom = html::parse(rows_html(200).as_bytes());
let styles = compute_styles(&dom, &user_agent_stylesheet(), &Stylesheet::default());
let fonts = test_fonts();
let pages = paginate_document(&dom, &styles, &fonts, &PageSettings::default());
fn positions(b: &LaidOutBox, out: &mut Vec<FragmentPosition>) {
match &b.content {
LaidOutContent::Table(_) => out.push(b.layout.fragment),
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for c in children {
positions(c, out);
}
}
_ => {}
}
}
let mut found = Vec::new();
for page in &pages {
for b in &page.boxes {
positions(b, &mut found);
}
}
assert!(
found.len() >= 3,
"expected several fragments, got {found:?}"
);
assert_eq!(found.first(), Some(&FragmentPosition::First));
assert_eq!(found.last(), Some(&FragmentPosition::Last));
assert!(found[1..found.len() - 1]
.iter()
.all(|p| *p == FragmentPosition::Middle));
}
#[test]
fn rows_keep_their_order_and_spacing_after_being_split() {
let dom = html::parse(rows_html(120).as_bytes());
let styles = compute_styles(&dom, &user_agent_stylesheet(), &Stylesheet::default());
let fonts = test_fonts();
let settings = PageSettings::default();
let pages = paginate_document(&dom, &styles, &fonts, &settings);
fn rows_of(b: &LaidOutBox, out: &mut Vec<f32>) {
match &b.content {
LaidOutContent::Table(table) => {
for row in &table.rows {
out.push(row.cells[0].layout.content.y);
}
}
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for c in children {
rows_of(c, out);
}
}
_ => {}
}
}
for page in &pages {
let mut ys = Vec::new();
for b in &page.boxes {
rows_of(b, &mut ys);
}
assert!(ys.windows(2).all(|w| w[1] > w[0]), "got {ys:?}");
assert!(
ys.iter().all(|y| *y >= 0.0 && *y <= settings.size.height),
"a row was placed outside the page: {ys:?}"
);
}
}
#[test]
fn thead_rows_are_repeated_on_every_page_but_body_rows_are_not() {
let rows: String = (0..120)
.map(|i| format!("<tr><td>b{i}</td></tr>"))
.collect();
let html_src =
format!("<table><thead><tr><td>H</td></tr></thead><tbody>{rows}</tbody></table>");
let dom = html::parse(html_src.as_bytes());
let styles = compute_styles(&dom, &user_agent_stylesheet(), &Stylesheet::default());
let fonts = test_fonts();
let pages = paginate_document(&dom, &styles, &fonts, &PageSettings::default());
fn sections(b: &LaidOutBox, out: &mut Vec<TableSection>) {
match &b.content {
LaidOutContent::Table(table) => {
out.extend(table.rows.iter().map(|r| r.section));
}
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
for c in children {
sections(c, out);
}
}
_ => {}
}
}
let mut head_total = 0;
let mut body_total = 0;
for page in &pages {
let mut found = Vec::new();
for b in &page.boxes {
sections(b, &mut found);
}
assert_eq!(
found.first(),
Some(&TableSection::Head),
"each page must start with the repeated header: {found:?}"
);
head_total += found.iter().filter(|s| **s == TableSection::Head).count();
body_total += found.iter().filter(|s| **s == TableSection::Body).count();
}
assert_eq!(head_total, pages.len(), "one header row per page");
assert_eq!(body_total, 120, "body rows must not be duplicated");
}
#[test]
fn a_header_taller_than_the_page_is_not_repeated() {
let rows: String = (0..40).map(|i| format!("<tr><td>b{i}</td></tr>")).collect();
let head: String = (0..80).map(|i| format!("<tr><td>h{i}</td></tr>")).collect();
let html_src = format!("<table><thead>{head}</thead><tbody>{rows}</tbody></table>");
let dom = html::parse(html_src.as_bytes());
let styles = compute_styles(&dom, &user_agent_stylesheet(), &Stylesheet::default());
let fonts = test_fonts();
let pages = paginate_document(&dom, &styles, &fonts, &PageSettings::default());
fn head_count(b: &LaidOutBox) -> usize {
match &b.content {
LaidOutContent::Table(table) => table
.rows
.iter()
.filter(|r| r.section == TableSection::Head)
.count(),
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
children.iter().map(head_count).sum()
}
_ => 0,
}
}
let total: usize = pages
.iter()
.map(|p| p.boxes.iter().map(head_count).sum::<usize>())
.sum();
assert_eq!(total, 80, "the oversized header must not be duplicated");
}
fn row_container_fragments(body: &str, css: &str) -> Vec<Layout> {
fn find(b: &LaidOutBox) -> Option<Layout> {
match &b.content {
LaidOutContent::Grid(_) | LaidOutContent::Table(_) => Some(b.layout),
LaidOutContent::Blocks(children) | LaidOutContent::Flex(children) => {
children.iter().find_map(find)
}
_ => None,
}
}
let settings = PageSettings {
size: crate::layout::PageSize {
width: 400.0,
height: 200.0,
},
margin: EdgeSizes::default(),
};
let dom = html::parse(body.as_bytes());
let author = parse_stylesheet(&format!("html, body {{ margin: 0; }} {css}"));
let styles = compute_styles(&dom, &user_agent_stylesheet(), &author);
let pages = paginate_document(&dom, &styles, &test_fonts(), &settings);
pages
.iter()
.filter_map(|page| page.boxes.iter().find_map(find))
.collect()
}
fn assert_sliced(fragments: &[Layout]) {
assert_eq!(fragments.len(), 3, "the container should break twice");
assert_eq!(fragments[0].fragment, FragmentPosition::First);
assert_eq!(fragments[1].fragment, FragmentPosition::Middle);
assert_eq!(fragments[2].fragment, FragmentPosition::Last);
assert_eq!(fragments[0].border_box().y, 0.0);
assert_eq!(fragments[0].border.top, 4.0);
assert_eq!(fragments[0].padding.top, 3.0);
for fragment in &fragments[1..] {
assert_eq!(fragment.border_box().y, 0.0);
assert_eq!(fragment.border.top, 0.0);
assert_eq!(fragment.padding.top, 0.0);
}
for fragment in &fragments[..2] {
assert_eq!(fragment.border.bottom, 0.0);
assert_eq!(fragment.padding.bottom, 0.0);
}
assert_eq!(fragments[2].border.bottom, 4.0);
assert_eq!(fragments[2].padding.bottom, 3.0);
}
#[test]
fn a_grid_split_across_pages_slices_its_border() {
let rows = r#"<div class="row"></div>"#.repeat(8);
let fragments = row_container_fragments(
&format!(r#"<div class="grid">{rows}</div>"#),
".grid { display: grid; border: 4px solid black; padding: 3px 0; } \
.row { height: 50px; }",
);
assert_sliced(&fragments);
}
#[test]
fn a_table_split_across_pages_slices_its_border() {
let rows = "<tr><td></td></tr>".repeat(8);
let fragments = row_container_fragments(
&format!("<table>{rows}</table>"),
"table { border: 4px solid black; padding: 3px 0; border-spacing: 0; } \
td { height: 50px; padding: 0; }",
);
assert_sliced(&fragments);
}
fn assert_first_fragment_moved_whole(fragments: &[Layout]) {
assert_eq!(fragments[0].fragment, FragmentPosition::First);
assert_eq!(
fragments[0].border_box().y,
0.0,
"the top border is off the page"
);
assert_eq!(fragments[0].content.y, 7.0);
}
#[test]
fn a_grid_moved_to_the_next_page_keeps_its_top_border_on_the_page() {
let rows = r#"<div class="row"></div>"#.repeat(8);
let fragments = row_container_fragments(
&format!(r#"<div style="height:170px"></div><div class="grid">{rows}</div>"#),
".grid { display: grid; border: 4px solid black; padding: 3px 0; } \
.row { height: 50px; }",
);
assert_first_fragment_moved_whole(&fragments);
}
#[test]
fn a_table_moved_to_the_next_page_keeps_its_top_border_on_the_page() {
let rows = "<tr><td></td></tr>".repeat(8);
let fragments = row_container_fragments(
&format!(r#"<div style="height:170px"></div><table>{rows}</table>"#),
"table { border: 4px solid black; padding: 3px 0; border-spacing: 0; } \
td { height: 50px; padding: 0; }",
);
assert_first_fragment_moved_whole(&fragments);
}
}