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
49/// The components of a file specification string (§7.11.2.1): the string
50/// splits on every solidus not preceded by a reverse solidus, and the
51/// reverse solidi that escape a solidus or each other are removed. An
52/// absolute specification starts with an empty component.
53///
54/// Covers ISO 32000-1 §7.11.2.
55pub 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                // The escaped byte is kept as is; a trailing reverse
62                // solidus has nothing to escape and is dropped.
63                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
74/// Reads a file specification from `object`: a dictionary per Table 44, or
75/// a string, which is the `/F` of a specification with nothing else.
76///
77/// Covers ISO 32000-1 §7.11.2 and §7.11.3.
78pub 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
118/// A string entry's bytes, resolving an indirect one.
119async 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
124/// A text string entry, decoded.
125async 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
131/// A date entry, parsed.
132async 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/// One entry of the catalog's `/EmbeddedFiles` name tree: the name it is
138/// filed under, its file specification, and the embedded file stream's
139/// own entries (Tables 45 and 46).
140///
141/// Covers ISO 32000-1 §7.11.4.
142#[derive(Debug, Clone, PartialEq)]
143pub struct EmbeddedFile {
144    /// The name tree key, decoded as a text string.
145    pub name: String,
146    /// The file specification the name maps to.
147    pub spec: FileSpec,
148    /// The stream's `/Subtype`: a MIME type, its name escapes undone.
149    pub mime: Option<String>,
150    /// `/Params /Size`: the uncompressed size in bytes.
151    pub size: Option<u64>,
152    /// `/Params /CreationDate`.
153    pub created: Option<Date>,
154    /// `/Params /ModDate`.
155    pub modified: Option<Date>,
156    /// `/Params /CheckSum`: the MD5 digest of the uncompressed bytes, as
157    /// stored.
158    pub checksum: Option<Vec<u8>>,
159}
160
161/// Every entry of the catalog's `/EmbeddedFiles` tree, in tree order; an
162/// entry whose value is not a file specification is left out.
163///
164/// Covers ISO 32000-1 §7.11.4.
165pub 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, &params, "CreationDate").await,
207            modified: date_entry(src, &params, "ModDate").await,
208            checksum: bytes_entry(src, &params, "CheckSum").await,
209        });
210    }
211    files
212}
213
214/// The decoded bytes of `file`'s embedded stream.
215///
216/// # Errors
217///
218/// `MissingKey("EF")` when the specification embeds no stream, a type
219/// mismatch when the reference is not a stream, and the stream's own
220/// decoding errors.
221///
222/// Covers ISO 32000-1 §7.11.4.
223pub 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    /// A one-page document with `/Names` `/EmbeddedFiles` pointing at
244    /// object 10; `objects` supplies 10 and up, `streams` any streams.
245    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    // Covers ISO 32000-1 §7.11.4 and §7.11.3.
263    #[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    // Covers ISO 32000-1 §7.11.2.
349    #[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        // The clause's example: a reverse solidus keeps the solidus literal.
358        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    // Covers ISO 32000-1 §7.11.3 and §7.11.2.
371    #[test]
372    fn a_file_specification_names_its_file_from_uf_then_f_then_the_legacy_keys() {
373        // Objects 20 to 23 are specifications of different forms; the tree
374        // itself is not consulted here.
375        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        // A bare string is the /F of a specification.
393        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        // /F decodes as a text string when /UF is absent.
398        let f_only = read(21).unwrap();
399        assert_eq!(f_only.name(), "é");
400        // The obsolescent keys still name an embedded stream.
401        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    // Covers ISO 32000-1 §7.11.4.
408    #[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}