Skip to main content

paperforge_pdf/
object.rs

1use std::fmt;
2
3use rustc_hash::FxHashMap;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
6pub struct ObjectId {
7    pub number: u32,
8    pub generation: u16,
9}
10
11impl ObjectId {
12    pub fn new(number: u32, generation: u16) -> Self {
13        Self { number, generation }
14    }
15}
16
17impl fmt::Display for ObjectId {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        write!(f, "{} {} R", self.number, self.generation)
20    }
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct PdfName(pub String);
25
26impl PdfName {
27    pub fn new(s: &str) -> Self {
28        Self(s.to_string())
29    }
30
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34}
35
36impl fmt::Display for PdfName {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        write!(f, "/{}", self.0)
39    }
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub struct PdfString(pub Vec<u8>);
44
45impl PdfString {
46    pub fn from_literal(s: &str) -> Self {
47        Self(s.as_bytes().to_vec())
48    }
49
50    pub fn from_bytes(bytes: &[u8]) -> Self {
51        Self(bytes.to_vec())
52    }
53
54    pub fn as_bytes(&self) -> &[u8] {
55        &self.0
56    }
57}
58
59/// Escapes raw bytes as the body of a PDF literal string (no surrounding parens),
60/// appending the escaped bytes to `out`. The output is always pure ASCII.
61///
62/// Parens, backslashes and EOL / control characters must be escaped, otherwise a
63/// round-trip through the parser would change the byte sequence (e.g. an unescaped
64/// `)` would close the string early). Bytes outside the printable ASCII range are
65/// emitted as 3-digit octal escapes so the output stays byte-exact.
66pub fn escape_literal_string_into(out: &mut Vec<u8>, bytes: &[u8]) {
67    for &b in bytes {
68        match b {
69            b'(' => out.extend_from_slice(b"\\("),
70            b')' => out.extend_from_slice(b"\\)"),
71            b'\\' => out.extend_from_slice(b"\\\\"),
72            b'\n' => out.extend_from_slice(b"\\n"),
73            b'\r' => out.extend_from_slice(b"\\r"),
74            b'\t' => out.extend_from_slice(b"\\t"),
75            8 => out.extend_from_slice(b"\\b"),
76            12 => out.extend_from_slice(b"\\f"),
77            0x20..=0x7e => out.push(b),
78            b => {
79                // 3-digit octal: \000 .. \377
80                out.push(b'\\');
81                out.push(b'0' + (b >> 6));
82                out.push(b'0' + ((b >> 3) & 7));
83                out.push(b'0' + (b & 7));
84            }
85        }
86    }
87}
88
89/// Escapes raw bytes as the body of a PDF literal string (no surrounding parens).
90/// See [`escape_literal_string_into`] for the escaping rules.
91pub fn escape_literal_string(bytes: &[u8]) -> String {
92    let mut out = Vec::with_capacity(bytes.len() + 8);
93    escape_literal_string_into(&mut out, bytes);
94    // The escaped output is always pure ASCII, so this conversion cannot fail.
95    String::from_utf8(out).expect("escaped literal string is ASCII")
96}
97
98impl fmt::Display for PdfString {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "({})", escape_literal_string(&self.0))
101    }
102}
103
104#[derive(Debug, Clone, PartialEq)]
105pub struct PdfArray(pub Vec<PdfObject>);
106
107impl PdfArray {
108    pub fn new() -> Self {
109        Self(Vec::new())
110    }
111
112    pub fn push(&mut self, obj: PdfObject) {
113        self.0.push(obj);
114    }
115
116    pub fn get(&self, index: usize) -> Option<&PdfObject> {
117        self.0.get(index)
118    }
119
120    pub fn len(&self) -> usize {
121        self.0.len()
122    }
123
124    pub fn is_empty(&self) -> bool {
125        self.0.is_empty()
126    }
127}
128
129impl Default for PdfArray {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135/// A PDF dictionary backed by an [`FxHashMap`]. PDF names are short strings, so
136/// the faster non-cryptographic hasher costs little in practice while speeding
137/// up both parsing and serialization. The hasher is not collision-resistant,
138/// but dictionaries parsed from untrusted input are bounded by the parser's
139/// `max_dict_entries` limit, which caps the worst-case collision cost.
140#[derive(Debug, Clone, PartialEq)]
141pub struct PdfDictionary {
142    entries: FxHashMap<PdfName, PdfObject>,
143}
144
145impl PdfDictionary {
146    pub fn new() -> Self {
147        Self {
148            entries: FxHashMap::default(),
149        }
150    }
151
152    pub fn insert(&mut self, key: &str, value: PdfObject) {
153        self.entries.insert(PdfName::new(key), value);
154    }
155
156    pub fn get(&self, key: &str) -> Option<&PdfObject> {
157        self.entries.get(&PdfName::new(key))
158    }
159
160    pub fn get_name(&self, key: &str) -> Option<&PdfName> {
161        match self.get(key) {
162            Some(PdfObject::Name(n)) => Some(n),
163            _ => None,
164        }
165    }
166
167    pub fn get_integer(&self, key: &str) -> Option<i64> {
168        match self.get(key) {
169            Some(PdfObject::Integer(i)) => Some(*i),
170            _ => None,
171        }
172    }
173
174    pub fn get_array(&self, key: &str) -> Option<&PdfArray> {
175        match self.get(key) {
176            Some(PdfObject::Array(a)) => Some(a),
177            _ => None,
178        }
179    }
180
181    pub fn get_dict(&self, key: &str) -> Option<&PdfDictionary> {
182        match self.get(key) {
183            Some(PdfObject::Dictionary(d)) => Some(d),
184            _ => None,
185        }
186    }
187
188    /// Returns the raw bytes of a string entry, if the value is a `PdfString`.
189    pub fn get_string_bytes(&self, key: &str) -> Option<&[u8]> {
190        self.get(key)
191            .and_then(|o| o.as_string())
192            .map(PdfString::as_bytes)
193    }
194
195    pub fn len(&self) -> usize {
196        self.entries.len()
197    }
198
199    pub fn is_empty(&self) -> bool {
200        self.entries.is_empty()
201    }
202
203    pub fn iter(&self) -> impl Iterator<Item = (&PdfName, &PdfObject)> {
204        self.entries.iter()
205    }
206}
207
208impl Default for PdfDictionary {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214#[derive(Debug, Clone, PartialEq)]
215pub struct PdfStream {
216    pub dictionary: PdfDictionary,
217    pub data: Vec<u8>,
218}
219
220impl PdfStream {
221    pub fn new(data: Vec<u8>) -> Self {
222        Self {
223            dictionary: PdfDictionary::new(),
224            data,
225        }
226    }
227
228    pub fn with_dict(dictionary: PdfDictionary, data: Vec<u8>) -> Self {
229        Self { dictionary, data }
230    }
231
232    pub fn length(&self) -> usize {
233        self.data.len()
234    }
235}
236
237#[derive(Debug, Clone, PartialEq)]
238pub enum PdfObject {
239    Null,
240    Boolean(bool),
241    Integer(i64),
242    Real(f64),
243    Name(PdfName),
244    String(PdfString),
245    Array(PdfArray),
246    Dictionary(PdfDictionary),
247    Stream(PdfStream),
248    Reference(ObjectId),
249}
250
251impl PdfObject {
252    pub fn is_null(&self) -> bool {
253        matches!(self, Self::Null)
254    }
255
256    pub fn as_bool(&self) -> Option<bool> {
257        match self {
258            Self::Boolean(b) => Some(*b),
259            _ => None,
260        }
261    }
262
263    pub fn as_integer(&self) -> Option<i64> {
264        match self {
265            Self::Integer(i) => Some(*i),
266            _ => None,
267        }
268    }
269
270    pub fn as_real(&self) -> Option<f64> {
271        match self {
272            Self::Real(r) => Some(*r),
273            Self::Integer(i) => Some(*i as f64),
274            _ => None,
275        }
276    }
277
278    pub fn as_name(&self) -> Option<&PdfName> {
279        match self {
280            Self::Name(n) => Some(n),
281            _ => None,
282        }
283    }
284
285    pub fn as_string(&self) -> Option<&PdfString> {
286        match self {
287            Self::String(s) => Some(s),
288            _ => None,
289        }
290    }
291
292    pub fn as_array(&self) -> Option<&PdfArray> {
293        match self {
294            Self::Array(a) => Some(a),
295            _ => None,
296        }
297    }
298
299    pub fn as_dict(&self) -> Option<&PdfDictionary> {
300        match self {
301            Self::Dictionary(d) => Some(d),
302            _ => None,
303        }
304    }
305
306    pub fn as_stream(&self) -> Option<&PdfStream> {
307        match self {
308            Self::Stream(s) => Some(s),
309            _ => None,
310        }
311    }
312
313    pub fn as_reference(&self) -> Option<ObjectId> {
314        match self {
315            Self::Reference(id) => Some(*id),
316            _ => None,
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn escape_literal_handles_all_special_bytes() {
327        let input: &[u8] = b"a(b)c\\d\ne\rf\t";
328        let escaped = escape_literal_string(input);
329        assert_eq!(escaped, "a\\(b\\)c\\\\d\\ne\\rf\\t");
330    }
331
332    #[test]
333    fn escape_literal_uses_octal_for_control_bytes() {
334        assert_eq!(
335            escape_literal_string(&[0x01, 0x07, 0x0b]),
336            "\\001\\007\\013"
337        );
338    }
339
340    #[test]
341    fn escape_literal_octal_escapes_non_ascii() {
342        // Non-ASCII bytes are emitted as 3-digit octal escapes so the output is
343        // pure ASCII and round-trips byte-exactly through any PDF reader.
344        assert_eq!(escape_literal_string(&[0x80]), "\\200");
345        assert_eq!(escape_literal_string(&[0xc3, 0xa9]), "\\303\\251");
346        assert_eq!(escape_literal_string(&[0xff]), "\\377");
347    }
348
349    #[test]
350    fn string_display_escapes_parens_and_backslash() {
351        let s = PdfString::from_literal("(a) \\ b");
352        assert_eq!(s.to_string(), "(\\(a\\) \\\\ b)");
353    }
354
355    #[test]
356    fn string_display_escapes_line_breaks() {
357        let s = PdfString::from_bytes(b"line1\nline2\r\nline3");
358        assert_eq!(s.to_string(), "(line1\\nline2\\r\\nline3)");
359    }
360}