use uzor::fonts::FontFamily;
use uzor_text::{paragraph_intrinsic_size, FontSpec, InlineBox, InlineBoxSlot, LineShaper, Paragraph, StyledRun};
use crate::compose::ComposeStyle;
use crate::master::{PageMaster, PageNumberStyle};
use crate::scene::{Block, BlockNode};
use crate::slice::{renumber_pages, slice_pages, OutlineEntry, Page};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TocStyle {
pub font: FontSpec,
pub dot_char: char,
pub level_indent_px: f64,
pub row_gap_px: f64,
}
impl Default for TocStyle {
fn default() -> Self {
Self { font: FontSpec::new(FontFamily::Roboto, 13.0), dot_char: '.', level_indent_px: 16.0, row_gap_px: 6.0 }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TocRow {
pub level: u8,
pub text: String,
pub indent_px: f64,
}
const LEADER_PAD_PX: f64 = 4.0;
pub fn dotted_leader(available_width: f64, dot_char: char, font: FontSpec, shaper: &dyn LineShaper) -> String {
if available_width <= 0.0 {
return String::new();
}
let mut dot_buf = [0u8; 4];
let single_dot: &str = dot_char.encode_utf8(&mut dot_buf);
let single_width = paragraph_intrinsic_size(single_dot, &font, f64::MAX, shaper).0;
if single_width <= 0.0 || single_width > available_width {
return String::new();
}
let mut count = (available_width / single_width).floor() as i64 + 2;
while count > 0 {
let candidate: String = std::iter::repeat(dot_char).take(count as usize).collect();
let measured = paragraph_intrinsic_size(&candidate, &font, f64::MAX, shaper).0;
if measured <= available_width {
return candidate;
}
count -= 1;
}
String::new()
}
fn build_toc_row_text(title: &str, number_text: &str, available_width: f64, style: &TocStyle, shaper: &dyn LineShaper) -> String {
let title_width = paragraph_intrinsic_size(title, &style.font, f64::MAX, shaper).0;
let number_width = paragraph_intrinsic_size(number_text, &style.font, f64::MAX, shaper).0;
let leader_budget = available_width - title_width - number_width - 2.0 * LEADER_PAD_PX;
let leader = dotted_leader(leader_budget, style.dot_char, style.font, shaper);
if leader.is_empty() {
format!("{title} {number_text}")
} else {
format!("{title} {leader} {number_text}")
}
}
pub fn build_toc_rows(entries: &[OutlineEntry], style: &TocStyle, row_width: f64, shaper: &dyn LineShaper) -> Vec<TocRow> {
entries
.iter()
.map(|entry| {
let indent_px = style.level_indent_px * entry.level.saturating_sub(1) as f64;
let available_width = (row_width - indent_px).max(0.0);
let number_text = (entry.page_index + 1).to_string();
let text = build_toc_row_text(&entry.title, &number_text, available_width, style, shaper);
TocRow { level: entry.level, text, indent_px }
})
.collect()
}
#[derive(Debug, Default)]
pub struct TocArena {
texts: Vec<String>,
}
impl TocArena {
pub fn new() -> Self {
Self { texts: Vec::new() }
}
pub(crate) fn push(&mut self, s: String) -> usize {
self.texts.push(s);
self.texts.len() - 1
}
pub(crate) fn text(&self, index: usize) -> &str {
&self.texts[index]
}
}
pub fn build_toc<'arena>(
arena: &'arena mut TocArena,
entries: &[OutlineEntry],
style: &TocStyle,
row_width: f64,
shaper: &dyn LineShaper,
) -> Vec<BlockNode<'arena>> {
let rows = build_toc_rows(entries, style, row_width, shaper);
let mut text_indices = Vec::with_capacity(rows.len());
for row in &rows {
text_indices.push(arena.push(row.text.clone()));
}
let mut out = Vec::with_capacity(rows.len());
for ((row, entry), text_index) in rows.into_iter().zip(entries.iter()).zip(text_indices) {
let text: &'arena str = arena.text(text_index);
let runs: &'arena [StyledRun<'arena>] = &*vec![StyledRun::new(text, style.font)].leak();
let boxes: &'arena [InlineBoxSlot] = &*vec![InlineBoxSlot::new(0, 0, InlineBox::in_flow(0, row.indent_px, 1.0))].leak();
let paragraph = Paragraph::new(runs, row_width).with_inline_boxes(boxes);
out.push(BlockNode::new(Block::Paragraph(paragraph)).with_link_target(entry.page_index));
}
out
}
fn collect_shifted_entries(pages: &[Page<'_>], page_offset: u32) -> Vec<OutlineEntry> {
let mut out = Vec::new();
for page in pages {
for entry in &page.outline {
out.push(OutlineEntry { level: entry.level, title: entry.title.clone(), page_index: entry.page_index + page_offset });
}
}
out
}
const MAX_TOC_FIXPOINT_ITERATIONS: usize = 3;
pub fn compose_document_with_toc<'a>(
body_pages: Vec<Page<'a>>,
toc_master: &PageMaster<'a>,
toc_style: &TocStyle,
shaper: &dyn LineShaper,
page_number_style: Option<&PageNumberStyle>,
arena: &'a mut TocArena,
) -> Vec<Page<'a>> {
let toc_row_width = toc_master.body_rect().width;
let toc_compose_style = ComposeStyle::new(toc_style.row_gap_px, toc_style.font);
let mut toc_page_offset = 0u32;
let mut converged = false;
for _ in 0..MAX_TOC_FIXPOINT_ITERATIONS {
let entries = collect_shifted_entries(&body_pages, toc_page_offset);
let mut probe_arena = TocArena::new();
let probe_flow = build_toc(&mut probe_arena, &entries, toc_style, toc_row_width, shaper);
let probe_pages = slice_pages(&probe_flow, toc_master, &toc_compose_style, shaper);
let new_offset = probe_pages.len() as u32;
if new_offset == toc_page_offset {
converged = true;
break;
}
toc_page_offset = new_offset;
}
if converged {
let entries = collect_shifted_entries(&body_pages, toc_page_offset);
let toc_flow: &'a [BlockNode<'a>] = build_toc(arena, &entries, toc_style, toc_row_width, shaper).leak();
let toc_pages = slice_pages(toc_flow, toc_master, &toc_compose_style, shaper);
let mut all: Vec<Page<'a>> = Vec::with_capacity(toc_pages.len() + body_pages.len());
all.extend(toc_pages);
all.extend(body_pages);
return renumber_pages(all, page_number_style);
}
panic!(
"compose_document_with_toc: TOC page count failed to converge within {MAX_TOC_FIXPOINT_ITERATIONS} iterations. \
This function's own row-builder always reserves space for the page-number text's OWN width before deciding \
the dotted-leader's dot count, which makes a TOC row's height (hence `toc_pages.len()`) invariant to the \
page-number offset in every case this crate can construct — reaching this panic means that invariant was \
violated, a genuine bug in this function, not a caller-input problem."
);
}
#[cfg(test)]
mod tests {
use super::*;
use uzor_text::CosmicShaper;
fn style() -> TocStyle {
TocStyle::default()
}
#[test]
fn dotted_leader_fills_the_available_width_as_tightly_as_possible_without_exceeding_it() {
let shaper = CosmicShaper::headless();
let font = style().font;
let available = 120.0;
let leader = dotted_leader(available, '.', font, &shaper);
assert!(!leader.is_empty(), "a generous available width must produce a real leader run");
let measured = paragraph_intrinsic_size(&leader, &font, f64::MAX, &shaper).0;
assert!(measured <= available, "leader must never exceed its own available width, got {measured} > {available}");
let one_more: String = format!("{leader}.");
let one_more_width = paragraph_intrinsic_size(&one_more, &font, f64::MAX, &shaper).0;
assert!(one_more_width > available, "one additional dot must no longer fit — leader must be the tightest fit, not an underfill");
}
#[test]
fn dotted_leader_is_empty_when_available_width_is_non_positive_or_smaller_than_one_dot() {
let shaper = CosmicShaper::headless();
let font = style().font;
assert_eq!(dotted_leader(0.0, '.', font, &shaper), "");
assert_eq!(dotted_leader(-5.0, '.', font, &shaper), "");
assert_eq!(dotted_leader(0.001, '.', font, &shaper), "");
}
#[test]
fn a_normal_row_fills_the_row_width_tightly_with_the_number_at_the_right_edge() {
let shaper = CosmicShaper::headless();
let toc_style = style();
const ROW_WIDTH: f64 = 300.0;
let entries = [OutlineEntry { level: 1, title: "Introduction".to_owned(), page_index: 0 }];
let rows = build_toc_rows(&entries, &toc_style, ROW_WIDTH, &shaper);
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert!(row.text.ends_with('1'), "row text must end with the resolved page number, got {:?}", row.text);
assert!(row.text.contains("..."), "a normal-width row must contain a real dot leader, got {:?}", row.text);
let full_width = paragraph_intrinsic_size(&row.text, &toc_style.font, f64::MAX, &shaper).0;
assert!(full_width <= ROW_WIDTH - row.indent_px + 1.0, "row must fit within its own available width, got {full_width}");
assert!(full_width > ROW_WIDTH - row.indent_px - 40.0, "row must fill its own width tightly, not underfill by a wide margin, got {full_width}");
}
#[test]
fn a_long_title_truncates_the_leader_never_the_page_number() {
let shaper = CosmicShaper::headless();
let toc_style = style();
const ROW_WIDTH: f64 = 200.0;
let long_title = "A deliberately very long section title that already consumes almost the entire available row width on its own";
let entries = [OutlineEntry { level: 1, title: long_title.to_owned(), page_index: 41 }];
let rows = build_toc_rows(&entries, &toc_style, ROW_WIDTH, &shaper);
let row = &rows[0];
assert!(!row.text.contains("..."), "an overflowing title must leave the leader empty, not a partial/degenerate dot run");
assert!(row.text.ends_with("42"), "the resolved page number (42, 1-based) must always be present intact, got {:?}", row.text);
assert!(row.text.starts_with(long_title), "the title must never itself be truncated by this function", );
}
#[test]
fn outline_levels_and_pages_are_correct_across_a_multi_run_concat_with_renumbering() {
use uzor::fonts::FontFamily;
use uzor_text::StyledRun;
use crate::master::Margins;
let shaper = CosmicShaper::headless();
let font = FontSpec::new(FontFamily::Roboto, 14.0);
let style = ComposeStyle::new(4.0, font);
let master = PageMaster::new(300.0, 200.0, Margins::uniform(20.0));
let body_width = master.body_rect().width;
let a_run = [StyledRun::new("Section A", font)];
let a_flow = [BlockNode::new(Block::Paragraph(Paragraph::new(&a_run, body_width))).with_outline(1, "Section A")];
let pages_a = slice_pages(&a_flow, &master, &style, &shaper);
assert_eq!(pages_a.len(), 1);
let filler_run = [StyledRun::new(
"Filler text repeated to consume enough vertical space that the following heading \
lands on this section's own second page rather than its first.",
font,
)];
let b_heading_run = [StyledRun::new("Section B", font)];
let b_sub_run = [StyledRun::new("Section B.1", font)];
let mut b_flow_vec: Vec<BlockNode<'_>> = Vec::new();
for _ in 0..10 {
b_flow_vec.push(BlockNode::new(Block::Paragraph(Paragraph::new(&filler_run, body_width))));
}
b_flow_vec.push(BlockNode::new(Block::Paragraph(Paragraph::new(&b_heading_run, body_width))).with_outline(1, "Section B"));
b_flow_vec.push(BlockNode::new(Block::Paragraph(Paragraph::new(&b_sub_run, body_width))).with_outline(2, "Section B.1"));
let pages_b = slice_pages(&b_flow_vec, &master, &style, &shaper);
assert!(pages_b.len() >= 2, "fixture must be tuned so section B's own heading lands on ITS second page");
let mut all: Vec<Page<'_>> = Vec::new();
all.extend(pages_a);
all.extend(pages_b);
let all = renumber_pages(all, None);
let mut collected: Vec<OutlineEntry> = Vec::new();
for page in &all {
collected.extend(page.outline.iter().cloned());
}
assert_eq!(collected.len(), 3, "exactly 3 tagged headings across both sections");
assert_eq!(collected[0].title, "Section A");
assert_eq!(collected[0].level, 1);
assert_eq!(collected[0].page_index, 0, "section A's heading is on the FIRST global page");
assert_eq!(collected[1].title, "Section B");
assert_eq!(collected[1].level, 1);
assert!(collected[1].page_index >= 2, "section B's heading must be on its own SECOND section page, which is GLOBAL page >= 2 (1 from section A + at least 1 filler page)");
assert_eq!(collected[2].title, "Section B.1");
assert_eq!(collected[2].level, 2);
assert!(collected[2].page_index >= collected[1].page_index, "the sub-heading must land on the SAME or a LATER global page than its immediately preceding heading");
}
#[test]
fn toc_insertion_shifts_a_headings_page_and_converges_with_matching_final_numbers() {
use uzor::fonts::FontFamily;
use uzor_text::StyledRun;
use crate::master::Margins;
let shaper = CosmicShaper::headless();
let font = FontSpec::new(FontFamily::Roboto, 14.0);
let style = ComposeStyle::new(4.0, font);
let body_master = PageMaster::new(300.0, 220.0, Margins::uniform(20.0));
let toc_master = PageMaster::new(300.0, 220.0, Margins::uniform(20.0));
let body_width = body_master.body_rect().width;
let filler_run = [StyledRun::new(
"Filler text repeated to consume enough vertical space that the tagged heading below \
lands on the body's own second page, not its first.",
font,
)];
let heading_run = [StyledRun::new("Deep Section", font)];
let mut body: Vec<BlockNode<'_>> = Vec::new();
for _ in 0..8 {
body.push(BlockNode::new(Block::Paragraph(Paragraph::new(&filler_run, body_width))));
}
body.push(BlockNode::new(Block::Paragraph(Paragraph::new(&heading_run, body_width))).with_outline(1, "Deep Section"));
let toc_style = TocStyle { row_gap_px: 40.0, ..TocStyle::default() }; let body_pages_for_toc = slice_pages(&body, &body_master, &style, &shaper);
let mut arena = TocArena::new();
let pages = compose_document_with_toc(body_pages_for_toc, &toc_master, &toc_style, &shaper, None, &mut arena);
let body_alone = slice_pages(&body, &body_master, &style, &shaper);
let body_local_index = body_alone
.iter()
.find(|p| !p.outline.is_empty())
.expect("heading must land on some body-local page")
.index;
assert!(body_local_index >= 1, "fixture must be tuned so the heading is NOT on the body's own first page");
let toc_page_count = pages.len() - body_alone.len();
let final_entry = pages.iter().flat_map(|p| p.outline.iter()).find(|e| e.title == "Deep Section").expect("heading's outline entry must survive to the final document");
assert_eq!(
final_entry.page_index,
toc_page_count as u32 + body_local_index,
"the final global page index must equal the TOC's own page count plus the heading's body-local index"
);
let toc_row_text: Vec<String> = pages
.iter()
.take(toc_page_count)
.flat_map(|p| p.frame.blocks.iter())
.filter_map(|b| b.paragraph_layout.as_ref())
.map(|layout| layout.glyphs.iter().map(|g| g.cluster.as_str()).collect::<String>())
.collect();
let joined = toc_row_text.join(" ");
let expected_number = (final_entry.page_index + 1).to_string();
assert!(joined.contains(&expected_number), "the rendered TOC row must contain the FINAL, correctly-shifted page number {expected_number:?}, got {joined:?}");
}
#[test]
fn build_toc_produces_block_nodes_whose_text_is_arena_backed_and_matches_build_toc_rows() {
let shaper = CosmicShaper::headless();
let toc_style = style();
const ROW_WIDTH: f64 = 300.0;
let entries = [
OutlineEntry { level: 1, title: "Introduction".to_owned(), page_index: 0 },
OutlineEntry { level: 2, title: "Background".to_owned(), page_index: 2 },
];
let expected_rows = build_toc_rows(&entries, &toc_style, ROW_WIDTH, &shaper);
let mut arena = TocArena::new();
let nodes = build_toc(&mut arena, &entries, &toc_style, ROW_WIDTH, &shaper);
assert_eq!(nodes.len(), expected_rows.len());
for (node, expected) in nodes.iter().zip(expected_rows.iter()) {
let Block::Paragraph(p) = &node.kind else { panic!("a TOC row must be a real Block::Paragraph") };
assert_eq!(p.runs.len(), 1);
assert_eq!(p.runs[0].text, expected.text, "row text must round-trip through the arena verbatim");
}
}
#[test]
fn toc_arena_can_be_reused_sequentially_across_multiple_build_toc_calls_without_corrupting_earlier_rows() {
let shaper = CosmicShaper::headless();
let toc_style = style();
const ROW_WIDTH: f64 = 300.0;
let entries_a = [OutlineEntry { level: 1, title: "First Pass Heading".to_owned(), page_index: 0 }];
let entries_b = [OutlineEntry { level: 1, title: "Second Pass Heading".to_owned(), page_index: 3 }];
let mut arena = TocArena::new();
let first_text = {
let nodes_a = build_toc(&mut arena, &entries_a, &toc_style, ROW_WIDTH, &shaper);
let Block::Paragraph(pa) = &nodes_a[0].kind else { panic!("expected a paragraph") };
pa.runs[0].text.to_owned()
};
let nodes_b = build_toc(&mut arena, &entries_b, &toc_style, ROW_WIDTH, &shaper);
let Block::Paragraph(pb) = &nodes_b[0].kind else { panic!("expected a paragraph") };
assert!(first_text.starts_with("First Pass Heading"), "the FIRST batch's own text must have resolved correctly before the arena was reused, got {first_text:?}");
assert!(pb.runs[0].text.starts_with("Second Pass Heading"), "the SECOND batch must resolve its own distinct text after reuse, got {:?}", pb.runs[0].text);
}
}