Skip to main content

pdfrum_object/
name.rs

1//! PDF name objects (ISO 32000-1 ยง7.3.5) and the `#xx` escape codec.
2//!
3//! Names are stored *decoded*: `/A#42` and `/AB` are the same name, and
4//! equality compares the decoded bytes. Encoding back to file syntax happens
5//! only in the writer.
6
7use std::borrow::Cow;
8
9use pdfrum_common::hex_digit;
10
11use crate::string::decode_text;
12
13/// A PDF name, holding its decoded bytes without the leading `/`.
14///
15/// Names are almost always ASCII identifiers, but the syntax permits any byte
16/// through `#xx` escapes, so the storage is bytes rather than a `String`.
17/// Specification-defined keys are `'static` and cost nothing to name; parsed
18/// ones own their bytes.
19///
20/// ```
21/// use pdfrum_object::{Name, names};
22///
23/// let n = Name::new(b"Length".to_vec());
24/// assert_eq!(n.as_str(), Some("Length"));
25/// assert_eq!(&n, names::LENGTH);
26///
27/// // `#xx` escapes are resolved on the way in, so spellings unify.
28/// assert_eq!(Name::decode(b"A#42"), Name::new(b"AB".to_vec()));
29/// ```
30#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct Name(Cow<'static, [u8]>);
32
33impl Name {
34    /// A name from bytes that are already decoded.
35    #[must_use]
36    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
37        Self(Cow::Owned(bytes.into()))
38    }
39
40    /// A name from a `'static` spelling, without copying. `bytes` must
41    /// already be decoded, which every specification-defined key is.
42    #[must_use]
43    pub const fn from_static(bytes: &'static [u8]) -> Self {
44        Self(Cow::Borrowed(bytes))
45    }
46
47    /// A name from the raw token following `/` in a file, resolving `#xx`
48    /// escapes โ€” see [`name_decode`].
49    #[must_use]
50    pub fn decode(raw: &[u8]) -> Self {
51        Self(Cow::Owned(name_decode(raw)))
52    }
53
54    /// The decoded bytes.
55    #[must_use]
56    pub fn as_bytes(&self) -> &[u8] {
57        &self.0
58    }
59
60    /// The name as UTF-8, or `None` for the rare name that is not.
61    #[must_use]
62    pub fn as_str(&self) -> Option<&str> {
63        std::str::from_utf8(&self.0).ok()
64    }
65
66    /// The name read as a text string, for dumps that print names as text.
67    #[must_use]
68    pub fn as_text(&self) -> Cow<'_, str> {
69        decode_text(&self.0)
70    }
71
72    /// The file syntax for this name, including the leading `/` โ€”
73    /// see [`name_encode`].
74    #[must_use]
75    pub fn encode(&self) -> Vec<u8> {
76        let mut out = vec![b'/'];
77        out.extend_from_slice(&name_encode(&self.0));
78        out
79    }
80}
81
82impl std::fmt::Debug for Name {
83    /// Prints the name the way a file spells it (`/Length`), so an object
84    /// dump reads like the PDF it came from rather than like a byte array.
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        write!(f, "{}", String::from_utf8_lossy(&self.encode()))
87    }
88}
89
90impl AsRef<[u8]> for Name {
91    fn as_ref(&self) -> &[u8] {
92        &self.0
93    }
94}
95
96impl From<&[u8]> for Name {
97    fn from(bytes: &[u8]) -> Self {
98        Self::new(bytes.to_vec())
99    }
100}
101
102impl From<&str> for Name {
103    fn from(s: &str) -> Self {
104        Self::new(s.as_bytes().to_vec())
105    }
106}
107
108// PDFium's classifier also calls `0x80` and `0xFF` whitespace; both are
109// already at or above `0x80`, so they escape either way and the distinction
110// is invisible to `name_encode`.
111/// Bytes the PDF grammar treats as whitespace (ISO 32000-1 table 1).
112const fn is_pdf_whitespace(b: u8) -> bool {
113    matches!(b, 0x00 | 0x09 | 0x0A | 0x0C | 0x0D | 0x20)
114}
115
116/// Bytes the PDF grammar treats as delimiters (ISO 32000-1 table 2).
117const fn is_pdf_delimiter(b: u8) -> bool {
118    matches!(
119        b,
120        b'%' | b'(' | b')' | b'/' | b'<' | b'>' | b'[' | b']' | b'{' | b'}'
121    )
122}
123
124/// Resolve `#xx` escapes in a raw name token.
125///
126/// An escape needs **both** following bytes to be present *and* another byte
127/// after them, so a `#` in the last two positions stays literal: `#4` decodes
128/// to `#4`, while `#41` decodes to `A` and `#411` to `A1`. A non-hexadecimal
129/// byte inside an escape counts as zero rather than aborting the escape.
130///
131/// ```
132/// use pdfrum_object::name_decode;
133///
134/// assert_eq!(name_decode(b"#41"), b"A");
135/// assert_eq!(name_decode(b"#4"), b"#4");
136/// assert_eq!(name_decode(b"#411"), b"A1");
137/// ```
138#[must_use]
139pub fn name_decode(raw: &[u8]) -> Vec<u8> {
140    let mut out = Vec::with_capacity(raw.len());
141    let mut i = 0;
142    while let Some(&b) = raw.get(i) {
143        if let (b'#', Some(&hi), Some(&lo)) = (b, raw.get(i + 1), raw.get(i + 2)) {
144            // A non-hexadecimal byte counts as zero, matching the permissive
145            // classifier a malformed `#xx` escape falls through to.
146            let digit = |b: u8| hex_digit(b).unwrap_or(0);
147            out.push(digit(hi).wrapping_mul(16).wrapping_add(digit(lo)));
148            i += 3;
149        } else {
150            out.push(b);
151            i += 1;
152        }
153    }
154    out
155}
156
157/// Spell a name's bytes as file syntax (without the leading `/`).
158///
159/// Bytes at or above `0x80`, whitespace, delimiters, and `#` itself each
160/// become `#` plus two uppercase hexadecimal digits.
161///
162/// ```
163/// use pdfrum_object::name_encode;
164///
165/// assert_eq!(name_encode(b"A"), b"A");
166/// assert_eq!(name_encode(b"#"), b"#23");
167/// assert_eq!(name_encode(b" "), b"#20");
168/// assert_eq!(name_encode(b"f\xc2\xa5"), b"f#C2#A5");
169/// ```
170#[must_use]
171pub fn name_encode(bytes: &[u8]) -> Vec<u8> {
172    let mut out = Vec::with_capacity(bytes.len());
173    for b in bytes {
174        if *b >= 0x80 || is_pdf_whitespace(*b) || *b == b'#' || is_pdf_delimiter(*b) {
175            out.push(b'#');
176            out.extend_from_slice(&hex_pair(*b));
177        } else {
178            out.push(*b);
179        }
180    }
181    out
182}
183
184/// The two uppercase hexadecimal digits spelling one byte.
185pub(crate) const fn hex_pair(b: u8) -> [u8; 2] {
186    const fn digit(nibble: u8) -> u8 {
187        match nibble {
188            0..=9 => b'0' + nibble,
189            _ => b'A' + nibble - 10,
190        }
191    }
192    [digit(b >> 4), digit(b & 0x0F)]
193}
194
195/// Declare PDF name constants: one table, no desyncing spellings.
196///
197/// Each entry names a Rust constant and the exact bytes the specification
198/// spells the key with โ€” a table that would otherwise be written twice, in
199/// the constants module and at every use site.
200///
201/// ```
202/// pdfrum_object::names! {
203///     /// The stream's declared byte length.
204///     LENGTH = "Length";
205/// }
206/// assert_eq!(LENGTH.as_str(), Some("Length"));
207/// ```
208// One of the two sanctioned macros in the project.
209#[macro_export]
210macro_rules! names {
211    ($($(#[$meta:meta])* $konst:ident = $spelling:literal;)*) => {
212        $(
213            $(#[$meta])*
214            pub const $konst: &$crate::Name =
215                &$crate::Name::from_static($spelling.as_bytes());
216        )*
217    };
218}
219
220#[cfg(test)]
221mod tests {
222    use super::{Name, name_decode, name_encode};
223
224    // From fpdf_parser_utility_unittest.cpp:23-30.
225    #[test]
226    fn name_decode_needs_a_full_escape() {
227        assert_eq!(name_decode(b""), b"");
228        assert_eq!(name_decode(b"A"), b"A");
229        assert_eq!(name_decode(b"#"), b"#");
230        assert_eq!(name_decode(b"#4"), b"#4");
231        assert_eq!(name_decode(b"#41"), b"A");
232        assert_eq!(name_decode(b"#411"), b"A1");
233    }
234
235    #[test]
236    fn name_decode_treats_non_hex_as_zero() {
237        assert_eq!(name_decode(b"#zz9"), b"\x009");
238        assert_eq!(name_decode(b"#4z9"), b"\x409");
239    }
240
241    // From fpdf_parser_utility_unittest.cpp:32-41.
242    #[test]
243    fn name_encode_escapes_the_grammar_bytes() {
244        assert_eq!(name_encode(b""), b"");
245        assert_eq!(name_encode(b"A"), b"A");
246        assert_eq!(name_encode(b"#"), b"#23");
247        assert_eq!(name_encode(b" "), b"#20");
248        assert_eq!(
249            name_encode(b"!@#$%^&*()<>[]"),
250            b"!@#23$#25^&*#28#29#3C#3E#5B#5D"
251        );
252        assert_eq!(name_encode(b"\xc2"), b"#C2");
253        assert_eq!(name_encode(b"f\xc2\xa5"), b"f#C2#A5");
254    }
255
256    #[test]
257    fn spellings_unify_after_decoding() {
258        assert_eq!(Name::decode(b"A#42"), Name::decode(b"AB"));
259        assert_eq!(Name::decode(b"Lengt#68").as_str(), Some("Length"));
260    }
261
262    #[test]
263    fn static_and_owned_names_compare_equal() {
264        assert_eq!(Name::from_static(b"Type"), Name::from("Type"));
265    }
266
267    #[test]
268    fn encode_round_trips_through_decode() {
269        for name in [&b"Length"[..], b"a b", b"#", b"\xFF\x00", b"(x)"] {
270            let encoded = name_encode(name);
271            assert_eq!(name_decode(&encoded), name, "round trip of {name:?}");
272        }
273    }
274
275    #[test]
276    fn name_encode_includes_the_slash() {
277        assert_eq!(Name::from("Length").encode(), b"/Length");
278        assert_eq!(Name::from("a b").encode(), b"/a#20b");
279    }
280}