1use crate::font_resolver::FontResolver;
30use crate::geometry::{Constraints, Rect, Size};
31use crate::layoutable::{line_height_pt, LayoutCtx, LayoutResult, Layoutable};
32use crate::render_node::{RenderNode, StructRole};
33use crate::text::text_width_pt;
34use crate::warnings::LayoutWarning;
35use lightweight_pdf_core::{Align, Element, TableOfContents, TextStyle};
36use std::collections::HashMap;
37
38#[derive(Clone, Debug)]
42pub struct TocHeading {
43 pub depth: u8,
44 pub title: String,
45 pub anchor: String,
50}
51
52fn synthetic_anchor(id: usize) -> String {
53 format!("\u{0}toc-heading-{id}")
54}
55
56fn prepare_one(element: &Element, headings: &mut Vec<TocHeading>, next_id: &mut usize) -> Element {
60 match element {
61 Element::Text(t) => {
62 let Some(depth) = t.outline_level else {
63 return element.clone();
64 };
65 let mut t = t.clone();
66 let anchor = t.anchor.clone().unwrap_or_else(|| {
67 let name = synthetic_anchor(*next_id);
68 *next_id += 1;
69 name
70 });
71 t.anchor = Some(anchor.clone());
72 headings.push(TocHeading {
73 depth,
74 title: t.content.clone(),
75 anchor,
76 });
77 Element::Text(t)
78 }
79 Element::Row(r) => {
80 let mut r = r.clone();
81 r.children = r.children.iter().map(|c| prepare_one(c, headings, next_id)).collect();
82 Element::Row(r)
83 }
84 Element::Column(c) => {
85 let mut c = c.clone();
86 c.children = c.children.iter().map(|c| prepare_one(c, headings, next_id)).collect();
87 Element::Column(c)
88 }
89 _ => element.clone(),
90 }
91}
92
93pub fn prepare_toc(elements: &[Element]) -> (Vec<Element>, Vec<TocHeading>) {
97 let mut headings = Vec::new();
98 let mut next_id = 0usize;
99 let prepared = elements.iter().map(|e| prepare_one(e, &mut headings, &mut next_id)).collect();
100 (prepared, headings)
101}
102
103pub fn collect_anchor_pages(pages: &[RenderNode]) -> HashMap<String, usize> {
107 let mut out = HashMap::new();
108 for (i, page) in pages.iter().enumerate() {
109 collect_anchor_pages_in_node(page, i + 1, &mut out);
110 }
111 out
112}
113
114fn collect_anchor_pages_in_node(node: &RenderNode, page_number: usize, out: &mut HashMap<String, usize>) {
115 match node {
116 RenderNode::Group { children, .. } => {
117 for child in children {
118 collect_anchor_pages_in_node(child, page_number, out);
119 }
120 }
121 RenderNode::Tagged { inner, .. } => collect_anchor_pages_in_node(inner, page_number, out),
122 RenderNode::TextLines { anchor: Some(name), .. } => {
123 out.entry(name.clone()).or_insert(page_number);
124 }
125 _ => {}
126 }
127}
128
129fn matching_headings<'a>(ctx: &'a LayoutCtx, toc: &TableOfContents) -> impl Iterator<Item = &'a TocHeading> {
130 let max_depth = toc.max_depth;
131 ctx.toc_headings.iter().filter(move |h| h.depth <= max_depth).skip(toc.skip)
132}
133
134fn toc_entry_line(
140 resolver: &dyn FontResolver,
141 style: &TextStyle,
142 heading: &TocHeading,
143 page_text: &str,
144 area_width: f32,
145 leader: char,
146) -> String {
147 let indent = " ".repeat(heading.depth.saturating_sub(1) as usize);
148 let title = format!("{indent}{}", heading.title);
149 let space_w = text_width_pt(resolver, style.font, style.size, " ");
150 let base_width = text_width_pt(resolver, style.font, style.size, &title)
151 + 2.0 * space_w
152 + text_width_pt(resolver, style.font, style.size, page_text);
153 let available = (area_width - base_width).max(0.0);
154 let leader_w = text_width_pt(resolver, style.font, style.size, &leader.to_string());
155 let leader_count = if leader_w > 0.0 {
156 (available / leader_w).floor() as usize
157 } else {
158 0
159 };
160 let leaders: String = std::iter::repeat_n(leader, leader_count).collect();
161 format!("{title} {leaders} {page_text}")
162}
163
164fn entry_node(
165 resolver: &dyn FontResolver,
166 style: &TextStyle,
167 heading: &TocHeading,
168 page_text: &str,
169 area: Rect,
170 leader: char,
171) -> RenderNode {
172 let line = toc_entry_line(resolver, style, heading, page_text, area.width, leader);
173 RenderNode::TextLines {
174 area,
175 style: TextStyle {
176 align: Align::Start,
177 ..*style
178 },
179 lines: vec![line],
180 paragraph_end: vec![true],
181 line_height_pt: area.height,
182 url: None,
183 anchor: None,
184 link_to: Some(heading.anchor.clone()),
185 outline_level: None,
186 }
187}
188
189impl Layoutable for TableOfContents {
190 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
191 let width = self.common.width.unwrap_or(constraints.max_width);
192 let n = matching_headings(ctx, self).count();
193 let lh = line_height_pt(&self.style);
194 Size {
195 width: self.common.width.unwrap_or(width),
196 height: self.common.height.unwrap_or(n as f32 * lh),
197 }
198 }
199
200 fn layout(&self, ctx: &LayoutCtx, area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
201 let entries: Vec<&TocHeading> = matching_headings(ctx, self).collect();
202 let lh = line_height_pt(&self.style);
203 let max_fit = (((area.height + 0.01) / lh).floor().max(0.0) as usize).min(entries.len());
204
205 let rendered: Vec<RenderNode> = entries[..max_fit]
206 .iter()
207 .enumerate()
208 .map(|(i, heading)| {
209 let page_text = ctx
210 .toc_heading_pages
211 .and_then(|pages| pages.get(&heading.anchor))
212 .map(|p| p.to_string())
213 .unwrap_or_default();
214 let entry_area = Rect {
215 x: area.x,
216 y: area.y + i as f32 * lh,
217 width: area.width,
218 height: lh,
219 };
220 RenderNode::tagged(
224 StructRole::TocItem,
225 entry_node(ctx.resolver, &self.style, heading, &page_text, entry_area, self.leader),
226 )
227 })
228 .collect();
229
230 let current = if rendered.is_empty() {
231 RenderNode::Empty
232 } else {
233 RenderNode::clipped(
234 Rect {
235 height: max_fit as f32 * lh,
236 ..area
237 },
238 RenderNode::Group {
239 area: Rect {
240 height: max_fit as f32 * lh,
241 ..area
242 },
243 clip: false,
244 background: None,
245 border: None,
246 corner_radius: 0.0,
247 children: rendered,
248 },
249 )
250 };
251
252 if max_fit >= entries.len() {
253 LayoutResult::Fit(current)
254 } else {
255 LayoutResult::Split {
256 current,
257 remainder: Element::TableOfContents(TableOfContents {
258 skip: self.skip + max_fit,
259 ..self.clone()
260 }),
261 }
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use lightweight_pdf_core::Text;
270
271 #[test]
272 fn prepare_toc_collects_headings_in_document_order_and_assigns_synthetic_anchors() {
273 let elements = vec![
274 Element::Text(Text::new("Intro").heading1()),
275 Element::Text(Text::new("Body copy")),
276 Element::Text(Text::new("Details").heading2().anchor("my-anchor")),
277 ];
278 let (prepared, headings) = prepare_toc(&elements);
279 assert_eq!(headings.len(), 2);
280 assert_eq!(headings[0].title, "Intro");
281 assert_eq!(headings[0].depth, 1);
282 assert!(
283 headings[0].anchor.starts_with('\u{0}'),
284 "expected a synthetic anchor for the un-anchored heading"
285 );
286 assert_eq!(headings[1].anchor, "my-anchor", "an author-set anchor must be kept as-is");
287
288 let Element::Text(first) = &prepared[0] else {
289 panic!("expected a Text element");
290 };
291 assert_eq!(first.anchor.as_deref(), Some(headings[0].anchor.as_str()));
292 }
293
294 #[test]
295 fn prepare_toc_recurses_into_row_and_column_children() {
296 let elements = vec![Element::Column(
297 lightweight_pdf_core::Column::new().child(Text::new("Nested heading").heading1()),
298 )];
299 let (_prepared, headings) = prepare_toc(&elements);
300 assert_eq!(headings.len(), 1);
301 assert_eq!(headings[0].title, "Nested heading");
302 }
303
304 #[test]
305 fn collect_anchor_pages_maps_each_anchor_to_its_first_page() {
306 let page1 = RenderNode::TextLines {
307 area: Rect {
308 x: 0.0,
309 y: 0.0,
310 width: 100.0,
311 height: 10.0,
312 },
313 style: TextStyle::default(),
314 lines: vec!["Heading".into()],
315 paragraph_end: vec![true],
316 line_height_pt: 10.0,
317 url: None,
318 anchor: Some("h1".into()),
319 link_to: None,
320 outline_level: Some(1),
321 };
322 let pages = collect_anchor_pages(&[RenderNode::Empty, page1]);
323 assert_eq!(pages.get("h1"), Some(&2), "the heading landed on the second page");
324 }
325}