use std::collections::HashSet;
use crate::destination::{Action, Destination, parse_action, parse_destination};
use crate::diagnostics::{LocationHint, ParsePhase, Severity, WarningSink};
use crate::metadata::pdf_string_to_rust_pub;
use crate::page_tree::PageInfo;
use crate::resolver::Resolver;
const MAX_OUTLINE_NODES: usize = 100_000;
const MAX_OUTLINE_DEPTH: u32 = 64;
#[derive(Debug, Clone)]
pub struct OutlineItem {
pub title: String,
pub destination: Option<Destination>,
pub action: Option<Action>,
pub children: Vec<OutlineItem>,
pub color: Option<[f32; 3]>,
pub style: OutlineStyle,
pub open: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct OutlineStyle {
pub italic: bool,
pub bold: bool,
}
impl OutlineStyle {
fn from_flags(flags: i64) -> Self {
Self {
italic: flags & 1 != 0,
bold: flags & 2 != 0,
}
}
}
pub fn parse_outline_tree(
resolver: &Resolver,
pages: &[PageInfo],
sink: &WarningSink,
) -> Vec<OutlineItem> {
let Some(outlines_dict_ref) = catalog_outlines_ref(resolver) else {
return Vec::new();
};
let Ok(outlines) = resolver.resolve(outlines_dict_ref.0, outlines_dict_ref.1) else {
return Vec::new();
};
let Some(dict) = outlines.as_dict() else {
return Vec::new();
};
let Some(first) = dict.get_ref(b"First") else {
return Vec::new();
};
let mut visited = HashSet::new();
let mut node_count = 0usize;
walk_siblings(
resolver,
pages,
first,
&mut visited,
&mut node_count,
0,
sink,
)
}
fn walk_siblings(
resolver: &Resolver,
pages: &[PageInfo],
start: (u32, u16),
visited: &mut HashSet<u32>,
node_count: &mut usize,
depth: u32,
sink: &WarningSink,
) -> Vec<OutlineItem> {
let mut items = Vec::new();
if depth >= MAX_OUTLINE_DEPTH {
sink.record(
ParsePhase::Outline,
None,
Severity::Error,
format!(
"outline depth limit {MAX_OUTLINE_DEPTH} reached; \
deeper entries truncated"
),
);
return items;
}
let mut current = Some(start);
while let Some((num, gen_num)) = current {
if !visited.insert(num) {
sink.record(
ParsePhase::Outline,
Some(LocationHint::Object {
obj_num: num,
gen_num,
}),
Severity::Warning,
"outline cycle detected; sibling chain truncated",
);
break;
}
if *node_count >= MAX_OUTLINE_NODES {
sink.record(
ParsePhase::Outline,
None,
Severity::Error,
format!(
"outline node limit {MAX_OUTLINE_NODES} reached; \
remaining entries dropped"
),
);
break;
}
*node_count += 1;
let Ok(node) = resolver.resolve(num, gen_num) else {
sink.record(
ParsePhase::Outline,
Some(LocationHint::Object {
obj_num: num,
gen_num,
}),
Severity::Warning,
"outline node could not be resolved; chain stopped",
);
break;
};
let Some(dict) = node.as_dict() else {
sink.record(
ParsePhase::Outline,
Some(LocationHint::Object {
obj_num: num,
gen_num,
}),
Severity::Warning,
"outline node is not a dict; chain stopped",
);
break;
};
let title = dict
.get(b"Title")
.and_then(pdf_string_to_rust_pub)
.unwrap_or_default();
let dest_obj = dict.get(b"Dest");
let action_obj = dict.get(b"A");
let destination = dest_obj.and_then(|o| parse_destination(resolver, pages, o));
let action = action_obj.and_then(|o| parse_action(resolver, pages, o));
let color = dict.get_array(b"C").and_then(|arr| {
if arr.len() == 3 {
Some([
arr[0].as_f64().unwrap_or(0.0) as f32,
arr[1].as_f64().unwrap_or(0.0) as f32,
arr[2].as_f64().unwrap_or(0.0) as f32,
])
} else {
None
}
});
let style = OutlineStyle::from_flags(dict.get_int(b"F").unwrap_or(0));
let count = dict.get_int(b"Count").unwrap_or(0);
let open = count > 0;
let children = if let Some(child_first) = dict.get_ref(b"First") {
walk_siblings(
resolver,
pages,
child_first,
visited,
node_count,
depth + 1,
sink,
)
} else {
Vec::new()
};
items.push(OutlineItem {
title,
destination,
action,
children,
color,
style,
open,
});
current = dict.get_ref(b"Next");
}
items
}
fn catalog_outlines_ref(resolver: &Resolver) -> Option<(u32, u16)> {
let catalog = catalog_dict(resolver)?;
if let Some(r) = catalog.get_ref(b"Outlines") {
return Some(r);
}
None
}
fn catalog_dict(resolver: &Resolver) -> Option<crate::objects::PdfDict> {
if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
&& let Ok(obj) = resolver.resolve(num, gen_num)
&& let Some(dict) = obj.as_dict()
{
return Some(dict.clone());
}
crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
}
pub fn count_items(items: &[OutlineItem]) -> usize {
items.iter().map(|it| 1 + count_items(&it.children)).sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outline_style_flags_round_trip() {
assert_eq!(OutlineStyle::from_flags(0), OutlineStyle::default());
let italic = OutlineStyle::from_flags(1);
assert!(italic.italic && !italic.bold);
let bold = OutlineStyle::from_flags(2);
assert!(!bold.italic && bold.bold);
let both = OutlineStyle::from_flags(3);
assert!(both.italic && both.bold);
let extra = OutlineStyle::from_flags(0xFF);
assert!(extra.italic && extra.bold);
}
#[test]
fn count_items_recurses() {
let leaf = OutlineItem {
title: "leaf".to_string(),
destination: None,
action: None,
children: vec![],
color: None,
style: OutlineStyle::default(),
open: false,
};
let parent = OutlineItem {
title: "parent".to_string(),
destination: None,
action: None,
children: vec![leaf.clone(), leaf.clone()],
color: None,
style: OutlineStyle::default(),
open: true,
};
assert_eq!(count_items(std::slice::from_ref(&parent)), 3);
assert_eq!(count_items(&[parent.clone(), parent]), 6);
}
#[test]
fn depth_cap_short_circuits() {
assert_eq!(MAX_OUTLINE_DEPTH, 64);
}
}