1use crate::date::Date;
7use crate::error::{Error, Result};
8use crate::names::{names_with, NameTree};
9use crate::object::{decode_text_string, Dict, ObjRef, Object};
10use crate::source::AsyncObjectSource;
11use crate::tree::resolved_dict;
12
13#[derive(Debug, Clone, PartialEq, Default)]
17pub struct FileSpec {
18 pub file: Option<Vec<u8>>,
21 pub unicode_file: Option<String>,
23 pub description: Option<String>,
25 pub file_system: Option<String>,
28 pub volatile: bool,
31 pub embedded: Option<ObjRef>,
34}
35
36impl FileSpec {
37 pub fn name(&self) -> String {
42 self.unicode_file
43 .clone()
44 .or_else(|| self.file.as_deref().map(decode_text_string))
45 .unwrap_or_default()
46 }
47
48 pub fn url(&self) -> Option<String> {
54 if self.file_system.as_deref() != Some("URL") {
55 return None;
56 }
57 self.file.as_deref().map(decode_text_string)
58 }
59}
60
61pub async fn file_spec_data_with<S: AsyncObjectSource>(
71 src: &S,
72 spec: &FileSpec,
73) -> Result<Vec<u8>> {
74 let r = spec.embedded.ok_or(Error::MissingKey("EF"))?;
75 match src.get(r).await? {
76 Object::Stream(stream) => src.stream_data(&stream).await,
77 other => Err(Error::TypeMismatch {
78 expected: "stream",
79 found: crate::document::type_name(&other),
80 }),
81 }
82}
83
84pub fn spec_components(spec: &[u8]) -> Vec<Vec<u8>> {
91 let mut components = vec![Vec::new()];
92 let mut bytes = spec.iter();
93 while let Some(&b) = bytes.next() {
94 match b {
95 b'\\' => {
96 if let Some(&escaped) = bytes.next() {
99 components.last_mut().expect("never empty").push(escaped);
100 }
101 }
102 b'/' => components.push(Vec::new()),
103 other => components.last_mut().expect("never empty").push(other),
104 }
105 }
106 components
107}
108
109pub async fn file_spec_with<S: AsyncObjectSource>(src: &S, object: &Object) -> Option<FileSpec> {
114 let dict = match src.resolve(object).await.ok()? {
115 Object::String(bytes) => {
116 return Some(FileSpec {
117 file: Some(bytes),
118 ..FileSpec::default()
119 })
120 }
121 Object::Dict(dict) => dict,
122 _ => return None,
123 };
124 let embedded = match dict.get("EF") {
125 Some(ef) => match resolved_dict(src, ef).await {
126 Some(ef) => ["UF", "F", "DOS", "Mac", "Unix"]
127 .iter()
128 .find_map(|key| ef.get(key).and_then(Object::as_ref)),
129 None => None,
130 },
131 None => None,
132 };
133 Some(FileSpec {
134 file: bytes_entry(src, &dict, "F").await,
135 unicode_file: text_entry(src, &dict, "UF").await,
136 description: text_entry(src, &dict, "Desc").await,
137 file_system: match dict.get("FS") {
138 Some(fs) => src
139 .resolve(fs)
140 .await
141 .ok()
142 .and_then(|fs| fs.as_name().map(|n| n.0.clone())),
143 None => None,
144 },
145 volatile: match dict.get("V") {
146 Some(v) => src.resolve(v).await.ok().and_then(|v| v.as_bool()) == Some(true),
147 None => false,
148 },
149 embedded,
150 })
151}
152
153pub(crate) async fn bytes_entry<S: AsyncObjectSource>(
155 src: &S,
156 dict: &Dict,
157 key: &str,
158) -> Option<Vec<u8>> {
159 let value = src.resolve(dict.get(key)?).await.ok()?;
160 value.as_str_bytes().map(<[u8]>::to_vec)
161}
162
163async fn text_entry<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<String> {
165 bytes_entry(src, dict, key)
166 .await
167 .map(|bytes| decode_text_string(&bytes))
168}
169
170async fn date_entry<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<Date> {
172 let bytes = bytes_entry(src, dict, key).await?;
173 Date::parse_pdf(std::str::from_utf8(&bytes).ok()?)
174}
175
176#[derive(Debug, Clone, PartialEq)]
182pub struct EmbeddedFile {
183 pub name: String,
185 pub spec: FileSpec,
187 pub mime: Option<String>,
189 pub size: Option<u64>,
191 pub created: Option<Date>,
193 pub modified: Option<Date>,
195 pub checksum: Option<Vec<u8>>,
198}
199
200pub async fn embedded_files_with<S: AsyncObjectSource>(
205 src: &S,
206 trailer: &Dict,
207) -> Vec<EmbeddedFile> {
208 let mut files = Vec::new();
209 for (key, value) in names_with(src, trailer, NameTree::EmbeddedFiles).await {
210 let Some(spec) = file_spec_with(src, &value).await else {
211 continue;
212 };
213 let stream = match spec.embedded {
214 Some(r) => match src.get(r).await {
215 Ok(Object::Stream(stream)) => Some(stream.dict),
216 _ => None,
217 },
218 None => None,
219 };
220 let params = match stream.as_ref().and_then(|s| s.get("Params")) {
221 Some(p) => resolved_dict(src, p).await,
222 None => None,
223 };
224 let params = params.unwrap_or_default();
225 files.push(EmbeddedFile {
226 name: decode_text_string(&key),
227 spec,
228 mime: match stream.as_ref().and_then(|s| s.get("Subtype")) {
229 Some(subtype) => src
230 .resolve(subtype)
231 .await
232 .ok()
233 .and_then(|s| s.as_name().map(|n| n.0.clone())),
234 None => None,
235 },
236 size: match params.get("Size") {
237 Some(size) => src
238 .resolve(size)
239 .await
240 .ok()
241 .and_then(|s| s.as_int())
242 .and_then(|s| u64::try_from(s).ok()),
243 None => None,
244 },
245 created: date_entry(src, ¶ms, "CreationDate").await,
246 modified: date_entry(src, ¶ms, "ModDate").await,
247 checksum: bytes_entry(src, ¶ms, "CheckSum").await,
248 });
249 }
250 files
251}
252
253pub async fn embedded_file_data_with<S: AsyncObjectSource>(
263 src: &S,
264 file: &EmbeddedFile,
265) -> Result<Vec<u8>> {
266 file_spec_data_with(src, &file.spec).await
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::Document;
273 use pdfboss_testkit::PdfBuilder;
274
275 fn doc(objects: &[(u32, &str)], streams: &[(u32, &str, &[u8])]) -> Document {
278 let mut b = PdfBuilder::new();
279 b.object(
280 1,
281 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 10 0 R >> >>",
282 );
283 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
284 b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
285 for (num, body) in objects {
286 b.object(*num, body);
287 }
288 for (num, dict, data) in streams {
289 b.stream(*num, dict, data);
290 }
291 Document::load(b.build(1)).expect("load")
292 }
293
294 #[test]
298 fn url_specifications_name_a_url() {
299 let url = FileSpec {
300 file: Some(b"ftp://www.beatles.com/Movies/AbbeyRoad.mov".to_vec()),
301 file_system: Some("URL".to_string()),
302 ..FileSpec::default()
303 };
304 assert_eq!(
305 url.url().as_deref(),
306 Some("ftp://www.beatles.com/Movies/AbbeyRoad.mov")
307 );
308 let plain = FileSpec {
309 file: Some(b"notes.txt".to_vec()),
310 ..FileSpec::default()
311 };
312 assert_eq!(plain.url(), None);
313 let doc = doc(
314 &[(
315 10,
316 "<< /Names [(site) << /FS /URL /F (http://x.example/a) >>] >>",
317 )],
318 &[],
319 );
320 let files = doc.embedded_files();
321 assert_eq!(files[0].spec.url().as_deref(), Some("http://x.example/a"));
322 assert!(doc.embedded_file_data(&files[0]).is_err());
323 }
324
325 #[test]
327 fn embedded_files_come_from_the_name_tree() {
328 let doc = doc(
329 &[
330 (
331 10,
332 "<< /Names [(notes.txt) 11 0 R <FEFF00E90074> 13 0 R (external) 15 0 R (junk) 7] >>",
333 ),
334 (
335 11,
336 "<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /Desc <FEFF004E006F00740065> \
337 /EF << /F 12 0 R >> >>",
338 ),
339 (
340 13,
341 "<< /Type /Filespec /F (data/e#t.csv) /UF <FEFF00E9002E006300730076> \
342 /EF << /UF 14 0 R /F 12 0 R >> /V true >>",
343 ),
344 (15, "<< /Type /Filespec /FS /URL /F (http://example.com/x.pdf) >>"),
345 ],
346 &[
347 (
348 12,
349 "/Type /EmbeddedFile /Subtype /text#2Fplain /Params << /Size 5 \
350 /ModDate (D:20260907120000Z) /CheckSum <5d41402abc4b2a76b9719d911017c592> >>",
351 b"hello",
352 ),
353 (
354 14,
355 "/Type /EmbeddedFile /Subtype /text#2Fcsv /Params << /CreationDate (D:2025) >>",
356 b"a,b\n1,2\n",
357 ),
358 ],
359 );
360 let files = doc.embedded_files();
361 let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
362 assert_eq!(
363 names,
364 ["notes.txt", "ét", "external"],
365 "one per name, the number skipped"
366 );
367
368 let notes = &files[0];
369 assert_eq!(notes.spec.name(), "notes.txt");
370 assert_eq!(notes.spec.description.as_deref(), Some("Note"));
371 assert_eq!(notes.mime.as_deref(), Some("text/plain"));
372 assert_eq!(notes.size, Some(5));
373 assert_eq!(
374 notes.modified.map(|d| d.to_pdf_string()),
375 Some("D:20260907120000Z".to_string())
376 );
377 assert_eq!(
378 notes.checksum,
379 Some(vec![
380 0x5d, 0x41, 0x40, 0x2a, 0xbc, 0x4b, 0x2a, 0x76, 0xb9, 0x71, 0x9d, 0x91, 0x10, 0x17,
381 0xc5, 0x92
382 ])
383 );
384 assert!(!notes.spec.volatile);
385 assert_eq!(doc.embedded_file_data(notes).unwrap(), b"hello");
386
387 let csv = &files[1];
388 assert_eq!(csv.spec.name(), "é.csv", "/UF wins over /F");
389 assert_eq!(csv.spec.file.as_deref(), Some(b"data/e#t.csv".as_slice()));
390 assert_eq!(
391 csv.spec.embedded,
392 Some(ObjRef { num: 14, gen: 0 }),
393 "/EF /UF wins"
394 );
395 assert_eq!(csv.mime.as_deref(), Some("text/csv"));
396 assert_eq!(csv.size, None);
397 assert_eq!(csv.created.map(|d| d.year), Some(2025));
398 assert!(csv.spec.volatile);
399 assert_eq!(doc.embedded_file_data(csv).unwrap(), b"a,b\n1,2\n");
400
401 let external = &files[2];
402 assert_eq!(external.spec.file_system.as_deref(), Some("URL"));
403 assert_eq!(external.spec.embedded, None);
404 assert_eq!(external.mime, None);
405 assert!(matches!(
406 doc.embedded_file_data(external),
407 Err(Error::MissingKey("EF"))
408 ));
409 }
410
411 #[test]
413 fn file_specification_strings_split_on_unescaped_solidus() {
414 let parts = |s: &[u8]| -> Vec<String> {
415 spec_components(s)
416 .into_iter()
417 .map(|c| String::from_utf8(c).unwrap())
418 .collect()
419 };
420 assert_eq!(parts(b"in\\/out"), ["in/out"]);
422 assert_eq!(parts(b"ArtFiles/Figure1.pdf"), ["ArtFiles", "Figure1.pdf"]);
423 assert_eq!(
424 parts(b"/usr/local/x.pdf"),
425 ["", "usr", "local", "x.pdf"],
426 "absolute"
427 );
428 assert_eq!(parts(b"a\\\\b"), ["a\\b"], "an escaped reverse solidus");
429 assert_eq!(parts(b"dir/"), ["dir", ""], "an empty last component");
430 assert_eq!(parts(b""), [""]);
431 }
432
433 #[test]
435 fn a_file_specification_names_its_file_from_uf_then_f_then_the_legacy_keys() {
436 let doc = doc(
439 &[
440 (10, "<< /Names [] >>"),
441 (20, "(readme.txt)"),
442 (21, "<< /Type /Filespec /F <FEFF00E9> >>"),
443 (
444 22,
445 "<< /Type /Filespec /DOS (X.EPS) /EF << /DOS 31 0 R >> >>",
446 ),
447 (23, "42"),
448 ],
449 &[],
450 );
451 let read = |num: u32| {
452 let obj = Object::Ref(ObjRef { num, gen: 0 });
453 crate::block_on(file_spec_with(&crate::Immediate(&doc), &obj))
454 };
455 let bare = read(20).unwrap();
457 assert_eq!(bare.file.as_deref(), Some(b"readme.txt".as_slice()));
458 assert_eq!(bare.name(), "readme.txt");
459 assert_eq!(bare.embedded, None);
460 let f_only = read(21).unwrap();
462 assert_eq!(f_only.name(), "é");
463 let legacy = read(22).unwrap();
465 assert_eq!(legacy.embedded, Some(ObjRef { num: 31, gen: 0 }));
466 assert_eq!(legacy.name(), "");
467 assert_eq!(read(23), None, "not a specification");
468 }
469
470 #[test]
472 fn documents_without_embedded_files_have_none() {
473 let mut b = PdfBuilder::new();
474 b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
475 b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
476 let plain = Document::load(b.build(1)).unwrap();
477 assert!(plain.embedded_files().is_empty());
478 }
479}