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