Skip to main content

oxidize_pdf/parser/
outline.rs

1//! Bounded, read-only parsing of document outlines and destinations.
2
3use 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/// Resource limits for parsing untrusted outline and destination trees.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct OutlineReadOptions {
18    /// Maximum outline items visited.
19    pub max_items: usize,
20    /// Maximum outline and name-tree nesting depth.
21    pub max_depth: usize,
22    /// Maximum named destinations collected.
23    pub max_named_destinations: usize,
24    /// Maximum name-tree nodes visited, including empty intermediate nodes.
25    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}