Skip to main content

paperforge_pdf/
object.rs

1use std::borrow::Cow;
2use std::fmt;
3
4use rustc_hash::FxHashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub struct ObjectId {
8    pub number: u32,
9    pub generation: u16,
10}
11
12impl ObjectId {
13    pub fn new(number: u32, generation: u16) -> Self {
14        Self { number, generation }
15    }
16}
17
18impl fmt::Display for ObjectId {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        write!(f, "{} {} R", self.number, self.generation)
21    }
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct PdfName(Cow<'static, str>);
26
27/// Common PDF dictionary keys. `PdfName::new` interns these to a `'static`
28/// borrow so dictionary inserts allocate nothing for them (document building
29/// calls `insert` on every key, thousands of times per doc). Sorted for
30/// `binary_search`.
31const COMMON_KEYS: [&str; 44] = [
32    "Author",
33    "BaseFont",
34    "Catalog",
35    "CIDFontType2",
36    "CIDSystemInfo",
37    "Contents",
38    "Count",
39    "CreationDate",
40    "Creator",
41    "CropBox",
42    "DescendantFonts",
43    "Encoding",
44    "Encrypt",
45    "Filter",
46    "First",
47    "FirstChar",
48    "Font",
49    "FontDescriptor",
50    "FontFile2",
51    "Image",
52    "Index",
53    "Info",
54    "Kids",
55    "LastChar",
56    "Length",
57    "MediaBox",
58    "ModDate",
59    "N",
60    "Name",
61    "ObjStm",
62    "Ordering",
63    "Page",
64    "Pages",
65    "Parent",
66    "ProcSet",
67    "Producer",
68    "Registry",
69    "Resources",
70    "Root",
71    "Rotate",
72    "Size",
73    "Subtype",
74    "Supplement",
75    "Type",
76];
77
78impl PdfName {
79    /// Creates a name. Names matching a common PDF key borrow a static
80    /// string and allocate nothing; arbitrary names are owned (same cost as
81    /// before). Dictionary inserts call this on every key, so the static fast
82    /// path removes thousands of small allocations when building documents.
83    pub fn new(s: &str) -> Self {
84        if COMMON_KEYS.binary_search(&s).is_ok() {
85            let idx = COMMON_KEYS.binary_search(&s).unwrap();
86            return Self(Cow::Borrowed(COMMON_KEYS[idx]));
87        }
88        Self(Cow::Owned(s.to_string()))
89    }
90
91    pub fn as_str(&self) -> &str {
92        self.0.as_ref()
93    }
94}
95
96impl fmt::Display for PdfName {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(f, "/{}", self.0)
99    }
100}
101
102#[derive(Debug, Clone, PartialEq)]
103pub struct PdfString(pub Vec<u8>);
104
105impl PdfString {
106    pub fn from_literal(s: &str) -> Self {
107        Self(s.as_bytes().to_vec())
108    }
109
110    pub fn from_bytes(bytes: &[u8]) -> Self {
111        Self(bytes.to_vec())
112    }
113
114    pub fn as_bytes(&self) -> &[u8] {
115        &self.0
116    }
117}
118
119/// Escapes raw bytes as the body of a PDF literal string (no surrounding parens),
120/// appending the escaped bytes to `out`. The output is always pure ASCII.
121///
122/// Parens, backslashes and EOL / control characters must be escaped, otherwise a
123/// round-trip through the parser would change the byte sequence (e.g. an unescaped
124/// `)` would close the string early). Bytes outside the printable ASCII range are
125/// emitted as 3-digit octal escapes so the output stays byte-exact.
126pub fn escape_literal_string_into(out: &mut Vec<u8>, bytes: &[u8]) {
127    for &b in bytes {
128        match b {
129            b'(' => out.extend_from_slice(b"\\("),
130            b')' => out.extend_from_slice(b"\\)"),
131            b'\\' => out.extend_from_slice(b"\\\\"),
132            b'\n' => out.extend_from_slice(b"\\n"),
133            b'\r' => out.extend_from_slice(b"\\r"),
134            b'\t' => out.extend_from_slice(b"\\t"),
135            8 => out.extend_from_slice(b"\\b"),
136            12 => out.extend_from_slice(b"\\f"),
137            0x20..=0x7e => out.push(b),
138            b => {
139                // 3-digit octal: \000 .. \377
140                out.push(b'\\');
141                out.push(b'0' + (b >> 6));
142                out.push(b'0' + ((b >> 3) & 7));
143                out.push(b'0' + (b & 7));
144            }
145        }
146    }
147}
148
149/// Escapes raw bytes as the body of a PDF literal string (no surrounding parens).
150/// See [`escape_literal_string_into`] for the escaping rules.
151pub fn escape_literal_string(bytes: &[u8]) -> String {
152    let mut out = Vec::with_capacity(bytes.len() + 8);
153    escape_literal_string_into(&mut out, bytes);
154    // The escaped output is always pure ASCII, so this conversion cannot fail.
155    String::from_utf8(out).expect("escaped literal string is ASCII")
156}
157
158impl fmt::Display for PdfString {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        write!(f, "({})", escape_literal_string(&self.0))
161    }
162}
163
164#[derive(Debug, Clone, PartialEq)]
165pub struct PdfArray(pub Vec<PdfObject>);
166
167impl PdfArray {
168    pub fn new() -> Self {
169        Self(Vec::new())
170    }
171
172    /// Creates an array with room for `capacity` elements, avoiding the
173    /// grow-realloc when the size is known up front.
174    pub fn with_capacity(capacity: usize) -> Self {
175        Self(Vec::with_capacity(capacity))
176    }
177
178    pub fn push(&mut self, obj: PdfObject) {
179        self.0.push(obj);
180    }
181
182    pub fn get(&self, index: usize) -> Option<&PdfObject> {
183        self.0.get(index)
184    }
185
186    pub fn len(&self) -> usize {
187        self.0.len()
188    }
189
190    pub fn is_empty(&self) -> bool {
191        self.0.is_empty()
192    }
193}
194
195impl Default for PdfArray {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201/// A PDF dictionary backed by an [`FxHashMap`]. PDF names are short strings, so
202/// the faster non-cryptographic hasher costs little in practice while speeding
203/// up both parsing and serialization. The hasher is not collision-resistant,
204/// but dictionaries parsed from untrusted input are bounded by the parser's
205/// `max_dict_entries` limit, which caps the worst-case collision cost.
206#[derive(Debug, Clone, PartialEq)]
207pub struct PdfDictionary {
208    entries: FxHashMap<PdfName, PdfObject>,
209}
210
211impl PdfDictionary {
212    pub fn new() -> Self {
213        Self {
214            entries: FxHashMap::default(),
215        }
216    }
217
218    /// Creates a dictionary with room for `capacity` entries, avoiding the
219    /// rehash/regrow work when the size is known up front (hot path in
220    /// document building).
221    pub fn with_capacity(capacity: usize) -> Self {
222        Self {
223            entries: FxHashMap::with_capacity_and_hasher(capacity, Default::default()),
224        }
225    }
226
227    pub fn insert(&mut self, key: &str, value: PdfObject) {
228        self.entries.insert(PdfName::new(key), value);
229    }
230
231    pub fn get(&self, key: &str) -> Option<&PdfObject> {
232        self.entries.get(&PdfName::new(key))
233    }
234
235    pub fn get_name(&self, key: &str) -> Option<&PdfName> {
236        match self.get(key) {
237            Some(PdfObject::Name(n)) => Some(n),
238            _ => None,
239        }
240    }
241
242    pub fn get_integer(&self, key: &str) -> Option<i64> {
243        match self.get(key) {
244            Some(PdfObject::Integer(i)) => Some(*i),
245            _ => None,
246        }
247    }
248
249    pub fn get_array(&self, key: &str) -> Option<&PdfArray> {
250        match self.get(key) {
251            Some(PdfObject::Array(a)) => Some(a),
252            _ => None,
253        }
254    }
255
256    pub fn get_dict(&self, key: &str) -> Option<&PdfDictionary> {
257        match self.get(key) {
258            Some(PdfObject::Dictionary(d)) => Some(d),
259            _ => None,
260        }
261    }
262
263    /// Returns the raw bytes of a string entry, if the value is a `PdfString`.
264    pub fn get_string_bytes(&self, key: &str) -> Option<&[u8]> {
265        self.get(key)
266            .and_then(|o| o.as_string())
267            .map(PdfString::as_bytes)
268    }
269
270    pub fn len(&self) -> usize {
271        self.entries.len()
272    }
273
274    pub fn is_empty(&self) -> bool {
275        self.entries.is_empty()
276    }
277
278    pub fn iter(&self) -> impl Iterator<Item = (&PdfName, &PdfObject)> {
279        self.entries.iter()
280    }
281}
282
283impl Default for PdfDictionary {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[derive(Debug, Clone, PartialEq)]
290pub struct PdfStream {
291    pub dictionary: PdfDictionary,
292    pub data: Vec<u8>,
293}
294
295impl PdfStream {
296    pub fn new(data: Vec<u8>) -> Self {
297        Self {
298            dictionary: PdfDictionary::new(),
299            data,
300        }
301    }
302
303    pub fn with_dict(dictionary: PdfDictionary, data: Vec<u8>) -> Self {
304        Self { dictionary, data }
305    }
306
307    pub fn length(&self) -> usize {
308        self.data.len()
309    }
310}
311
312#[derive(Debug, Clone, PartialEq)]
313pub enum PdfObject {
314    Null,
315    Boolean(bool),
316    Integer(i64),
317    Real(f64),
318    Name(PdfName),
319    String(PdfString),
320    Array(PdfArray),
321    Dictionary(PdfDictionary),
322    Stream(PdfStream),
323    Reference(ObjectId),
324}
325
326impl PdfObject {
327    pub fn is_null(&self) -> bool {
328        matches!(self, Self::Null)
329    }
330
331    pub fn as_bool(&self) -> Option<bool> {
332        match self {
333            Self::Boolean(b) => Some(*b),
334            _ => None,
335        }
336    }
337
338    pub fn as_integer(&self) -> Option<i64> {
339        match self {
340            Self::Integer(i) => Some(*i),
341            _ => None,
342        }
343    }
344
345    pub fn as_real(&self) -> Option<f64> {
346        match self {
347            Self::Real(r) => Some(*r),
348            Self::Integer(i) => Some(*i as f64),
349            _ => None,
350        }
351    }
352
353    pub fn as_name(&self) -> Option<&PdfName> {
354        match self {
355            Self::Name(n) => Some(n),
356            _ => None,
357        }
358    }
359
360    pub fn as_string(&self) -> Option<&PdfString> {
361        match self {
362            Self::String(s) => Some(s),
363            _ => None,
364        }
365    }
366
367    pub fn as_array(&self) -> Option<&PdfArray> {
368        match self {
369            Self::Array(a) => Some(a),
370            _ => None,
371        }
372    }
373
374    pub fn as_dict(&self) -> Option<&PdfDictionary> {
375        match self {
376            Self::Dictionary(d) => Some(d),
377            _ => None,
378        }
379    }
380
381    pub fn as_stream(&self) -> Option<&PdfStream> {
382        match self {
383            Self::Stream(s) => Some(s),
384            _ => None,
385        }
386    }
387
388    pub fn as_reference(&self) -> Option<ObjectId> {
389        match self {
390            Self::Reference(id) => Some(*id),
391            _ => None,
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn escape_literal_handles_all_special_bytes() {
402        let input: &[u8] = b"a(b)c\\d\ne\rf\t";
403        let escaped = escape_literal_string(input);
404        assert_eq!(escaped, "a\\(b\\)c\\\\d\\ne\\rf\\t");
405    }
406
407    #[test]
408    fn escape_literal_uses_octal_for_control_bytes() {
409        assert_eq!(
410            escape_literal_string(&[0x01, 0x07, 0x0b]),
411            "\\001\\007\\013"
412        );
413    }
414
415    #[test]
416    fn escape_literal_octal_escapes_non_ascii() {
417        // Non-ASCII bytes are emitted as 3-digit octal escapes so the output is
418        // pure ASCII and round-trips byte-exactly through any PDF reader.
419        assert_eq!(escape_literal_string(&[0x80]), "\\200");
420        assert_eq!(escape_literal_string(&[0xc3, 0xa9]), "\\303\\251");
421        assert_eq!(escape_literal_string(&[0xff]), "\\377");
422    }
423
424    #[test]
425    fn string_display_escapes_parens_and_backslash() {
426        let s = PdfString::from_literal("(a) \\ b");
427        assert_eq!(s.to_string(), "(\\(a\\) \\\\ b)");
428    }
429
430    #[test]
431    fn string_display_escapes_line_breaks() {
432        let s = PdfString::from_bytes(b"line1\nline2\r\nline3");
433        assert_eq!(s.to_string(), "(line1\\nline2\\r\\nline3)");
434    }
435}