Skip to main content

lightweight_pdf_layout/
toc.rs

1//! `TableOfContents` (issue #10): self-populates from every
2//! `Text::outline_level` heading in the document, with correct page
3//! numbers, by riding the two-pass pagination `pagination::paginate`
4//! already runs to determine `total_pages` for Header/Footer.
5//!
6//! - [`prepare_toc`] runs once, before either pass: it walks the
7//!   (pre-layout) `Element` tree collecting every heading in document
8//!   order, and returns a *clone* of that tree where every heading that
9//!   didn't already have an author-set `Text::anchor` gets a synthetic
10//!   one — needed so a `TableOfContents` entry can `link_to` it without
11//!   the author having to hand-anchor every heading. `LayoutCtx::toc_headings`
12//!   is this same list, identical in both passes (it doesn't depend on
13//!   page numbers at all — only on the document's own content).
14//! - Pass 1 runs (unaware of any of this beyond `toc_headings` being
15//!   available), and [`collect_anchor_pages`] walks its resulting
16//!   `RenderNode` tree to find which page every anchor (heading or not)
17//!   landed on.
18//! - Pass 2 sees that map as `LayoutCtx::toc_heading_pages` and a
19//!   `TableOfContents::layout` fills in real page numbers.
20//!
21//! Page-count stability (the two-pass architecture's core invariant,
22//! `pagination.rs`'s module doc) holds because a `TableOfContents`'s
23//! height only depends on how many headings match `max_depth` — fixed,
24//! known content, the same in both passes — never on what page number
25//! text ends up printed next to them: entries never wrap (always exactly
26//! one line each), so differing digit counts between passes can't shift
27//! a line count.
28
29use 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/// One heading, discovered from the pre-layout `Element` tree by
39/// [`prepare_toc`] — everything a `TableOfContents` entry needs except
40/// the page number (added later, pass 2 only, via `LayoutCtx::toc_heading_pages`).
41#[derive(Clone, Debug)]
42pub struct TocHeading {
43    pub depth: u8,
44    pub title: String,
45    /// Either the heading's own `Text::anchor`, or a synthetic one
46    /// `prepare_toc` assigned (NUL-prefixed — not something an author can
47    /// type via a normal string literal, so it can never collide with a
48    /// real anchor name).
49    pub anchor: String,
50}
51
52fn synthetic_anchor(id: usize) -> String {
53    format!("\u{0}toc-heading-{id}")
54}
55
56/// Recurses only `Row`/`Column` children — headings inside a `Table`
57/// cell or `List` item are out of scope for V1 (same "document this
58/// instead of chasing every container" call as rich text's scope notes).
59fn 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
93/// Runs once per `paginate()` call, before pass 1. Returns the (possibly
94/// anchor-injected) element tree to lay out instead of `doc.children`,
95/// plus the heading list both passes see via `LayoutCtx::toc_headings`.
96pub 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
103/// Walks pass 1's resulting pages once, recording the (1-based) page
104/// number every anchor (heading or not — cheaper to collect all of them
105/// than to filter) first appears on.
106pub 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
134/// One entry's full display line: `{indent}{title} {leader...} {page}`,
135/// with the leader run sized so the whole line comes as close to
136/// `area_width` as an integer number of leader characters allows —
137/// there's no true right-alignment machinery here, just enough leader
138/// fill to visually line up the page-number column.
139fn 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                // `entry_node` builds its `RenderNode` directly, not via
221                // `Element::layout`'s dispatch — so it needs its own
222                // explicit tag (issue #27), unlike `Text`/`Image`/etc.
223                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}