use std::collections::{HashMap, HashSet};
use uzor::types::Rect;
use uzor_text::{LineShaper, Paragraph, StyledRun};
use crate::compose::{compose, ComposeStyle};
use crate::master::{HeaderPlaceholder, PageMaster, PageNumberStyle};
use crate::region::{FixedRegionSequence, Frame, PageRegionSequence, Region};
use crate::scene::{resolve_block_ids, Block, BlockId, BlockNode, Footnote, ListBlock, ListItem, MarkerStyle};
pub struct Page<'a> {
pub index: u32,
pub total: u32,
pub frame: Frame<'a>,
pub extra_frames: Vec<Frame<'a>>,
pub header: Option<Frame<'a>>,
pub footer: Option<Frame<'a>>,
pub page_number: Option<PageNumberPlacement>,
pub outline: Vec<OutlineEntry>,
pub links: Vec<LinkEntry>,
pub footnotes: Option<FootnotePlacement<'a>>,
}
pub struct FootnotePlacement<'a> {
pub frame: Frame<'a>,
pub clamped: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OutlineEntry {
pub level: u8,
pub title: String,
pub page_index: u32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkEntry {
pub rect: Rect,
pub target_page: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PageNumberPlacement {
pub text: String,
pub rect: Rect,
}
pub fn slice_pages<'a>(flow: &'a [BlockNode<'a>], master: &PageMaster<'a>, style: &ComposeStyle, shaper: &dyn LineShaper) -> Vec<Page<'a>> {
let mut regions = PageRegionSequence::new(master.body_rect()).with_columns(master.columns, master.column_gap);
let raw_frames = compose(flow, &mut regions, style, shaper);
let columns = master.columns.max(1);
let mut pages: Vec<Page<'a>> = Vec::new();
let mut raw_frames = raw_frames.into_iter();
let mut index = 0u32;
while let Some(frame) = raw_frames.next() {
let mut extra_frames = Vec::new();
for _ in 1..columns {
match raw_frames.next() {
Some(f) => extra_frames.push(f),
None => break,
}
}
pages.push(Page {
index,
total: 0,
frame,
extra_frames,
header: None,
footer: None,
page_number: None,
outline: Vec::new(),
links: Vec::new(),
footnotes: None,
});
index += 1;
}
let total = pages.len() as u32;
let header_has_placeholder = master.header.is_some_and(header_uses_placeholder);
let footer_has_placeholder = master.footer.is_some_and(header_uses_placeholder);
let header_frame = if header_has_placeholder { None } else { master.header.map(|content| compose_margin_box(content, master.header_rect(), style, shaper)) };
let footer_frame = if footer_has_placeholder { None } else { master.footer.map(|content| compose_margin_box(content, master.footer_content_rect(), style, shaper)) };
for page in &mut pages {
page.total = total;
page.header = header_frame.clone();
page.footer = footer_frame.clone();
page.page_number = master
.page_number_token
.as_ref()
.map(|token| PageNumberPlacement { text: token.format_for(page.index, total), rect: master.page_number_rect() });
}
attach_navigation_entries(flow, &mut pages);
if header_has_placeholder || footer_has_placeholder {
let titles = resolve_running_titles(&pages);
for (page, title) in pages.iter_mut().zip(titles.iter()) {
if header_has_placeholder {
if let Some(content) = master.header {
page.header = Some(compose_placeholder_margin_box(content, master.header_rect(), style, shaper, title));
}
}
if footer_has_placeholder {
if let Some(content) = master.footer {
page.footer = Some(compose_placeholder_margin_box(content, master.footer_content_rect(), style, shaper, title));
}
}
}
}
attach_footnotes(flow, &mut pages, master, style, shaper);
pages
}
fn header_uses_placeholder(content: &[BlockNode<'_>]) -> bool {
content.iter().any(|n| n.header_placeholder.is_some())
}
fn resolve_running_titles(pages: &[Page<'_>]) -> Vec<String> {
let mut current = String::new();
pages
.iter()
.map(|page| {
if let Some(last) = page.outline.last() {
current = last.title.clone();
}
current.clone()
})
.collect()
}
fn rebuild_simple_node<'a>(node: &BlockNode<'a>, kind: Block<'a>) -> BlockNode<'a> {
BlockNode {
id: node.id,
kind,
break_control: node.break_control,
outline: node.outline.clone(),
link_target: node.link_target,
caption: node.caption.clone(),
footnotes: node.footnotes,
header_placeholder: node.header_placeholder,
}
}
fn resolve_header_content<'a>(content: &'a [BlockNode<'a>], title: &str, style: &ComposeStyle) -> Vec<BlockNode<'a>> {
content
.iter()
.filter_map(|node| {
if node.header_placeholder == Some(HeaderPlaceholder::CurrentOutlineTitle) {
let Block::Paragraph(p) = &node.kind else { return None };
let leaked_text: &'static str = Box::leak(title.to_owned().into_boxed_str());
let font = p.runs.first().map(|r| r.font).unwrap_or(style.default_font);
let run: &'static [StyledRun<'static>] = &*vec![StyledRun::new(leaked_text, font)].leak();
let substituted = Paragraph { runs: run, ..*p };
return Some(rebuild_simple_node(node, Block::Paragraph(substituted)));
}
match &node.kind {
Block::Paragraph(p) => Some(rebuild_simple_node(node, Block::Paragraph(*p))),
Block::Spacer(g) => Some(rebuild_simple_node(node, Block::Spacer(*g))),
_ => None,
}
})
.collect()
}
fn compose_placeholder_margin_box<'a>(content: &'a [BlockNode<'a>], rect: Rect, style: &ComposeStyle, shaper: &dyn LineShaper, title: &str) -> Frame<'a> {
let substituted: &'a [BlockNode<'a>] = resolve_header_content(content, title, style).leak();
compose_margin_box(substituted, rect, style, shaper)
}
fn attach_navigation_entries<'a>(flow: &'a [BlockNode<'a>], pages: &mut [Page<'a>]) {
let ids = resolve_block_ids(flow);
let mut outline_tags: HashMap<BlockId, &crate::scene::OutlineTag> = HashMap::new();
let mut link_targets: HashMap<BlockId, u32> = HashMap::new();
for (node, id) in flow.iter().zip(ids.iter()) {
if let Some(tag) = &node.outline {
outline_tags.insert(*id, tag);
}
if let Some(target) = node.link_target {
link_targets.insert(*id, target);
}
}
if outline_tags.is_empty() && link_targets.is_empty() {
return;
}
let mut emitted: HashSet<BlockId> = HashSet::new();
for page in pages.iter_mut() {
let mut outline_entries = Vec::new();
let mut link_entries = Vec::new();
for placed in page.frame.blocks.iter().chain(page.extra_frames.iter().flat_map(|f| f.blocks.iter())) {
if let Some(tag) = outline_tags.get(&placed.id) {
if emitted.insert(placed.id) {
outline_entries.push(OutlineEntry { level: tag.level, title: tag.title.clone(), page_index: page.index });
}
}
if let Some(&target_page) = link_targets.get(&placed.id) {
link_entries.push(LinkEntry { rect: placed.rect, target_page });
}
}
page.outline = outline_entries;
page.links = link_entries;
}
}
pub fn renumber_pages<'a>(mut pages: Vec<Page<'a>>, page_number_style: Option<&PageNumberStyle>) -> Vec<Page<'a>> {
let total = pages.len() as u32;
for (i, page) in pages.iter_mut().enumerate() {
page.index = i as u32;
page.total = total;
for entry in &mut page.outline {
entry.page_index = page.index;
}
if let Some(style) = page_number_style {
if let Some(number) = &mut page.page_number {
number.text = style.format_for(page.index, total);
}
}
}
pages
}
fn compose_margin_box<'a>(content: &'a [BlockNode<'a>], rect: Rect, style: &ComposeStyle, shaper: &dyn LineShaper) -> Frame<'a> {
let mut regions = FixedRegionSequence::new(rect);
compose(content, &mut regions, style, shaper)
.into_iter()
.next()
.unwrap_or(Frame { region: Region { rect }, blocks: Vec::new(), overflow: None })
}
const FOOTNOTE_MARKER_INDENT: f64 = 20.0;
fn attach_footnotes<'a>(flow: &'a [BlockNode<'a>], pages: &mut [Page<'a>], master: &PageMaster<'a>, style: &ComposeStyle, shaper: &dyn LineShaper) {
if master.footnote_zone_height.is_none() {
return;
}
let ids = resolve_block_ids(flow);
let mut registry: HashMap<BlockId, &'a [Footnote<'a>]> = HashMap::new();
for (node, id) in flow.iter().zip(ids.iter()) {
if !node.footnotes.is_empty() {
registry.insert(*id, node.footnotes);
}
}
if registry.is_empty() {
return;
}
let mut numbers: HashMap<(BlockId, u64), u32> = HashMap::new();
let mut next_number = 1u32;
for (node, id) in flow.iter().zip(ids.iter()) {
for local_index in 0..node.footnotes.len() {
numbers.insert((*id, local_index as u64), next_number);
next_number += 1;
}
}
let zone_rect = master.footnote_zone_rect();
for page in pages.iter_mut() {
let mut found: Vec<(u32, &'a [BlockNode<'a>])> = Vec::new();
for placed in page.frame.blocks.iter().chain(page.extra_frames.iter().flat_map(|f| f.blocks.iter())) {
let Some(footnotes) = registry.get(&placed.id) else { continue };
let Some(layout) = &placed.paragraph_layout else { continue };
for placed_box in &layout.boxes {
let Some(footnote) = footnotes.get(placed_box.id as usize) else { continue };
let number = numbers.get(&(placed.id, placed_box.id)).copied().unwrap_or(0);
found.push((number, footnote.content));
}
}
if found.is_empty() {
continue;
}
let (frame, clamped) = compose_footnote_zone(&found, zone_rect, style, shaper);
page.footnotes = Some(FootnotePlacement { frame, clamped });
}
}
fn compose_footnote_zone<'a>(footnotes: &[(u32, &'a [BlockNode<'a>])], zone_rect: Rect, style: &ComposeStyle, shaper: &dyn LineShaper) -> (Frame<'a>, bool) {
let empty_frame = || Frame { region: Region { rect: zone_rect }, blocks: Vec::new(), overflow: None };
if footnotes.is_empty() || zone_rect.height <= 0.0 {
return (empty_frame(), false);
}
let start = footnotes[0].0;
let items: Vec<ListItem<'a>> = footnotes.iter().map(|(_, content)| ListItem::new(content)).collect();
let items: &'a [ListItem<'a>] = items.leak();
let list = ListBlock::new(items, MarkerStyle::numbered(start), FOOTNOTE_MARKER_INDENT);
let list_flow: &'a [BlockNode<'a>] = vec![BlockNode::new(Block::List(list))].leak();
let mut regions = FixedRegionSequence::new(zone_rect);
let frame = compose(list_flow, &mut regions, style, shaper).into_iter().next().unwrap_or_else(empty_frame);
let overflows_bottom_edge = frame.blocks.iter().any(|b| b.rect.y + b.rect.height > zone_rect.y + zone_rect.height + 1e-6);
let clamped = frame.overflow.is_some() || overflows_bottom_edge;
(frame, clamped)
}
#[cfg(test)]
mod tests {
use super::*;
use uzor::fonts::FontFamily;
use uzor_text::{layout_paragraph, CosmicShaper, FontSpec, Paragraph, StyledRun};
use crate::master::{Margins, PageNumberFormat, PageNumberStyle};
use crate::scene::{Block, BlockNode};
const SENTENCE: &str =
"This fixed seeded sentence exists only to consume vertical space \
for the page-slicing test, repeated enough times to overflow more \
than two pages but noticeably less than three whole pages of body \
text at this fixture's chosen page geometry.";
const WRAPPING_PARAGRAPH: &str =
"A paragraph placed near the bottom of the page, deliberately long \
enough that it must wrap across several lines and therefore cannot \
possibly fit whole into the small remaining sliver of vertical \
space left below a filler spacer that already consumed two thirds \
of this fixture's short page body.";
fn body_font() -> FontSpec {
FontSpec::new(FontFamily::Roboto, 16.0)
}
#[test]
fn content_worth_about_two_and_a_half_pages_slices_into_exactly_three() {
let font = body_font();
let paragraph_texts: Vec<String> = (0..20).map(|i| format!("Paragraph {i}: {SENTENCE}")).collect();
let runs: Vec<[uzor_text::StyledRun<'_>; 1]> =
paragraph_texts.iter().map(|t| [StyledRun::new(t.as_str(), font)]).collect();
let flow: Vec<BlockNode<'_>> = runs
.iter()
.map(|r| BlockNode::new(Block::Paragraph(Paragraph::new(r, 515.0))))
.collect();
let master = PageMaster::new(595.0, 842.0, Margins::uniform(40.0));
let style = ComposeStyle::new(12.0, font);
let shaper = CosmicShaper::headless();
let pages = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), 3, "fixture is tuned to land on exactly 3 pages, got {}", pages.len());
for page in &pages[..2] {
assert!(!page.frame.blocks.is_empty(), "every non-final page must carry content");
}
let last = pages.last().expect("at least one page");
assert!(!last.frame.blocks.is_empty(), "the final page must still carry its partial content");
let pages_again = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), pages_again.len());
for (a, b) in pages.iter().zip(pages_again.iter()) {
assert_eq!(a.frame.blocks.len(), b.frame.blocks.len());
}
}
#[test]
fn a_block_placed_two_thirds_down_a_page_only_gets_the_remaining_third() {
let font = body_font();
let master = PageMaster::new(400.0, 300.0, Margins::uniform(0.0));
let body = master.body_rect();
let filler_gap = body.height * (2.0 / 3.0);
let runs = [StyledRun::new(WRAPPING_PARAGRAPH, font)];
let flow = [BlockNode::new(Block::Spacer(filler_gap)), BlockNode::new(Block::Paragraph(Paragraph::new(&runs, body.width)))];
let style = ComposeStyle::new(0.0, font);
let shaper = CosmicShaper::headless();
let full_layout = layout_paragraph(&Paragraph::new(&runs, body.width), &shaper);
let remaining_before_paragraph = body.height - filler_gap;
assert!(
full_layout.height > remaining_before_paragraph,
"fixture must be tall enough to overflow the remaining 1/3 of the page"
);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), 2, "fixture must split across exactly 2 pages");
let first_page = &pages[0];
let paragraph_placement = first_page
.frame
.blocks
.iter()
.find(|b| matches!(b.kind, Block::Paragraph(_)))
.expect("paragraph must be placed on the first page (even if only partially)");
assert!(
paragraph_placement.rect.height <= remaining_before_paragraph + 1.0,
"paragraph placed 2/3 down the page must only get the remaining 1/3 of region height, got {} vs budget {}",
paragraph_placement.rect.height,
remaining_before_paragraph
);
let second_page = &pages[1];
let remainder_placement = second_page
.frame
.blocks
.iter()
.find(|b| matches!(b.kind, Block::Paragraph(_)))
.expect("the paragraph's remainder must continue onto the second page");
let remainder_layout = remainder_placement.paragraph_layout.as_ref().expect("paragraph placements always carry a layout");
let head_layout = paragraph_placement.paragraph_layout.as_ref().expect("paragraph placements always carry a layout");
assert_eq!(
head_layout.lines.len() + remainder_layout.lines.len(),
full_layout.lines.len(),
"every line of the paragraph must be conserved across the split, none dropped or duplicated"
);
}
#[test]
fn header_footer_and_page_number_are_attached_to_every_sliced_page() {
let font = body_font();
let shaper = CosmicShaper::headless();
let header_run = [StyledRun::new("Header", font)];
let header_flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&header_run, 200.0)))];
let footer_run = [StyledRun::new("Footer", font)];
let footer_flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&footer_run, 200.0)))];
let master = PageMaster::new(400.0, 300.0, Margins::uniform(30.0))
.with_header(&header_flow)
.with_footer(&footer_flow)
.with_page_number(PageNumberStyle::new(PageNumberFormat::OfTotal, 1));
let paragraph_texts: Vec<String> = (0..8).map(|i| format!("Paragraph {i}: {SENTENCE}")).collect();
let runs: Vec<[StyledRun<'_>; 1]> = paragraph_texts.iter().map(|t| [StyledRun::new(t.as_str(), font)]).collect();
let flow: Vec<BlockNode<'_>> =
runs.iter().map(|r| BlockNode::new(Block::Paragraph(Paragraph::new(r, master.body_rect().width)))).collect();
let style = ComposeStyle::new(0.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert!(pages.len() >= 2, "fixture must span at least 2 pages to prove the number/total wiring");
for (i, page) in pages.iter().enumerate() {
assert!(page.header.as_ref().is_some_and(|h| !h.blocks.is_empty()), "every page must carry the header");
assert!(page.footer.as_ref().is_some_and(|f| !f.blocks.is_empty()), "every page must carry the footer");
let number = page.page_number.as_ref().expect("page-number token was configured on the master");
assert_eq!(number.text, format!("{} of {}", i + 1, pages.len()));
}
}
#[test]
fn a_master_without_header_footer_or_page_number_produces_none_for_all_three() {
let font = body_font();
let shaper = CosmicShaper::headless();
let runs = [StyledRun::new("A single short paragraph.", font)];
let flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&runs, 400.0)))];
let master = PageMaster::new(500.0, 700.0, Margins::uniform(40.0));
let style = ComposeStyle::new(0.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), 1);
assert!(pages[0].header.is_none());
assert!(pages[0].footer.is_none());
assert!(pages[0].page_number.is_none());
}
#[test]
fn two_column_master_fills_column_one_before_column_two_on_the_same_page() {
let font = body_font();
let shaper = CosmicShaper::headless();
let master = PageMaster::new(600.0, 500.0, Margins::uniform(20.0)).with_columns(2, 18.0);
let column_width = master.column_width();
assert!(column_width < master.body_rect().width, "a 2-column body must be narrower per column than the full body");
let paragraph_texts: Vec<String> = (0..14).map(|i| format!("Column paragraph number {i} of the fixture.")).collect();
let runs: Vec<[StyledRun<'_>; 1]> = paragraph_texts.iter().map(|t| [StyledRun::new(t.as_str(), font)]).collect();
let flow: Vec<BlockNode<'_>> = runs.iter().map(|r| BlockNode::new(Block::Paragraph(Paragraph::new(r, column_width)))).collect();
let style = ComposeStyle::new(6.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), 1, "fixture must be tuned to fit within column 1 + column 2 of ONE page");
assert!(!pages[0].frame.blocks.is_empty(), "column 1 must carry content");
assert!(!pages[0].extra_frames.is_empty(), "column 2 must carry the overflow from column 1");
let col1_width = pages[0].frame.region.rect.width;
let col2_width = pages[0].extra_frames[0].region.rect.width;
assert!((col1_width - column_width).abs() < 1e-6);
assert!((col2_width - column_width).abs() < 1e-6);
for placed in &pages[0].frame.blocks {
assert!(placed.rect.width <= col1_width + 0.01, "column 1 content must never exceed column 1's own width");
}
for placed in &pages[0].extra_frames[0].blocks {
assert!(placed.rect.width <= col2_width + 0.01, "column 2 content must never exceed column 2's own width");
}
assert!(pages[0].frame.overflow.is_some(), "column 1 must report overflow into column 2 for this fixture to be meaningful");
}
#[test]
fn a_table_inside_a_column_is_sized_to_the_column_width_not_the_full_body() {
use crate::scene::{Block, ColumnSpec, TableBlock, TableCell, TableRow};
let font = body_font();
let shaper = CosmicShaper::headless();
let master = PageMaster::new(600.0, 500.0, Margins::uniform(20.0)).with_columns(2, 18.0);
let column_width = master.column_width();
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)];
let columns = [ColumnSpec::Fraction(1.0)];
let table = TableBlock::new(&columns, &rows);
let flow = [BlockNode::new(Block::Table(table))];
let style = ComposeStyle::new(6.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
let placed = pages[0].frame.blocks.first().expect("table must be placed in column 1");
let table_width: f64 = placed.table_placement.as_ref().expect("table placement present").column_widths.iter().sum();
assert!(
(table_width - column_width).abs() < 1e-6,
"a Fraction(1.0) column inside a table must fill the CONTAINING column's width ({column_width}), got {table_width}"
);
assert!(table_width < master.body_rect().width - 1.0, "the table must be narrower than the full (un-columned) body width");
}
#[test]
fn running_header_resolves_the_current_outline_title_per_page() {
use crate::master::HeaderPlaceholder;
let font = body_font();
let shaper = CosmicShaper::headless();
let filler_run = [StyledRun::new(SENTENCE, font)];
let heading_a_run = [StyledRun::new("Chapter One", font)];
let heading_b_run = [StyledRun::new("Chapter Two", font)];
let mut flow: Vec<BlockNode<'_>> = Vec::new();
for _ in 0..6 {
flow.push(BlockNode::new(Block::Paragraph(Paragraph::new(&filler_run, 400.0))));
}
flow.push(BlockNode::new(Block::Paragraph(Paragraph::new(&heading_a_run, 400.0))).with_outline(1, "Chapter One"));
for _ in 0..6 {
flow.push(BlockNode::new(Block::Paragraph(Paragraph::new(&filler_run, 400.0))));
}
flow.push(BlockNode::new(Block::Paragraph(Paragraph::new(&heading_b_run, 400.0))).with_outline(1, "Chapter Two"));
for _ in 0..6 {
flow.push(BlockNode::new(Block::Paragraph(Paragraph::new(&filler_run, 400.0))));
}
let placeholder_run = [StyledRun::new("placeholder", font)];
let header_flow =
[BlockNode::new(Block::Paragraph(Paragraph::new(&placeholder_run, 400.0))).with_header_placeholder(HeaderPlaceholder::CurrentOutlineTitle)];
let master = PageMaster::new(500.0, 250.0, Margins::uniform(30.0)).with_header(&header_flow);
let style = ComposeStyle::new(6.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert!(pages.len() >= 4, "fixture must be tuned to span several pages, got {}", pages.len());
fn header_text(page: &Page<'_>) -> String {
page.header
.as_ref()
.map(|h| h.blocks.iter().filter_map(|b| b.paragraph_layout.as_ref()).flat_map(|l| l.glyphs.iter().map(|g| g.cluster.clone())).collect::<String>())
.unwrap_or_default()
}
let idx_a = pages.iter().position(|p| p.outline.iter().any(|e| e.title == "Chapter One")).expect("Chapter One must land on some page");
let idx_b = pages.iter().position(|p| p.outline.iter().any(|e| e.title == "Chapter Two")).expect("Chapter Two must land on some page");
assert!(idx_a < idx_b, "fixture must be tuned so Chapter One precedes Chapter Two");
for (i, page) in pages.iter().enumerate() {
let text = header_text(page);
if i < idx_a {
assert!(text.is_empty(), "page {i} (before any heading) must have an EMPTY running header, got {text:?}");
} else if i < idx_b {
assert!(text.contains("Chapter One"), "page {i} must show the running title \"Chapter One\", got {text:?}");
} else {
assert!(text.contains("Chapter Two"), "page {i} must show the running title \"Chapter Two\", got {text:?}");
}
}
}
#[test]
fn a_plain_header_with_no_placeholder_is_unaffected_by_the_running_header_machinery() {
let font = body_font();
let shaper = CosmicShaper::headless();
let header_run = [StyledRun::new("Static Header", font)];
let header_flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&header_run, 400.0)))];
let runs = [StyledRun::new("A single short paragraph.", font)];
let flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&runs, 400.0)))];
let master = PageMaster::new(500.0, 300.0, Margins::uniform(30.0)).with_header(&header_flow);
let style = ComposeStyle::new(0.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), 1);
assert!(pages[0].header.as_ref().is_some_and(|h| !h.blocks.is_empty()));
}
#[test]
fn a_footnote_marker_produces_a_numbered_entry_in_the_reserved_footnote_zone() {
use uzor_text::{InlineBox, InlineBoxSlot, VerticalAlign};
use crate::scene::Footnote;
let font = body_font();
let shaper = CosmicShaper::headless();
let footnote_body_run = [StyledRun::new("This is the footnote body text.", font)];
let footnote_content = [BlockNode::new(Block::Paragraph(Paragraph::new(&footnote_body_run, 300.0)))];
let footnotes = [Footnote::new(&footnote_content)];
let body_text = "Body text with a marker here";
let marker_text = "1";
let body_run = StyledRun::new(body_text, font);
let marker_run = StyledRun::new(marker_text, font).with_vertical_align(VerticalAlign::Super);
let runs = [body_run, marker_run];
let marker_box = InlineBox::out_of_flow(0); let slots = [InlineBoxSlot::new(1, marker_text.len(), marker_box)];
let paragraph = Paragraph::new(&runs, 400.0).with_inline_boxes(&slots);
let node = BlockNode::new(Block::Paragraph(paragraph)).with_footnotes(&footnotes);
let flow = [node];
let master = PageMaster::new(500.0, 400.0, Margins::uniform(30.0)).with_footnote_zone(100.0);
let style = ComposeStyle::new(6.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), 1);
let fp = pages[0].footnotes.as_ref().expect("footnote zone must be populated");
assert!(!fp.clamped, "a short footnote inside a generous zone must never report clamped");
assert_eq!(fp.frame.region.rect, master.footnote_zone_rect(), "the footnote frame's own region must be the master's own reserved zone rect exactly");
let list_placement = fp.frame.blocks.first().and_then(|b| b.list_placement.as_ref()).expect("footnote zone content is a real Block::List placement");
assert_eq!(list_placement.items.len(), 1);
assert_eq!(list_placement.items[0].marker_text, "1.");
let body_text_joined: String = list_placement.items[0]
.content
.iter()
.filter_map(|b| b.paragraph_layout.as_ref())
.flat_map(|l| l.glyphs.iter().map(|g| g.cluster.clone()))
.collect();
assert!(body_text_joined.contains("footnote body"), "the footnote's own authored content must render inside the zone, got {body_text_joined:?}");
}
#[test]
fn no_footnote_zone_configured_means_page_footnotes_is_always_none() {
use uzor_text::{InlineBox, InlineBoxSlot, VerticalAlign};
use crate::scene::Footnote;
let font = body_font();
let shaper = CosmicShaper::headless();
let footnote_body_run = [StyledRun::new("body", font)];
let footnote_content = [BlockNode::new(Block::Paragraph(Paragraph::new(&footnote_body_run, 300.0)))];
let footnotes = [Footnote::new(&footnote_content)];
let body_run = StyledRun::new("text", font);
let marker_run = StyledRun::new("1", font).with_vertical_align(VerticalAlign::Super);
let runs = [body_run, marker_run];
let slots = [InlineBoxSlot::new(1, "1".len(), InlineBox::out_of_flow(0))];
let paragraph = Paragraph::new(&runs, 400.0).with_inline_boxes(&slots);
let flow = [BlockNode::new(Block::Paragraph(paragraph)).with_footnotes(&footnotes)];
let master = PageMaster::new(500.0, 400.0, Margins::uniform(30.0)); let style = ComposeStyle::new(6.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert!(pages[0].footnotes.is_none());
}
#[test]
fn a_footnote_follows_its_own_markers_paragraph_fragment_across_a_page_split() {
use uzor_text::{InlineBox, InlineBoxSlot};
use crate::scene::Footnote;
let font = body_font();
let shaper = CosmicShaper::headless();
let footnote_a_run = [StyledRun::new("Early footnote body.", font)];
let footnote_a_nodes = [BlockNode::new(Block::Paragraph(Paragraph::new(&footnote_a_run, 300.0)))];
let footnote_b_run = [StyledRun::new("Late footnote body.", font)];
let footnote_b_nodes = [BlockNode::new(Block::Paragraph(Paragraph::new(&footnote_b_run, 300.0)))];
let footnotes = [Footnote::new(&footnote_a_nodes), Footnote::new(&footnote_b_nodes)];
const TEXT: &str = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron";
const NARROW_WIDTH: f64 = 120.0;
const MARKER_A_OFFSET: usize = 16; let body_run = StyledRun::new(TEXT, font);
let marker_a = InlineBox::out_of_flow(0);
let marker_b = InlineBox::out_of_flow(1);
let slots = [InlineBoxSlot::new(0, MARKER_A_OFFSET, marker_a), InlineBoxSlot::new(0, TEXT.len(), marker_b)];
let runs = [body_run];
let paragraph_template = Paragraph::new(&runs, NARROW_WIDTH).with_inline_boxes(&slots);
let full = layout_paragraph(¶graph_template, &shaper);
assert!(full.lines.len() >= 6, "fixture must wrap to several lines, got {}", full.lines.len());
assert_eq!(full.boxes.len(), 2, "both markers must be placed");
let box_a_line = full.boxes.iter().find(|b| b.id == 0).expect("marker A placed").line_index;
let box_b_line = full.boxes.iter().find(|b| b.id == 1).expect("marker B placed").line_index;
assert!(box_a_line < box_b_line, "fixture must place the two markers on DIFFERENT lines, marker A earlier");
let split_line = box_a_line + 1;
assert!(split_line <= box_b_line, "fixture must be tuned so marker B's own line lands AFTER the split point");
let head_height: f64 = full.lines[..split_line].iter().map(|l| l.height).sum();
let tail_height: f64 = full.lines[split_line..].iter().map(|l| l.height).sum();
let region_height = head_height.max(tail_height) + 0.5;
let node = BlockNode::new(Block::Paragraph(paragraph_template)).with_footnotes(&footnotes);
let flow = [node];
let margins = Margins::uniform(20.0);
const FOOTNOTE_ZONE: f64 = 80.0;
let page_height = region_height + margins.top + margins.bottom + FOOTNOTE_ZONE;
let master = PageMaster::new(NARROW_WIDTH + margins.left + margins.right, page_height, margins).with_footnote_zone(FOOTNOTE_ZONE);
let style = ComposeStyle::new(0.0, font).with_min_orphan_lines(0).with_min_widow_lines(0);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), 2, "fixture must be tuned to split across exactly 2 pages, got {}", pages.len());
fn marker_texts(fp: &FootnotePlacement<'_>) -> Vec<String> {
fp.frame.blocks.first().and_then(|b| b.list_placement.as_ref()).map(|lp| lp.items.iter().map(|i| i.marker_text.clone()).collect()).unwrap_or_default()
}
let page1_fn = pages[0].footnotes.as_ref().expect("page 1 must carry the early marker's footnote");
let page2_fn = pages[1].footnotes.as_ref().expect("page 2 must carry the late marker's footnote");
assert_eq!(marker_texts(page1_fn), vec!["1."], "page 1's footnote zone must hold ONLY the early marker's own footnote");
assert_eq!(marker_texts(page2_fn), vec!["2."], "page 2's footnote zone must hold the late marker's footnote, continuing the GLOBAL numbering");
}
#[test]
fn a_footnote_taller_than_its_own_zone_is_clamped_never_spilling_to_a_second_page() {
use uzor_text::{InlineBox, InlineBoxSlot, VerticalAlign};
use crate::scene::Footnote;
let font = body_font();
let shaper = CosmicShaper::headless();
let long_run = [StyledRun::new(SENTENCE, font)];
let footnote_nodes = [BlockNode::new(Block::Paragraph(Paragraph::new(&long_run, 300.0)))];
let footnotes = [Footnote::new(&footnote_nodes)];
let body_run = StyledRun::new("Body text.", font);
let marker_run = StyledRun::new("1", font).with_vertical_align(VerticalAlign::Super);
let runs = [body_run, marker_run];
let slots = [InlineBoxSlot::new(1, "1".len(), InlineBox::out_of_flow(0))];
let paragraph = Paragraph::new(&runs, 300.0).with_inline_boxes(&slots);
let node = BlockNode::new(Block::Paragraph(paragraph)).with_footnotes(&footnotes);
let flow = [node];
const TINY_ZONE: f64 = 20.0; let master = PageMaster::new(400.0, 300.0, Margins::uniform(20.0)).with_footnote_zone(TINY_ZONE);
let style = ComposeStyle::new(0.0, font);
let pages = slice_pages(&flow, &master, &style, &shaper);
assert_eq!(pages.len(), 1, "the body content itself is short — only the footnote zone is deliberately tight");
let fp = pages[0].footnotes.as_ref().expect("footnote zone must still be populated");
assert!(fp.clamped, "a footnote taller than its own reserved zone must report clamped, never silently fit");
assert!(!fp.frame.blocks.is_empty(), "the overflowing footnote must still be VISIBLY placed, never dropped");
}
fn collect_paragraph_baselines(pages: &[Page<'_>]) -> Vec<f64> {
pages
.iter()
.flat_map(|p| p.frame.blocks.iter().chain(p.extra_frames.iter().flat_map(|f| f.blocks.iter())))
.filter_map(|b| {
let layout = b.paragraph_layout.as_ref()?;
let line = layout.lines.first()?;
Some(b.rect.y + line.baseline_y)
})
.collect()
}
#[test]
fn two_column_master_shares_one_baseline_grid_across_both_columns_when_enabled() {
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) {}
}
const PITCH: f64 = 18.0; const HEADING_FONT: FontSpec = FontSpec { family: FontFamily::Roboto, size_px: 21.0, bold: true, italic: false };
const BODY_FONT: FontSpec = FontSpec { family: FontFamily::Roboto, size_px: 13.0, bold: false, italic: false };
const FIGURE_HEIGHT: f64 = 133.0;
let master = PageMaster::new(620.0, 310.0, Margins::uniform(30.0)).with_columns(2, 20.0);
let column_width = master.column_width();
let heading_run = [StyledRun::new("Grid Section", HEADING_FONT)];
let body_texts: Vec<String> = (0..16).map(|i| format!("Row {i}.")).collect();
let body_runs: Vec<[StyledRun<'_>; 1]> = body_texts.iter().map(|t| [StyledRun::new(t.as_str(), BODY_FONT)]).collect();
let stub = StubFigure;
let mut flow: Vec<BlockNode<'_>> = vec![
BlockNode::new(Block::Paragraph(Paragraph::new(&heading_run, column_width))),
BlockNode::new(Block::Spacer(10.0)),
];
for run in &body_runs[..6] {
flow.push(BlockNode::new(Block::Paragraph(Paragraph::new(run, column_width))));
}
flow.push(BlockNode::new(Block::Figure(FigureBlock::new(&stub, BlockSizing::FixedHeight(FIGURE_HEIGHT)))));
for run in &body_runs[6..] {
flow.push(BlockNode::new(Block::Paragraph(Paragraph::new(run, column_width))));
}
let shaper = CosmicShaper::headless();
let grid_origin = master.body_rect().y;
let style_off_a = ComposeStyle::new(8.0, BODY_FONT);
let style_off_b = ComposeStyle::new(8.0, BODY_FONT);
let pages_off_a = slice_pages(&flow, &master, &style_off_a, &shaper);
let pages_off_b = slice_pages(&flow, &master, &style_off_b, &shaper);
assert_eq!(pages_off_a.len(), pages_off_b.len());
for (a, b) in pages_off_a.iter().zip(pages_off_b.iter()) {
let a_rects: Vec<Rect> = a.frame.blocks.iter().chain(a.extra_frames.iter().flat_map(|f| f.blocks.iter())).map(|p| p.rect).collect();
let b_rects: Vec<Rect> = b.frame.blocks.iter().chain(b.extra_frames.iter().flat_map(|f| f.blocks.iter())).map(|p| p.rect).collect();
assert_eq!(a_rects, b_rects, "grid OFF (the default) must reproduce byte-identical geometry — this track changes nothing on the disabled path");
}
let off_baselines = collect_paragraph_baselines(&pages_off_a);
assert!(
off_baselines.iter().any(|y| {
let remainder = (y - grid_origin).rem_euclid(PITCH);
remainder.min(PITCH - remainder) > 1.0
}),
"fixture must be tuned so the UN-gridded layout does NOT already coincidentally land every baseline on the grid"
);
let style_on = ComposeStyle::new(8.0, BODY_FONT).with_baseline_grid(PITCH);
let pages_on = slice_pages(&flow, &master, &style_on, &shaper);
assert!(pages_on.iter().any(|p| !p.extra_frames.is_empty()), "fixture must be tuned so content actually spills into column 2");
let on_baselines = collect_paragraph_baselines(&pages_on);
assert!(on_baselines.len() >= 10, "fixture must place enough paragraphs to meaningfully exercise the grid, got {}", on_baselines.len());
for y in &on_baselines {
let local = y - grid_origin;
let remainder = local.rem_euclid(PITCH);
let distance_to_grid = remainder.min(PITCH - remainder);
assert!(distance_to_grid < 1e-3, "baseline at absolute y={y} (local={local}) must land on a shared grid multiple of {PITCH}, off by {distance_to_grid}");
}
}
}