Skip to main content

oxideav_pdf/reader/
outline.rs

1//! Round-25 — PDF outline (bookmark) tree reader.
2//!
3//! Walks the catalog → `/Outlines` → tree of outline-item dicts per
4//! ISO 32000-1 §12.3.3 (Tables 152 + 153) and surfaces each bookmark
5//! as an [`OutlineNode`]. The doubly-linked-list `/First` / `/Last` /
6//! `/Next` / `/Prev` shape collapses back to a parent-owned `children`
7//! vec — the more usable form for callers walking a bookmark tree.
8//!
9//! Each leaf's `/Dest` (or `/A << /S /GoTo /D ... >>`) is parsed back
10//! into an [`crate::outline::OutlineDestination`]; the page-ref
11//! component is resolved against the document's page index list so
12//! the destination's `page_index()` matches the writer-side input
13//! (0-based, in `/Pages` tree DFS order).
14//!
15//! Unsupported destinations (named destinations, remote go-to,
16//! action chains) surface as `destination = None` with the original
17//! raw text in [`OutlineNode::raw_dest`] — the tree is still walked
18//! end-to-end rather than aborting, so callers that just want the
19//! title hierarchy still get a usable result.
20//!
21//! Cycle detection: outline trees are required to be acyclic
22//! (Table 153's /Parent / /Prev / /Next forms a strict tree), but a
23//! malformed PDF could close a /Next cycle. The walker carries a
24//! visited-set keyed by outline-item id and aborts the level when a
25//! cycle is observed (returns the prefix walked so far).
26
27use std::collections::{HashMap, HashSet};
28
29use crate::error::PdfError;
30use crate::objects::{Dict, Object, ObjectId};
31use crate::outline::OutlineDestination;
32use crate::reader::document::DocumentReader;
33
34/// One node in the parsed outline tree.
35#[derive(Debug, Clone)]
36pub struct OutlineNode {
37    /// `/Title` text (decoded — `LiteralString` PDFDocEncoding or
38    /// `HexString` UTF-16BE with BOM).
39    pub title: String,
40    /// `/Dest` parsed back to a structured destination, when the dest
41    /// is an explicit array per Table 151. Named destinations (where
42    /// `/Dest` is a Name or string) and action-driven destinations
43    /// (where `/A` carries a `/GoTo` action) lower to `None` here;
44    /// the raw text is retained in [`Self::raw_dest`].
45    pub destination: Option<OutlineDestination>,
46    /// Verbatim text of the `/Dest` entry, in case the structured
47    /// parse above fell back to `None`. Useful for callers that want
48    /// to surface named destinations or unsupported variants
49    /// untouched.
50    pub raw_dest: Option<String>,
51    /// `/Count` value when present. Per Table 153, a positive value
52    /// means "open with N visible descendants"; a negative value
53    /// means "closed with |N| hidden descendants"; absent means "no
54    /// descendants".
55    pub count: Option<i64>,
56    /// Resolved children — the `/First` / `/Next` chain expanded back
57    /// into a parent-owned vec.
58    pub children: Vec<OutlineNode>,
59}
60
61impl OutlineNode {
62    /// Sum-of-self-and-descendants — the "visible item" count a
63    /// conforming reader would sum across an entirely-open tree.
64    pub fn descendant_count(&self) -> usize {
65        1 + self
66            .children
67            .iter()
68            .map(Self::descendant_count)
69            .sum::<usize>()
70    }
71
72    /// True when the outline item is open (per `/Count` sign).
73    pub fn is_open(&self) -> bool {
74        // Open ⇔ Count > 0 (or absent and there are no children).
75        match self.count {
76            Some(n) => n > 0,
77            None => self.children.is_empty(),
78        }
79    }
80}
81
82/// Top-level outline-tree result.
83///
84/// `roots` carries the top-level bookmarks in Catalog order; `count`
85/// is the value of the outline dict's own `/Count` entry per Table
86/// 152 (when present).
87#[derive(Debug, Clone, Default)]
88pub struct PdfOutline {
89    pub roots: Vec<OutlineNode>,
90    pub count: Option<i64>,
91}
92
93/// Walk the catalog → `/Outlines` → tree, returning every bookmark
94/// as a structured [`PdfOutline`]. Returns `Ok(None)` when the
95/// catalog has no `/Outlines` entry (a perfectly valid bookmark-free
96/// document).
97pub fn outline(reader: &mut DocumentReader<'_>) -> Result<Option<PdfOutline>, PdfError> {
98    // Build a 0-based page-index map so each `/Dest [<page-ref> ...]`
99    // can resolve back to the same index the writer started from.
100    let page_index_map = build_page_index_map(reader)?;
101
102    let root_id = reader.xref().root()?;
103    let catalog = reader.resolve(root_id)?;
104    let Object::Dict(catalog_dict) = catalog else {
105        return Err(PdfError::other(format!(
106            "PDF outline reader: /Root must be a dict (got {catalog:?})"
107        )));
108    };
109    let outlines_obj = catalog_dict
110        .entries()
111        .iter()
112        .find(|(k, _)| k == "Outlines")
113        .map(|(_, v)| v.clone());
114    let Some(outlines_obj) = outlines_obj else {
115        return Ok(None);
116    };
117    let outline_root_id = match outlines_obj {
118        Object::Reference(id) => id,
119        _ => {
120            return Err(PdfError::other(
121                "PDF outline reader: /Outlines must be an indirect reference",
122            ));
123        }
124    };
125
126    let outline_root_dict = match reader.resolve(outline_root_id)? {
127        Object::Dict(d) => d,
128        other => {
129            return Err(PdfError::other(format!(
130                "PDF outline reader: outline root resolves to non-dict ({other:?})"
131            )));
132        }
133    };
134
135    let count = outline_root_dict
136        .entries()
137        .iter()
138        .find(|(k, _)| k == "Count")
139        .and_then(|(_, v)| match v {
140            Object::Integer(n) => Some(*n),
141            _ => None,
142        });
143
144    let first_id = outline_root_dict
145        .entries()
146        .iter()
147        .find(|(k, _)| k == "First")
148        .and_then(|(_, v)| match v {
149            Object::Reference(id) => Some(*id),
150            _ => None,
151        });
152
153    let roots = match first_id {
154        Some(first) => walk_level(reader, first, &page_index_map)?,
155        None => Vec::new(),
156    };
157
158    Ok(Some(PdfOutline { roots, count }))
159}
160
161/// Walk one level of the outline, starting at `first_id` and
162/// following `/Next` until exhausted (or a cycle is hit). Per item
163/// also recurses through `/First` to collect the children.
164fn walk_level(
165    reader: &mut DocumentReader<'_>,
166    first_id: ObjectId,
167    page_index_map: &HashMap<u32, usize>,
168) -> Result<Vec<OutlineNode>, PdfError> {
169    let mut out = Vec::new();
170    let mut visited: HashSet<u32> = HashSet::new();
171    let mut cur = Some(first_id);
172    while let Some(id) = cur {
173        // Cycle guard — refuse to walk through a node we've already
174        // emitted at this level.
175        if !visited.insert(id.number) {
176            break;
177        }
178        // Cap the level length defensively (a malformed PDF could
179        // chain billions of items via /Next).
180        if out.len() > 10_000 {
181            break;
182        }
183
184        let item = match reader.resolve(id)? {
185            Object::Dict(d) => d,
186            other => {
187                return Err(PdfError::other(format!(
188                    "PDF outline reader: outline item {id:?} is not a dict (got {other:?})"
189                )));
190            }
191        };
192
193        let title = decode_text(
194            item.entries()
195                .iter()
196                .find(|(k, _)| k == "Title")
197                .map(|(_, v)| v),
198        );
199        let (destination, raw_dest) = decode_dest_or_action(reader, &item, page_index_map)?;
200        let count = item
201            .entries()
202            .iter()
203            .find(|(k, _)| k == "Count")
204            .and_then(|(_, v)| match v {
205                Object::Integer(n) => Some(*n),
206                _ => None,
207            });
208
209        let children = match item
210            .entries()
211            .iter()
212            .find(|(k, _)| k == "First")
213            .and_then(|(_, v)| match v {
214                Object::Reference(id) => Some(*id),
215                _ => None,
216            }) {
217            Some(child_first) => walk_level(reader, child_first, page_index_map)?,
218            None => Vec::new(),
219        };
220
221        out.push(OutlineNode {
222            title: title.unwrap_or_default(),
223            destination,
224            raw_dest,
225            count,
226            children,
227        });
228
229        cur = item
230            .entries()
231            .iter()
232            .find(|(k, _)| k == "Next")
233            .and_then(|(_, v)| match v {
234                Object::Reference(id) => Some(*id),
235                _ => None,
236            });
237    }
238    Ok(out)
239}
240
241/// Try to extract a structured destination from the outline item.
242/// Falls back to a raw text representation when the destination is a
243/// named one (Name / byte-string) or hides behind a `/A << /S /GoTo
244/// /D ... >>` action chain.
245fn decode_dest_or_action(
246    reader: &mut DocumentReader<'_>,
247    item: &Dict,
248    page_index_map: &HashMap<u32, usize>,
249) -> Result<(Option<OutlineDestination>, Option<String>), PdfError> {
250    // /Dest takes precedence over /A per Table 153.
251    if let Some(dest) = item
252        .entries()
253        .iter()
254        .find(|(k, _)| k == "Dest")
255        .map(|(_, v)| v.clone())
256    {
257        return Ok(decode_dest_value(reader, dest, page_index_map));
258    }
259    if let Some(action) = item
260        .entries()
261        .iter()
262        .find(|(k, _)| k == "A")
263        .map(|(_, v)| v.clone())
264    {
265        let action = reader.deref(action)?;
266        if let Object::Dict(adict) = action {
267            // /S /GoTo + /D <dest>.
268            let is_goto = matches!(
269                adict.entries().iter().find(|(k, _)| k == "S").map(|(_, v)| v),
270                Some(Object::Name(s)) if s == "GoTo"
271            );
272            if is_goto {
273                if let Some(d) = adict
274                    .entries()
275                    .iter()
276                    .find(|(k, _)| k == "D")
277                    .map(|(_, v)| v.clone())
278                {
279                    return Ok(decode_dest_value(reader, d, page_index_map));
280                }
281            }
282            // /S /URI — surface the URI text in raw_dest.
283            let is_uri = matches!(
284                adict.entries().iter().find(|(k, _)| k == "S").map(|(_, v)| v),
285                Some(Object::Name(s)) if s == "URI"
286            );
287            if is_uri {
288                let uri =
289                    adict
290                        .entries()
291                        .iter()
292                        .find(|(k, _)| k == "URI")
293                        .and_then(|(_, v)| match v {
294                            Object::LiteralString(b) | Object::HexString(b) => {
295                                Some(String::from_utf8_lossy(b).into_owned())
296                            }
297                            _ => None,
298                        });
299                return Ok((None, uri.map(|s| format!("uri:{s}"))));
300            }
301        }
302    }
303    Ok((None, None))
304}
305
306/// Decode a `/Dest` (or action `/D`) value into a structured
307/// [`OutlineDestination`] when it's an explicit array, falling back
308/// to the raw text when it's a named destination or otherwise
309/// unparseable.
310fn decode_dest_value(
311    reader: &mut DocumentReader<'_>,
312    dest: Object,
313    page_index_map: &HashMap<u32, usize>,
314) -> (Option<OutlineDestination>, Option<String>) {
315    let dest = match reader.deref(dest) {
316        Ok(d) => d,
317        Err(_) => return (None, Some("<unresolvable>".into())),
318    };
319    match dest {
320        Object::Array(items) => match decode_explicit_dest(&items, page_index_map) {
321            Some(d) => (Some(d), None),
322            None => (None, Some(format_array_dest(&items))),
323        },
324        Object::Name(s) => (None, Some(format!("named:{s}"))),
325        Object::LiteralString(b) | Object::HexString(b) => {
326            (None, Some(format!("named:{}", String::from_utf8_lossy(&b))))
327        }
328        _ => (None, None),
329    }
330}
331
332/// Parse `[ <page-ref> /Mode ... ]` per ISO 32000-1 Table 151.
333fn decode_explicit_dest(
334    items: &[Object],
335    page_index_map: &HashMap<u32, usize>,
336) -> Option<OutlineDestination> {
337    if items.len() < 2 {
338        return None;
339    }
340    let page_index = match &items[0] {
341        Object::Reference(id) => *page_index_map.get(&id.number)?,
342        // Remote-go-to dests use an integer page number — round-25
343        // doesn't surface them; fall through to None.
344        _ => return None,
345    };
346    let mode = match &items[1] {
347        Object::Name(n) => n.as_str(),
348        _ => return None,
349    };
350
351    // Optional-numeric helper: PDF Real / Integer / Null.
352    let opt = |o: Option<&Object>| match o {
353        Some(Object::Real(f)) => Some(*f as f32),
354        Some(Object::Integer(n)) => Some(*n as f32),
355        Some(Object::Null) | None => None,
356        _ => None,
357    };
358    let req = |o: Option<&Object>| -> Option<f32> {
359        match o {
360            Some(Object::Real(f)) => Some(*f as f32),
361            Some(Object::Integer(n)) => Some(*n as f32),
362            _ => None,
363        }
364    };
365
366    match mode {
367        "XYZ" => Some(OutlineDestination::Xyz {
368            page_index,
369            left: opt(items.get(2)),
370            top: opt(items.get(3)),
371            zoom: opt(items.get(4)).filter(|z| *z != 0.0),
372        }),
373        "Fit" => Some(OutlineDestination::Fit { page_index }),
374        "FitH" => Some(OutlineDestination::FitH {
375            page_index,
376            top: opt(items.get(2)),
377        }),
378        "FitV" => Some(OutlineDestination::FitV {
379            page_index,
380            left: opt(items.get(2)),
381        }),
382        "FitR" => Some(OutlineDestination::FitR {
383            page_index,
384            left: req(items.get(2))?,
385            bottom: req(items.get(3))?,
386            right: req(items.get(4))?,
387            top: req(items.get(5))?,
388        }),
389        "FitB" => Some(OutlineDestination::FitB { page_index }),
390        "FitBH" => Some(OutlineDestination::FitBH {
391            page_index,
392            top: opt(items.get(2)),
393        }),
394        "FitBV" => Some(OutlineDestination::FitBV {
395            page_index,
396            left: opt(items.get(2)),
397        }),
398        _ => None,
399    }
400}
401
402fn format_array_dest(items: &[Object]) -> String {
403    let mut out = String::from("[");
404    for (i, it) in items.iter().enumerate() {
405        if i > 0 {
406            out.push(' ');
407        }
408        match it {
409            Object::Reference(id) => out.push_str(&format!("{} 0 R", id.number)),
410            Object::Name(n) => {
411                out.push('/');
412                out.push_str(n);
413            }
414            Object::Integer(n) => out.push_str(&n.to_string()),
415            Object::Real(f) => out.push_str(&format!("{f}")),
416            Object::Null => out.push_str("null"),
417            _ => out.push('?'),
418        }
419    }
420    out.push(']');
421    out
422}
423
424/// Walk the `/Pages` tree (DFS) to produce a `page-object-id-number
425/// → 0-based-DFS-index` map. Mirrors the writer's emission order so
426/// destinations round-trip cleanly. Cached behaviour is fine — we
427/// only call this once per outline read.
428pub(crate) fn build_page_index_map(
429    reader: &mut DocumentReader<'_>,
430) -> Result<HashMap<u32, usize>, PdfError> {
431    let root_id = reader.xref().root()?;
432    let catalog = reader.resolve(root_id)?;
433    let Object::Dict(catalog_dict) = catalog else {
434        return Ok(HashMap::new());
435    };
436    let pages_ref = catalog_dict
437        .entries()
438        .iter()
439        .find(|(k, _)| k == "Pages")
440        .and_then(|(_, v)| match v {
441            Object::Reference(id) => Some(*id),
442            _ => None,
443        });
444    let Some(pages_root) = pages_ref else {
445        return Ok(HashMap::new());
446    };
447    let mut leaves: Vec<u32> = Vec::new();
448    walk_pages(reader, pages_root, &mut leaves)?;
449    Ok(leaves
450        .into_iter()
451        .enumerate()
452        .map(|(i, n)| (n, i))
453        .collect())
454}
455
456pub(crate) fn walk_pages(
457    reader: &mut DocumentReader<'_>,
458    node_id: ObjectId,
459    out: &mut Vec<u32>,
460) -> Result<(), PdfError> {
461    let node = reader.resolve(node_id)?;
462    let Object::Dict(d) = node else {
463        return Ok(());
464    };
465    let kind = d
466        .entries()
467        .iter()
468        .find(|(k, _)| k == "Type")
469        .and_then(|(_, v)| match v {
470            Object::Name(s) => Some(s.as_str()),
471            _ => None,
472        });
473    match kind {
474        Some("Page") => {
475            out.push(node_id.number);
476        }
477        Some("Pages") | None => {
478            if let Some(Object::Array(kids)) = d
479                .entries()
480                .iter()
481                .find(|(k, _)| k == "Kids")
482                .map(|(_, v)| v)
483            {
484                for it in kids.clone() {
485                    if let Object::Reference(id) = it {
486                        // Bound the recursion depth defensively.
487                        if out.len() > 100_000 {
488                            return Ok(());
489                        }
490                        walk_pages(reader, id, out)?;
491                    }
492                }
493            }
494        }
495        _ => {}
496    }
497    Ok(())
498}
499
500fn decode_text(o: Option<&Object>) -> Option<String> {
501    match o? {
502        Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
503        Object::HexString(b) => {
504            if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
505                let utf16: Vec<u16> = b[2..]
506                    .chunks_exact(2)
507                    .map(|c| u16::from_be_bytes([c[0], c[1]]))
508                    .collect();
509                Some(String::from_utf16_lossy(&utf16))
510            } else {
511                Some(String::from_utf8_lossy(b).into_owned())
512            }
513        }
514        _ => None,
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn descendant_count_sums_recursively() {
524        let leaf = OutlineNode {
525            title: "leaf".into(),
526            destination: None,
527            raw_dest: None,
528            count: None,
529            children: Vec::new(),
530        };
531        let parent = OutlineNode {
532            title: "parent".into(),
533            destination: None,
534            raw_dest: None,
535            count: Some(2),
536            children: vec![leaf.clone(), leaf.clone()],
537        };
538        assert_eq!(parent.descendant_count(), 3);
539    }
540
541    #[test]
542    fn is_open_recognises_count_sign() {
543        let mut n = OutlineNode {
544            title: "x".into(),
545            destination: None,
546            raw_dest: None,
547            count: Some(2),
548            children: Vec::new(),
549        };
550        assert!(n.is_open());
551        n.count = Some(-2);
552        assert!(!n.is_open());
553        n.count = None;
554        assert!(n.is_open()); // childless / absent treated as open
555    }
556
557    #[test]
558    fn explicit_dest_fit_decodes() {
559        let map: HashMap<u32, usize> = [(7u32, 3usize)].into_iter().collect();
560        let arr = vec![
561            Object::Reference(ObjectId::new(7)),
562            Object::Name("Fit".into()),
563        ];
564        let d = decode_explicit_dest(&arr, &map).unwrap();
565        assert_eq!(d, OutlineDestination::Fit { page_index: 3 });
566    }
567
568    #[test]
569    fn explicit_dest_xyz_decodes_with_nulls() {
570        let map: HashMap<u32, usize> = [(11u32, 5usize)].into_iter().collect();
571        let arr = vec![
572            Object::Reference(ObjectId::new(11)),
573            Object::Name("XYZ".into()),
574            Object::Null,
575            Object::Integer(800),
576            Object::Integer(0), // 0 zoom → None per Table 151
577        ];
578        let d = decode_explicit_dest(&arr, &map).unwrap();
579        assert_eq!(
580            d,
581            OutlineDestination::Xyz {
582                page_index: 5,
583                left: None,
584                top: Some(800.0),
585                zoom: None,
586            }
587        );
588    }
589
590    #[test]
591    fn explicit_dest_fitr_requires_four_numbers() {
592        let map: HashMap<u32, usize> = [(2u32, 0usize)].into_iter().collect();
593        let arr = vec![
594            Object::Reference(ObjectId::new(2)),
595            Object::Name("FitR".into()),
596            Object::Real(10.0),
597            Object::Real(20.0),
598            Object::Real(30.0),
599            Object::Real(40.0),
600        ];
601        let d = decode_explicit_dest(&arr, &map).unwrap();
602        assert_eq!(
603            d,
604            OutlineDestination::FitR {
605                page_index: 0,
606                left: 10.0,
607                bottom: 20.0,
608                right: 30.0,
609                top: 40.0,
610            }
611        );
612    }
613
614    #[test]
615    fn explicit_dest_unknown_mode_returns_none() {
616        let map: HashMap<u32, usize> = [(3u32, 1usize)].into_iter().collect();
617        let arr = vec![
618            Object::Reference(ObjectId::new(3)),
619            Object::Name("Unknown".into()),
620        ];
621        assert!(decode_explicit_dest(&arr, &map).is_none());
622    }
623}