1use crate::document::PdfDocument;
7use crate::error::{Error, Result};
8use crate::object::Object;
9
10#[derive(Debug, Clone)]
12pub struct OutlineItem {
13 pub title: String,
15
16 pub dest: Option<Destination>,
19
20 pub children: Vec<OutlineItem>,
22}
23
24#[derive(Debug, Clone)]
26pub enum Destination {
27 PageIndex(usize),
29
30 Named(String),
32}
33
34impl PdfDocument {
35 pub fn get_outline(&self) -> Result<Option<Vec<OutlineItem>>> {
60 let catalog = self.catalog()?;
61
62 let outlines_ref = match catalog.as_dict() {
63 Some(dict) => match dict.get("Outlines") {
64 Some(Object::Reference(obj_ref)) => *obj_ref,
65 _ => return Ok(None),
66 },
67 None => return Ok(None),
68 };
69
70 let outlines_dict = self.load_object(outlines_ref)?;
71
72 let first_ref = match outlines_dict.as_dict() {
73 Some(dict) => match dict.get("First") {
74 Some(Object::Reference(obj_ref)) => *obj_ref,
75 _ => return Ok(None),
76 },
77 None => return Ok(None),
78 };
79
80 let mut items = Vec::new();
81 let mut current_ref = Some(first_ref);
82
83 while let Some(item_ref) = current_ref {
84 if let Ok(item) = self.parse_outline_item(item_ref) {
85 items.push(item);
86 }
87
88 let item_obj = self.load_object(item_ref)?;
89 current_ref = match item_obj.as_dict() {
90 Some(dict) => match dict.get("Next") {
91 Some(Object::Reference(obj_ref)) => Some(*obj_ref),
92 _ => None,
93 },
94 None => None,
95 };
96 }
97
98 if items.is_empty() { Ok(None) } else { Ok(Some(items)) }
99 }
100
101 fn parse_outline_item(&self, item_ref: crate::object::ObjectRef) -> Result<OutlineItem> {
103 let item_obj = self.load_object(item_ref)?;
104
105 let title = match item_obj.as_dict().and_then(|dict| dict.get("Title")) {
112 Some(Object::String(s)) => crate::optional_content::decode_pdf_text_string(s),
113 Some(Object::Reference(r)) => self
114 .load_object(*r)
115 .ok()
116 .as_ref()
117 .and_then(Object::as_string)
118 .map(crate::optional_content::decode_pdf_text_string)
119 .unwrap_or_else(|| "(No Title)".to_string()),
120 _ => "(No Title)".to_string(),
121 };
122
123 let dest = self.parse_outline_destination(&item_obj)?;
124
125 let mut children = Vec::new();
126 if let Some(dict) = item_obj.as_dict()
127 && let Some(Object::Reference(first_child_ref)) = dict.get("First")
128 {
129 let mut child_ref = Some(*first_child_ref);
130
131 while let Some(c_ref) = child_ref {
132 if let Ok(child) = self.parse_outline_item(c_ref) {
133 children.push(child);
134 }
135
136 let child_obj = self.load_object(c_ref)?;
137 child_ref = match child_obj.as_dict() {
138 Some(dict) => match dict.get("Next") {
139 Some(Object::Reference(obj_ref)) => Some(*obj_ref),
140 _ => None,
141 },
142 None => None,
143 };
144 }
145 }
146
147 Ok(OutlineItem { title, dest, children })
148 }
149
150 fn parse_outline_destination(&self, item: &Object) -> Result<Option<Destination>> {
152 let dict = match item.as_dict() {
153 Some(d) => d,
154 None => return Ok(None),
155 };
156
157 if let Some(dest_obj) = dict.get("Dest") {
158 return self.resolve_destination(dest_obj);
159 }
160
161 let mut action = dict.get("A");
162
163 let obj;
164 if let Some(Object::Reference(obj_ref)) = action {
165 obj = self.load_object(*obj_ref)?;
166 action = Some(&obj);
167 }
168
169 if let Some(Object::Dictionary(action)) = action
170 && let Some(dest_obj) = action.get("D")
171 {
172 return self.resolve_destination(dest_obj);
173 }
174
175 Ok(None)
176 }
177
178 pub fn resolve_named_destination(&self, name: &str) -> Result<Option<usize>> {
199 self.resolve_named_destination_bytes(name.as_bytes())
200 }
201
202 fn resolve_named_destination_bytes(&self, name: &[u8]) -> Result<Option<usize>> {
213 let catalog = self.catalog()?;
214 let Some(cat) = catalog.as_dict() else {
215 return Ok(None);
216 };
217 let resolve = |r: crate::object::ObjectRef| self.load_object(r).ok();
218 let Some(dest) = lookup_named_dest(cat, name, &resolve, 0) else {
219 return Ok(None);
220 };
221 match self.resolve_destination(&dest)? {
224 Some(Destination::PageIndex(i)) => Ok(Some(i)),
225 _ => Ok(None),
226 }
227 }
228
229 fn resolve_destination(&self, dest_obj: &Object) -> Result<Option<Destination>> {
231 match dest_obj {
232 Object::String(name) => match self.resolve_named_destination_bytes(name)? {
242 Some(idx) => Ok(Some(Destination::PageIndex(idx))),
243 None => Ok(Some(Destination::Named(String::from_utf8_lossy(name).to_string()))),
244 },
245 Object::Name(name) => match self.resolve_named_destination(name)? {
246 Some(idx) => Ok(Some(Destination::PageIndex(idx))),
247 None => Ok(Some(Destination::Named(name.clone()))),
248 },
249
250 Object::Array(arr) if !arr.is_empty() => match &arr[0] {
251 Object::Reference(page_ref) => {
252 if let Ok(page_index) = self.find_page_index(*page_ref) {
253 Ok(Some(Destination::PageIndex(page_index)))
254 } else {
255 Ok(None)
256 }
257 }
258 _ => Ok(None),
259 },
260
261 Object::Reference(dest_ref) => {
262 let resolved = self.load_object(*dest_ref)?;
263 self.resolve_destination(&resolved)
264 }
265
266 _ => Ok(None),
267 }
268 }
269
270 fn find_page_index(&self, page_ref: crate::object::ObjectRef) -> Result<usize> {
272 let refs = self.all_page_refs()?;
275 refs.iter()
276 .position(|r| *r == page_ref)
277 .ok_or_else(|| Error::InvalidPdf(format!("Page reference {:?} not found", page_ref)))
278 }
279}
280
281const NAME_TREE_MAX_DEPTH: u8 = 32;
284
285fn deref_obj(o: &Object, resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>) -> Option<Object> {
287 match o {
288 Object::Reference(r) => resolve(*r),
289 other => Some(other.clone()),
290 }
291}
292
293fn normalize_dest_value(v: &Object, resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>) -> Option<Object> {
297 let v = deref_obj(v, resolve)?;
298 if let Some(d) = v.as_dict()
299 && let Some(inner) = d.get("D")
300 {
301 return deref_obj(inner, resolve);
302 }
303 if v.as_array().is_some() {
304 return Some(v);
305 }
306 None
307}
308
309fn walk_name_tree(
312 node: &Object,
313 target: &[u8],
314 resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>,
315 depth: u8,
316) -> Option<Object> {
317 if depth > NAME_TREE_MAX_DEPTH {
318 return None;
319 }
320 let node = deref_obj(node, resolve)?;
321 let dict = node.as_dict()?;
322
323 if let Some(names) = dict
324 .get("Names")
325 .and_then(|n| deref_obj(n, resolve))
326 .filter(|d| d.as_array().is_some())
327 {
328 let arr = names.as_array().expect("checked array above");
329 let mut i = 0;
330 while i + 1 < arr.len() {
331 if arr[i].as_string() == Some(target) {
332 return normalize_dest_value(&arr[i + 1], resolve);
333 }
334 i += 2;
335 }
336 if !dict.contains_key("Kids") {
338 return None;
339 }
340 }
341
342 if let Some(kids) = dict
343 .get("Kids")
344 .and_then(|k| deref_obj(k, resolve))
345 .filter(|k| k.as_array().is_some())
346 {
347 for kid in kids.as_array().expect("checked array above") {
348 let Some(kid_node) = deref_obj(kid, resolve) else {
349 continue;
350 };
351 let in_range = kid_limits_admit(&kid_node, target, resolve);
352 if in_range && let Some(found) = walk_name_tree(&kid_node, target, resolve, depth + 1) {
353 return Some(found);
354 }
355 }
356 }
357 None
358}
359
360fn kid_limits_admit(
364 kid_node: &Object,
365 target: &[u8],
366 resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>,
367) -> bool {
368 let Some(lim) = kid_node.as_dict().and_then(|d| d.get("Limits")) else {
369 return true;
370 };
371 let Some(limits) = deref_obj(lim, resolve) else {
372 return true;
373 };
374 match limits.as_array() {
375 Some(l) if l.len() == 2 => match (l[0].as_string(), l[1].as_string()) {
376 (Some(lo), Some(hi)) => lo <= target && target <= hi,
377 _ => true,
378 },
379 _ => true,
380 }
381}
382
383fn lookup_named_dest(
388 catalog: &std::collections::HashMap<String, Object>,
389 target: &[u8],
390 resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>,
391 depth: u8,
392) -> Option<Object> {
393 if let Some(dests) = catalog.get("Dests").and_then(|d| deref_obj(d, resolve))
394 && let Some(dd) = dests.as_dict()
395 {
396 let key = String::from_utf8_lossy(target);
397 if let Some(v) = dd.get(key.as_ref())
398 && let Some(found) = normalize_dest_value(v, resolve)
399 {
400 return Some(found);
401 }
402 }
403 let names = catalog.get("Names").and_then(|n| deref_obj(n, resolve))?;
404 let root = names.as_dict()?.get("Dests").and_then(|d| deref_obj(d, resolve))?;
405 walk_name_tree(&root, target, resolve, depth)
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use std::collections::HashMap;
412
413 fn s(b: &str) -> Object {
414 Object::String(b.as_bytes().to_vec())
415 }
416 fn arr_dest(page_obj: u32) -> Object {
417 Object::Array(vec![
418 Object::Reference(crate::object::ObjectRef::new(page_obj, 0)),
419 Object::Name("Fit".to_string()),
420 ])
421 }
422 fn no_resolve() -> impl Fn(crate::object::ObjectRef) -> Option<Object> {
423 |_r| None
424 }
425
426 #[test]
427 fn test_outline_item_creation() {
428 let item = OutlineItem {
429 title: "Chapter 1".to_string(),
430 dest: Some(Destination::PageIndex(0)),
431 children: vec![],
432 };
433
434 assert_eq!(item.title, "Chapter 1");
435 assert!(matches!(item.dest, Some(Destination::PageIndex(0))));
436 assert!(item.children.is_empty());
437 }
438
439 #[test]
440 fn test_outline_hierarchy() {
441 let child = OutlineItem {
442 title: "Section 1.1".to_string(),
443 dest: Some(Destination::PageIndex(1)),
444 children: vec![],
445 };
446
447 let parent = OutlineItem {
448 title: "Chapter 1".to_string(),
449 dest: Some(Destination::PageIndex(0)),
450 children: vec![child],
451 };
452
453 assert_eq!(parent.children.len(), 1);
454 assert_eq!(parent.children[0].title, "Section 1.1");
455 }
456
457 #[test]
459 fn dests_dict_direct_array() {
460 let mut dests = HashMap::new();
461 dests.insert("chap1".to_string(), arr_dest(7));
462 let mut cat = HashMap::new();
463 cat.insert("Dests".to_string(), Object::Dictionary(dests));
464 let r = no_resolve();
465 let got = lookup_named_dest(&cat, b"chap1", &r, 0).expect("found");
466 assert_eq!(got.as_array().unwrap()[0].as_reference().unwrap().id, 7);
467 assert!(lookup_named_dest(&cat, b"missing", &r, 0).is_none());
468 }
469
470 #[test]
472 fn dests_dict_d_wrapper() {
473 let mut inner = HashMap::new();
474 inner.insert("D".to_string(), arr_dest(9));
475 let mut dests = HashMap::new();
476 dests.insert("c".to_string(), Object::Dictionary(inner));
477 let mut cat = HashMap::new();
478 cat.insert("Dests".to_string(), Object::Dictionary(dests));
479 let got = lookup_named_dest(&cat, b"c", &no_resolve(), 0).expect("found");
480 assert_eq!(got.as_array().unwrap()[0].as_reference().unwrap().id, 9);
481 }
482
483 #[test]
485 fn names_tree_flat_leaf() {
486 let dests_root = Object::Dictionary(HashMap::from([(
487 "Names".to_string(),
488 Object::Array(vec![s("a"), arr_dest(1), s("b"), arr_dest(4), s("c"), arr_dest(6)]),
489 )]));
490 let names = Object::Dictionary(HashMap::from([("Dests".to_string(), dests_root)]));
491 let cat = HashMap::from([("Names".to_string(), names)]);
492 let r = no_resolve();
493 assert_eq!(
494 lookup_named_dest(&cat, b"b", &r, 0).unwrap().as_array().unwrap()[0]
495 .as_reference()
496 .unwrap()
497 .id,
498 4
499 );
500 assert!(lookup_named_dest(&cat, b"z", &r, 0).is_none());
501 }
502
503 #[test]
507 fn names_tree_kids_with_limits_and_indirection() {
508 use crate::object::ObjectRef;
509 let leaf_l = Object::Dictionary(HashMap::from([
510 ("Limits".to_string(), Object::Array(vec![s("a"), s("b")])),
511 (
512 "Names".to_string(),
513 Object::Array(vec![s("a"), arr_dest(1), s("b"), arr_dest(2)]),
514 ),
515 ]));
516 let leaf_r = Object::Dictionary(HashMap::from([
517 ("Limits".to_string(), Object::Array(vec![s("m"), s("z")])),
518 (
519 "Names".to_string(),
520 Object::Array(vec![s("m"), arr_dest(3), s("z"), arr_dest(4)]),
521 ),
522 ]));
523 let resolve = move |rf: ObjectRef| match rf.id {
524 100 => Some(leaf_l.clone()),
525 101 => Some(leaf_r.clone()),
526 _ => None,
527 };
528 let root = Object::Dictionary(HashMap::from([(
529 "Kids".to_string(),
530 Object::Array(vec![
531 Object::Reference(ObjectRef::new(100, 0)),
532 Object::Reference(ObjectRef::new(101, 0)),
533 ]),
534 )]));
535 let names = Object::Dictionary(HashMap::from([("Dests".to_string(), root)]));
536 let cat = HashMap::from([("Names".to_string(), names)]);
537 assert_eq!(
538 lookup_named_dest(&cat, b"z", &resolve, 0).unwrap().as_array().unwrap()[0]
539 .as_reference()
540 .unwrap()
541 .id,
542 4
543 );
544 assert_eq!(
545 lookup_named_dest(&cat, b"a", &resolve, 0).unwrap().as_array().unwrap()[0]
546 .as_reference()
547 .unwrap()
548 .id,
549 1
550 );
551 assert!(lookup_named_dest(&cat, b"c", &resolve, 0).is_none());
552 }
553
554 #[test]
557 fn names_tree_cyclic_kids_is_bounded() {
558 use crate::object::ObjectRef;
559 let resolve = |rf: ObjectRef| {
560 if rf.id == 200 {
561 Some(Object::Dictionary(HashMap::from([(
562 "Kids".to_string(),
563 Object::Array(vec![Object::Reference(ObjectRef::new(200, 0))]),
564 )])))
565 } else {
566 None
567 }
568 };
569 let names = Object::Dictionary(HashMap::from([(
570 "Dests".to_string(),
571 Object::Reference(ObjectRef::new(200, 0)),
572 )]));
573 let cat = HashMap::from([("Names".to_string(), names)]);
574 assert!(lookup_named_dest(&cat, b"x", &resolve, 0).is_none());
575 }
576
577 #[test]
579 fn no_dests_no_names_is_none() {
580 let cat = HashMap::from([("Type".to_string(), Object::Name("Catalog".to_string()))]);
581 assert!(lookup_named_dest(&cat, b"anything", &no_resolve(), 0).is_none());
582 }
583
584 #[test]
597 fn utf16be_key_requires_raw_byte_lookup_not_lossy_roundtrip() {
598 let utf16_key: &[u8] = &[0xFE, 0xFF, 0x00, 0x41, 0x00, 0x33];
600 let dests_root = Object::Dictionary(HashMap::from([(
601 "Names".to_string(),
602 Object::Array(vec![Object::String(utf16_key.to_vec()), arr_dest(3)]),
603 )]));
604 let names = Object::Dictionary(HashMap::from([("Dests".to_string(), dests_root)]));
605 let cat = HashMap::from([("Names".to_string(), names)]);
606 let r = no_resolve();
607
608 let found = lookup_named_dest(&cat, utf16_key, &r, 0).expect("raw bytes must resolve");
611 assert_eq!(found.as_array().unwrap()[0].as_reference().unwrap().id, 3);
612
613 let lossy_roundtrip = String::from_utf8_lossy(utf16_key).to_string().into_bytes();
616 assert_eq!(
617 lossy_roundtrip,
618 vec![0xEF, 0xBF, 0xBD, 0xEF, 0xBF, 0xBD, 0x00, 0x41, 0x00, 0x33],
619 "sanity: the lossy round-trip must mangle the BOM exactly as reported"
620 );
621 assert!(lookup_named_dest(&cat, &lossy_roundtrip, &r, 0).is_none());
622 }
623}