Skip to main content

pdfboss_core/
embedded_file.rs

1//! File specifications and embedded files (ISO 32000-1 §7.11.2 to §7.11.4):
2//! the strings and dictionaries that name a file, and the streams that
3//! carry a file's bytes inside the document, listed through the catalog's
4//! `/EmbeddedFiles` name tree.
5
6use 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/// A file specification (Table 44), from a dictionary or a bare string.
14///
15/// Covers ISO 32000-1 §7.11.3.
16#[derive(Debug, Clone, PartialEq, Default)]
17pub struct FileSpec {
18    /// `/F`: the file specification string as stored (§7.11.2), or the
19    /// whole specification when it was a bare string.
20    pub file: Option<Vec<u8>>,
21    /// `/UF`: the Unicode form of the specification, decoded.
22    pub unicode_file: Option<String>,
23    /// `/Desc`, decoded.
24    pub description: Option<String>,
25    /// `/FS`: the file system that interprets the specification, `URL`
26    /// being the one the standard defines.
27    pub file_system: Option<String>,
28    /// `/V`: whether the file changes often enough that it must not be
29    /// cached.
30    pub volatile: bool,
31    /// The embedded file stream from `/EF`: its `/UF`, else `/F`, else one
32    /// of the obsolescent `/DOS`, `/Mac` and `/Unix` entries.
33    pub embedded: Option<ObjRef>,
34}
35
36impl FileSpec {
37    /// The name to show: `/UF`, else `/F` decoded as a text string, else
38    /// empty.
39    ///
40    /// Covers ISO 32000-1 §7.11.3.
41    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    /// The URL a specification whose `/FS` is `URL` names: its `/F` read
49    /// as 7-bit ASCII, which the clause requires (a byte outside ASCII is
50    /// kept through PDFDocEncoding). `None` for any other file system.
51    ///
52    /// Covers ISO 32000-1 §7.11.5.
53    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
61/// The decoded bytes of the stream a file specification embeds.
62///
63/// # Errors
64///
65/// `MissingKey("EF")` when the specification embeds no stream, a type
66/// mismatch when the reference is not a stream, and the stream's own
67/// decoding errors.
68///
69/// Covers ISO 32000-1 §7.11.4.
70pub 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
84/// The components of a file specification string (§7.11.2.1): the string
85/// splits on every solidus not preceded by a reverse solidus, and the
86/// reverse solidi that escape a solidus or each other are removed. An
87/// absolute specification starts with an empty component.
88///
89/// Covers ISO 32000-1 §7.11.2.
90pub 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                // The escaped byte is kept as is; a trailing reverse
98                // solidus has nothing to escape and is dropped.
99                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
111/// Reads a file specification from `object`: a dictionary per Table 44, or
112/// a string, which is the `/F` of a specification with nothing else.
113///
114/// Covers ISO 32000-1 §7.11.2 and §7.11.3.
115pub 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
155/// A string entry's bytes, resolving an indirect one.
156pub(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
165/// A text string entry, decoded.
166async 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
172/// A date entry, parsed.
173async 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/// One entry of the catalog's `/EmbeddedFiles` name tree: the name it is
179/// filed under, its file specification, and the embedded file stream's
180/// own entries (Tables 45 and 46).
181///
182/// Covers ISO 32000-1 §7.11.4.
183#[derive(Debug, Clone, PartialEq)]
184pub struct EmbeddedFile {
185    /// The name tree key, decoded as a text string.
186    pub name: String,
187    /// The file specification the name maps to.
188    pub spec: FileSpec,
189    /// The stream's `/Subtype`: a MIME type, its name escapes undone.
190    pub mime: Option<String>,
191    /// `/Params /Size`: the uncompressed size in bytes.
192    pub size: Option<u64>,
193    /// `/Params /CreationDate`.
194    pub created: Option<Date>,
195    /// `/Params /ModDate`.
196    pub modified: Option<Date>,
197    /// `/Params /CheckSum`: the MD5 digest of the uncompressed bytes, as
198    /// stored.
199    pub checksum: Option<Vec<u8>>,
200}
201
202/// Every entry of the catalog's `/EmbeddedFiles` tree, in tree order; an
203/// entry whose value is not a file specification is left out.
204///
205/// Covers ISO 32000-1 §7.11.4.
206pub 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, &params, "CreationDate").await,
248            modified: date_entry(src, &params, "ModDate").await,
249            checksum: bytes_entry(src, &params, "CheckSum").await,
250        });
251    }
252    files
253}
254
255/// The decoded bytes of `file`'s embedded stream.
256///
257/// # Errors
258///
259/// `MissingKey("EF")` when the specification embeds no stream, a type
260/// mismatch when the reference is not a stream, and the stream's own
261/// decoding errors.
262///
263/// Covers ISO 32000-1 §7.11.4.
264pub 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    /// A one-page document with `/Names` `/EmbeddedFiles` pointing at
278    /// object 10; `objects` supplies 10 and up, `streams` any streams.
279    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    /// A specification whose `/FS` is `URL` names a URL in `/F`; any other
297    /// file system, or none, names no URL.
298    // Covers ISO 32000-1 §7.11.5.
299    #[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    // Covers ISO 32000-1 §7.11.4 and §7.11.3.
328    #[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    // Covers ISO 32000-1 §7.11.2.
414    #[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        // The clause's example: a reverse solidus keeps the solidus literal.
423        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    // Covers ISO 32000-1 §7.11.3 and §7.11.2.
436    #[test]
437    fn a_file_specification_names_its_file_from_uf_then_f_then_the_legacy_keys() {
438        // Objects 20 to 23 are specifications of different forms; the tree
439        // itself is not consulted here.
440        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        // A bare string is the /F of a specification.
458        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        // /F decodes as a text string when /UF is absent.
463        let f_only = read(21).unwrap();
464        assert_eq!(f_only.name(), "é");
465        // The obsolescent keys still name an embedded stream.
466        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    // Covers ISO 32000-1 §7.11.4.
473    #[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}