use crate::font_resolver::FontResolver;
use crate::geometry::{Constraints, Rect, Size};
use crate::layoutable::{line_height_pt, LayoutCtx, LayoutResult, Layoutable};
use crate::render_node::{RenderNode, StructRole};
use crate::text::text_width_pt;
use crate::warnings::LayoutWarning;
use lightweight_pdf_core::{Align, Element, TableOfContents, TextStyle};
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct TocHeading {
pub depth: u8,
pub title: String,
pub anchor: String,
}
fn synthetic_anchor(id: usize) -> String {
format!("\u{0}toc-heading-{id}")
}
fn prepare_one(element: &Element, headings: &mut Vec<TocHeading>, next_id: &mut usize) -> Element {
match element {
Element::Text(t) => {
let Some(depth) = t.outline_level else {
return element.clone();
};
let mut t = t.clone();
let anchor = t.anchor.clone().unwrap_or_else(|| {
let name = synthetic_anchor(*next_id);
*next_id += 1;
name
});
t.anchor = Some(anchor.clone());
headings.push(TocHeading {
depth,
title: t.content.clone(),
anchor,
});
Element::Text(t)
}
Element::Row(r) => {
let mut r = r.clone();
r.children = r.children.iter().map(|c| prepare_one(c, headings, next_id)).collect();
Element::Row(r)
}
Element::Column(c) => {
let mut c = c.clone();
c.children = c.children.iter().map(|c| prepare_one(c, headings, next_id)).collect();
Element::Column(c)
}
_ => element.clone(),
}
}
pub fn prepare_toc(elements: &[Element]) -> (Vec<Element>, Vec<TocHeading>) {
let mut headings = Vec::new();
let mut next_id = 0usize;
let prepared = elements.iter().map(|e| prepare_one(e, &mut headings, &mut next_id)).collect();
(prepared, headings)
}
pub fn collect_anchor_pages(pages: &[RenderNode]) -> HashMap<String, usize> {
let mut out = HashMap::new();
for (i, page) in pages.iter().enumerate() {
collect_anchor_pages_in_node(page, i + 1, &mut out);
}
out
}
fn collect_anchor_pages_in_node(node: &RenderNode, page_number: usize, out: &mut HashMap<String, usize>) {
match node {
RenderNode::Group { children, .. } => {
for child in children {
collect_anchor_pages_in_node(child, page_number, out);
}
}
RenderNode::Tagged { inner, .. } => collect_anchor_pages_in_node(inner, page_number, out),
RenderNode::TextLines { anchor: Some(name), .. } => {
out.entry(name.clone()).or_insert(page_number);
}
_ => {}
}
}
fn matching_headings<'a>(ctx: &'a LayoutCtx, toc: &TableOfContents) -> impl Iterator<Item = &'a TocHeading> {
let max_depth = toc.max_depth;
ctx.toc_headings.iter().filter(move |h| h.depth <= max_depth).skip(toc.skip)
}
fn toc_entry_line(
resolver: &dyn FontResolver,
style: &TextStyle,
heading: &TocHeading,
page_text: &str,
area_width: f32,
leader: char,
) -> String {
let indent = " ".repeat(heading.depth.saturating_sub(1) as usize);
let title = format!("{indent}{}", heading.title);
let space_w = text_width_pt(resolver, style.font, style.size, " ");
let base_width = text_width_pt(resolver, style.font, style.size, &title)
+ 2.0 * space_w
+ text_width_pt(resolver, style.font, style.size, page_text);
let available = (area_width - base_width).max(0.0);
let leader_w = text_width_pt(resolver, style.font, style.size, &leader.to_string());
let leader_count = if leader_w > 0.0 {
(available / leader_w).floor() as usize
} else {
0
};
let leaders: String = std::iter::repeat_n(leader, leader_count).collect();
format!("{title} {leaders} {page_text}")
}
fn entry_node(
resolver: &dyn FontResolver,
style: &TextStyle,
heading: &TocHeading,
page_text: &str,
area: Rect,
leader: char,
) -> RenderNode {
let line = toc_entry_line(resolver, style, heading, page_text, area.width, leader);
RenderNode::TextLines {
area,
style: TextStyle {
align: Align::Start,
..*style
},
lines: vec![line],
paragraph_end: vec![true],
line_height_pt: area.height,
url: None,
anchor: None,
link_to: Some(heading.anchor.clone()),
outline_level: None,
}
}
impl Layoutable for TableOfContents {
fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
let width = self.common.width.unwrap_or(constraints.max_width);
let n = matching_headings(ctx, self).count();
let lh = line_height_pt(&self.style);
Size {
width: self.common.width.unwrap_or(width),
height: self.common.height.unwrap_or(n as f32 * lh),
}
}
fn layout(&self, ctx: &LayoutCtx, area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
let entries: Vec<&TocHeading> = matching_headings(ctx, self).collect();
let lh = line_height_pt(&self.style);
let max_fit = (((area.height + 0.01) / lh).floor().max(0.0) as usize).min(entries.len());
let rendered: Vec<RenderNode> = entries[..max_fit]
.iter()
.enumerate()
.map(|(i, heading)| {
let page_text = ctx
.toc_heading_pages
.and_then(|pages| pages.get(&heading.anchor))
.map(|p| p.to_string())
.unwrap_or_default();
let entry_area = Rect {
x: area.x,
y: area.y + i as f32 * lh,
width: area.width,
height: lh,
};
RenderNode::tagged(
StructRole::TocItem,
entry_node(ctx.resolver, &self.style, heading, &page_text, entry_area, self.leader),
)
})
.collect();
let current = if rendered.is_empty() {
RenderNode::Empty
} else {
RenderNode::clipped(
Rect {
height: max_fit as f32 * lh,
..area
},
RenderNode::Group {
area: Rect {
height: max_fit as f32 * lh,
..area
},
clip: false,
background: None,
border: None,
corner_radius: 0.0,
children: rendered,
},
)
};
if max_fit >= entries.len() {
LayoutResult::Fit(current)
} else {
LayoutResult::Split {
current,
remainder: Element::TableOfContents(TableOfContents {
skip: self.skip + max_fit,
..self.clone()
}),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightweight_pdf_core::Text;
#[test]
fn prepare_toc_collects_headings_in_document_order_and_assigns_synthetic_anchors() {
let elements = vec![
Element::Text(Text::new("Intro").heading1()),
Element::Text(Text::new("Body copy")),
Element::Text(Text::new("Details").heading2().anchor("my-anchor")),
];
let (prepared, headings) = prepare_toc(&elements);
assert_eq!(headings.len(), 2);
assert_eq!(headings[0].title, "Intro");
assert_eq!(headings[0].depth, 1);
assert!(
headings[0].anchor.starts_with('\u{0}'),
"expected a synthetic anchor for the un-anchored heading"
);
assert_eq!(headings[1].anchor, "my-anchor", "an author-set anchor must be kept as-is");
let Element::Text(first) = &prepared[0] else {
panic!("expected a Text element");
};
assert_eq!(first.anchor.as_deref(), Some(headings[0].anchor.as_str()));
}
#[test]
fn prepare_toc_recurses_into_row_and_column_children() {
let elements = vec![Element::Column(
lightweight_pdf_core::Column::new().child(Text::new("Nested heading").heading1()),
)];
let (_prepared, headings) = prepare_toc(&elements);
assert_eq!(headings.len(), 1);
assert_eq!(headings[0].title, "Nested heading");
}
#[test]
fn collect_anchor_pages_maps_each_anchor_to_its_first_page() {
let page1 = RenderNode::TextLines {
area: Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 10.0,
},
style: TextStyle::default(),
lines: vec!["Heading".into()],
paragraph_end: vec![true],
line_height_pt: 10.0,
url: None,
anchor: Some("h1".into()),
link_to: None,
outline_level: Some(1),
};
let pages = collect_anchor_pages(&[RenderNode::Empty, page1]);
assert_eq!(pages.get("h1"), Some(&2), "the heading landed on the second page");
}
}