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
49pub fn spec_components(spec: &[u8]) -> Vec<Vec<u8>> {
56 let mut components = vec![Vec::new()];
57 let mut bytes = spec.iter();
58 while let Some(&b) = bytes.next() {
59 match b {
60 b'\\' => {
61 if let Some(&escaped) = bytes.next() {
64 components.last_mut().expect("never empty").push(escaped);
65 }
66 }
67 b'/' => components.push(Vec::new()),
68 other => components.last_mut().expect("never empty").push(other),
69 }
70 }
71 components
72}
73
74pub async fn file_spec_with<S: AsyncObjectSource>(src: &S, object: &Object) -> Option<FileSpec> {
79 let dict = match src.resolve(object).await.ok()? {
80 Object::String(bytes) => {
81 return Some(FileSpec {
82 file: Some(bytes),
83 ..FileSpec::default()
84 })
85 }
86 Object::Dict(dict) => dict,
87 _ => return None,
88 };
89 let embedded = match dict.get("EF") {
90 Some(ef) => match resolved_dict(src, ef).await {
91 Some(ef) => ["UF", "F", "DOS", "Mac", "Unix"]
92 .iter()
93 .find_map(|key| ef.get(key).and_then(Object::as_ref)),
94 None => None,
95 },
96 None => None,
97 };
98 Some(FileSpec {
99 file: bytes_entry(src, &dict, "F").await,
100 unicode_file: text_entry(src, &dict, "UF").await,
101 description: text_entry(src, &dict, "Desc").await,
102 file_system: match dict.get("FS") {
103 Some(fs) => src
104 .resolve(fs)
105 .await
106 .ok()
107 .and_then(|fs| fs.as_name().map(|n| n.0.clone())),
108 None => None,
109 },
110 volatile: match dict.get("V") {
111 Some(v) => src.resolve(v).await.ok().and_then(|v| v.as_bool()) == Some(true),
112 None => false,
113 },
114 embedded,
115 })
116}
117
118async fn bytes_entry<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<Vec<u8>> {
120 let value = src.resolve(dict.get(key)?).await.ok()?;
121 value.as_str_bytes().map(<[u8]>::to_vec)
122}
123
124async fn text_entry<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<String> {
126 bytes_entry(src, dict, key)
127 .await
128 .map(|bytes| decode_text_string(&bytes))
129}
130
131async fn date_entry<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<Date> {
133 let bytes = bytes_entry(src, dict, key).await?;
134 Date::parse_pdf(std::str::from_utf8(&bytes).ok()?)
135}
136
137#[derive(Debug, Clone, PartialEq)]
143pub struct EmbeddedFile {
144 pub name: String,
146 pub spec: FileSpec,
148 pub mime: Option<String>,
150 pub size: Option<u64>,
152 pub created: Option<Date>,
154 pub modified: Option<Date>,
156 pub checksum: Option<Vec<u8>>,
159}
160
161pub async fn embedded_files_with<S: AsyncObjectSource>(
166 src: &S,
167 trailer: &Dict,
168) -> Vec<EmbeddedFile> {
169 let mut files = Vec::new();
170 for (key, value) in names_with(src, trailer, NameTree::EmbeddedFiles).await {
171 let Some(spec) = file_spec_with(src, &value).await else {
172 continue;
173 };
174 let stream = match spec.embedded {
175 Some(r) => match src.get(r).await {
176 Ok(Object::Stream(stream)) => Some(stream.dict),
177 _ => None,
178 },
179 None => None,
180 };
181 let params = match stream.as_ref().and_then(|s| s.get("Params")) {
182 Some(p) => resolved_dict(src, p).await,
183 None => None,
184 };
185 let params = params.unwrap_or_default();
186 files.push(EmbeddedFile {
187 name: decode_text_string(&key),
188 spec,
189 mime: match stream.as_ref().and_then(|s| s.get("Subtype")) {
190 Some(subtype) => src
191 .resolve(subtype)
192 .await
193 .ok()
194 .and_then(|s| s.as_name().map(|n| n.0.clone())),
195 None => None,
196 },
197 size: match params.get("Size") {
198 Some(size) => src
199 .resolve(size)
200 .await
201 .ok()
202 .and_then(|s| s.as_int())
203 .and_then(|s| u64::try_from(s).ok()),
204 None => None,
205 },
206 created: date_entry(src, ¶ms, "CreationDate").await,
207 modified: date_entry(src, ¶ms, "ModDate").await,
208 checksum: bytes_entry(src, ¶ms, "CheckSum").await,
209 });
210 }
211 files
212}
213
214pub async fn embedded_file_data_with<S: AsyncObjectSource>(
224 src: &S,
225 file: &EmbeddedFile,
226) -> Result<Vec<u8>> {
227 let r = file.spec.embedded.ok_or(Error::MissingKey("EF"))?;
228 match src.get(r).await? {
229 Object::Stream(stream) => src.stream_data(&stream).await,
230 other => Err(Error::TypeMismatch {
231 expected: "stream",
232 found: crate::document::type_name(&other),
233 }),
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::Document;
241 use pdfboss_testkit::PdfBuilder;
242
243 fn doc(objects: &[(u32, &str)], streams: &[(u32, &str, &[u8])]) -> Document {
246 let mut b = PdfBuilder::new();
247 b.object(
248 1,
249 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 10 0 R >> >>",
250 );
251 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
252 b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
253 for (num, body) in objects {
254 b.object(*num, body);
255 }
256 for (num, dict, data) in streams {
257 b.stream(*num, dict, data);
258 }
259 Document::load(b.build(1)).expect("load")
260 }
261
262 #[test]
264 fn embedded_files_come_from_the_name_tree() {
265 let doc = doc(
266 &[
267 (
268 10,
269 "<< /Names [(notes.txt) 11 0 R <FEFF00E90074> 13 0 R (external) 15 0 R (junk) 7] >>",
270 ),
271 (
272 11,
273 "<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /Desc <FEFF004E006F00740065> \
274 /EF << /F 12 0 R >> >>",
275 ),
276 (
277 13,
278 "<< /Type /Filespec /F (data/e#t.csv) /UF <FEFF00E9002E006300730076> \
279 /EF << /UF 14 0 R /F 12 0 R >> /V true >>",
280 ),
281 (15, "<< /Type /Filespec /FS /URL /F (http://example.com/x.pdf) >>"),
282 ],
283 &[
284 (
285 12,
286 "/Type /EmbeddedFile /Subtype /text#2Fplain /Params << /Size 5 \
287 /ModDate (D:20260907120000Z) /CheckSum <5d41402abc4b2a76b9719d911017c592> >>",
288 b"hello",
289 ),
290 (
291 14,
292 "/Type /EmbeddedFile /Subtype /text#2Fcsv /Params << /CreationDate (D:2025) >>",
293 b"a,b\n1,2\n",
294 ),
295 ],
296 );
297 let files = doc.embedded_files();
298 let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
299 assert_eq!(
300 names,
301 ["notes.txt", "ét", "external"],
302 "one per name, the number skipped"
303 );
304
305 let notes = &files[0];
306 assert_eq!(notes.spec.name(), "notes.txt");
307 assert_eq!(notes.spec.description.as_deref(), Some("Note"));
308 assert_eq!(notes.mime.as_deref(), Some("text/plain"));
309 assert_eq!(notes.size, Some(5));
310 assert_eq!(
311 notes.modified.map(|d| d.to_pdf_string()),
312 Some("D:20260907120000Z".to_string())
313 );
314 assert_eq!(
315 notes.checksum,
316 Some(vec![
317 0x5d, 0x41, 0x40, 0x2a, 0xbc, 0x4b, 0x2a, 0x76, 0xb9, 0x71, 0x9d, 0x91, 0x10, 0x17,
318 0xc5, 0x92
319 ])
320 );
321 assert!(!notes.spec.volatile);
322 assert_eq!(doc.embedded_file_data(notes).unwrap(), b"hello");
323
324 let csv = &files[1];
325 assert_eq!(csv.spec.name(), "é.csv", "/UF wins over /F");
326 assert_eq!(csv.spec.file.as_deref(), Some(b"data/e#t.csv".as_slice()));
327 assert_eq!(
328 csv.spec.embedded,
329 Some(ObjRef { num: 14, gen: 0 }),
330 "/EF /UF wins"
331 );
332 assert_eq!(csv.mime.as_deref(), Some("text/csv"));
333 assert_eq!(csv.size, None);
334 assert_eq!(csv.created.map(|d| d.year), Some(2025));
335 assert!(csv.spec.volatile);
336 assert_eq!(doc.embedded_file_data(csv).unwrap(), b"a,b\n1,2\n");
337
338 let external = &files[2];
339 assert_eq!(external.spec.file_system.as_deref(), Some("URL"));
340 assert_eq!(external.spec.embedded, None);
341 assert_eq!(external.mime, None);
342 assert!(matches!(
343 doc.embedded_file_data(external),
344 Err(Error::MissingKey("EF"))
345 ));
346 }
347
348 #[test]
350 fn file_specification_strings_split_on_unescaped_solidus() {
351 let parts = |s: &[u8]| -> Vec<String> {
352 spec_components(s)
353 .into_iter()
354 .map(|c| String::from_utf8(c).unwrap())
355 .collect()
356 };
357 assert_eq!(parts(b"in\\/out"), ["in/out"]);
359 assert_eq!(parts(b"ArtFiles/Figure1.pdf"), ["ArtFiles", "Figure1.pdf"]);
360 assert_eq!(
361 parts(b"/usr/local/x.pdf"),
362 ["", "usr", "local", "x.pdf"],
363 "absolute"
364 );
365 assert_eq!(parts(b"a\\\\b"), ["a\\b"], "an escaped reverse solidus");
366 assert_eq!(parts(b"dir/"), ["dir", ""], "an empty last component");
367 assert_eq!(parts(b""), [""]);
368 }
369
370 #[test]
372 fn a_file_specification_names_its_file_from_uf_then_f_then_the_legacy_keys() {
373 let doc = doc(
376 &[
377 (10, "<< /Names [] >>"),
378 (20, "(readme.txt)"),
379 (21, "<< /Type /Filespec /F <FEFF00E9> >>"),
380 (
381 22,
382 "<< /Type /Filespec /DOS (X.EPS) /EF << /DOS 31 0 R >> >>",
383 ),
384 (23, "42"),
385 ],
386 &[],
387 );
388 let read = |num: u32| {
389 let obj = Object::Ref(ObjRef { num, gen: 0 });
390 crate::block_on(file_spec_with(&crate::Immediate(&doc), &obj))
391 };
392 let bare = read(20).unwrap();
394 assert_eq!(bare.file.as_deref(), Some(b"readme.txt".as_slice()));
395 assert_eq!(bare.name(), "readme.txt");
396 assert_eq!(bare.embedded, None);
397 let f_only = read(21).unwrap();
399 assert_eq!(f_only.name(), "é");
400 let legacy = read(22).unwrap();
402 assert_eq!(legacy.embedded, Some(ObjRef { num: 31, gen: 0 }));
403 assert_eq!(legacy.name(), "");
404 assert_eq!(read(23), None, "not a specification");
405 }
406
407 #[test]
409 fn documents_without_embedded_files_have_none() {
410 let mut b = PdfBuilder::new();
411 b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
412 b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
413 let plain = Document::load(b.build(1)).unwrap();
414 assert!(plain.embedded_files().is_empty());
415 }
416}