use uzor::types::Rect;
use uzor_text::{layout_paragraph, FontSpec, LineShaper, Paragraph, ParagraphLayout};
use super::baseline_grid::{start_delta, trailing_extra};
use super::island_layout::{island_placement_rect, island_strip_rects};
use super::keep_break::BreakControl;
use super::list_layout::{items_fitting, list_total_height, measure_list_items, place_list_items, ComposedListItem};
use super::table_layout::{header_group_height, measure_and_layout_table, place_table_rows, rows_fitting, table_total_height, ComposedRow};
use super::{lines_fitting, slice_layout_lines, widow_orphan_count};
use crate::region::{Frame, PlacedBlock, RegionSequence};
use crate::scene::{resolve_block_ids, Block, BlockId, BlockNode};
const DEFAULT_MIN_WIDOW_ORPHAN_LINES: usize = 2;
#[derive(Debug, Clone, Copy)]
pub struct ComposeStyle {
pub paragraph_spacing: f64,
pub default_font: FontSpec,
pub min_orphan_lines: usize,
pub min_widow_lines: usize,
pub baseline_grid: Option<f64>,
}
impl ComposeStyle {
pub fn new(paragraph_spacing: f64, default_font: FontSpec) -> Self {
Self {
paragraph_spacing,
default_font,
min_orphan_lines: DEFAULT_MIN_WIDOW_ORPHAN_LINES,
min_widow_lines: DEFAULT_MIN_WIDOW_ORPHAN_LINES,
baseline_grid: None,
}
}
pub fn from_theme(theme: &crate::style::Theme, paragraph_spacing: f64) -> Self {
Self {
paragraph_spacing,
default_font: theme.font_spec(crate::style::FontRole::Body),
min_orphan_lines: DEFAULT_MIN_WIDOW_ORPHAN_LINES,
min_widow_lines: DEFAULT_MIN_WIDOW_ORPHAN_LINES,
baseline_grid: None,
}
}
pub fn with_min_orphan_lines(mut self, min_orphan_lines: usize) -> Self {
self.min_orphan_lines = min_orphan_lines;
self
}
pub fn with_min_widow_lines(mut self, min_widow_lines: usize) -> Self {
self.min_widow_lines = min_widow_lines;
self
}
pub fn with_baseline_grid(mut self, pitch: f64) -> Self {
self.baseline_grid = if pitch > 0.0 { Some(pitch) } else { None };
self
}
}
enum InProgress<'a> {
None,
Paragraph { layout: ParagraphLayout, next_line: usize },
Table { column_widths: Vec<f64>, rows: Vec<ComposedRow<'a>>, next_row: usize },
List { items: Vec<ComposedListItem<'a>>, next_item: usize },
}
fn full_height_if_whole(kind: &Block<'_>, region_width: f64, remaining_height: f64, style: &ComposeStyle, shaper: &dyn LineShaper) -> f64 {
match kind {
Block::Spacer(gap) => *gap,
Block::Paragraph(p) => layout_paragraph(&Paragraph { max_width: region_width, ..*p }, shaper).height,
Block::Figure(fb) => fb.sizing.resolve_height(region_width, remaining_height),
Block::Image(ib) => ib.sizing.resolve_height(region_width, remaining_height),
Block::Island(island) => island.image.sizing.resolve_height(island.width, remaining_height),
Block::Table(table) => {
let (_, rows) = measure_and_layout_table(table, region_width, style, shaper);
table_total_height(&rows)
}
Block::List(list) => {
let content_width = (region_width - list.indent_px).max(0.0);
let items = measure_list_items(list, content_width, style, shaper);
list_total_height(&items, style.paragraph_spacing)
}
}
}
fn has_room_for_next(next: &Block<'_>, region_width: f64, remaining_height: f64, style: &ComposeStyle, shaper: &dyn LineShaper) -> bool {
if remaining_height <= 0.0 {
return false;
}
match next {
Block::Spacer(gap) => *gap <= remaining_height,
Block::Paragraph(p) => {
let full = layout_paragraph(&Paragraph { max_width: region_width, ..*p }, shaper);
full.lines.is_empty() || lines_fitting(&full, 0, remaining_height, false) > 0
}
Block::Figure(fb) => fb.sizing.resolve_height(region_width, remaining_height) <= remaining_height,
Block::Image(ib) => ib.sizing.resolve_height(region_width, remaining_height) <= remaining_height,
Block::Island(island) => island.image.sizing.resolve_height(island.width, remaining_height) <= remaining_height,
Block::Table(table) => {
let (_, rows) = measure_and_layout_table(table, region_width, style, shaper);
rows.first().map_or(true, |r| r.height <= remaining_height)
}
Block::List(list) => {
let content_width = (region_width - list.indent_px).max(0.0);
let items = measure_list_items(list, content_width, style, shaper);
items.first().map_or(true, |it| it.content_height <= remaining_height)
}
}
}
pub fn compose<'a>(flow: &'a [BlockNode<'a>], regions: &mut dyn RegionSequence, style: &ComposeStyle, shaper: &dyn LineShaper) -> Vec<Frame<'a>> {
let ids = resolve_block_ids(flow);
let mut frames = Vec::new();
let mut block_idx = 0usize;
let mut progress: InProgress<'a> = InProgress::None;
while block_idx < flow.len() {
let Some(region) = regions.next() else { break };
let (blocks, overflow) = place_into_region(flow, &ids, &mut block_idx, &mut progress, region.rect, style, shaper);
frames.push(Frame { region, blocks, overflow });
}
frames
}
#[allow(clippy::too_many_arguments)]
fn place_into_region<'a>(
flow: &'a [BlockNode<'a>],
ids: &[BlockId],
block_idx: &mut usize,
progress: &mut InProgress<'a>,
region_rect: Rect,
style: &ComposeStyle,
shaper: &dyn LineShaper,
) -> (Vec<PlacedBlock<'a>>, Option<&'a BlockNode<'a>>) {
let region_bottom = region_rect.y + region_rect.height;
let mut cursor_y = region_rect.y;
let mut blocks: Vec<PlacedBlock<'a>> = Vec::new();
let mut overflow = None;
while *block_idx < flow.len() {
let node = &flow[*block_idx];
if let Some(pitch) = style.baseline_grid {
let is_fresh = match &*progress {
InProgress::None => true,
InProgress::Paragraph { next_line, .. } => *next_line == 0,
InProgress::Table { next_row, .. } => *next_row == 0,
InProgress::List { next_item, .. } => *next_item == 0,
};
if is_fresh && !matches!(node.kind, Block::Spacer(_)) {
let offset = match (&node.kind, &*progress) {
(Block::Paragraph(_), InProgress::Paragraph { layout, .. }) => layout.lines.first().map_or(0.0, |l| l.baseline_y),
(Block::Paragraph(p), _) => {
let full = layout_paragraph(&Paragraph { max_width: region_rect.width, ..*p }, shaper);
full.lines.first().map_or(0.0, |l| l.baseline_y)
}
_ => 0.0,
};
cursor_y += start_delta(cursor_y, region_rect.y, pitch, offset);
}
}
let remaining_height = (region_bottom - cursor_y).max(0.0);
let region_has_content = !blocks.is_empty();
if node.break_control == BreakControl::ForceBefore && region_has_content {
overflow = Some(node);
break;
}
if node.break_control == BreakControl::AvoidAfter && region_has_content && *block_idx + 1 < flow.len() {
let trial_height = full_height_if_whole(&node.kind, region_rect.width, remaining_height, style, shaper);
if trial_height <= remaining_height {
let remaining_after = (remaining_height - trial_height - style.paragraph_spacing).max(0.0);
let next_fits = has_room_for_next(&flow[*block_idx + 1].kind, region_rect.width, remaining_after, style, shaper);
if !next_fits {
overflow = Some(node);
break;
}
}
}
if let Block::Island(island) = &node.kind {
let island_height = island.image.sizing.resolve_height(island.width, remaining_height);
if island_height <= remaining_height || !region_has_content {
let island_rect = island_placement_rect(island, region_rect, cursor_y, island_height);
blocks.push(PlacedBlock {
id: ids[*block_idx],
rect: island_rect,
kind: &node.kind,
paragraph_layout: None,
table_placement: None,
list_placement: None,
});
let band_bottom = cursor_y + island_height;
let force_after = node.break_control == BreakControl::ForceAfter;
*block_idx += 1;
*progress = InProgress::None;
for strip in island_strip_rects(island, region_rect, island_rect, band_bottom) {
if *block_idx >= flow.len() {
break;
}
let (strip_blocks, _strip_overflow) = place_into_region(flow, ids, block_idx, progress, strip, style, shaper);
blocks.extend(strip_blocks);
}
let resume_y = match style.baseline_grid {
Some(pitch) => band_bottom + trailing_extra(region_rect.y, region_bottom, band_bottom, pitch),
None => band_bottom,
};
cursor_y = resume_y + style.paragraph_spacing;
if force_after {
break;
}
continue;
} else {
overflow = Some(node);
break;
}
}
let node_fully_placed = match &node.kind {
Block::Spacer(gap) => {
if *gap <= remaining_height || !region_has_content {
let rect = Rect::new(region_rect.x, cursor_y, region_rect.width, *gap);
blocks.push(PlacedBlock {
id: ids[*block_idx],
rect,
kind: &node.kind,
paragraph_layout: None,
table_placement: None,
list_placement: None,
});
cursor_y += gap;
*block_idx += 1;
*progress = InProgress::None;
true
} else {
overflow = Some(node);
break;
}
}
Block::Paragraph(paragraph) => {
if !matches!(*progress, InProgress::Paragraph { .. }) {
let measured = layout_paragraph(&Paragraph { max_width: region_rect.width, ..*paragraph }, shaper);
*progress = InProgress::Paragraph { layout: measured, next_line: 0 };
}
let InProgress::Paragraph { layout: full, next_line } = &*progress else {
unreachable!("just ensured Paragraph progress")
};
let next_line = *next_line;
if full.lines.is_empty() {
*block_idx += 1;
*progress = InProgress::None;
continue;
}
if node.break_control == BreakControl::AvoidInside && next_line == 0 && region_has_content {
let whole_height: f64 = full.lines.iter().map(|l| l.height).sum();
if whole_height > remaining_height {
overflow = Some(node);
break;
}
}
let count = lines_fitting(full, next_line, remaining_height, !region_has_content);
if count == 0 {
overflow = Some(node);
break;
}
let count = match widow_orphan_count(full.lines.len(), next_line, count, style.min_orphan_lines, style.min_widow_lines, region_has_content) {
Some(n) => n,
None => {
overflow = Some(node);
break;
}
};
let end_line = next_line + count;
let placed_layout = slice_layout_lines(full, next_line, end_line);
let full_len = full.lines.len();
let rect = Rect::new(region_rect.x, cursor_y, region_rect.width, placed_layout.height);
cursor_y += placed_layout.height;
blocks.push(PlacedBlock {
id: ids[*block_idx],
rect,
kind: &node.kind,
paragraph_layout: Some(placed_layout),
table_placement: None,
list_placement: None,
});
if end_line >= full_len {
cursor_y += style.paragraph_spacing;
*block_idx += 1;
*progress = InProgress::None;
true
} else {
if let InProgress::Paragraph { next_line, .. } = &mut *progress {
*next_line = end_line;
}
overflow = Some(node);
break;
}
}
Block::Figure(fb) => {
let content_height = fb.sizing.resolve_height(region_rect.width, remaining_height);
if content_height <= remaining_height || !region_has_content {
let rect = Rect::new(region_rect.x, cursor_y, region_rect.width, content_height);
cursor_y += content_height;
blocks.push(PlacedBlock {
id: ids[*block_idx],
rect,
kind: &node.kind,
paragraph_layout: None,
table_placement: None,
list_placement: None,
});
if let Some(pitch) = style.baseline_grid {
cursor_y += trailing_extra(region_rect.y, region_bottom, cursor_y, pitch);
}
cursor_y += style.paragraph_spacing;
*block_idx += 1;
*progress = InProgress::None;
true
} else {
overflow = Some(node);
break;
}
}
Block::Image(ib) => {
let content_height = ib.sizing.resolve_height(region_rect.width, remaining_height);
if content_height <= remaining_height || !region_has_content {
let rect = Rect::new(region_rect.x, cursor_y, region_rect.width, content_height);
cursor_y += content_height;
blocks.push(PlacedBlock {
id: ids[*block_idx],
rect,
kind: &node.kind,
paragraph_layout: None,
table_placement: None,
list_placement: None,
});
if let Some(pitch) = style.baseline_grid {
cursor_y += trailing_extra(region_rect.y, region_bottom, cursor_y, pitch);
}
cursor_y += style.paragraph_spacing;
*block_idx += 1;
*progress = InProgress::None;
true
} else {
overflow = Some(node);
break;
}
}
Block::Island(_) => unreachable!("Block::Island is handled before this match, via its own recursive strip-fill branch"),
Block::Table(table) => {
if !matches!(*progress, InProgress::Table { .. }) {
let (column_widths, rows) = measure_and_layout_table(table, region_rect.width, style, shaper);
*progress = InProgress::Table { column_widths, rows, next_row: 0 };
}
let InProgress::Table { column_widths, rows, next_row } = &*progress else {
unreachable!("just ensured Table progress")
};
let next_row = *next_row;
if rows.is_empty() {
*block_idx += 1;
*progress = InProgress::None;
continue;
}
if node.break_control == BreakControl::AvoidInside && next_row == 0 && region_has_content {
let whole_height = table_total_height(rows);
if whole_height > remaining_height {
overflow = Some(node);
break;
}
}
let header_reserved = if table.header_repeat && next_row > 0 { header_group_height(rows) } else { 0.0 };
let count = rows_fitting(rows, next_row, remaining_height, !region_has_content, header_reserved);
if count == 0 {
overflow = Some(node);
break;
}
let (placed_rows, placed_height) = place_table_rows(
rows,
next_row,
count,
column_widths,
table.cell_padding,
(region_rect.x, cursor_y),
table.header_repeat,
);
let total_rows = rows.len();
let column_widths_snapshot = column_widths.clone();
let rect = Rect::new(region_rect.x, cursor_y, column_widths_snapshot.iter().sum(), placed_height);
cursor_y += placed_height;
blocks.push(PlacedBlock {
id: ids[*block_idx],
rect,
kind: &node.kind,
paragraph_layout: None,
table_placement: Some(crate::region::TablePlacement { column_widths: column_widths_snapshot, rows: placed_rows }),
list_placement: None,
});
if next_row + count >= total_rows {
if let Some(pitch) = style.baseline_grid {
cursor_y += trailing_extra(region_rect.y, region_bottom, cursor_y, pitch);
}
cursor_y += style.paragraph_spacing;
*block_idx += 1;
*progress = InProgress::None;
true
} else {
if let InProgress::Table { next_row, .. } = &mut *progress {
*next_row += count;
}
overflow = Some(node);
break;
}
}
Block::List(list) => {
if !matches!(*progress, InProgress::List { .. }) {
let content_width = (region_rect.width - list.indent_px).max(0.0);
let items = measure_list_items(list, content_width, style, shaper);
*progress = InProgress::List { items, next_item: 0 };
}
let InProgress::List { items, next_item } = &*progress else {
unreachable!("just ensured List progress")
};
let next_item = *next_item;
if items.is_empty() {
*block_idx += 1;
*progress = InProgress::None;
continue;
}
if node.break_control == BreakControl::AvoidInside && next_item == 0 && region_has_content {
let whole_height = list_total_height(items, style.paragraph_spacing);
if whole_height > remaining_height {
overflow = Some(node);
break;
}
}
let count = items_fitting(items, next_item, remaining_height, style.paragraph_spacing, !region_has_content);
if count == 0 {
overflow = Some(node);
break;
}
let (placed_items, placed_height) =
place_list_items(items, next_item, count, list.indent_px, style.paragraph_spacing, (region_rect.x, cursor_y));
let total_items = items.len();
let rect = Rect::new(region_rect.x, cursor_y, region_rect.width, placed_height);
cursor_y += placed_height;
blocks.push(PlacedBlock {
id: ids[*block_idx],
rect,
kind: &node.kind,
paragraph_layout: None,
table_placement: None,
list_placement: Some(crate::region::ListPlacement { items: placed_items }),
});
if next_item + count >= total_items {
if let Some(pitch) = style.baseline_grid {
cursor_y += trailing_extra(region_rect.y, region_bottom, cursor_y, pitch);
}
cursor_y += style.paragraph_spacing;
*block_idx += 1;
*progress = InProgress::None;
true
} else {
if let InProgress::List { next_item, .. } = &mut *progress {
*next_item += count;
}
overflow = Some(node);
break;
}
}
};
if node_fully_placed && node.break_control == BreakControl::ForceAfter {
break;
}
}
(blocks, overflow)
}
#[cfg(test)]
mod tests {
use super::*;
use uzor::fonts::FontFamily;
use uzor_text::{layout_paragraph, CosmicShaper, Paragraph, StyledRun};
use crate::region::{PageRegionSequence, Region};
use crate::scene::BlockNode;
const TALL_REGION_HEIGHT: f64 = 2000.0;
const REGION_WIDTH: f64 = 300.0;
fn tall_region() -> Region {
Region { rect: Rect::new(0.0, 0.0, REGION_WIDTH, TALL_REGION_HEIGHT) }
}
struct OnceRegionSequence {
region: Option<Region>,
}
impl RegionSequence for OnceRegionSequence {
fn next(&mut self) -> Option<Region> {
self.region.take()
}
}
#[test]
fn two_paragraphs_and_a_spacer_stack_with_correct_gaps_and_exact_heights() {
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let runs_a = [StyledRun::new("First paragraph of the stacking fixture.", font)];
let runs_b = [StyledRun::new(
"Second paragraph, long enough to wrap onto more than one line at this narrow region width so the stacking test also exercises multi-line height.",
font,
)];
const SPACER_GAP: f64 = 24.0;
const PARAGRAPH_SPACING: f64 = 10.0;
let flow = [
BlockNode::new(Block::Paragraph(Paragraph::new(&runs_a, REGION_WIDTH))),
BlockNode::new(Block::Spacer(SPACER_GAP)),
BlockNode::new(Block::Paragraph(Paragraph::new(&runs_b, REGION_WIDTH))),
];
let expected_a = layout_paragraph(&Paragraph::new(&runs_a, REGION_WIDTH), &shaper);
let expected_b = layout_paragraph(&Paragraph::new(&runs_b, REGION_WIDTH), &shaper);
assert!(expected_b.lines.len() > 1, "fixture must wrap to multiple lines");
let style = ComposeStyle::new(PARAGRAPH_SPACING, font);
let mut regions = OnceRegionSequence { region: Some(tall_region()) };
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames.len(), 1, "a single tall region must hold every block in one frame");
let frame = &frames[0];
assert_eq!(frame.blocks.len(), 3);
assert!(frame.overflow.is_none());
let para_a = &frame.blocks[0];
assert_eq!(para_a.rect.y, 0.0);
assert_eq!(para_a.rect.height, expected_a.height, "placed rect height must equal the ParagraphLayout height exactly");
let spacer = &frame.blocks[1];
let expected_spacer_y = expected_a.height + PARAGRAPH_SPACING;
assert!((spacer.rect.y - expected_spacer_y).abs() < 1e-9);
assert_eq!(spacer.rect.height, SPACER_GAP);
assert!(spacer.paragraph_layout.is_none());
let para_b = &frame.blocks[2];
let expected_b_y = expected_spacer_y + SPACER_GAP;
assert!((para_b.rect.y - expected_b_y).abs() < 1e-9);
assert_eq!(para_b.rect.height, expected_b.height, "placed rect height must equal the ParagraphLayout height exactly");
}
#[test]
fn a_paragraph_taller_than_one_region_splits_at_a_line_boundary_and_conserves_every_line() {
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let text = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty";
let runs = [StyledRun::new(text, font)];
let narrow_width = 140.0;
let full = layout_paragraph(&Paragraph::new(&runs, narrow_width), &shaper);
assert!(full.lines.len() >= 6, "fixture must wrap to several lines");
let region_height = full.lines[0].height * (full.lines.len() as f64 / 2.0).floor();
let flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&runs, narrow_width)))];
let style = ComposeStyle::new(0.0, font);
let mut page_regions = PageRegionSequence::new(Rect::new(0.0, 0.0, narrow_width, region_height));
let frames = compose(&flow, &mut page_regions, &style, &shaper);
assert_eq!(frames.len(), 2, "the paragraph must span exactly 2 regions");
assert!(frames[0].overflow.is_some(), "the first frame must report the overflowing block");
assert!(frames[1].overflow.is_none(), "the second frame must finish the paragraph");
let head = frames[0].blocks[0].paragraph_layout.as_ref().expect("paragraph placement carries a layout");
let tail = frames[1].blocks[0].paragraph_layout.as_ref().expect("paragraph placement carries a layout");
assert_eq!(head.lines.len() + tail.lines.len(), full.lines.len(), "every line must be conserved across the split");
assert_eq!(head.glyphs.len() + tail.glyphs.len(), full.glyphs.len(), "every glyph must be conserved across the split");
assert!(head.height <= region_height + 1.0, "the head placement must fit within the region it was split for");
}
#[test]
fn widow_orphan_control_defers_the_whole_paragraph_when_the_head_would_be_a_1_line_orphan() {
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let text = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty";
let runs = [StyledRun::new(text, font)];
let narrow_width = 140.0;
let full = layout_paragraph(&Paragraph::new(&runs, narrow_width), &shaper);
assert!(full.lines.len() >= 6, "fixture must wrap to several lines");
let one_line = full.lines[0].height;
let page_height = full.height + one_line * 3.0;
let filler_gap = page_height - (one_line + 0.5);
let flow = [
BlockNode::new(Block::Spacer(filler_gap)),
BlockNode::new(Block::Paragraph(Paragraph::new(&runs, narrow_width))),
];
let style = ComposeStyle::new(0.0, font);
let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, narrow_width, page_height));
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames[0].blocks.len(), 1, "page 1 holds only the filler spacer — the paragraph never starts here");
assert!(
frames[0].blocks.iter().all(|b| !matches!(b.kind, Block::Paragraph(_))),
"an orphan-violating 1-line head must never be placed"
);
let page2 = &frames[1];
let placed = page2.blocks.iter().find_map(|b| b.paragraph_layout.as_ref()).expect("the paragraph lands on the fresh next region");
assert!(placed.lines.len() >= 2, "the deferred paragraph must start with at least min_orphan_lines on its fresh region");
}
#[test]
fn widow_orphan_control_pulls_a_line_over_when_the_continuation_would_be_a_1_line_widow() {
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let text = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty";
let runs = [StyledRun::new(text, font)];
let narrow_width = 140.0;
let full = layout_paragraph(&Paragraph::new(&runs, narrow_width), &shaper);
assert!(full.lines.len() >= 6, "fixture must wrap to several lines");
let one_line = full.lines[0].height;
assert!(full.lines.iter().all(|l| (l.height - one_line).abs() < 1e-6), "fixture lines must be equal height for a predictable split point");
let region_height = one_line * (full.lines.len() - 1) as f64 + 0.5;
let flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&runs, narrow_width)))];
let style = ComposeStyle::new(0.0, font); let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, narrow_width, region_height));
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames.len(), 2, "the paragraph must still span exactly 2 regions");
let head = frames[0].blocks[0].paragraph_layout.as_ref().expect("head placement carries a layout");
let tail = frames[1].blocks[0].paragraph_layout.as_ref().expect("tail placement carries a layout");
assert_eq!(tail.lines.len(), 2, "the widow-prone 1-line continuation must be widened to 2 lines by pulling a line back from the head");
assert_eq!(head.lines.len(), full.lines.len() - 2, "the head shrinks by exactly the 1 line pulled over");
assert_eq!(head.lines.len() + tail.lines.len(), full.lines.len(), "every line must still be conserved across the split");
}
#[test]
fn widow_orphan_control_disabled_reproduces_the_old_split_point_byte_identically() {
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let text = "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty";
let runs = [StyledRun::new(text, font)];
let narrow_width = 140.0;
let full = layout_paragraph(&Paragraph::new(&runs, narrow_width), &shaper);
let one_line = full.lines[0].height;
let region_height = one_line * (full.lines.len() - 1) as f64 + 0.5;
let flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&runs, narrow_width)))];
let style = ComposeStyle::new(0.0, font).with_min_orphan_lines(0).with_min_widow_lines(0);
let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, narrow_width, region_height));
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames.len(), 2);
let head = frames[0].blocks[0].paragraph_layout.as_ref().expect("head placement carries a layout");
let tail = frames[1].blocks[0].paragraph_layout.as_ref().expect("tail placement carries a layout");
assert_eq!(head.lines.len(), full.lines.len() - 1, "disabled controls must reproduce the raw lines_fitting budget exactly");
assert_eq!(tail.lines.len(), 1, "disabled controls must leave the raw 1-line widow untouched");
}
#[test]
fn a_figure_taller_than_the_remaining_region_lands_whole_on_the_next_region() {
use crate::scene::{Block, BlockSizing, FigureBlock, TypesetFigure};
use uzor::render::RenderContext;
use uzor_figures::FigureTheme;
struct StubFigure;
impl TypesetFigure for StubFigure {
fn render(&self, _ctx: &mut dyn RenderContext, _rect: Rect, _theme: &FigureTheme) {}
}
let shaper = CosmicShaper::headless();
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let style = ComposeStyle::new(0.0, font);
const PAGE_HEIGHT: f64 = 300.0;
const FILLER_GAP: f64 = 250.0; const FIGURE_HEIGHT: f64 = 120.0;
let stub = StubFigure;
let flow = [
BlockNode::new(Block::Spacer(FILLER_GAP)),
BlockNode::new(Block::Figure(FigureBlock::new(&stub, BlockSizing::FixedHeight(FIGURE_HEIGHT)))),
];
let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, 400.0, PAGE_HEIGHT));
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames.len(), 2, "the figure must land on a fresh second region, never split");
assert_eq!(frames[0].blocks.len(), 1, "page 1 holds only the filler spacer");
assert!(matches!(frames[0].blocks[0].kind, Block::Spacer(_)));
assert_eq!(frames[1].blocks.len(), 1, "the figure is placed WHOLE on page 2");
let figure_placement = &frames[1].blocks[0];
assert!(matches!(figure_placement.kind, Block::Figure(_)));
assert_eq!(figure_placement.rect.height, FIGURE_HEIGHT, "a deferred figure is never squashed to fit — full height preserved");
assert_eq!(figure_placement.rect.y, 0.0, "the figure starts at the top of the fresh region");
}
#[test]
fn baseline_grid_rounds_a_figures_trailing_height_so_the_following_paragraph_lands_on_grid() {
use crate::scene::{BlockSizing, FigureBlock, TypesetFigure};
use uzor::render::RenderContext;
use uzor_figures::FigureTheme;
struct StubFigure;
impl TypesetFigure for StubFigure {
fn render(&self, _ctx: &mut dyn RenderContext, _rect: Rect, _theme: &FigureTheme) {}
}
const PITCH: f64 = 20.0;
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 14.0);
let body_run = [StyledRun::new("After the figure.", font)];
let stub = StubFigure;
let flow = [
BlockNode::new(Block::Figure(FigureBlock::new(&stub, BlockSizing::FixedHeight(97.0)))), BlockNode::new(Block::Paragraph(Paragraph::new(&body_run, 300.0))),
];
let shaper = CosmicShaper::headless();
let style_off = ComposeStyle::new(6.0, font);
let mut regions_off = PageRegionSequence::new(Rect::new(0.0, 0.0, 300.0, 500.0));
let frames_off = compose(&flow, &mut regions_off, &style_off, &shaper);
let body_off = frames_off[0].blocks.iter().find(|b| matches!(b.kind, Block::Paragraph(_))).expect("body paragraph placed");
let baseline_off = body_off.rect.y + body_off.paragraph_layout.as_ref().expect("paragraph layout present").lines[0].baseline_y;
let remainder_off = baseline_off.rem_euclid(PITCH);
assert!(remainder_off.min(PITCH - remainder_off) > 1.0, "the UN-gridded baseline must not already coincidentally land on the grid, or the ON case below would prove nothing");
let style_on = ComposeStyle::new(6.0, font).with_baseline_grid(PITCH);
let mut regions_on = PageRegionSequence::new(Rect::new(0.0, 0.0, 300.0, 500.0));
let frames_on = compose(&flow, &mut regions_on, &style_on, &shaper);
let body_on = frames_on[0].blocks.iter().find(|b| matches!(b.kind, Block::Paragraph(_))).expect("body paragraph placed");
let baseline_on = body_on.rect.y + body_on.paragraph_layout.as_ref().expect("paragraph layout present").lines[0].baseline_y;
let remainder_on = baseline_on.rem_euclid(PITCH);
assert!(remainder_on.min(PITCH - remainder_on) < 1e-3, "the paragraph after the odd-height figure must land on grid, got baseline {baseline_on} (remainder {remainder_on})");
let figure_on = frames_on[0].blocks.iter().find(|b| matches!(b.kind, Block::Figure(_))).expect("figure placed");
assert_eq!(figure_on.rect.height, 97.0, "grid rounding must never stretch a figure's own placed rect — only the cursor advances further");
}
#[test]
fn keep_with_next_never_separates_a_heading_from_its_following_block_across_a_region() {
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let heading_run = [StyledRun::new("Section Heading", font)];
let body_text = "This body paragraph is long enough that it will not fit in a sliver of \
remaining space left after a heading placed near the bottom of a short region, forcing \
the keep-with-next rule to move the heading down onto the next region together with it.";
let body_run = [StyledRun::new(body_text, font)];
const REGION_HEIGHT: f64 = 220.0;
const FILLER_GAP: f64 = 180.0;
let flow = [
BlockNode::new(Block::Spacer(FILLER_GAP)),
BlockNode::new(Block::Paragraph(Paragraph::new(&heading_run, 300.0))).with_break_control(BreakControl::AvoidAfter),
BlockNode::new(Block::Paragraph(Paragraph::new(&body_run, 300.0))),
];
let style = ComposeStyle::new(6.0, font);
let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, 300.0, REGION_HEIGHT));
let frames = compose(&flow, &mut regions, &style, &shaper);
let page1_has_heading = frames[0].blocks.iter().any(|b| matches!(b.kind, Block::Paragraph(p) if p.runs[0].text == "Section Heading"));
assert!(!page1_has_heading, "the heading must be pushed onto the next region together with its following block, not stranded alone");
let page2 = frames.iter().find(|f| f.blocks.iter().any(|b| matches!(b.kind, Block::Paragraph(p) if p.runs[0].text == "Section Heading")));
let page2 = page2.expect("the heading must land on some later region");
assert!(
page2.blocks.len() >= 2 || page2.overflow.is_some(),
"the heading's own region must also carry (or be about to carry) the following block"
);
}
#[test]
fn compose_style_from_theme_resolves_default_font_through_the_theme() {
let theme = crate::style::Theme::light_report();
let style = ComposeStyle::from_theme(&theme, 8.0);
assert_eq!(style.default_font, theme.font_spec(crate::style::FontRole::Body));
assert_eq!(style.paragraph_spacing, 8.0);
}
#[test]
fn a_table_taller_than_one_region_splits_between_rows_never_mid_row() {
use crate::scene::{ColumnSpec, TableBlock, TableCell, TableRow};
let shaper = CosmicShaper::headless();
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let style = ComposeStyle::new(0.0, font);
let cell_run = [StyledRun::new("cell", font)];
let cell_nodes = [BlockNode::new(Block::Paragraph(Paragraph::new(&cell_run, f64::MAX)))];
let cells = [TableCell::new(&cell_nodes)];
let rows = [
TableRow::new(&cells),
TableRow::new(&cells),
TableRow::new(&cells),
TableRow::new(&cells),
TableRow::new(&cells),
TableRow::new(&cells),
];
let columns = [ColumnSpec::Auto];
let table = TableBlock::new(&columns, &rows);
let (_, measured_rows) = measure_and_layout_table(&table, 200.0, &style, &shaper);
let row_height = measured_rows[0].height;
assert!(measured_rows.iter().all(|r| (r.height - row_height).abs() < 1e-6), "fixture rows must be equal height");
let region_height = row_height * 4.0 + 0.5;
let flow = [BlockNode::new(Block::Table(TableBlock::new(&columns, &rows)))];
let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, 200.0, region_height));
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames.len(), 2, "the table must span exactly 2 regions");
assert!(frames[0].overflow.is_some(), "the first frame must report the table as still overflowing");
assert!(frames[1].overflow.is_none(), "the second frame must finish the table");
let page1_table = frames[0].blocks[0].table_placement.as_ref().expect("table placement present");
let page2_table = frames[1].blocks[0].table_placement.as_ref().expect("table placement present");
assert_eq!(page1_table.rows.len(), 4, "page 1 must hold exactly the 4 rows that fit whole");
assert_eq!(page2_table.rows.len(), 2, "page 2 must hold the remaining 2 rows");
assert_eq!(page1_table.rows.len() + page2_table.rows.len(), rows.len(), "every source row must be conserved exactly once");
for placed_row in page1_table.rows.iter().chain(page2_table.rows.iter()) {
assert!((placed_row.rect.height - row_height).abs() < 1e-6, "every placed row keeps its own full, whole height — never split mid-row");
}
}
fn stub_island<'a>(rgba: &'a [u8], anchor: crate::scene::IslandAnchor, width: f64, margin: f64, height: f64) -> crate::scene::AnchoredIsland<'a> {
use crate::scene::{BlockSizing, ImageBlock, ImageFit};
crate::scene::AnchoredIsland::new(ImageBlock::new(rgba, 100, 100, BlockSizing::FixedHeight(height), ImageFit::Cover), anchor, width, margin)
}
#[test]
fn an_island_taller_than_the_remaining_region_lands_whole_on_the_next_region() {
use crate::scene::IslandAnchor;
let shaper = CosmicShaper::headless();
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 16.0);
let style = ComposeStyle::new(0.0, font);
const PAGE_HEIGHT: f64 = 300.0;
const FILLER_GAP: f64 = 250.0; const ISLAND_HEIGHT: f64 = 120.0;
let rgba = [0u8; 4];
let island = stub_island(&rgba, IslandAnchor::Left, 150.0, 10.0, ISLAND_HEIGHT);
let flow = [BlockNode::new(Block::Spacer(FILLER_GAP)), BlockNode::new(Block::Island(island))];
let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, 400.0, PAGE_HEIGHT));
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames.len(), 2, "the island must land on a fresh second region, never split");
assert_eq!(frames[0].blocks.len(), 1, "page 1 holds only the filler spacer");
assert_eq!(frames[1].blocks.len(), 1, "the island is placed WHOLE on page 2 (no strip content follows it here)");
let island_placement = &frames[1].blocks[0];
assert!(matches!(island_placement.kind, Block::Island(_)));
assert_eq!(island_placement.rect.height, ISLAND_HEIGHT, "a deferred island is never squashed to fit — full height preserved");
assert_eq!(island_placement.rect.y, 0.0, "the island starts at the top of the fresh region");
}
#[test]
fn island_left_anchor_fills_a_single_right_strip_and_right_anchor_fills_a_single_left_strip() {
use crate::scene::IslandAnchor;
let shaper = CosmicShaper::headless();
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 14.0);
let style = ComposeStyle::new(4.0, font);
const REGION_W: f64 = 400.0;
const ISLAND_W: f64 = 150.0;
const MARGIN: f64 = 10.0;
const ISLAND_H: f64 = 100.0;
let rgba = [0u8; 4];
let left_island = stub_island(&rgba, IslandAnchor::Left, ISLAND_W, MARGIN, ISLAND_H);
let strip_run = [StyledRun::new("beside the left-anchored image", font)];
let flow = [
BlockNode::new(Block::Island(left_island)),
BlockNode::new(Block::Paragraph(Paragraph::new(&strip_run, REGION_W))),
];
let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, REGION_W, 500.0));
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames.len(), 1);
let island_placement = frames[0].blocks.iter().find(|b| matches!(b.kind, Block::Island(_))).expect("island placed");
let text_placement = frames[0].blocks.iter().find(|b| matches!(b.kind, Block::Paragraph(_))).expect("strip text placed");
assert!(text_placement.rect.x >= island_placement.rect.x + island_placement.rect.width, "Left anchor: text must sit at/right of the island's own right edge (plus margin)");
assert!(
(text_placement.rect.y - island_placement.rect.y).abs() < 1e-6,
"strip content starts at the SAME y as the island's own top — beside it, not below it"
);
let right_island = stub_island(&rgba, IslandAnchor::Right, ISLAND_W, MARGIN, ISLAND_H);
let flow2 = [
BlockNode::new(Block::Island(right_island)),
BlockNode::new(Block::Paragraph(Paragraph::new(&strip_run, REGION_W))),
];
let mut regions2 = PageRegionSequence::new(Rect::new(0.0, 0.0, REGION_W, 500.0));
let frames2 = compose(&flow2, &mut regions2, &style, &shaper);
let island_placement2 = frames2[0].blocks.iter().find(|b| matches!(b.kind, Block::Island(_))).expect("island placed");
let text_placement2 = frames2[0].blocks.iter().find(|b| matches!(b.kind, Block::Paragraph(_))).expect("strip text placed");
assert!(
text_placement2.rect.x + text_placement2.rect.width <= island_placement2.rect.x + 0.01,
"Right anchor: text must sit at/left of the island's own left edge (minus margin)"
);
}
#[test]
fn island_center_anchor_flows_text_down_left_strip_then_right_strip_then_resumes_full_width_below() {
use crate::scene::IslandAnchor;
let shaper = CosmicShaper::headless();
let font = uzor_text::FontSpec::new(FontFamily::Roboto, 14.0);
let style = ComposeStyle::new(4.0, font);
const REGION_W: f64 = 500.0;
const ISLAND_W: f64 = 200.0;
const MARGIN: f64 = 10.0;
let expected_side_width = (REGION_W - ISLAND_W - 2.0 * MARGIN) / 2.0;
let left_run = [StyledRun::new("Left.", font)];
let right_run = [StyledRun::new("Right.", font)];
let below_run = [StyledRun::new("Below.", font)];
let left_layout = layout_paragraph(&Paragraph::new(&left_run, expected_side_width), &shaper);
assert_eq!(left_layout.lines.len(), 1, "fixture text must stay on one line at the strip's own width");
let island_height = left_layout.height + 2.0;
let rgba = [0u8; 4];
let island = stub_island(&rgba, IslandAnchor::Center, ISLAND_W, MARGIN, island_height);
let flow = [
BlockNode::new(Block::Island(island)),
BlockNode::new(Block::Paragraph(Paragraph::new(&left_run, REGION_W))),
BlockNode::new(Block::Paragraph(Paragraph::new(&right_run, REGION_W))),
BlockNode::new(Block::Paragraph(Paragraph::new(&below_run, REGION_W))),
];
let mut regions = PageRegionSequence::new(Rect::new(0.0, 0.0, REGION_W, 600.0));
let frames = compose(&flow, &mut regions, &style, &shaper);
assert_eq!(frames.len(), 1);
let blocks = &frames[0].blocks;
assert_eq!(blocks.len(), 4, "island + 3 paragraphs, all on ONE page");
let island_placement = blocks.iter().find(|b| matches!(b.kind, Block::Island(_))).expect("island placed");
let left_placed = blocks.iter().find(|b| matches!(b.kind, Block::Paragraph(p) if p.runs[0].text == "Left.")).expect("left text placed");
let right_placed = blocks.iter().find(|b| matches!(b.kind, Block::Paragraph(p) if p.runs[0].text == "Right.")).expect("right text placed");
let below_placed = blocks.iter().find(|b| matches!(b.kind, Block::Paragraph(p) if p.runs[0].text == "Below.")).expect("below text placed");
assert!((left_placed.rect.width - expected_side_width).abs() < 1e-6, "left-strip text must be measured at the left strip's own width");
assert!(left_placed.rect.x + left_placed.rect.width <= island_placement.rect.x + 0.01, "left-strip text must never overlap the island rect");
assert!((right_placed.rect.width - expected_side_width).abs() < 1e-6, "right-strip text must be measured at the right strip's own width");
assert!(right_placed.rect.x >= island_placement.rect.x + island_placement.rect.width - 0.01, "right-strip text must never overlap the island rect");
assert!(right_placed.rect.x > left_placed.rect.x, "the right strip must sit to the right of the left strip");
assert!((below_placed.rect.width - REGION_W).abs() < 1e-6, "content after the island's own band must resume at FULL region width");
assert!(
below_placed.rect.y >= island_placement.rect.y + island_placement.rect.height - 0.01,
"full-width content must resume at/below the island's own bottom edge"
);
}
}