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