1use std::collections::{HashMap, HashSet};
28
29use crate::error::PdfError;
30use crate::objects::{Dict, Object, ObjectId};
31use crate::outline::OutlineDestination;
32use crate::reader::document::DocumentReader;
33
34#[derive(Debug, Clone)]
36pub struct OutlineNode {
37 pub title: String,
40 pub destination: Option<OutlineDestination>,
46 pub raw_dest: Option<String>,
51 pub count: Option<i64>,
56 pub children: Vec<OutlineNode>,
59}
60
61impl OutlineNode {
62 pub fn descendant_count(&self) -> usize {
65 1 + self
66 .children
67 .iter()
68 .map(Self::descendant_count)
69 .sum::<usize>()
70 }
71
72 pub fn is_open(&self) -> bool {
74 match self.count {
76 Some(n) => n > 0,
77 None => self.children.is_empty(),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Default)]
88pub struct PdfOutline {
89 pub roots: Vec<OutlineNode>,
90 pub count: Option<i64>,
91}
92
93pub fn outline(reader: &mut DocumentReader<'_>) -> Result<Option<PdfOutline>, PdfError> {
98 let page_index_map = build_page_index_map(reader)?;
101
102 let root_id = reader.xref().root()?;
103 let catalog = reader.resolve(root_id)?;
104 let Object::Dict(catalog_dict) = catalog else {
105 return Err(PdfError::other(format!(
106 "PDF outline reader: /Root must be a dict (got {catalog:?})"
107 )));
108 };
109 let outlines_obj = catalog_dict
110 .entries()
111 .iter()
112 .find(|(k, _)| k == "Outlines")
113 .map(|(_, v)| v.clone());
114 let Some(outlines_obj) = outlines_obj else {
115 return Ok(None);
116 };
117 let outline_root_id = match outlines_obj {
118 Object::Reference(id) => id,
119 _ => {
120 return Err(PdfError::other(
121 "PDF outline reader: /Outlines must be an indirect reference",
122 ));
123 }
124 };
125
126 let outline_root_dict = match reader.resolve(outline_root_id)? {
127 Object::Dict(d) => d,
128 other => {
129 return Err(PdfError::other(format!(
130 "PDF outline reader: outline root resolves to non-dict ({other:?})"
131 )));
132 }
133 };
134
135 let count = outline_root_dict
136 .entries()
137 .iter()
138 .find(|(k, _)| k == "Count")
139 .and_then(|(_, v)| match v {
140 Object::Integer(n) => Some(*n),
141 _ => None,
142 });
143
144 let first_id = outline_root_dict
145 .entries()
146 .iter()
147 .find(|(k, _)| k == "First")
148 .and_then(|(_, v)| match v {
149 Object::Reference(id) => Some(*id),
150 _ => None,
151 });
152
153 let roots = match first_id {
154 Some(first) => walk_level(reader, first, &page_index_map)?,
155 None => Vec::new(),
156 };
157
158 Ok(Some(PdfOutline { roots, count }))
159}
160
161fn walk_level(
165 reader: &mut DocumentReader<'_>,
166 first_id: ObjectId,
167 page_index_map: &HashMap<u32, usize>,
168) -> Result<Vec<OutlineNode>, PdfError> {
169 let mut out = Vec::new();
170 let mut visited: HashSet<u32> = HashSet::new();
171 let mut cur = Some(first_id);
172 while let Some(id) = cur {
173 if !visited.insert(id.number) {
176 break;
177 }
178 if out.len() > 10_000 {
181 break;
182 }
183
184 let item = match reader.resolve(id)? {
185 Object::Dict(d) => d,
186 other => {
187 return Err(PdfError::other(format!(
188 "PDF outline reader: outline item {id:?} is not a dict (got {other:?})"
189 )));
190 }
191 };
192
193 let title = decode_text(
194 item.entries()
195 .iter()
196 .find(|(k, _)| k == "Title")
197 .map(|(_, v)| v),
198 );
199 let (destination, raw_dest) = decode_dest_or_action(reader, &item, page_index_map)?;
200 let count = item
201 .entries()
202 .iter()
203 .find(|(k, _)| k == "Count")
204 .and_then(|(_, v)| match v {
205 Object::Integer(n) => Some(*n),
206 _ => None,
207 });
208
209 let children = match item
210 .entries()
211 .iter()
212 .find(|(k, _)| k == "First")
213 .and_then(|(_, v)| match v {
214 Object::Reference(id) => Some(*id),
215 _ => None,
216 }) {
217 Some(child_first) => walk_level(reader, child_first, page_index_map)?,
218 None => Vec::new(),
219 };
220
221 out.push(OutlineNode {
222 title: title.unwrap_or_default(),
223 destination,
224 raw_dest,
225 count,
226 children,
227 });
228
229 cur = item
230 .entries()
231 .iter()
232 .find(|(k, _)| k == "Next")
233 .and_then(|(_, v)| match v {
234 Object::Reference(id) => Some(*id),
235 _ => None,
236 });
237 }
238 Ok(out)
239}
240
241fn decode_dest_or_action(
246 reader: &mut DocumentReader<'_>,
247 item: &Dict,
248 page_index_map: &HashMap<u32, usize>,
249) -> Result<(Option<OutlineDestination>, Option<String>), PdfError> {
250 if let Some(dest) = item
252 .entries()
253 .iter()
254 .find(|(k, _)| k == "Dest")
255 .map(|(_, v)| v.clone())
256 {
257 return Ok(decode_dest_value(reader, dest, page_index_map));
258 }
259 if let Some(action) = item
260 .entries()
261 .iter()
262 .find(|(k, _)| k == "A")
263 .map(|(_, v)| v.clone())
264 {
265 let action = reader.deref(action)?;
266 if let Object::Dict(adict) = action {
267 let is_goto = matches!(
269 adict.entries().iter().find(|(k, _)| k == "S").map(|(_, v)| v),
270 Some(Object::Name(s)) if s == "GoTo"
271 );
272 if is_goto {
273 if let Some(d) = adict
274 .entries()
275 .iter()
276 .find(|(k, _)| k == "D")
277 .map(|(_, v)| v.clone())
278 {
279 return Ok(decode_dest_value(reader, d, page_index_map));
280 }
281 }
282 let is_uri = matches!(
284 adict.entries().iter().find(|(k, _)| k == "S").map(|(_, v)| v),
285 Some(Object::Name(s)) if s == "URI"
286 );
287 if is_uri {
288 let uri =
289 adict
290 .entries()
291 .iter()
292 .find(|(k, _)| k == "URI")
293 .and_then(|(_, v)| match v {
294 Object::LiteralString(b) | Object::HexString(b) => {
295 Some(String::from_utf8_lossy(b).into_owned())
296 }
297 _ => None,
298 });
299 return Ok((None, uri.map(|s| format!("uri:{s}"))));
300 }
301 }
302 }
303 Ok((None, None))
304}
305
306fn decode_dest_value(
311 reader: &mut DocumentReader<'_>,
312 dest: Object,
313 page_index_map: &HashMap<u32, usize>,
314) -> (Option<OutlineDestination>, Option<String>) {
315 let dest = match reader.deref(dest) {
316 Ok(d) => d,
317 Err(_) => return (None, Some("<unresolvable>".into())),
318 };
319 match dest {
320 Object::Array(items) => match decode_explicit_dest(&items, page_index_map) {
321 Some(d) => (Some(d), None),
322 None => (None, Some(format_array_dest(&items))),
323 },
324 Object::Name(s) => (None, Some(format!("named:{s}"))),
325 Object::LiteralString(b) | Object::HexString(b) => {
326 (None, Some(format!("named:{}", String::from_utf8_lossy(&b))))
327 }
328 _ => (None, None),
329 }
330}
331
332fn decode_explicit_dest(
334 items: &[Object],
335 page_index_map: &HashMap<u32, usize>,
336) -> Option<OutlineDestination> {
337 if items.len() < 2 {
338 return None;
339 }
340 let page_index = match &items[0] {
341 Object::Reference(id) => *page_index_map.get(&id.number)?,
342 _ => return None,
345 };
346 let mode = match &items[1] {
347 Object::Name(n) => n.as_str(),
348 _ => return None,
349 };
350
351 let opt = |o: Option<&Object>| match o {
353 Some(Object::Real(f)) => Some(*f as f32),
354 Some(Object::Integer(n)) => Some(*n as f32),
355 Some(Object::Null) | None => None,
356 _ => None,
357 };
358 let req = |o: Option<&Object>| -> Option<f32> {
359 match o {
360 Some(Object::Real(f)) => Some(*f as f32),
361 Some(Object::Integer(n)) => Some(*n as f32),
362 _ => None,
363 }
364 };
365
366 match mode {
367 "XYZ" => Some(OutlineDestination::Xyz {
368 page_index,
369 left: opt(items.get(2)),
370 top: opt(items.get(3)),
371 zoom: opt(items.get(4)).filter(|z| *z != 0.0),
372 }),
373 "Fit" => Some(OutlineDestination::Fit { page_index }),
374 "FitH" => Some(OutlineDestination::FitH {
375 page_index,
376 top: opt(items.get(2)),
377 }),
378 "FitV" => Some(OutlineDestination::FitV {
379 page_index,
380 left: opt(items.get(2)),
381 }),
382 "FitR" => Some(OutlineDestination::FitR {
383 page_index,
384 left: req(items.get(2))?,
385 bottom: req(items.get(3))?,
386 right: req(items.get(4))?,
387 top: req(items.get(5))?,
388 }),
389 "FitB" => Some(OutlineDestination::FitB { page_index }),
390 "FitBH" => Some(OutlineDestination::FitBH {
391 page_index,
392 top: opt(items.get(2)),
393 }),
394 "FitBV" => Some(OutlineDestination::FitBV {
395 page_index,
396 left: opt(items.get(2)),
397 }),
398 _ => None,
399 }
400}
401
402fn format_array_dest(items: &[Object]) -> String {
403 let mut out = String::from("[");
404 for (i, it) in items.iter().enumerate() {
405 if i > 0 {
406 out.push(' ');
407 }
408 match it {
409 Object::Reference(id) => out.push_str(&format!("{} 0 R", id.number)),
410 Object::Name(n) => {
411 out.push('/');
412 out.push_str(n);
413 }
414 Object::Integer(n) => out.push_str(&n.to_string()),
415 Object::Real(f) => out.push_str(&format!("{f}")),
416 Object::Null => out.push_str("null"),
417 _ => out.push('?'),
418 }
419 }
420 out.push(']');
421 out
422}
423
424pub(crate) fn build_page_index_map(
429 reader: &mut DocumentReader<'_>,
430) -> Result<HashMap<u32, usize>, PdfError> {
431 let root_id = reader.xref().root()?;
432 let catalog = reader.resolve(root_id)?;
433 let Object::Dict(catalog_dict) = catalog else {
434 return Ok(HashMap::new());
435 };
436 let pages_ref = catalog_dict
437 .entries()
438 .iter()
439 .find(|(k, _)| k == "Pages")
440 .and_then(|(_, v)| match v {
441 Object::Reference(id) => Some(*id),
442 _ => None,
443 });
444 let Some(pages_root) = pages_ref else {
445 return Ok(HashMap::new());
446 };
447 let mut leaves: Vec<u32> = Vec::new();
448 walk_pages(reader, pages_root, &mut leaves)?;
449 Ok(leaves
450 .into_iter()
451 .enumerate()
452 .map(|(i, n)| (n, i))
453 .collect())
454}
455
456pub(crate) fn walk_pages(
457 reader: &mut DocumentReader<'_>,
458 node_id: ObjectId,
459 out: &mut Vec<u32>,
460) -> Result<(), PdfError> {
461 let node = reader.resolve(node_id)?;
462 let Object::Dict(d) = node else {
463 return Ok(());
464 };
465 let kind = d
466 .entries()
467 .iter()
468 .find(|(k, _)| k == "Type")
469 .and_then(|(_, v)| match v {
470 Object::Name(s) => Some(s.as_str()),
471 _ => None,
472 });
473 match kind {
474 Some("Page") => {
475 out.push(node_id.number);
476 }
477 Some("Pages") | None => {
478 if let Some(Object::Array(kids)) = d
479 .entries()
480 .iter()
481 .find(|(k, _)| k == "Kids")
482 .map(|(_, v)| v)
483 {
484 for it in kids.clone() {
485 if let Object::Reference(id) = it {
486 if out.len() > 100_000 {
488 return Ok(());
489 }
490 walk_pages(reader, id, out)?;
491 }
492 }
493 }
494 }
495 _ => {}
496 }
497 Ok(())
498}
499
500fn decode_text(o: Option<&Object>) -> Option<String> {
501 match o? {
502 Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
503 Object::HexString(b) => {
504 if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
505 let utf16: Vec<u16> = b[2..]
506 .chunks_exact(2)
507 .map(|c| u16::from_be_bytes([c[0], c[1]]))
508 .collect();
509 Some(String::from_utf16_lossy(&utf16))
510 } else {
511 Some(String::from_utf8_lossy(b).into_owned())
512 }
513 }
514 _ => None,
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn descendant_count_sums_recursively() {
524 let leaf = OutlineNode {
525 title: "leaf".into(),
526 destination: None,
527 raw_dest: None,
528 count: None,
529 children: Vec::new(),
530 };
531 let parent = OutlineNode {
532 title: "parent".into(),
533 destination: None,
534 raw_dest: None,
535 count: Some(2),
536 children: vec![leaf.clone(), leaf.clone()],
537 };
538 assert_eq!(parent.descendant_count(), 3);
539 }
540
541 #[test]
542 fn is_open_recognises_count_sign() {
543 let mut n = OutlineNode {
544 title: "x".into(),
545 destination: None,
546 raw_dest: None,
547 count: Some(2),
548 children: Vec::new(),
549 };
550 assert!(n.is_open());
551 n.count = Some(-2);
552 assert!(!n.is_open());
553 n.count = None;
554 assert!(n.is_open()); }
556
557 #[test]
558 fn explicit_dest_fit_decodes() {
559 let map: HashMap<u32, usize> = [(7u32, 3usize)].into_iter().collect();
560 let arr = vec![
561 Object::Reference(ObjectId::new(7)),
562 Object::Name("Fit".into()),
563 ];
564 let d = decode_explicit_dest(&arr, &map).unwrap();
565 assert_eq!(d, OutlineDestination::Fit { page_index: 3 });
566 }
567
568 #[test]
569 fn explicit_dest_xyz_decodes_with_nulls() {
570 let map: HashMap<u32, usize> = [(11u32, 5usize)].into_iter().collect();
571 let arr = vec![
572 Object::Reference(ObjectId::new(11)),
573 Object::Name("XYZ".into()),
574 Object::Null,
575 Object::Integer(800),
576 Object::Integer(0), ];
578 let d = decode_explicit_dest(&arr, &map).unwrap();
579 assert_eq!(
580 d,
581 OutlineDestination::Xyz {
582 page_index: 5,
583 left: None,
584 top: Some(800.0),
585 zoom: None,
586 }
587 );
588 }
589
590 #[test]
591 fn explicit_dest_fitr_requires_four_numbers() {
592 let map: HashMap<u32, usize> = [(2u32, 0usize)].into_iter().collect();
593 let arr = vec![
594 Object::Reference(ObjectId::new(2)),
595 Object::Name("FitR".into()),
596 Object::Real(10.0),
597 Object::Real(20.0),
598 Object::Real(30.0),
599 Object::Real(40.0),
600 ];
601 let d = decode_explicit_dest(&arr, &map).unwrap();
602 assert_eq!(
603 d,
604 OutlineDestination::FitR {
605 page_index: 0,
606 left: 10.0,
607 bottom: 20.0,
608 right: 30.0,
609 top: 40.0,
610 }
611 );
612 }
613
614 #[test]
615 fn explicit_dest_unknown_mode_returns_none() {
616 let map: HashMap<u32, usize> = [(3u32, 1usize)].into_iter().collect();
617 let arr = vec![
618 Object::Reference(ObjectId::new(3)),
619 Object::Name("Unknown".into()),
620 ];
621 assert!(decode_explicit_dest(&arr, &map).is_none());
622 }
623}