1use pdfrum_object::{Dict, Name, Object, Resolve, Stream, decode_text};
15
16use crate::names;
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct FileSpec {
21 pub object: Object,
23}
24
25const NAME_KEYS: [&Name; 5] = [names::UF, names::F, names::DOS, names::MAC, names::UNIX];
27
28impl FileSpec {
29 #[must_use]
39 pub fn new(object: Object) -> FileSpec {
40 FileSpec { object }
41 }
42
43 #[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.bytes)),
67 Object::Dict(dict) => decode_file_name(&dict_file_name(dict, r)),
68 _ => String::new(),
69 }
70 }
71
72 #[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 #[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
122fn is_url<R: Resolve>(dict: &Dict, r: &R) -> bool {
124 dict.byte_string(names::FS, r).as_deref() == Some(b"URL")
125}
126
127fn 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.bytes.to_vec()))
132 };
133
134 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 if is_url(dict, r) {
146 return name;
147 }
148 if name.is_empty() {
149 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
161fn latin1(bytes: &[u8]) -> String {
163 bytes.iter().map(|b| char::from(*b)).collect()
164}
165
166#[must_use]
181pub fn decode_file_name(name: &str) -> String {
182 name.to_owned()
183}
184
185#[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 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 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 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}