1use std::collections::HashSet;
18
19use crate::destination::{Action, Destination, parse_action, parse_destination};
20use crate::diagnostics::{LocationHint, ParsePhase, Severity, WarningSink};
21use crate::metadata::pdf_string_to_rust_pub;
22use crate::page_tree::PageInfo;
23use crate::resolver::Resolver;
24
25const MAX_OUTLINE_NODES: usize = 100_000;
30
31const MAX_OUTLINE_DEPTH: u32 = 64;
36
37#[derive(Debug, Clone)]
39pub struct OutlineItem {
40 pub title: String,
42 pub destination: Option<Destination>,
44 pub action: Option<Action>,
49 pub children: Vec<OutlineItem>,
51 pub color: Option<[f32; 3]>,
53 pub style: OutlineStyle,
55 pub open: bool,
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub struct OutlineStyle {
64 pub italic: bool,
65 pub bold: bool,
66}
67
68impl OutlineStyle {
69 fn from_flags(flags: i64) -> Self {
70 Self {
71 italic: flags & 1 != 0,
72 bold: flags & 2 != 0,
73 }
74 }
75}
76
77pub fn parse_outline_tree(
87 resolver: &Resolver,
88 pages: &[PageInfo],
89 sink: &WarningSink,
90) -> Vec<OutlineItem> {
91 let Some(outlines_dict_ref) = catalog_outlines_ref(resolver) else {
92 return Vec::new();
93 };
94 let Ok(outlines) = resolver.resolve(outlines_dict_ref.0, outlines_dict_ref.1) else {
95 return Vec::new();
96 };
97 let Some(dict) = outlines.as_dict() else {
98 return Vec::new();
99 };
100 let Some(first) = dict.get_ref(b"First") else {
101 return Vec::new();
102 };
103
104 let mut visited = HashSet::new();
105 let mut node_count = 0usize;
106 walk_siblings(
107 resolver,
108 pages,
109 first,
110 &mut visited,
111 &mut node_count,
112 0,
113 sink,
114 )
115}
116
117fn walk_siblings(
118 resolver: &Resolver,
119 pages: &[PageInfo],
120 start: (u32, u16),
121 visited: &mut HashSet<u32>,
122 node_count: &mut usize,
123 depth: u32,
124 sink: &WarningSink,
125) -> Vec<OutlineItem> {
126 let mut items = Vec::new();
127 if depth >= MAX_OUTLINE_DEPTH {
128 sink.record(
129 ParsePhase::Outline,
130 None,
131 Severity::Error,
132 format!(
133 "outline depth limit {MAX_OUTLINE_DEPTH} reached; \
134 deeper entries truncated"
135 ),
136 );
137 return items;
138 }
139 let mut current = Some(start);
140 while let Some((num, gen_num)) = current {
141 if !visited.insert(num) {
142 sink.record(
144 ParsePhase::Outline,
145 Some(LocationHint::Object {
146 obj_num: num,
147 gen_num,
148 }),
149 Severity::Warning,
150 "outline cycle detected; sibling chain truncated",
151 );
152 break;
153 }
154 if *node_count >= MAX_OUTLINE_NODES {
155 sink.record(
156 ParsePhase::Outline,
157 None,
158 Severity::Error,
159 format!(
160 "outline node limit {MAX_OUTLINE_NODES} reached; \
161 remaining entries dropped"
162 ),
163 );
164 break;
165 }
166 *node_count += 1;
167
168 let Ok(node) = resolver.resolve(num, gen_num) else {
169 sink.record(
170 ParsePhase::Outline,
171 Some(LocationHint::Object {
172 obj_num: num,
173 gen_num,
174 }),
175 Severity::Warning,
176 "outline node could not be resolved; chain stopped",
177 );
178 break;
179 };
180 let Some(dict) = node.as_dict() else {
181 sink.record(
182 ParsePhase::Outline,
183 Some(LocationHint::Object {
184 obj_num: num,
185 gen_num,
186 }),
187 Severity::Warning,
188 "outline node is not a dict; chain stopped",
189 );
190 break;
191 };
192
193 let title = dict
194 .get(b"Title")
195 .and_then(pdf_string_to_rust_pub)
196 .unwrap_or_default();
197
198 let dest_obj = dict.get(b"Dest");
199 let action_obj = dict.get(b"A");
200 let destination = dest_obj.and_then(|o| parse_destination(resolver, pages, o));
201 let action = action_obj.and_then(|o| parse_action(resolver, pages, o));
202
203 let color = dict.get_array(b"C").and_then(|arr| {
204 if arr.len() == 3 {
205 Some([
206 arr[0].as_f64().unwrap_or(0.0) as f32,
207 arr[1].as_f64().unwrap_or(0.0) as f32,
208 arr[2].as_f64().unwrap_or(0.0) as f32,
209 ])
210 } else {
211 None
212 }
213 });
214 let style = OutlineStyle::from_flags(dict.get_int(b"F").unwrap_or(0));
215 let count = dict.get_int(b"Count").unwrap_or(0);
216 let open = count > 0;
217
218 let children = if let Some(child_first) = dict.get_ref(b"First") {
219 walk_siblings(
220 resolver,
221 pages,
222 child_first,
223 visited,
224 node_count,
225 depth + 1,
226 sink,
227 )
228 } else {
229 Vec::new()
230 };
231
232 items.push(OutlineItem {
233 title,
234 destination,
235 action,
236 children,
237 color,
238 style,
239 open,
240 });
241
242 current = dict.get_ref(b"Next");
243 }
244 items
245}
246
247fn catalog_outlines_ref(resolver: &Resolver) -> Option<(u32, u16)> {
248 let catalog = catalog_dict(resolver)?;
249 if let Some(r) = catalog.get_ref(b"Outlines") {
250 return Some(r);
251 }
252 None
257}
258
259fn catalog_dict(resolver: &Resolver) -> Option<crate::objects::PdfDict> {
260 if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
261 && let Ok(obj) = resolver.resolve(num, gen_num)
262 && let Some(dict) = obj.as_dict()
263 {
264 return Some(dict.clone());
265 }
266 crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
267}
268
269pub fn count_items(items: &[OutlineItem]) -> usize {
274 items.iter().map(|it| 1 + count_items(&it.children)).sum()
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn outline_style_flags_round_trip() {
283 assert_eq!(OutlineStyle::from_flags(0), OutlineStyle::default());
284 let italic = OutlineStyle::from_flags(1);
285 assert!(italic.italic && !italic.bold);
286 let bold = OutlineStyle::from_flags(2);
287 assert!(!bold.italic && bold.bold);
288 let both = OutlineStyle::from_flags(3);
289 assert!(both.italic && both.bold);
290 let extra = OutlineStyle::from_flags(0xFF);
292 assert!(extra.italic && extra.bold);
293 }
294
295 #[test]
296 fn count_items_recurses() {
297 let leaf = OutlineItem {
298 title: "leaf".to_string(),
299 destination: None,
300 action: None,
301 children: vec![],
302 color: None,
303 style: OutlineStyle::default(),
304 open: false,
305 };
306 let parent = OutlineItem {
307 title: "parent".to_string(),
308 destination: None,
309 action: None,
310 children: vec![leaf.clone(), leaf.clone()],
311 color: None,
312 style: OutlineStyle::default(),
313 open: true,
314 };
315 assert_eq!(count_items(std::slice::from_ref(&parent)), 3);
316 assert_eq!(count_items(&[parent.clone(), parent]), 6);
317 }
318
319 #[test]
323 fn depth_cap_short_circuits() {
324 assert_eq!(MAX_OUTLINE_DEPTH, 64);
330 }
331}