Skip to main content

stet_pdf_reader/
outline.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Document outlines (bookmarks).
6//!
7//! The outline is a tree rooted at the catalog's `/Outlines` dict.
8//! Each node is a chain of siblings linked by `/Next` and `/Prev`,
9//! with the first sibling reachable via the parent's `/First` and
10//! the last via `/Last`. Children of a node are reached via the
11//! node's own `/First` chain.
12//!
13//! Each node may have a `/Dest` (destination) **or** an `/A` (action),
14//! plus optional `/C` (color), `/F` (style flags), and `/Count`
15//! (open-state and child count).
16
17use std::collections::HashSet;
18
19use crate::destination::{Action, Destination, parse_action, parse_destination};
20use crate::diagnostics::{LocationHint, ParsePhase, Severity, WarningSink};
21use crate::metadata::pdf_string_to_rust_pub;
22use crate::page_tree::PageInfo;
23use crate::resolver::Resolver;
24
25/// Maximum total nodes the outline walker will follow.
26///
27/// Real outlines rarely exceed a few thousand entries; this cap stops
28/// pathological or maliciously cyclic outlines from running away.
29const MAX_OUTLINE_NODES: usize = 100_000;
30
31/// Maximum nesting depth.
32///
33/// Same rationale as `MAX_OUTLINE_NODES`. Legitimate outlines almost
34/// never exceed 8 levels.
35const MAX_OUTLINE_DEPTH: u32 = 64;
36
37/// One bookmark / outline entry.
38#[derive(Debug, Clone)]
39pub struct OutlineItem {
40    /// Display title.
41    pub title: String,
42    /// Destination this bookmark navigates to (if any).
43    pub destination: Option<Destination>,
44    /// Action this bookmark fires (if any). Per spec a node has either
45    /// a `/Dest` or an `/A`, never both; if both are present, `destination`
46    /// is preferred and `action` is set as a fallback for callers that
47    /// want both.
48    pub action: Option<Action>,
49    /// Children, in display order.
50    pub children: Vec<OutlineItem>,
51    /// Optional RGB color from `/C` (each component 0.0–1.0).
52    pub color: Option<[f32; 3]>,
53    /// Style flags from `/F`.
54    pub style: OutlineStyle,
55    /// Whether this node is open in the default configuration.
56    /// Derived from the sign of `/Count` (positive = open, negative =
57    /// collapsed). Leaf nodes default to `false`.
58    pub open: bool,
59}
60
61/// Outline display-style flags from the `/F` integer.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub struct OutlineStyle {
64    pub italic: bool,
65    pub bold: bool,
66}
67
68impl OutlineStyle {
69    fn from_flags(flags: i64) -> Self {
70        Self {
71            italic: flags & 1 != 0,
72            bold: flags & 2 != 0,
73        }
74    }
75}
76
77/// Walk the catalog's `/Outlines` dict and produce a tree of
78/// [`OutlineItem`]s.
79///
80/// Returns an empty `Vec` when the document has no outline.
81/// Cycles, broken `/First`/`/Next` chains, and truncated trees are
82/// tolerated by bounding traversal with a visited set + depth cap;
83/// each truncation pushes a [`ParseWarning`] into `sink`.
84///
85/// [`ParseWarning`]: crate::ParseWarning
86pub fn parse_outline_tree(
87    resolver: &Resolver,
88    pages: &[PageInfo],
89    sink: &WarningSink,
90) -> Vec<OutlineItem> {
91    let Some(outlines_dict_ref) = catalog_outlines_ref(resolver) else {
92        return Vec::new();
93    };
94    let Ok(outlines) = resolver.resolve(outlines_dict_ref.0, outlines_dict_ref.1) else {
95        return Vec::new();
96    };
97    let Some(dict) = outlines.as_dict() else {
98        return Vec::new();
99    };
100    let Some(first) = dict.get_ref(b"First") else {
101        return Vec::new();
102    };
103
104    let mut visited = HashSet::new();
105    let mut node_count = 0usize;
106    walk_siblings(
107        resolver,
108        pages,
109        first,
110        &mut visited,
111        &mut node_count,
112        0,
113        sink,
114    )
115}
116
117fn walk_siblings(
118    resolver: &Resolver,
119    pages: &[PageInfo],
120    start: (u32, u16),
121    visited: &mut HashSet<u32>,
122    node_count: &mut usize,
123    depth: u32,
124    sink: &WarningSink,
125) -> Vec<OutlineItem> {
126    let mut items = Vec::new();
127    if depth >= MAX_OUTLINE_DEPTH {
128        sink.record(
129            ParsePhase::Outline,
130            None,
131            Severity::Error,
132            format!(
133                "outline depth limit {MAX_OUTLINE_DEPTH} reached; \
134                 deeper entries truncated"
135            ),
136        );
137        return items;
138    }
139    let mut current = Some(start);
140    while let Some((num, gen_num)) = current {
141        if !visited.insert(num) {
142            // Cycle — stop following this chain.
143            sink.record(
144                ParsePhase::Outline,
145                Some(LocationHint::Object {
146                    obj_num: num,
147                    gen_num,
148                }),
149                Severity::Warning,
150                "outline cycle detected; sibling chain truncated",
151            );
152            break;
153        }
154        if *node_count >= MAX_OUTLINE_NODES {
155            sink.record(
156                ParsePhase::Outline,
157                None,
158                Severity::Error,
159                format!(
160                    "outline node limit {MAX_OUTLINE_NODES} reached; \
161                     remaining entries dropped"
162                ),
163            );
164            break;
165        }
166        *node_count += 1;
167
168        let Ok(node) = resolver.resolve(num, gen_num) else {
169            sink.record(
170                ParsePhase::Outline,
171                Some(LocationHint::Object {
172                    obj_num: num,
173                    gen_num,
174                }),
175                Severity::Warning,
176                "outline node could not be resolved; chain stopped",
177            );
178            break;
179        };
180        let Some(dict) = node.as_dict() else {
181            sink.record(
182                ParsePhase::Outline,
183                Some(LocationHint::Object {
184                    obj_num: num,
185                    gen_num,
186                }),
187                Severity::Warning,
188                "outline node is not a dict; chain stopped",
189            );
190            break;
191        };
192
193        let title = dict
194            .get(b"Title")
195            .and_then(pdf_string_to_rust_pub)
196            .unwrap_or_default();
197
198        let dest_obj = dict.get(b"Dest");
199        let action_obj = dict.get(b"A");
200        let destination = dest_obj.and_then(|o| parse_destination(resolver, pages, o));
201        let action = action_obj.and_then(|o| parse_action(resolver, pages, o));
202
203        let color = dict.get_array(b"C").and_then(|arr| {
204            if arr.len() == 3 {
205                Some([
206                    arr[0].as_f64().unwrap_or(0.0) as f32,
207                    arr[1].as_f64().unwrap_or(0.0) as f32,
208                    arr[2].as_f64().unwrap_or(0.0) as f32,
209                ])
210            } else {
211                None
212            }
213        });
214        let style = OutlineStyle::from_flags(dict.get_int(b"F").unwrap_or(0));
215        let count = dict.get_int(b"Count").unwrap_or(0);
216        let open = count > 0;
217
218        let children = if let Some(child_first) = dict.get_ref(b"First") {
219            walk_siblings(
220                resolver,
221                pages,
222                child_first,
223                visited,
224                node_count,
225                depth + 1,
226                sink,
227            )
228        } else {
229            Vec::new()
230        };
231
232        items.push(OutlineItem {
233            title,
234            destination,
235            action,
236            children,
237            color,
238            style,
239            open,
240        });
241
242        current = dict.get_ref(b"Next");
243    }
244    items
245}
246
247fn catalog_outlines_ref(resolver: &Resolver) -> Option<(u32, u16)> {
248    let catalog = catalog_dict(resolver)?;
249    if let Some(r) = catalog.get_ref(b"Outlines") {
250        return Some(r);
251    }
252    // Some PDFs put /Outlines as a direct dict — not an indirect ref.
253    // In that rare case we resolve via the catalog's stored object
254    // number; we don't support that path now (every spec-conformant
255    // file uses an indirect ref) — treat as missing.
256    None
257}
258
259fn catalog_dict(resolver: &Resolver) -> Option<crate::objects::PdfDict> {
260    if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
261        && let Ok(obj) = resolver.resolve(num, gen_num)
262        && let Some(dict) = obj.as_dict()
263    {
264        return Some(dict.clone());
265    }
266    crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
267}
268
269/// Number of outline items in the tree, counting all descendants.
270///
271/// Useful for sanity-checking and for sizing UI widgets without
272/// flattening the tree.
273pub fn count_items(items: &[OutlineItem]) -> usize {
274    items.iter().map(|it| 1 + count_items(&it.children)).sum()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn outline_style_flags_round_trip() {
283        assert_eq!(OutlineStyle::from_flags(0), OutlineStyle::default());
284        let italic = OutlineStyle::from_flags(1);
285        assert!(italic.italic && !italic.bold);
286        let bold = OutlineStyle::from_flags(2);
287        assert!(!bold.italic && bold.bold);
288        let both = OutlineStyle::from_flags(3);
289        assert!(both.italic && both.bold);
290        // Higher bits are ignored.
291        let extra = OutlineStyle::from_flags(0xFF);
292        assert!(extra.italic && extra.bold);
293    }
294
295    #[test]
296    fn count_items_recurses() {
297        let leaf = OutlineItem {
298            title: "leaf".to_string(),
299            destination: None,
300            action: None,
301            children: vec![],
302            color: None,
303            style: OutlineStyle::default(),
304            open: false,
305        };
306        let parent = OutlineItem {
307            title: "parent".to_string(),
308            destination: None,
309            action: None,
310            children: vec![leaf.clone(), leaf.clone()],
311            color: None,
312            style: OutlineStyle::default(),
313            open: true,
314        };
315        assert_eq!(count_items(std::slice::from_ref(&parent)), 3);
316        assert_eq!(count_items(&[parent.clone(), parent]), 6);
317    }
318
319    /// Confirm the depth cap is enforced. The walker won't recurse past
320    /// MAX_OUTLINE_DEPTH; here we simulate by calling walk_siblings
321    /// with depth = MAX_OUTLINE_DEPTH and verifying it short-circuits.
322    #[test]
323    fn depth_cap_short_circuits() {
324        // We can't easily build a real Resolver here, but we can reason
325        // about the early return: walk_siblings starts with `if depth
326        // >= MAX_OUTLINE_DEPTH { return items; }` returning an empty
327        // Vec. The constant exists for that purpose; covered by
328        // integration tests below.
329        assert_eq!(MAX_OUTLINE_DEPTH, 64);
330    }
331}