Skip to main content

stet_pdf_reader/
embedded_files.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF embedded files (file attachments).
6//!
7//! PDFs can carry arbitrary file attachments via the catalog's
8//! `/Names /EmbeddedFiles` name tree (PDF 1.4+) or as the `/FS`
9//! target of a `/FileAttachment` annotation. This module exposes the
10//! document-level table; the per-annotation form is reachable via
11//! [`Annotation`]'s [`FileAttachmentAnnotation`].
12//!
13//! Each entry is a *file specification* describing the attachment
14//! (name, description, relationship hint, MIME type, modification
15//! dates, byte size) plus a reference to the embedded-file stream.
16//! The bytes themselves load on demand via
17//! [`PdfDocument::embedded_file_bytes`].
18//!
19//! [`Annotation`]: crate::Annotation
20//! [`FileAttachmentAnnotation`]: crate::FileAttachmentAnnotation
21//! [`PdfDocument::embedded_file_bytes`]: crate::PdfDocument::embedded_file_bytes
22
23use std::collections::HashMap;
24
25use crate::metadata::{PdfDate, pdf_string_to_rust_pub};
26use crate::name_tree::walk_name_tree;
27use crate::objects::{PdfDict, PdfObj};
28use crate::resolver::Resolver;
29
30/// One embedded file.
31#[derive(Debug, Clone)]
32pub struct EmbeddedFile {
33    /// Display name (the key from the embedded-files name tree).
34    pub name: String,
35    /// `/F` filename (legacy ASCII).
36    pub filename: Option<String>,
37    /// `/UF` unicode filename.
38    pub unicode_filename: Option<String>,
39    /// `/Desc` description.
40    pub description: Option<String>,
41    /// `/AFRelationship` — author's hint about how this attachment
42    /// relates to the host document.
43    pub relationship: Option<AfRelationship>,
44    /// `/Subtype` on the embedded-file stream — typically a MIME
45    /// type encoded as a PDF name (e.g. `text/csv` → `/text#2Fcsv`).
46    pub mime_type: Option<String>,
47    /// `/Params /Size` — original byte length of the file.
48    pub size: Option<u64>,
49    /// `/Params /CreationDate`.
50    pub creation_date: Option<PdfDate>,
51    /// `/Params /ModDate`.
52    pub mod_date: Option<PdfDate>,
53    /// `/Params /CheckSum` — typically MD5 of the original content.
54    pub checksum: Option<Vec<u8>>,
55    /// Embedded-file stream object number.
56    pub stream_obj_num: u32,
57    /// Embedded-file stream generation number.
58    pub stream_gen_num: u16,
59}
60
61/// `/AFRelationship` — relationship of an associated file to the host
62/// document, from PDF 2.0 §14.13.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum AfRelationship {
66    Source,
67    Data,
68    Alternative,
69    Supplement,
70    EncryptedPayload,
71    FormData,
72    Schema,
73    Unspecified,
74    /// An unknown name; preserved verbatim.
75    Other(String),
76}
77
78impl AfRelationship {
79    fn from_name(name: &[u8]) -> Self {
80        match name {
81            b"Source" => AfRelationship::Source,
82            b"Data" => AfRelationship::Data,
83            b"Alternative" => AfRelationship::Alternative,
84            b"Supplement" => AfRelationship::Supplement,
85            b"EncryptedPayload" => AfRelationship::EncryptedPayload,
86            b"FormData" => AfRelationship::FormData,
87            b"Schema" => AfRelationship::Schema,
88            b"Unspecified" => AfRelationship::Unspecified,
89            other => AfRelationship::Other(String::from_utf8_lossy(other).into_owned()),
90        }
91    }
92}
93
94/// Walk the catalog's `/Names /EmbeddedFiles` name tree and produce a
95/// map of every attachment, keyed by the tree's name.
96///
97/// Returns an empty map when the document has no embedded files.
98pub fn parse_embedded_files(resolver: &Resolver) -> HashMap<String, EmbeddedFile> {
99    let mut map = HashMap::new();
100
101    let Some(catalog) = catalog_dict(resolver) else {
102        return map;
103    };
104    let Some(names_obj) = catalog.get(b"Names") else {
105        return map;
106    };
107    let Ok(names) = resolver.deref(names_obj) else {
108        return map;
109    };
110    let Some(names_dict) = names.as_dict() else {
111        return map;
112    };
113    let Some(ef_root) = names_dict.get(b"EmbeddedFiles") else {
114        return map;
115    };
116
117    map = walk_name_tree(resolver, ef_root, parse_filespec);
118    // The walker stamps the tree key into the map's key; we still
119    // need to populate `EmbeddedFile::name` from it so the value
120    // self-describes. Fix that up here.
121    for (key, value) in map.iter_mut() {
122        value.name = key.clone();
123    }
124    map
125}
126
127fn parse_filespec(resolver: &Resolver, obj: &PdfObj) -> Option<EmbeddedFile> {
128    let resolved = resolver.deref(obj).ok()?;
129    let dict = resolved.as_dict()?;
130
131    // /EF subdict pointing to the embedded-file stream.
132    let ef_obj = dict.get(b"EF")?;
133    let ef = resolver.deref(ef_obj).ok()?;
134    let ef_dict = ef.as_dict()?;
135    // Prefer /UF, fall back to /F.
136    let stream_ref = ef_dict.get_ref(b"UF").or_else(|| ef_dict.get_ref(b"F"))?;
137
138    // Pull the embedded-file stream's dict for size/dates/checksum/mime.
139    let mut size = None;
140    let mut creation_date = None;
141    let mut mod_date = None;
142    let mut checksum = None;
143    let mut mime_type = None;
144
145    if let Ok(stream) = resolver.resolve(stream_ref.0, stream_ref.1)
146        && let Some(stream_dict) = stream.as_dict()
147    {
148        if let Some(subtype) = stream_dict.get_name(b"Subtype") {
149            mime_type = Some(decode_mime_name(subtype));
150        }
151        if let Some(params_obj) = stream_dict.get(b"Params")
152            && let Ok(params_resolved) = resolver.deref(params_obj)
153            && let Some(params) = params_resolved.as_dict()
154        {
155            size = params.get_int(b"Size").and_then(|n| u64::try_from(n).ok());
156            creation_date = params
157                .get(b"CreationDate")
158                .and_then(|o| o.as_str())
159                .and_then(PdfDate::parse);
160            mod_date = params
161                .get(b"ModDate")
162                .and_then(|o| o.as_str())
163                .and_then(PdfDate::parse);
164            checksum = params
165                .get(b"CheckSum")
166                .and_then(|o| o.as_str())
167                .map(<[u8]>::to_vec);
168        }
169    }
170
171    let filename = dict.get(b"F").and_then(pdf_string_to_rust_pub);
172    let unicode_filename = dict.get(b"UF").and_then(pdf_string_to_rust_pub);
173    let description = dict.get(b"Desc").and_then(pdf_string_to_rust_pub);
174    let relationship = dict
175        .get_name(b"AFRelationship")
176        .map(AfRelationship::from_name);
177
178    Some(EmbeddedFile {
179        name: String::new(), // filled in by caller from the tree key
180        filename,
181        unicode_filename,
182        description,
183        relationship,
184        mime_type,
185        size,
186        creation_date,
187        mod_date,
188        checksum,
189        stream_obj_num: stream_ref.0,
190        stream_gen_num: stream_ref.1,
191    })
192}
193
194/// PDF `/Subtype` for an embedded-file stream is a MIME type encoded
195/// as a PDF name, with `/` replaced by `#2F`. Decode it.
196fn decode_mime_name(name: &[u8]) -> String {
197    let s = String::from_utf8_lossy(name);
198    // Hex-decode `#XX` escapes.
199    let mut out = String::with_capacity(s.len());
200    let bytes = s.as_bytes();
201    let mut i = 0;
202    while i < bytes.len() {
203        if bytes[i] == b'#'
204            && i + 2 < bytes.len()
205            && let Ok(b) =
206                u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
207        {
208            out.push(b as char);
209            i += 3;
210            continue;
211        }
212        out.push(bytes[i] as char);
213        i += 1;
214    }
215    out
216}
217
218fn catalog_dict(resolver: &Resolver) -> Option<PdfDict> {
219    if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
220        && let Ok(obj) = resolver.resolve(num, gen_num)
221        && let Some(dict) = obj.as_dict()
222    {
223        return Some(dict.clone());
224    }
225    crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
226}
227
228/// Decode the bytes of an embedded-file stream by `(obj_num, gen_num)`.
229///
230/// This is a thin wrapper around `Resolver::stream_data` for callers
231/// who already hold an [`EmbeddedFile`] and want to pull the bytes
232/// without going through the higher-level
233/// `PdfDocument::embedded_file_bytes`.
234pub fn decode_embedded_file_stream(
235    resolver: &Resolver,
236    obj_num: u32,
237    gen_num: u16,
238) -> Result<Vec<u8>, crate::PdfError> {
239    resolver.stream_data(obj_num, gen_num)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn af_relationship_from_name() {
248        assert_eq!(AfRelationship::from_name(b"Source"), AfRelationship::Source);
249        assert_eq!(AfRelationship::from_name(b"Data"), AfRelationship::Data);
250        assert_eq!(
251            AfRelationship::from_name(b"Custom"),
252            AfRelationship::Other("Custom".to_string())
253        );
254    }
255
256    #[test]
257    fn decode_mime_name_with_hex_escape() {
258        assert_eq!(decode_mime_name(b"text#2Fcsv"), "text/csv");
259        assert_eq!(decode_mime_name(b"application#2Fpdf"), "application/pdf");
260        assert_eq!(decode_mime_name(b"plain"), "plain");
261    }
262}