Skip to main content

pdfrum_doc/nav/
filespec.rs

1//! File specifications (ISO 32000-1 §7.11): naming a file inside or outside
2//! the document.
3//!
4//! Five keys can hold a name and they are tried in a fixed precedence —
5//! `/UF`, `/F`, `/DOS`, `/Mac`, `/Unix` — with two details that matter:
6//!
7//! - Each is **type-filtered to a string**, so a `/UF` written as a *name*
8//!   contributes nothing. A name-valued `/UF /http://evil.org` is not a
9//!   file name.
10//! - `/UF` decodes as PDF text (byte-order mark aware); every other key, and
11//!   a bare string file specification, decodes as **Latin-1**. Two different
12//!   functions, deliberately not unified.
13
14use pdfrum_object::{Dict, Name, Object, Resolve, Stream, decode_text};
15
16use crate::names;
17
18/// A file specification: either a bare string or a dictionary of names.
19#[derive(Debug, Clone, PartialEq)]
20pub struct FileSpec {
21    /// The object the specification was read from.
22    pub object: Object,
23}
24
25/// The keys a file name can live under, in precedence order.
26const NAME_KEYS: [&Name; 5] = [names::UF, names::F, names::DOS, names::MAC, names::UNIX];
27
28impl FileSpec {
29    /// Wraps an object as a file specification.
30    ///
31    /// ```
32    /// use pdfrum_doc::FileSpec;
33    /// use pdfrum_object::{NoResolve, Object, PdfString};
34    ///
35    /// let spec = FileSpec::new(Object::Str(PdfString::literal(b"report.pdf")));
36    /// assert_eq!(spec.file_name(&NoResolve), "report.pdf");
37    /// ```
38    #[must_use]
39    pub fn new(object: Object) -> FileSpec {
40        FileSpec { object }
41    }
42
43    /// The file's name.
44    ///
45    /// An object that is neither a string nor a dictionary — a *name*, for
46    /// instance — has no file name at all.
47    ///
48    /// ```
49    /// use pdfrum_doc::FileSpec;
50    /// use pdfrum_object::{Dict, Name, NoResolve, Object, PdfString};
51    ///
52    /// // `/UF` outranks `/F`.
53    /// let dict = Dict::from_pairs([
54    ///     (Name::from("F"), Object::Str(PdfString::literal(b"old.pdf"))),
55    ///     (Name::from("UF"), Object::Str(PdfString::literal(b"new.pdf"))),
56    /// ]);
57    /// assert_eq!(FileSpec::new(Object::Dict(dict)).file_name(&NoResolve), "new.pdf");
58    ///
59    /// // Neither a string nor a dictionary: no file name at all.
60    /// let named = FileSpec::new(Object::Name(Name::from("F")));
61    /// assert_eq!(named.file_name(&NoResolve), "");
62    /// ```
63    #[must_use]
64    pub fn file_name<R: Resolve>(&self, r: &R) -> String {
65        match &self.object {
66            Object::Str(text) => decode_file_name(&latin1(text.as_bytes())),
67            Object::Dict(dict) => decode_file_name(&dict_file_name(dict, r)),
68            _ => String::new(),
69        }
70    }
71
72    /// The embedded file's stream, when the specification carries one.
73    ///
74    /// The presence test runs on the **outer** dictionary's name keys while
75    /// the stream comes from `/EF`, so a name with no matching `/EF` entry
76    /// falls through to the next key rather than ending the search. A `/FS`
77    /// of `URL` truncates the key list to `/UF` and `/F`.
78    ///
79    /// ```
80    /// use pdfrum_doc::FileSpec;
81    /// use pdfrum_object::{NoResolve, Object, PdfString};
82    ///
83    /// // A bare string names a file but embeds nothing.
84    /// let spec = FileSpec::new(Object::Str(PdfString::literal(b"report.pdf")));
85    /// assert!(spec.file_stream(&NoResolve).is_none());
86    /// ```
87    #[must_use]
88    pub fn file_stream<R: Resolve>(&self, r: &R) -> Option<Stream> {
89        let dict = self.object.as_dict()?;
90        let embedded = dict.dict(names::EF, r)?;
91        let keys = if is_url(dict, r) {
92            &NAME_KEYS[..2]
93        } else {
94            &NAME_KEYS[..]
95        };
96        for key in keys {
97            if dict.text(key, r).is_none_or(|text| text.is_empty()) {
98                continue;
99            }
100            if let Some(stream) = embedded.stream(key, r) {
101                return Some(stream);
102            }
103        }
104        None
105    }
106
107    /// The embedded file stream's `/Params` dictionary.
108    ///
109    /// ```
110    /// use pdfrum_doc::FileSpec;
111    /// use pdfrum_object::{NoResolve, Object, PdfString};
112    ///
113    /// let spec = FileSpec::new(Object::Str(PdfString::literal(b"report.pdf")));
114    /// assert!(spec.params(&NoResolve).is_none());
115    /// ```
116    #[must_use]
117    pub fn params<R: Resolve>(&self, r: &R) -> Option<Dict> {
118        self.file_stream(r)?.dict.dict(names::PARAMS, r)
119    }
120}
121
122/// Whether the specification says its names are URLs.
123fn is_url<R: Resolve>(dict: &Dict, r: &R) -> bool {
124    dict.byte_string(names::FS, r).as_deref() == Some(b"URL")
125}
126
127/// Picks a name out of a dictionary file specification.
128fn dict_file_name<R: Resolve>(dict: &Dict, r: &R) -> String {
129    let as_string = |key: &Name| {
130        dict.get(key, r)
131            .and_then(|v| v.get().as_string().map(|s| s.as_bytes().to_vec()))
132    };
133
134    // `/UF` is PDF text; everything else is Latin-1.
135    let mut name = as_string(names::UF)
136        .map(|bytes| decode_text(&bytes).into_owned())
137        .unwrap_or_default();
138    if name.is_empty() {
139        name = as_string(names::F)
140            .map(|bytes| latin1(&bytes))
141            .unwrap_or_default();
142    }
143    // A URL short-circuits **before** the platform translation, but after the
144    // `/UF` and `/F` reads.
145    if is_url(dict, r) {
146        return name;
147    }
148    if name.is_empty() {
149        // The loop stops at the first key whose value is a string — even an
150        // empty one — so a present-but-empty `/DOS` shadows `/Mac`.
151        for key in &NAME_KEYS[2..] {
152            if let Some(bytes) = as_string(key) {
153                name = latin1(&bytes);
154                break;
155            }
156        }
157    }
158    name
159}
160
161/// Byte-for-code-point decoding.
162fn latin1(bytes: &[u8]) -> String {
163    bytes.iter().map(|b| char::from(*b)).collect()
164}
165
166/// Translates a file name from PDF's platform-independent form.
167///
168/// On this platform it is the identity: the whole slash-translation machinery
169/// is compiled only for Windows and macOS. Those platform rules are not
170/// implemented here, and the Windows branch in particular reads past the end
171/// of a one- or two-character path, which we would not reproduce even if it
172/// were enabled.
173///
174/// ```
175/// use pdfrum_doc::nav::decode_file_name;
176///
177/// // The identity on this platform.
178/// assert_eq!(decode_file_name("dir/report.pdf"), "dir/report.pdf");
179/// ```
180#[must_use]
181pub fn decode_file_name(name: &str) -> String {
182    name.to_owned()
183}
184
185/// The inverse of [`decode_file_name`]; also the identity here.
186///
187/// ```
188/// use pdfrum_doc::nav::encode_file_name;
189///
190/// assert_eq!(encode_file_name("dir/report.pdf"), "dir/report.pdf");
191/// ```
192#[must_use]
193pub fn encode_file_name(name: &str) -> String {
194    name.to_owned()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::{FileSpec, decode_file_name, encode_file_name};
200    use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, Object, PdfString, Stream};
201
202    fn dict(pairs: &[(&str, Object)]) -> Dict {
203        Dict::from_pairs(
204            pairs
205                .iter()
206                .map(|(k, v)| (Name::from(*k), v.clone()))
207                .collect::<Vec<_>>(),
208        )
209    }
210
211    fn spec(pairs: &[(&str, Object)]) -> FileSpec {
212        FileSpec::new(Object::Dict(dict(pairs)))
213    }
214
215    fn text(bytes: &[u8]) -> Object {
216        Object::Str(PdfString::literal(bytes))
217    }
218
219    #[test]
220    fn the_round_trip_is_the_identity_on_this_platform() {
221        for path in [
222            "./docs/test.pdf",
223            "../test_docs/test.pdf",
224            "/usr/local/home/test.pdf",
225            "",
226            "test.pdf",
227        ] {
228            assert_eq!(decode_file_name(path), path);
229            assert_eq!(encode_file_name(path), path);
230        }
231    }
232
233    #[test]
234    fn a_bare_string_specification_reads_as_latin_one() {
235        let spec = FileSpec::new(text(b"caf\xE9.pdf"));
236        assert_eq!(spec.file_name(&NoResolve), "café.pdf");
237    }
238
239    #[test]
240    fn a_name_object_has_no_file_name_at_all() {
241        let spec = FileSpec::new(Object::Name(Name::from("test.pdf")));
242        assert_eq!(spec.file_name(&NoResolve), "");
243    }
244
245    #[test]
246    fn precedence_runs_unicode_then_file_then_the_three_platform_keys() {
247        // Set in reverse precedence; each addition takes over.
248        let mut pairs = vec![("Unix", text(b"unix.pdf"))];
249        assert_eq!(spec(&pairs).file_name(&NoResolve), "unix.pdf");
250        pairs.insert(0, ("Mac", text(b"mac.pdf")));
251        assert_eq!(spec(&pairs).file_name(&NoResolve), "mac.pdf");
252        pairs.insert(0, ("DOS", text(b"dos.pdf")));
253        assert_eq!(spec(&pairs).file_name(&NoResolve), "dos.pdf");
254        pairs.insert(0, ("F", text(b"f.pdf")));
255        assert_eq!(spec(&pairs).file_name(&NoResolve), "f.pdf");
256        pairs.insert(0, ("UF", text(b"uf.pdf")));
257        assert_eq!(spec(&pairs).file_name(&NoResolve), "uf.pdf");
258    }
259
260    #[test]
261    fn a_name_valued_key_contributes_nothing() {
262        // crbug.com/959183: a name-typed `/UF` is not a file name.
263        let evil = spec(&[
264            ("UF", Object::Name(Name::from("http://evil.org"))),
265            ("F", text(b"safe.pdf")),
266        ]);
267        assert_eq!(evil.file_name(&NoResolve), "safe.pdf");
268
269        for key in ["Unix", "Mac", "DOS", "F", "UF"] {
270            let only = spec(&[(key, Object::Name(Name::from("x.pdf")))]);
271            assert_eq!(only.file_name(&NoResolve), "", "{key}");
272        }
273    }
274
275    #[test]
276    fn a_present_but_empty_platform_key_shadows_the_next_one() {
277        let shadowed = spec(&[("DOS", text(b"")), ("Mac", text(b"mac.pdf"))]);
278        assert_eq!(shadowed.file_name(&NoResolve), "");
279    }
280
281    #[test]
282    fn a_url_specification_still_reads_its_unicode_name() {
283        let url = spec(&[
284            ("FS", Object::Name(Name::from("URL"))),
285            ("UF", text(b"http://example.com/x.pdf")),
286            ("DOS", text(b"ignored.pdf")),
287        ]);
288        assert_eq!(url.file_name(&NoResolve), "http://example.com/x.pdf");
289    }
290
291    #[test]
292    fn a_stream_needs_both_a_name_and_a_matching_embedded_entry() {
293        let stream = Object::Stream(Box::new(Stream::new(
294            dict(&[("Params", Object::Dict(dict(&[("Size", Object::Int(6))])))]),
295            ByteSpan::from(b"hello!".to_vec()),
296        )));
297        // A name with no `/EF` entry falls through rather than ending the
298        // search.
299        let spec = spec(&[
300            ("F", text(b"f.pdf")),
301            ("Unix", text(b"unix.pdf")),
302            ("EF", Object::Dict(dict(&[("Unix", stream)]))),
303        ]);
304        assert!(spec.file_stream(&NoResolve).is_some());
305        assert_eq!(
306            spec.params(&NoResolve)
307                .and_then(|p| p.int(pdfrum_object::names::SIZE, &NoResolve)),
308            Some(6)
309        );
310    }
311
312    #[test]
313    fn no_embedded_files_dictionary_means_no_stream() {
314        assert!(FileSpec::new(text(b"x")).file_stream(&NoResolve).is_none());
315        assert!(spec(&[("F", text(b"f"))]).file_stream(&NoResolve).is_none());
316        assert!(
317            spec(&[("F", text(b"f")), ("EF", Object::Dict(Dict::new()))])
318                .file_stream(&NoResolve)
319                .is_none()
320        );
321    }
322}