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}