use std::collections::{HashMap, HashSet};
use docling_core::debug_log;
use lopdf::{Dictionary, Document, Object, ObjectId};
const MAX_OUTLINE_ITEMS: usize = 10_000;
#[derive(Clone, Debug)]
pub struct OutlineItem {
pub title: String,
pub level: usize,
pub page_no: Option<usize>,
pub y_top: Option<f32>,
}
pub fn extract_outline(bytes: &[u8]) -> Vec<OutlineItem> {
let Ok(doc) = Document::load_mem(bytes) else {
return Vec::new();
};
let Ok(catalog) = doc.catalog() else {
return Vec::new();
};
let Some(outlines) = catalog.get(b"Outlines").ok().and_then(|o| as_dict(&doc, o)) else {
return Vec::new();
};
let Some(first) = outlines
.get(b"First")
.ok()
.and_then(|o| o.as_reference().ok())
else {
return Vec::new();
};
let page_index: HashMap<ObjectId, usize> = doc
.get_pages()
.into_iter()
.map(|(no, id)| (id, no as usize))
.collect();
let mut items = Vec::new();
let mut visited: HashSet<ObjectId> = HashSet::new();
let mut stack: Vec<(ObjectId, usize)> = vec![(first, 0)];
while let Some((id, level)) = stack.pop() {
if items.len() >= MAX_OUTLINE_ITEMS {
debug_log!("docling-pdf: outline truncated at {MAX_OUTLINE_ITEMS} entries");
break;
}
if !visited.insert(id) {
continue;
}
let Some(node) = doc.get_object(id).ok().and_then(|o| o.as_dict().ok()) else {
continue;
};
if let Some(next) = node.get(b"Next").ok().and_then(|o| o.as_reference().ok()) {
stack.push((next, level));
}
if let Some(child) = node.get(b"First").ok().and_then(|o| o.as_reference().ok()) {
stack.push((child, level + 1));
}
let title = node
.get(b"Title")
.ok()
.and_then(|o| deref(&doc, o))
.and_then(text_string)
.unwrap_or_default();
let title = title.trim();
if title.is_empty() {
continue;
}
let (page_no, y_top) = destination(&doc, catalog, node, &page_index);
items.push(OutlineItem {
title: title.to_string(),
level,
page_no,
y_top,
});
}
items
}
fn destination(
doc: &Document,
catalog: &Dictionary,
node: &Dictionary,
page_index: &HashMap<ObjectId, usize>,
) -> (Option<usize>, Option<f32>) {
let dest = node
.get(b"Dest")
.ok()
.and_then(|o| deref(doc, o))
.or_else(|| {
let action = node.get(b"A").ok().and_then(|o| as_dict_obj(doc, o))?;
let goto = action
.get(b"S")
.ok()
.and_then(|o| o.as_name().ok())
.is_none_or(|s| s == b"GoTo");
if !goto {
return None;
}
action.get(b"D").ok().and_then(|o| deref(doc, o))
});
let Some(dest) = dest else {
return (None, None);
};
let array = match dest {
Object::Array(a) => Some(a.clone()),
Object::Name(n) => named_destination(doc, catalog, n),
Object::String(s, _) => named_destination(doc, catalog, s),
_ => None,
};
let Some(array) = array else {
return (None, None);
};
dest_array(doc, &array, page_index)
}
fn dest_array(
doc: &Document,
array: &[Object],
page_index: &HashMap<ObjectId, usize>,
) -> (Option<usize>, Option<f32>) {
let Some(page_obj) = array.first() else {
return (None, None);
};
let (page_no, page_id) = match page_obj {
Object::Reference(id) => (page_index.get(id).copied(), Some(*id)),
Object::Integer(i) if *i >= 0 => (Some(*i as usize + 1), None),
_ => (None, None),
};
let view = array.get(1).and_then(|o| o.as_name().ok());
let y_index = match view {
Some(b"XYZ") => Some(3), Some(b"FitH") | Some(b"FitBH") => Some(2), Some(b"FitR") => Some(5), _ => None,
};
let y_pdf = y_index.and_then(|i| array.get(i)).and_then(as_number);
let y_top = match (y_pdf, page_id) {
(Some(y), Some(id)) => Some(crate::textparse::page_box(doc, id).top() - y),
_ => None,
};
(page_no, y_top)
}
fn named_destination(doc: &Document, catalog: &Dictionary, name: &[u8]) -> Option<Vec<Object>> {
let value = catalog
.get(b"Dests")
.ok()
.and_then(|o| as_dict(doc, o))
.and_then(|dests| dests.get(name).ok())
.and_then(|o| deref(doc, o))
.cloned()
.or_else(|| {
let names = catalog.get(b"Names").ok().and_then(|o| as_dict(doc, o))?;
let tree = names.get(b"Dests").ok().and_then(|o| deref(doc, o))?;
name_tree_lookup(doc, tree, name, 0)
})?;
match value {
Object::Array(a) => Some(a),
Object::Dictionary(d) => match d.get(b"D").ok().and_then(|o| deref(doc, o)) {
Some(Object::Array(a)) => Some(a.clone()),
_ => None,
},
_ => None,
}
}
fn name_tree_lookup(doc: &Document, node: &Object, key: &[u8], depth: usize) -> Option<Object> {
if depth > 16 {
return None;
}
let dict = as_dict_obj(doc, node)?;
if let Some(Object::Array(pairs)) = dict.get(b"Names").ok().and_then(|o| deref(doc, o)) {
for pair in pairs.chunks(2) {
if let [Object::String(k, _), v] = pair {
if k == key {
return deref(doc, v).cloned();
}
}
}
}
if let Some(Object::Array(kids)) = dict.get(b"Kids").ok().and_then(|o| deref(doc, o)) {
for kid in kids {
if let Some(found) = name_tree_lookup(doc, kid, key, depth + 1) {
return Some(found);
}
}
}
None
}
fn text_string(obj: &Object) -> Option<String> {
let Object::String(bytes, _) = obj else {
return None;
};
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
let units: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
return Some(String::from_utf16_lossy(&units));
}
Some(bytes.iter().map(|&b| b as char).collect())
}
fn as_number(obj: &Object) -> Option<f32> {
match obj {
Object::Integer(i) => Some(*i as f32),
Object::Real(r) => Some(*r),
_ => None,
}
}
fn deref<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Object> {
match obj {
Object::Reference(id) => doc.get_object(*id).ok(),
other => Some(other),
}
}
fn as_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
deref(doc, obj)?.as_dict().ok()
}
fn as_dict_obj<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
as_dict(doc, obj)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> Option<Vec<u8>> {
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/data/pdf/sources")
.join(name);
std::fs::read(p).ok()
}
#[test]
fn reads_a_real_arxiv_outline() {
let Some(bytes) = fixture("2206.01062.pdf") else {
eprintln!("skipping: corpus fixture not present");
return;
};
let items = extract_outline(&bytes);
assert!(items.len() >= 8, "expected the paper's sections");
assert_eq!(items[0].title, "Abstract");
assert_eq!(items[0].page_no, Some(1));
assert!(items[0].y_top.is_some(), "XYZ top resolves");
assert!(items.iter().all(|i| i.level == 0));
assert!(items.iter().any(|i| i.title == "6 Conclusion"));
assert!(
items.iter().all(|i| i.page_no.is_some()),
"every entry's target page resolves"
);
}
#[test]
fn no_outline_is_an_empty_list() {
let Some(bytes) = fixture("multi_page.pdf") else {
eprintln!("skipping: corpus fixture not present");
return;
};
let _ = extract_outline(&bytes);
assert!(extract_outline(b"%PDF-1.4 not really a pdf").is_empty());
assert!(extract_outline(&[]).is_empty());
}
}