Skip to main content

docling_pdf/
outline.rs

1//! Extract a PDF's outline (bookmarks / table of contents) — the most
2//! authoritative heading-hierarchy signal a PDF carries (#302, docling's
3//! `docling/utils/pdf_outline.py`).
4//!
5//! Pure lopdf — no pdfium — so it works wherever the crate compiles and adds
6//! nothing to the pipeline unless the heading-hierarchy stage asks for it.
7//! Returns a flat, document-ordered list; each entry carries its own 0-based
8//! depth so no tree structure is needed for matching. Everything is
9//! best-effort: a malformed or encrypted outline yields an empty list, never
10//! an error.
11
12use std::collections::{HashMap, HashSet};
13
14use docling_core::debug_log;
15use lopdf::{Dictionary, Document, Object, ObjectId};
16
17/// Defensive cap on extracted entries: outlines beyond this are generated
18/// pathology (real documents carry hundreds, rarely thousands), and the walk
19/// degrades to a truncated list rather than unbounded memory/time.
20const MAX_OUTLINE_ITEMS: usize = 10_000;
21
22/// A single PDF bookmark / table-of-contents entry.
23#[derive(Clone, Debug)]
24pub struct OutlineItem {
25    pub title: String,
26    /// 0-based depth as reported by the PDF outline; compressed to contiguous
27    /// levels by the heading-hierarchy stage.
28    pub level: usize,
29    /// 1-based target page; `None` when the entry has no resolvable page.
30    pub page_no: Option<usize>,
31    /// Top-left-origin vertical position of the target on its page, when the
32    /// destination view encodes one (XYZ / FitH / FitBH / FitR).
33    pub y_top: Option<f32>,
34}
35
36/// Parse the outline out of raw PDF bytes. Empty when the document has no
37/// outline or it cannot be read.
38pub fn extract_outline(bytes: &[u8]) -> Vec<OutlineItem> {
39    let Ok(doc) = Document::load_mem(bytes) else {
40        return Vec::new();
41    };
42    let Ok(catalog) = doc.catalog() else {
43        return Vec::new();
44    };
45    let Some(outlines) = catalog.get(b"Outlines").ok().and_then(|o| as_dict(&doc, o)) else {
46        return Vec::new();
47    };
48    let Some(first) = outlines
49        .get(b"First")
50        .ok()
51        .and_then(|o| o.as_reference().ok())
52    else {
53        return Vec::new();
54    };
55
56    // Page object id → 1-based index, for resolving destination pages.
57    let page_index: HashMap<ObjectId, usize> = doc
58        .get_pages()
59        .into_iter()
60        .map(|(no, id)| (id, no as usize))
61        .collect();
62
63    let mut items = Vec::new();
64    // Iterative pre-order walk with a visited guard: real documents nest
65    // hundreds of levels deep and malformed ones can cycle through /Next.
66    let mut visited: HashSet<ObjectId> = HashSet::new();
67    let mut stack: Vec<(ObjectId, usize)> = vec![(first, 0)];
68    while let Some((id, level)) = stack.pop() {
69        if items.len() >= MAX_OUTLINE_ITEMS {
70            debug_log!("docling-pdf: outline truncated at {MAX_OUTLINE_ITEMS} entries");
71            break;
72        }
73        if !visited.insert(id) {
74            continue;
75        }
76        let Some(node) = doc.get_object(id).ok().and_then(|o| o.as_dict().ok()) else {
77            continue;
78        };
79        // Siblings after children on the stack ⇒ push /Next first, /First last.
80        if let Some(next) = node.get(b"Next").ok().and_then(|o| o.as_reference().ok()) {
81            stack.push((next, level));
82        }
83        if let Some(child) = node.get(b"First").ok().and_then(|o| o.as_reference().ok()) {
84            stack.push((child, level + 1));
85        }
86        let title = node
87            .get(b"Title")
88            .ok()
89            .and_then(|o| deref(&doc, o))
90            .and_then(text_string)
91            .unwrap_or_default();
92        let title = title.trim();
93        if title.is_empty() {
94            continue;
95        }
96        let (page_no, y_top) = destination(&doc, catalog, node, &page_index);
97        items.push(OutlineItem {
98            title: title.to_string(),
99            level,
100            page_no,
101            y_top,
102        });
103    }
104    items
105}
106
107/// Resolve an outline item's target: `/Dest` directly, or the `/A` action's
108/// `/D` when the action is a GoTo. Returns `(1-based page, top-left y)`.
109fn destination(
110    doc: &Document,
111    catalog: &Dictionary,
112    node: &Dictionary,
113    page_index: &HashMap<ObjectId, usize>,
114) -> (Option<usize>, Option<f32>) {
115    let dest = node
116        .get(b"Dest")
117        .ok()
118        .and_then(|o| deref(doc, o))
119        .or_else(|| {
120            let action = node.get(b"A").ok().and_then(|o| as_dict_obj(doc, o))?;
121            let goto = action
122                .get(b"S")
123                .ok()
124                .and_then(|o| o.as_name().ok())
125                .is_none_or(|s| s == b"GoTo");
126            if !goto {
127                return None;
128            }
129            action.get(b"D").ok().and_then(|o| deref(doc, o))
130        });
131    let Some(dest) = dest else {
132        return (None, None);
133    };
134    // A named destination (name or byte string) resolves through the catalog.
135    let array = match dest {
136        Object::Array(a) => Some(a.clone()),
137        Object::Name(n) => named_destination(doc, catalog, n),
138        Object::String(s, _) => named_destination(doc, catalog, s),
139        _ => None,
140    };
141    let Some(array) = array else {
142        return (None, None);
143    };
144    dest_array(doc, &array, page_index)
145}
146
147/// Decode an explicit destination array: `[page /XYZ left top zoom]`,
148/// `[page /FitH top]`, `[page /FitBH top]`, `[page /FitR l b r t]`. Views
149/// without a usable vertical (Fit, FitV, FitB, FitBV) yield a page only —
150/// exactly docling's `_view_top_index`.
151fn dest_array(
152    doc: &Document,
153    array: &[Object],
154    page_index: &HashMap<ObjectId, usize>,
155) -> (Option<usize>, Option<f32>) {
156    let Some(page_obj) = array.first() else {
157        return (None, None);
158    };
159    let (page_no, page_id) = match page_obj {
160        Object::Reference(id) => (page_index.get(id).copied(), Some(*id)),
161        // A bare integer is a 0-based page index (seen in the wild).
162        Object::Integer(i) if *i >= 0 => (Some(*i as usize + 1), None),
163        _ => (None, None),
164    };
165    let view = array.get(1).and_then(|o| o.as_name().ok());
166    let y_index = match view {
167        Some(b"XYZ") => Some(3),                   // [page /XYZ left top zoom]
168        Some(b"FitH") | Some(b"FitBH") => Some(2), // [page /FitH top]
169        Some(b"FitR") => Some(5),                  // [page /FitR left bottom right top]
170        _ => None,
171    };
172    let y_pdf = y_index.and_then(|i| array.get(i)).and_then(as_number);
173    let y_top = match (y_pdf, page_id) {
174        // PDF y-up → top-left origin needs the page height.
175        // Destinations are user-space; the y-down frame starts at the display
176        // box's top edge (CropBox, like pdfium), not the MediaBox's.
177        (Some(y), Some(id)) => Some(crate::textparse::page_box(doc, id).top() - y),
178        _ => None,
179    };
180    (page_no, y_top)
181}
182
183/// Resolve a named destination: the PDF 1.1 catalog `/Dests` dictionary, or
184/// the `/Names` → `/Dests` name tree. The resolved value may itself be a
185/// dictionary wrapping the array under `/D`.
186fn named_destination(doc: &Document, catalog: &Dictionary, name: &[u8]) -> Option<Vec<Object>> {
187    let value = catalog
188        .get(b"Dests")
189        .ok()
190        .and_then(|o| as_dict(doc, o))
191        .and_then(|dests| dests.get(name).ok())
192        .and_then(|o| deref(doc, o))
193        .cloned()
194        .or_else(|| {
195            let names = catalog.get(b"Names").ok().and_then(|o| as_dict(doc, o))?;
196            let tree = names.get(b"Dests").ok().and_then(|o| deref(doc, o))?;
197            name_tree_lookup(doc, tree, name, 0)
198        })?;
199    match value {
200        Object::Array(a) => Some(a),
201        Object::Dictionary(d) => match d.get(b"D").ok().and_then(|o| deref(doc, o)) {
202            Some(Object::Array(a)) => Some(a.clone()),
203            _ => None,
204        },
205        _ => None,
206    }
207}
208
209/// Look a key up in a name tree (`/Names` leaf arrays, `/Kids` interior
210/// nodes). Depth-bounded; ignores the `/Limits` optimization and just walks —
211/// outlines are read once per conversion.
212fn name_tree_lookup(doc: &Document, node: &Object, key: &[u8], depth: usize) -> Option<Object> {
213    if depth > 16 {
214        return None;
215    }
216    let dict = as_dict_obj(doc, node)?;
217    if let Some(Object::Array(pairs)) = dict.get(b"Names").ok().and_then(|o| deref(doc, o)) {
218        for pair in pairs.chunks(2) {
219            if let [Object::String(k, _), v] = pair {
220                if k == key {
221                    return deref(doc, v).cloned();
222                }
223            }
224        }
225    }
226    if let Some(Object::Array(kids)) = dict.get(b"Kids").ok().and_then(|o| deref(doc, o)) {
227        for kid in kids {
228            if let Some(found) = name_tree_lookup(doc, kid, key, depth + 1) {
229                return Some(found);
230            }
231        }
232    }
233    None
234}
235
236/// Decode a PDF text string: UTF-16BE with a `FE FF` BOM, else
237/// PDFDocEncoding (treated as Latin-1 — identical for the printable range).
238fn text_string(obj: &Object) -> Option<String> {
239    let Object::String(bytes, _) = obj else {
240        return None;
241    };
242    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
243        let units: Vec<u16> = bytes[2..]
244            .chunks_exact(2)
245            .map(|c| u16::from_be_bytes([c[0], c[1]]))
246            .collect();
247        return Some(String::from_utf16_lossy(&units));
248    }
249    Some(bytes.iter().map(|&b| b as char).collect())
250}
251
252fn as_number(obj: &Object) -> Option<f32> {
253    match obj {
254        Object::Integer(i) => Some(*i as f32),
255        Object::Real(r) => Some(*r),
256        _ => None,
257    }
258}
259
260fn deref<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Object> {
261    match obj {
262        Object::Reference(id) => doc.get_object(*id).ok(),
263        other => Some(other),
264    }
265}
266
267fn as_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
268    deref(doc, obj)?.as_dict().ok()
269}
270
271fn as_dict_obj<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
272    as_dict(doc, obj)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    /// Shared corpus fixtures live at the repo root (CLAUDE.md); tests run
280    /// with CWD = the crate dir.
281    fn fixture(name: &str) -> Option<Vec<u8>> {
282        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
283            .join("../../tests/data/pdf/sources")
284            .join(name);
285        std::fs::read(p).ok()
286    }
287
288    #[test]
289    fn reads_a_real_arxiv_outline() {
290        // DocLayNet paper: a real multi-level outline with XYZ destinations.
291        let Some(bytes) = fixture("2206.01062.pdf") else {
292            eprintln!("skipping: corpus fixture not present");
293            return;
294        };
295        let items = extract_outline(&bytes);
296        // The DocLayNet paper's outline is flat (8 top-level sections), in
297        // document order, with XYZ destinations resolving page and position.
298        assert!(items.len() >= 8, "expected the paper's sections");
299        assert_eq!(items[0].title, "Abstract");
300        assert_eq!(items[0].page_no, Some(1));
301        assert!(items[0].y_top.is_some(), "XYZ top resolves");
302        assert!(items.iter().all(|i| i.level == 0));
303        assert!(items.iter().any(|i| i.title == "6 Conclusion"));
304        assert!(
305            items.iter().all(|i| i.page_no.is_some()),
306            "every entry's target page resolves"
307        );
308    }
309
310    #[test]
311    fn no_outline_is_an_empty_list() {
312        let Some(bytes) = fixture("multi_page.pdf") else {
313            eprintln!("skipping: corpus fixture not present");
314            return;
315        };
316        // Whether or not this fixture carries an outline, the call must not
317        // fail; garbage input must also yield an empty list, never a panic.
318        let _ = extract_outline(&bytes);
319        assert!(extract_outline(b"%PDF-1.4 not really a pdf").is_empty());
320        assert!(extract_outline(&[]).is_empty());
321    }
322}