use crate::document::PdfDocument;
use crate::error::{Error, Result};
use crate::object::Object;
#[derive(Debug, Clone)]
pub struct OutlineItem {
pub title: String,
pub dest: Option<Destination>,
pub children: Vec<OutlineItem>,
}
#[derive(Debug, Clone)]
pub enum Destination {
PageIndex(usize),
Named(String),
}
impl PdfDocument {
pub fn get_outline(&self) -> Result<Option<Vec<OutlineItem>>> {
let catalog = self.catalog()?;
let outlines_ref = match catalog.as_dict() {
Some(dict) => match dict.get("Outlines") {
Some(Object::Reference(obj_ref)) => *obj_ref,
_ => return Ok(None),
},
None => return Ok(None),
};
let outlines_dict = self.load_object(outlines_ref)?;
let first_ref = match outlines_dict.as_dict() {
Some(dict) => match dict.get("First") {
Some(Object::Reference(obj_ref)) => *obj_ref,
_ => return Ok(None),
},
None => return Ok(None),
};
let mut items = Vec::new();
let mut current_ref = Some(first_ref);
while let Some(item_ref) = current_ref {
if let Ok(item) = self.parse_outline_item(item_ref) {
items.push(item);
}
let item_obj = self.load_object(item_ref)?;
current_ref = match item_obj.as_dict() {
Some(dict) => match dict.get("Next") {
Some(Object::Reference(obj_ref)) => Some(*obj_ref),
_ => None,
},
None => None,
};
}
if items.is_empty() { Ok(None) } else { Ok(Some(items)) }
}
fn parse_outline_item(&self, item_ref: crate::object::ObjectRef) -> Result<OutlineItem> {
let item_obj = self.load_object(item_ref)?;
let title = match item_obj.as_dict().and_then(|dict| dict.get("Title")) {
Some(Object::String(s)) => crate::optional_content::decode_pdf_text_string(s),
Some(Object::Reference(r)) => self
.load_object(*r)
.ok()
.as_ref()
.and_then(Object::as_string)
.map(crate::optional_content::decode_pdf_text_string)
.unwrap_or_else(|| "(No Title)".to_string()),
_ => "(No Title)".to_string(),
};
let dest = self.parse_outline_destination(&item_obj)?;
let mut children = Vec::new();
if let Some(dict) = item_obj.as_dict()
&& let Some(Object::Reference(first_child_ref)) = dict.get("First")
{
let mut child_ref = Some(*first_child_ref);
while let Some(c_ref) = child_ref {
if let Ok(child) = self.parse_outline_item(c_ref) {
children.push(child);
}
let child_obj = self.load_object(c_ref)?;
child_ref = match child_obj.as_dict() {
Some(dict) => match dict.get("Next") {
Some(Object::Reference(obj_ref)) => Some(*obj_ref),
_ => None,
},
None => None,
};
}
}
Ok(OutlineItem { title, dest, children })
}
fn parse_outline_destination(&self, item: &Object) -> Result<Option<Destination>> {
let dict = match item.as_dict() {
Some(d) => d,
None => return Ok(None),
};
if let Some(dest_obj) = dict.get("Dest") {
return self.resolve_destination(dest_obj);
}
let mut action = dict.get("A");
let obj;
if let Some(Object::Reference(obj_ref)) = action {
obj = self.load_object(*obj_ref)?;
action = Some(&obj);
}
if let Some(Object::Dictionary(action)) = action
&& let Some(dest_obj) = action.get("D")
{
return self.resolve_destination(dest_obj);
}
Ok(None)
}
pub fn resolve_named_destination(&self, name: &str) -> Result<Option<usize>> {
let catalog = self.catalog()?;
let Some(cat) = catalog.as_dict() else {
return Ok(None);
};
let resolve = |r: crate::object::ObjectRef| self.load_object(r).ok();
let Some(dest) = lookup_named_dest(cat, name.as_bytes(), &resolve, 0) else {
return Ok(None);
};
match self.resolve_destination(&dest)? {
Some(Destination::PageIndex(i)) => Ok(Some(i)),
_ => Ok(None),
}
}
fn resolve_destination(&self, dest_obj: &Object) -> Result<Option<Destination>> {
match dest_obj {
Object::String(name) => {
let s = String::from_utf8_lossy(name).to_string();
match self.resolve_named_destination(&s)? {
Some(idx) => Ok(Some(Destination::PageIndex(idx))),
None => Ok(Some(Destination::Named(s))),
}
}
Object::Name(name) => match self.resolve_named_destination(name)? {
Some(idx) => Ok(Some(Destination::PageIndex(idx))),
None => Ok(Some(Destination::Named(name.clone()))),
},
Object::Array(arr) if !arr.is_empty() => match &arr[0] {
Object::Reference(page_ref) => {
if let Ok(page_index) = self.find_page_index(*page_ref) {
Ok(Some(Destination::PageIndex(page_index)))
} else {
Ok(None)
}
}
_ => Ok(None),
},
Object::Reference(dest_ref) => {
let resolved = self.load_object(*dest_ref)?;
self.resolve_destination(&resolved)
}
_ => Ok(None),
}
}
fn find_page_index(&self, page_ref: crate::object::ObjectRef) -> Result<usize> {
let refs = self.all_page_refs()?;
refs.iter()
.position(|r| *r == page_ref)
.ok_or_else(|| Error::InvalidPdf(format!("Page reference {:?} not found", page_ref)))
}
}
const NAME_TREE_MAX_DEPTH: u8 = 32;
fn deref_obj(o: &Object, resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>) -> Option<Object> {
match o {
Object::Reference(r) => resolve(*r),
other => Some(other.clone()),
}
}
fn normalize_dest_value(v: &Object, resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>) -> Option<Object> {
let v = deref_obj(v, resolve)?;
if let Some(d) = v.as_dict()
&& let Some(inner) = d.get("D")
{
return deref_obj(inner, resolve);
}
if v.as_array().is_some() {
return Some(v);
}
None
}
fn walk_name_tree(
node: &Object,
target: &[u8],
resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>,
depth: u8,
) -> Option<Object> {
if depth > NAME_TREE_MAX_DEPTH {
return None;
}
let node = deref_obj(node, resolve)?;
let dict = node.as_dict()?;
if let Some(names) = dict
.get("Names")
.and_then(|n| deref_obj(n, resolve))
.filter(|d| d.as_array().is_some())
{
let arr = names.as_array().expect("checked array above");
let mut i = 0;
while i + 1 < arr.len() {
if arr[i].as_string() == Some(target) {
return normalize_dest_value(&arr[i + 1], resolve);
}
i += 2;
}
if !dict.contains_key("Kids") {
return None;
}
}
if let Some(kids) = dict
.get("Kids")
.and_then(|k| deref_obj(k, resolve))
.filter(|k| k.as_array().is_some())
{
for kid in kids.as_array().expect("checked array above") {
let Some(kid_node) = deref_obj(kid, resolve) else {
continue;
};
let in_range = match kid_node.as_dict().and_then(|d| d.get("Limits")) {
Some(lim) => match deref_obj(lim, resolve).as_ref().and_then(Object::as_array) {
Some(l) if l.len() == 2 => match (l[0].as_string(), l[1].as_string()) {
(Some(lo), Some(hi)) => lo <= target && target <= hi,
_ => true,
},
_ => true,
},
None => true,
};
if in_range && let Some(found) = walk_name_tree(&kid_node, target, resolve, depth + 1) {
return Some(found);
}
}
}
None
}
fn lookup_named_dest(
catalog: &std::collections::HashMap<String, Object>,
target: &[u8],
resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>,
depth: u8,
) -> Option<Object> {
if let Some(dests) = catalog.get("Dests").and_then(|d| deref_obj(d, resolve))
&& let Some(dd) = dests.as_dict()
{
let key = String::from_utf8_lossy(target);
if let Some(v) = dd.get(key.as_ref())
&& let Some(found) = normalize_dest_value(v, resolve)
{
return Some(found);
}
}
let names = catalog.get("Names").and_then(|n| deref_obj(n, resolve))?;
let root = names.as_dict()?.get("Dests").and_then(|d| deref_obj(d, resolve))?;
walk_name_tree(&root, target, resolve, depth)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn s(b: &str) -> Object {
Object::String(b.as_bytes().to_vec())
}
fn arr_dest(page_obj: u32) -> Object {
Object::Array(vec![
Object::Reference(crate::object::ObjectRef::new(page_obj, 0)),
Object::Name("Fit".to_string()),
])
}
fn no_resolve() -> impl Fn(crate::object::ObjectRef) -> Option<Object> {
|_r| None
}
#[test]
fn test_outline_item_creation() {
let item = OutlineItem {
title: "Chapter 1".to_string(),
dest: Some(Destination::PageIndex(0)),
children: vec![],
};
assert_eq!(item.title, "Chapter 1");
assert!(matches!(item.dest, Some(Destination::PageIndex(0))));
assert!(item.children.is_empty());
}
#[test]
fn test_outline_hierarchy() {
let child = OutlineItem {
title: "Section 1.1".to_string(),
dest: Some(Destination::PageIndex(1)),
children: vec![],
};
let parent = OutlineItem {
title: "Chapter 1".to_string(),
dest: Some(Destination::PageIndex(0)),
children: vec![child],
};
assert_eq!(parent.children.len(), 1);
assert_eq!(parent.children[0].title, "Section 1.1");
}
#[test]
fn dests_dict_direct_array() {
let mut dests = HashMap::new();
dests.insert("chap1".to_string(), arr_dest(7));
let mut cat = HashMap::new();
cat.insert("Dests".to_string(), Object::Dictionary(dests));
let r = no_resolve();
let got = lookup_named_dest(&cat, b"chap1", &r, 0).expect("found");
assert_eq!(got.as_array().unwrap()[0].as_reference().unwrap().id, 7);
assert!(lookup_named_dest(&cat, b"missing", &r, 0).is_none());
}
#[test]
fn dests_dict_d_wrapper() {
let mut inner = HashMap::new();
inner.insert("D".to_string(), arr_dest(9));
let mut dests = HashMap::new();
dests.insert("c".to_string(), Object::Dictionary(inner));
let mut cat = HashMap::new();
cat.insert("Dests".to_string(), Object::Dictionary(dests));
let got = lookup_named_dest(&cat, b"c", &no_resolve(), 0).expect("found");
assert_eq!(got.as_array().unwrap()[0].as_reference().unwrap().id, 9);
}
#[test]
fn names_tree_flat_leaf() {
let dests_root = Object::Dictionary(HashMap::from([(
"Names".to_string(),
Object::Array(vec![s("a"), arr_dest(1), s("b"), arr_dest(4), s("c"), arr_dest(6)]),
)]));
let names = Object::Dictionary(HashMap::from([("Dests".to_string(), dests_root)]));
let cat = HashMap::from([("Names".to_string(), names)]);
let r = no_resolve();
assert_eq!(
lookup_named_dest(&cat, b"b", &r, 0).unwrap().as_array().unwrap()[0]
.as_reference()
.unwrap()
.id,
4
);
assert!(lookup_named_dest(&cat, b"z", &r, 0).is_none());
}
#[test]
fn names_tree_kids_with_limits_and_indirection() {
use crate::object::ObjectRef;
let leaf_l = Object::Dictionary(HashMap::from([
("Limits".to_string(), Object::Array(vec![s("a"), s("b")])),
(
"Names".to_string(),
Object::Array(vec![s("a"), arr_dest(1), s("b"), arr_dest(2)]),
),
]));
let leaf_r = Object::Dictionary(HashMap::from([
("Limits".to_string(), Object::Array(vec![s("m"), s("z")])),
(
"Names".to_string(),
Object::Array(vec![s("m"), arr_dest(3), s("z"), arr_dest(4)]),
),
]));
let resolve = move |rf: ObjectRef| match rf.id {
100 => Some(leaf_l.clone()),
101 => Some(leaf_r.clone()),
_ => None,
};
let root = Object::Dictionary(HashMap::from([(
"Kids".to_string(),
Object::Array(vec![
Object::Reference(ObjectRef::new(100, 0)),
Object::Reference(ObjectRef::new(101, 0)),
]),
)]));
let names = Object::Dictionary(HashMap::from([("Dests".to_string(), root)]));
let cat = HashMap::from([("Names".to_string(), names)]);
assert_eq!(
lookup_named_dest(&cat, b"z", &resolve, 0).unwrap().as_array().unwrap()[0]
.as_reference()
.unwrap()
.id,
4
);
assert_eq!(
lookup_named_dest(&cat, b"a", &resolve, 0).unwrap().as_array().unwrap()[0]
.as_reference()
.unwrap()
.id,
1
);
assert!(lookup_named_dest(&cat, b"c", &resolve, 0).is_none());
}
#[test]
fn names_tree_cyclic_kids_is_bounded() {
use crate::object::ObjectRef;
let resolve = |rf: ObjectRef| {
if rf.id == 200 {
Some(Object::Dictionary(HashMap::from([(
"Kids".to_string(),
Object::Array(vec![Object::Reference(ObjectRef::new(200, 0))]),
)])))
} else {
None
}
};
let names = Object::Dictionary(HashMap::from([(
"Dests".to_string(),
Object::Reference(ObjectRef::new(200, 0)),
)]));
let cat = HashMap::from([("Names".to_string(), names)]);
assert!(lookup_named_dest(&cat, b"x", &resolve, 0).is_none());
}
#[test]
fn no_dests_no_names_is_none() {
let cat = HashMap::from([("Type".to_string(), Object::Name("Catalog".to_string()))]);
assert!(lookup_named_dest(&cat, b"anything", &no_resolve(), 0).is_none());
}
}