1use std::collections::{HashMap, HashSet};
13
14use docling_core::debug_log;
15use lopdf::{Dictionary, Document, Object, ObjectId};
16
17const MAX_OUTLINE_ITEMS: usize = 10_000;
21
22#[derive(Clone, Debug)]
24pub struct OutlineItem {
25 pub title: String,
26 pub level: usize,
29 pub page_no: Option<usize>,
31 pub y_top: Option<f32>,
34}
35
36pub 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 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 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 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
107fn 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 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
147fn 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 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), Some(b"FitH") | Some(b"FitBH") => Some(2), Some(b"FitR") => Some(5), _ => 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 (Some(y), Some(id)) => Some(crate::textparse::page_box(doc, id).top() - y),
178 _ => None,
179 };
180 (page_no, y_top)
181}
182
183fn 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
209fn 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
236fn 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 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 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 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 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}