Skip to main content

stet_pdf_reader/
objects.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF object model for reading.
6
7use std::fmt;
8
9/// A PDF object parsed from file data.
10#[derive(Clone, PartialEq)]
11pub enum PdfObj {
12    Null,
13    Bool(bool),
14    Int(i64),
15    Real(f64),
16    /// Name object (without leading `/`).
17    Name(Vec<u8>),
18    /// String object (literal or hex, already decoded).
19    Str(Vec<u8>),
20    Array(Vec<PdfObj>),
21    Dict(PdfDict),
22    /// Stream: dictionary + location of raw data in source.
23    Stream {
24        dict: PdfDict,
25        data_offset: usize,
26        data_len: usize,
27    },
28    /// Indirect reference (object number, generation number).
29    Ref(u32, u16),
30}
31
32impl PdfObj {
33    /// Return the integer value, or None.
34    pub fn as_int(&self) -> Option<i64> {
35        match self {
36            PdfObj::Int(n) => Some(*n),
37            _ => None,
38        }
39    }
40
41    /// Return a numeric value as f64 (int or real), or None.
42    pub fn as_f64(&self) -> Option<f64> {
43        match self {
44            PdfObj::Int(n) => Some(*n as f64),
45            PdfObj::Real(f) => Some(*f),
46            _ => None,
47        }
48    }
49
50    /// Return the name bytes, or None.
51    pub fn as_name(&self) -> Option<&[u8]> {
52        match self {
53            PdfObj::Name(n) => Some(n),
54            _ => None,
55        }
56    }
57
58    /// Return the boolean value, or None.
59    pub fn as_bool(&self) -> Option<bool> {
60        match self {
61            PdfObj::Bool(b) => Some(*b),
62            _ => None,
63        }
64    }
65
66    /// Return the string bytes, or None.
67    pub fn as_str(&self) -> Option<&[u8]> {
68        match self {
69            PdfObj::Str(s) => Some(s),
70            _ => None,
71        }
72    }
73
74    /// Return the array, or None.
75    pub fn as_array(&self) -> Option<&[PdfObj]> {
76        match self {
77            PdfObj::Array(a) => Some(a),
78            _ => None,
79        }
80    }
81
82    /// Return the dict, or None.
83    pub fn as_dict(&self) -> Option<&PdfDict> {
84        match self {
85            PdfObj::Dict(d) => Some(d),
86            PdfObj::Stream { dict, .. } => Some(dict),
87            _ => None,
88        }
89    }
90
91    /// Return the indirect reference, or None.
92    pub fn as_ref(&self) -> Option<(u32, u16)> {
93        match self {
94            PdfObj::Ref(n, g) => Some((*n, *g)),
95            _ => None,
96        }
97    }
98
99    /// Return the reference OR extract one from a Ref-typed object.
100    /// For non-Ref objects, returns None.
101    pub fn to_ref(&self) -> Option<(u32, u16)> {
102        self.as_ref()
103    }
104}
105
106impl fmt::Debug for PdfObj {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        match self {
109            PdfObj::Null => write!(f, "null"),
110            PdfObj::Bool(b) => write!(f, "{b}"),
111            PdfObj::Int(n) => write!(f, "{n}"),
112            PdfObj::Real(r) => write!(f, "{r}"),
113            PdfObj::Name(n) => write!(f, "/{}", String::from_utf8_lossy(n)),
114            PdfObj::Str(s) => write!(f, "({:?})", String::from_utf8_lossy(s)),
115            PdfObj::Array(a) => f.debug_list().entries(a.iter()).finish(),
116            PdfObj::Dict(d) => write!(f, "{d:?}"),
117            PdfObj::Stream { dict, data_len, .. } => {
118                write!(f, "{dict:?} stream({data_len} bytes)")
119            }
120            PdfObj::Ref(n, g) => write!(f, "{n} {g} R"),
121        }
122    }
123}
124
125/// A PDF dictionary: ordered list of (name, value) pairs.
126#[derive(Clone, PartialEq, Default)]
127pub struct PdfDict(Vec<(Vec<u8>, PdfObj)>);
128
129impl PdfDict {
130    pub fn new() -> Self {
131        Self(Vec::new())
132    }
133
134    /// Look up a key by name (without leading `/`).
135    pub fn get(&self, key: &[u8]) -> Option<&PdfObj> {
136        self.0.iter().rev().find(|(k, _)| k == key).map(|(_, v)| v)
137    }
138
139    /// Get a name value for a key.
140    pub fn get_name(&self, key: &[u8]) -> Option<&[u8]> {
141        self.get(key).and_then(|v| v.as_name())
142    }
143
144    /// Get an integer value for a key.
145    pub fn get_int(&self, key: &[u8]) -> Option<i64> {
146        self.get(key).and_then(|v| v.as_int())
147    }
148
149    /// Get a numeric (int or real) value as f64.
150    pub fn get_f64(&self, key: &[u8]) -> Option<f64> {
151        self.get(key).and_then(|v| v.as_f64())
152    }
153
154    /// Get a boolean value for a key.
155    pub fn get_bool(&self, key: &[u8]) -> Option<bool> {
156        self.get(key).and_then(|v| v.as_bool())
157    }
158
159    /// Get an array value for a key.
160    pub fn get_array(&self, key: &[u8]) -> Option<&[PdfObj]> {
161        self.get(key).and_then(|v| v.as_array())
162    }
163
164    /// Get a dict value for a key.
165    pub fn get_dict(&self, key: &[u8]) -> Option<&PdfDict> {
166        self.get(key).and_then(|v| v.as_dict())
167    }
168
169    /// Get an indirect reference for a key.
170    pub fn get_ref(&self, key: &[u8]) -> Option<(u32, u16)> {
171        self.get(key).and_then(|v| v.as_ref())
172    }
173
174    /// Get a reference or inline dict — returns the PdfObj for the caller to resolve.
175    pub fn get_derefable(&self, key: &[u8]) -> Option<&PdfObj> {
176        self.get(key)
177    }
178
179    /// Insert or replace a key-value pair.
180    pub fn insert(&mut self, key: Vec<u8>, val: PdfObj) {
181        // For small dicts, check for duplicates (PDF spec says keys should be
182        // unique, but malformed files exist). For large dicts, skip the O(n)
183        // scan — the duplicate check would be O(n²) for 74K+ entry dicts.
184        if self.0.len() < 100 {
185            if let Some(entry) = self.0.iter_mut().find(|(k, _)| k == &key) {
186                entry.1 = val;
187                return;
188            }
189        }
190        self.0.push((key, val));
191    }
192
193    /// Get all entries.
194    pub fn entries(&self) -> &[(Vec<u8>, PdfObj)] {
195        &self.0
196    }
197
198    /// Consume the dict and return owned entries.
199    pub fn into_entries(self) -> Vec<(Vec<u8>, PdfObj)> {
200        self.0
201    }
202
203    /// Create a dict from owned entries.
204    pub fn from_entries(entries: Vec<(Vec<u8>, PdfObj)>) -> Self {
205        PdfDict(entries)
206    }
207
208    /// Number of entries.
209    pub fn len(&self) -> usize {
210        self.0.len()
211    }
212
213    /// Whether the dict is empty.
214    pub fn is_empty(&self) -> bool {
215        self.0.is_empty()
216    }
217}
218
219impl fmt::Debug for PdfDict {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        write!(f, "<<")?;
222        for (k, v) in &self.0 {
223            write!(f, " /{} {v:?}", String::from_utf8_lossy(k))?;
224        }
225        write!(f, " >>")
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn dict_get_and_insert() {
235        let mut d = PdfDict::new();
236        d.insert(b"Type".to_vec(), PdfObj::Name(b"Page".to_vec()));
237        d.insert(b"Count".to_vec(), PdfObj::Int(5));
238
239        assert_eq!(d.get_name(b"Type"), Some(b"Page".as_slice()));
240        assert_eq!(d.get_int(b"Count"), Some(5));
241        assert_eq!(d.get(b"Missing"), None);
242        assert_eq!(d.len(), 2);
243    }
244
245    #[test]
246    fn dict_insert_replaces() {
247        let mut d = PdfDict::new();
248        d.insert(b"Key".to_vec(), PdfObj::Int(1));
249        d.insert(b"Key".to_vec(), PdfObj::Int(2));
250        assert_eq!(d.get_int(b"Key"), Some(2));
251        assert_eq!(d.len(), 1);
252    }
253
254    #[test]
255    fn obj_as_f64_int_and_real() {
256        assert_eq!(PdfObj::Int(42).as_f64(), Some(42.0));
257        assert_eq!(PdfObj::Real(2.5).as_f64(), Some(2.5));
258        assert_eq!(PdfObj::Null.as_f64(), None);
259    }
260
261    #[test]
262    fn obj_as_dict_from_stream() {
263        let mut d = PdfDict::new();
264        d.insert(b"Length".to_vec(), PdfObj::Int(100));
265        let obj = PdfObj::Stream {
266            dict: d.clone(),
267            data_offset: 0,
268            data_len: 100,
269        };
270        assert!(obj.as_dict().is_some());
271        assert_eq!(obj.as_dict().unwrap().get_int(b"Length"), Some(100));
272    }
273}