1use super::objects::{PdfArray, PdfDictionary, PdfObject};
4use super::{ParseError, ParseResult, PdfReader};
5use crate::geometry::{Point, Rectangle};
6use crate::graphics::Color;
7use crate::structure::{
8 Destination, DestinationType, OutlineFlags, OutlineItem, OutlineTree, PageDestination,
9};
10use std::collections::{HashMap, HashSet};
11use std::io::{Read, Seek};
12
13type ObjectRef = (u32, u16);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct OutlineReadOptions {
18 pub max_items: usize,
20 pub max_depth: usize,
22 pub max_named_destinations: usize,
24 pub max_name_tree_nodes: usize,
26}
27
28impl Default for OutlineReadOptions {
29 fn default() -> Self {
30 Self {
31 max_items: 100_000,
32 max_depth: 256,
33 max_named_destinations: 100_000,
34 max_name_tree_nodes: 100_000,
35 }
36 }
37}
38
39pub(crate) fn read_outline<R: Read + Seek>(
40 reader: &mut PdfReader<R>,
41 pages: &HashMap<ObjectRef, u32>,
42 options: &OutlineReadOptions,
43) -> ParseResult<Option<OutlineTree>> {
44 let catalog = reader.catalog()?.clone();
45 let Some(outlines_value) = catalog.get("Outlines").cloned() else {
46 return Ok(None);
47 };
48 let (root, root_ref) = resolve_dictionary(reader, &outlines_value, "/Catalog/Outlines")?;
49 let root_ref = root_ref.ok_or_else(|| malformed("/Outlines must be an indirect object"))?;
50 let Some(first) = root.get("First") else {
51 if root.contains_key("Last") {
52 return Err(malformed("/Outlines has Last without First"));
53 }
54 return Ok(Some(OutlineTree::new()));
55 };
56 let first = require_reference(first, "/Outlines/First")?;
57 let named = read_named_destinations(reader, &catalog, options)?;
58 let last = root
59 .get("Last")
60 .ok_or_else(|| malformed("/Outlines has First without Last"))
61 .and_then(|value| require_reference(value, "/Outlines/Last"))?;
62 let mut parser = OutlineParser {
63 reader,
64 pages,
65 named,
66 options,
67 visited: HashSet::new(),
68 active_destinations: HashSet::new(),
69 active_names: HashSet::new(),
70 item_count: 0,
71 };
72 let items = parser.read_siblings(first, Some(root_ref), Some(last), 0, "/Outlines")?;
73 Ok(Some(OutlineTree { items }))
74}
75
76struct OutlineParser<'a, R: Read + Seek> {
77 reader: &'a mut PdfReader<R>,
78 pages: &'a HashMap<ObjectRef, u32>,
79 named: HashMap<Vec<u8>, PdfObject>,
80 options: &'a OutlineReadOptions,
81 visited: HashSet<ObjectRef>,
82 active_destinations: HashSet<ObjectRef>,
83 active_names: HashSet<Vec<u8>>,
84 item_count: usize,
85}
86
87impl<R: Read + Seek> OutlineParser<'_, R> {
88 fn read_siblings(
89 &mut self,
90 first: ObjectRef,
91 parent: Option<ObjectRef>,
92 expected_last: Option<ObjectRef>,
93 depth: usize,
94 path: &str,
95 ) -> ParseResult<Vec<OutlineItem>> {
96 if depth > self.options.max_depth {
97 return Err(malformed("outline nesting exceeds configured limit"));
98 }
99 let mut result = Vec::new();
100 let mut current = Some(first);
101 let mut previous = None;
102 let mut final_ref = None;
103 while let Some(reference) = current {
104 self.item_count = self
105 .item_count
106 .checked_add(1)
107 .ok_or_else(|| malformed("outline item count overflow"))?;
108 if self.item_count > self.options.max_items {
109 return Err(malformed("outline item count exceeds configured limit"));
110 }
111 if !self.visited.insert(reference) {
112 return Err(malformed(
113 "outline hierarchy contains a cycle or duplicate item",
114 ));
115 }
116 let value = PdfObject::Reference(reference.0, reference.1);
117 let (dictionary, _) = resolve_dictionary(self.reader, &value, path)?;
118 if dictionary.get("Parent").and_then(PdfObject::as_reference) != parent {
119 return Err(malformed(
120 "outline item Parent does not match its containing list",
121 ));
122 }
123 if dictionary.get("Prev").and_then(PdfObject::as_reference) != previous {
124 return Err(malformed("outline item Prev link is inconsistent"));
125 }
126 let title = self
127 .resolve_optional(dictionary.get("Title"))?
128 .as_ref()
129 .and_then(PdfObject::as_string)
130 .map(|title| title.to_text())
131 .ok_or_else(|| malformed("outline item has no string Title"))?;
132 let destination = self.read_item_destination(&dictionary, path)?;
133 let flags = self
134 .resolve_optional(dictionary.get("F"))?
135 .as_ref()
136 .and_then(PdfObject::as_integer)
137 .unwrap_or(0);
138 if flags < 0 || flags & !3 != 0 {
139 return Err(malformed("outline item F contains unsupported flag bits"));
140 }
141 let color = read_color(self.resolve_optional(dictionary.get("C"))?.as_ref())?;
142 let first_child = dictionary
143 .get("First")
144 .map(|value| require_reference(value, "outline First"))
145 .transpose()?;
146 let last_child = dictionary
147 .get("Last")
148 .map(|value| require_reference(value, "outline Last"))
149 .transpose()?;
150 if first_child.is_some() != last_child.is_some() {
151 return Err(malformed(
152 "outline item must contain both First and Last child links",
153 ));
154 }
155 let children = match first_child {
156 Some(first_child) => {
157 self.read_siblings(first_child, Some(reference), last_child, depth + 1, path)?
158 }
159 None => Vec::new(),
160 };
161 let open = self
162 .resolve_optional(dictionary.get("Count"))?
163 .as_ref()
164 .and_then(PdfObject::as_integer)
165 .map_or(true, |count| count >= 0);
166 result.push(OutlineItem {
167 title,
168 destination,
169 children,
170 color,
171 flags: OutlineFlags {
172 italic: flags & 1 != 0,
173 bold: flags & 2 != 0,
174 },
175 open,
176 });
177 previous = Some(reference);
178 final_ref = Some(reference);
179 current = dictionary
180 .get("Next")
181 .map(|value| require_reference(value, "outline Next"))
182 .transpose()?;
183 }
184 if expected_last.is_some() && final_ref != expected_last {
185 return Err(malformed(
186 "outline Last link does not identify the final sibling",
187 ));
188 }
189 Ok(result)
190 }
191
192 fn read_item_destination(
193 &mut self,
194 dictionary: &PdfDictionary,
195 path: &str,
196 ) -> ParseResult<Option<Destination>> {
197 if dictionary.contains_key("Dest") && dictionary.contains_key("A") {
198 return Err(malformed("outline item contains both Dest and A"));
199 }
200 if let Some(value) = dictionary.get("Dest") {
201 return self.resolve_destination(value, path).map(Some);
202 }
203 let Some(action_value) = dictionary.get("A") else {
204 return Ok(None);
205 };
206 let (action, _) = resolve_dictionary(self.reader, action_value, "outline action")?;
207 if action
208 .get("S")
209 .and_then(PdfObject::as_name)
210 .map(|name| name.as_str())
211 != Some("GoTo")
212 {
213 return Ok(None);
214 }
215 let value = action
216 .get("D")
217 .ok_or_else(|| malformed("GoTo action has no D destination"))?;
218 self.resolve_destination(value, path).map(Some)
219 }
220
221 fn resolve_optional(&mut self, value: Option<&PdfObject>) -> ParseResult<Option<PdfObject>> {
222 value
223 .map(|value| resolve_object(self.reader, value))
224 .transpose()
225 }
226
227 fn resolve_destination(&mut self, value: &PdfObject, path: &str) -> ParseResult<Destination> {
228 self.resolve_destination_at(value, path, 0)
229 }
230
231 fn resolve_destination_at(
232 &mut self,
233 value: &PdfObject,
234 path: &str,
235 depth: usize,
236 ) -> ParseResult<Destination> {
237 if depth > self.options.max_depth {
238 return Err(malformed("destination resolution exceeds configured depth"));
239 }
240 let reference = value.as_reference();
241 if reference.is_some_and(|reference| !self.active_destinations.insert(reference)) {
242 return Err(malformed("destination objects contain a cycle"));
243 }
244 let value = resolve_object(self.reader, value)?;
245 let result = match value {
246 PdfObject::Array(array) => parse_destination_array(&array, self.pages),
247 PdfObject::Name(name) => self.resolve_named(name.as_str().as_bytes(), path),
248 PdfObject::String(name) => self.resolve_named(name.as_bytes(), path),
249 PdfObject::Dictionary(dictionary) => {
250 let value = dictionary
251 .get("D")
252 .ok_or_else(|| malformed("destination dictionary has no D entry"))?;
253 self.resolve_destination_at(value, path, depth + 1)
254 }
255 _ => Err(malformed(format!("malformed destination at {path}"))),
256 };
257 if let Some(reference) = reference {
258 self.active_destinations.remove(&reference);
259 }
260 result
261 }
262
263 fn resolve_named(&mut self, name: &[u8], path: &str) -> ParseResult<Destination> {
264 if !self.active_names.insert(name.to_vec()) {
265 return Err(malformed("named destinations contain a cycle"));
266 }
267 let value = self
268 .named
269 .get(name)
270 .cloned()
271 .ok_or_else(|| malformed(format!("unknown named destination at {path}")))?;
272 let result = self.resolve_destination_at(&value, path, self.active_names.len());
273 self.active_names.remove(name);
274 result
275 }
276}
277
278fn parse_destination_array(
279 array: &PdfArray,
280 pages: &HashMap<ObjectRef, u32>,
281) -> ParseResult<Destination> {
282 if array.0.len() < 2 {
283 return Err(malformed("destination array has fewer than two entries"));
284 }
285 let page = match &array.0[0] {
286 PdfObject::Reference(number, generation) => pages
287 .get(&(*number, *generation))
288 .copied()
289 .ok_or_else(|| malformed("destination page reference is not in the page tree"))?,
290 PdfObject::Integer(index) if *index >= 0 => {
291 let index = u32::try_from(*index)
292 .map_err(|_| malformed("destination page index is out of range"))?;
293 if index as usize >= pages.len() {
294 return Err(malformed("destination page index is outside the page tree"));
295 }
296 index
297 }
298 _ => {
299 return Err(malformed(
300 "destination target is not a page reference or non-negative index",
301 ))
302 }
303 };
304 let kind = array.0[1]
305 .as_name()
306 .ok_or_else(|| malformed("destination view type is not a name"))?
307 .as_str();
308 let param = |index: usize| -> ParseResult<Option<f64>> {
309 match array.0.get(index) {
310 Some(PdfObject::Null) => Ok(None),
311 Some(value) => value
312 .as_real()
313 .filter(|value| value.is_finite())
314 .map(Some)
315 .ok_or_else(|| malformed("destination parameter is not a finite number or null")),
316 None => Err(malformed("destination is missing a required parameter")),
317 }
318 };
319 let required =
320 |index: usize| param(index)?.ok_or_else(|| malformed("FitR parameters cannot be null"));
321 let dest_type = match kind {
322 "XYZ" => {
323 let zoom = param(4)?;
324 if zoom.is_some_and(|zoom| zoom < 0.0) {
325 return Err(malformed("XYZ zoom cannot be negative"));
326 }
327 DestinationType::XYZ {
328 left: param(2)?,
329 top: param(3)?,
330 zoom,
331 }
332 }
333 "Fit" => DestinationType::Fit,
334 "FitH" => DestinationType::FitH { top: param(2)? },
335 "FitV" => DestinationType::FitV { left: param(2)? },
336 "FitR" => DestinationType::FitR {
337 rect: Rectangle::new(
338 Point::new(required(2)?, required(3)?),
339 Point::new(required(4)?, required(5)?),
340 ),
341 },
342 "FitB" => DestinationType::FitB,
343 "FitBH" => DestinationType::FitBH { top: param(2)? },
344 "FitBV" => DestinationType::FitBV { left: param(2)? },
345 _ => return Err(malformed(format!("unknown destination view type {kind}"))),
346 };
347 Ok(Destination {
348 page: PageDestination::PageNumber(page),
349 dest_type,
350 })
351}
352
353fn read_named_destinations<R: Read + Seek>(
354 reader: &mut PdfReader<R>,
355 catalog: &PdfDictionary,
356 options: &OutlineReadOptions,
357) -> ParseResult<HashMap<Vec<u8>, PdfObject>> {
358 let mut result = HashMap::new();
359 if let Some(legacy) = catalog.get("Dests") {
360 let (dictionary, _) = resolve_dictionary(reader, legacy, "/Catalog/Dests")?;
361 for (name, value) in dictionary.0 {
362 insert_named(&mut result, name.0.into_bytes(), value, options)?;
363 }
364 }
365 if let Some(names) = catalog.get("Names") {
366 let (names, _) = resolve_dictionary(reader, names, "/Catalog/Names")?;
367 if let Some(destinations) = names.get("Dests") {
368 let mut active = HashSet::new();
369 let mut nodes = 0usize;
370 read_name_tree(
371 reader,
372 destinations,
373 0,
374 &mut active,
375 &mut nodes,
376 &mut result,
377 options,
378 )?;
379 }
380 }
381 Ok(result)
382}
383
384fn read_name_tree<R: Read + Seek>(
385 reader: &mut PdfReader<R>,
386 value: &PdfObject,
387 depth: usize,
388 active: &mut HashSet<ObjectRef>,
389 nodes: &mut usize,
390 result: &mut HashMap<Vec<u8>, PdfObject>,
391 options: &OutlineReadOptions,
392) -> ParseResult<Option<(Vec<u8>, Vec<u8>)>> {
393 if depth > options.max_depth {
394 return Err(malformed("destination name tree exceeds configured depth"));
395 }
396 *nodes = nodes
397 .checked_add(1)
398 .ok_or_else(|| malformed("destination name-tree node count overflow"))?;
399 if *nodes > options.max_name_tree_nodes {
400 return Err(malformed(
401 "destination name-tree nodes exceed configured limit",
402 ));
403 }
404 let reference = value.as_reference();
405 if reference.is_some_and(|reference| !active.insert(reference)) {
406 return Err(malformed("destination name tree contains a cycle"));
407 }
408 let (dictionary, _) = resolve_dictionary(reader, value, "destination name tree")?;
409 if dictionary.contains_key("Names") && dictionary.contains_key("Kids") {
410 return Err(malformed("name-tree node contains both Names and Kids"));
411 }
412 let limits = dictionary.get("Limits").map(read_name_limits).transpose()?;
413 let actual_range = if let Some(names) = dictionary.get("Names") {
414 let names = resolve_object(reader, names)?;
415 let names = names
416 .as_array()
417 .ok_or_else(|| malformed("name-tree Names is not an array"))?;
418 if names.0.len() % 2 != 0 {
419 return Err(malformed("name-tree Names array has odd length"));
420 }
421 let mut previous: Option<Vec<u8>> = None;
422 let mut first_key = None;
423 let mut last_key = None;
424 for pair in names.0.chunks_exact(2) {
425 let key = pair[0]
426 .as_string()
427 .ok_or_else(|| malformed("name-tree key is not a string"))?
428 .as_bytes()
429 .to_vec();
430 if previous.as_ref().is_some_and(|previous| previous >= &key) {
431 return Err(malformed("name-tree keys are not strictly increasing"));
432 }
433 first_key.get_or_insert_with(|| key.clone());
434 last_key = Some(key.clone());
435 previous = Some(key.clone());
436 insert_named(result, key, pair[1].clone(), options)?;
437 }
438 first_key.zip(last_key)
439 } else if let Some(kids) = dictionary.get("Kids") {
440 let kids = resolve_object(reader, kids)?;
441 let kids = kids
442 .as_array()
443 .ok_or_else(|| malformed("name-tree Kids is not an array"))?;
444 let mut first_key = None;
445 let mut last_key: Option<Vec<u8>> = None;
446 for child in &kids.0 {
447 let child_range =
448 read_name_tree(reader, child, depth + 1, active, nodes, result, options)?;
449 let Some((lower, upper)) = child_range else {
450 continue;
451 };
452 if last_key.as_ref().is_some_and(|previous| previous >= &lower) {
453 return Err(malformed(
454 "name-tree child ranges overlap or are not strictly increasing",
455 ));
456 }
457 first_key.get_or_insert_with(|| lower.clone());
458 last_key = Some(upper);
459 }
460 first_key.zip(last_key)
461 } else {
462 None
463 };
464 match (limits, actual_range.as_ref()) {
465 (Some((lower, upper)), Some((actual_lower, actual_upper)))
466 if lower != *actual_lower || upper != *actual_upper =>
467 {
468 return Err(malformed("name-tree Limits do not match subtree keys"));
469 }
470 (Some(_), None) => {
471 return Err(malformed("empty name-tree node has non-empty Limits"));
472 }
473 _ => {}
474 }
475 if let Some(reference) = reference {
476 active.remove(&reference);
477 }
478 Ok(actual_range)
479}
480
481fn read_name_limits(value: &PdfObject) -> ParseResult<(Vec<u8>, Vec<u8>)> {
482 let limits = value
483 .as_array()
484 .ok_or_else(|| malformed("name-tree Limits is not an array"))?;
485 if limits.0.len() != 2 {
486 return Err(malformed("name-tree Limits must contain two strings"));
487 }
488 let lower = limits.0[0]
489 .as_string()
490 .ok_or_else(|| malformed("name-tree lower limit is not a string"))?
491 .as_bytes()
492 .to_vec();
493 let upper = limits.0[1]
494 .as_string()
495 .ok_or_else(|| malformed("name-tree upper limit is not a string"))?
496 .as_bytes()
497 .to_vec();
498 if lower > upper {
499 return Err(malformed("name-tree Limits are reversed"));
500 }
501 Ok((lower, upper))
502}
503
504fn insert_named(
505 result: &mut HashMap<Vec<u8>, PdfObject>,
506 key: Vec<u8>,
507 value: PdfObject,
508 options: &OutlineReadOptions,
509) -> ParseResult<()> {
510 if result.contains_key(&key) {
511 return Err(malformed("duplicate named destination"));
512 }
513 if result.len() >= options.max_named_destinations {
514 return Err(malformed("named destinations exceed configured limit"));
515 }
516 result.insert(key, value);
517 Ok(())
518}
519
520fn read_color(value: Option<&PdfObject>) -> ParseResult<Option<Color>> {
521 let Some(value) = value else { return Ok(None) };
522 let array = value
523 .as_array()
524 .ok_or_else(|| malformed("outline C is not an RGB array"))?;
525 if array.0.len() != 3 {
526 return Err(malformed("outline C must have exactly three components"));
527 }
528 let mut values = [0.0; 3];
529 for (index, value) in array.0.iter().enumerate() {
530 values[index] = value
531 .as_real()
532 .filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
533 .ok_or_else(|| malformed("outline color component is outside 0..=1"))?;
534 }
535 Ok(Some(Color::Rgb(values[0], values[1], values[2])))
536}
537
538fn resolve_object<R: Read + Seek>(
539 reader: &mut PdfReader<R>,
540 value: &PdfObject,
541) -> ParseResult<PdfObject> {
542 match value.as_reference() {
543 Some((number, generation)) => reader.get_object(number, generation).cloned(),
544 None => Ok(value.clone()),
545 }
546}
547
548fn resolve_dictionary<R: Read + Seek>(
549 reader: &mut PdfReader<R>,
550 value: &PdfObject,
551 path: &str,
552) -> ParseResult<(PdfDictionary, Option<ObjectRef>)> {
553 let reference = value.as_reference();
554 let value = resolve_object(reader, value)?;
555 value
556 .as_dict()
557 .cloned()
558 .map(|dictionary| (dictionary, reference))
559 .ok_or_else(|| malformed(format!("{path} is not a dictionary")))
560}
561
562fn require_reference(value: &PdfObject, path: &str) -> ParseResult<ObjectRef> {
563 value
564 .as_reference()
565 .ok_or_else(|| malformed(format!("{path} is not an indirect reference")))
566}
567
568fn malformed(message: impl Into<String>) -> ParseError {
569 ParseError::SyntaxError {
570 position: 0,
571 message: format!("outline: {}", message.into()),
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::parser::objects::PdfName;
579
580 fn destination(kind: &str, parameters: Vec<PdfObject>) -> DestinationType {
581 let mut values = vec![
582 PdfObject::Reference(10, 0),
583 PdfObject::Name(PdfName(kind.to_string())),
584 ];
585 values.extend(parameters);
586 let pages = HashMap::from([((10, 0), 3)]);
587 parse_destination_array(&PdfArray(values), &pages)
588 .expect("valid destination")
589 .dest_type
590 }
591
592 #[test]
593 fn exposes_every_standard_destination_view() {
594 assert!(matches!(destination("Fit", vec![]), DestinationType::Fit));
595 assert!(matches!(
596 destination("FitH", vec![PdfObject::Integer(20)]),
597 DestinationType::FitH { top: Some(20.0) }
598 ));
599 assert!(matches!(
600 destination("FitV", vec![PdfObject::Null]),
601 DestinationType::FitV { left: None }
602 ));
603 assert!(matches!(
604 destination(
605 "FitR",
606 vec![
607 PdfObject::Integer(0),
608 PdfObject::Integer(1),
609 PdfObject::Integer(2),
610 PdfObject::Integer(3),
611 ],
612 ),
613 DestinationType::FitR { .. }
614 ));
615 assert!(matches!(destination("FitB", vec![]), DestinationType::FitB));
616 assert!(matches!(
617 destination("FitBH", vec![PdfObject::Real(4.0)]),
618 DestinationType::FitBH { top: Some(4.0) }
619 ));
620 assert!(matches!(
621 destination("FitBV", vec![PdfObject::Null]),
622 DestinationType::FitBV { left: None }
623 ));
624 }
625}