Skip to main content

oxideav_pdf/reader/
attachments.rs

1//! Round-33 — embedded file attachment reader
2//! (ISO 32000-1 §7.11 + §3.10 + §7.7.4 + §7.9.6).
3//!
4//! Walks the catalog's `/Names → /EmbeddedFiles` name tree, surfacing
5//! each registered file specification (`/Filespec`) as a structured
6//! [`PdfAttachment`] carrying:
7//!
8//! * The user-visible file name (preferring `/UF` UTF-16BE over `/F`
9//!   PDFDocEncoded per §7.11.2 Table 43).
10//! * The MIME type from the embedded-file stream's `/Subtype` per
11//!   §7.11.4 Table 45 (when present — the writer always emits it but
12//!   third-party PDFs sometimes omit it).
13//! * The decoded file payload (the embedded-file stream's body, with
14//!   `/Filter` reversed if needed — `FlateDecode` is the only filter
15//!   the symmetric writer emits, but we handle the no-filter case too).
16//! * The optional `/Params /ModDate` modification date (raw PDF date
17//!   string, no parse).
18//!
19//! This is the reader-side counterpart to
20//! [`crate::write_pdf_with_attachments`]. The walker is best-effort —
21//! malformed entries are skipped silently rather than aborting the
22//! whole tree (matches the round-26 annotation reader's contract).
23
24use crate::attachments::AfRelationship;
25use crate::error::PdfError;
26use crate::objects::{Dict, Object};
27use crate::reader::document::{decode_stream, DocumentReader};
28
29/// One embedded-file attachment surfaced by [`attachments`].
30#[derive(Debug, Clone)]
31pub struct PdfAttachment {
32    /// File name from `/UF` (UTF-16BE) or `/F` (PDFDocEncoded). The
33    /// reader prefers `/UF` when present per §7.11.2 Table 43.
34    pub name: String,
35    /// MIME type from the embedded-file stream's `/Subtype`. `None`
36    /// when omitted by the producer.
37    pub mime_type: Option<String>,
38    /// Decoded file payload — `/Filter`-reversed bytes.
39    pub bytes: Vec<u8>,
40    /// `/Params /ModDate` modification date (raw PDF date string,
41    /// `D:YYYYMMDDHHmmSSOHH'mm'` per §7.9.4). `None` when absent.
42    pub modified: Option<String>,
43    /// `/AFRelationship` Name from the filespec dict, parsed per
44    /// ISO 32000-2 §7.11.3 Table 44. `None` when the filespec omits
45    /// the entry (the spec defaults the *meaning* to `Unspecified`
46    /// but we surface absence as `None` so callers can distinguish a
47    /// PDF 1.x attachment from a PDF 2.0 producer that explicitly
48    /// wrote `/AFRelationship /Unspecified`). Vendor / second-class
49    /// names (§Annex E) outside the enumerated eight also surface as
50    /// `None` — the reader refuses to coerce unknown names.
51    pub af_relationship: Option<AfRelationship>,
52}
53
54/// Walk the catalog → `/Names → /EmbeddedFiles` name tree and surface
55/// every embedded file as a structured [`PdfAttachment`].
56///
57/// Returns `Ok(vec![])` when:
58///
59/// * The catalog has no `/Names` entry, or
60/// * `/Names` is present but has no `/EmbeddedFiles` sub-entry, or
61/// * The `/EmbeddedFiles` name tree is empty.
62///
63/// Returns [`PdfError::Other`] only when the catalog itself can't be
64/// resolved or doesn't decode to a Dict — every other malformed branch
65/// is skipped.
66pub fn attachments(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfAttachment>, PdfError> {
67    let root_id = reader.xref().root()?;
68    let catalog = reader.resolve(root_id)?;
69    let Object::Dict(catalog_dict) = catalog else {
70        return Err(PdfError::other(format!(
71            "PDF attachments reader: /Root must be a dict (got {catalog:?})"
72        )));
73    };
74
75    // Walk to /Names dict.
76    let names_obj = catalog_dict
77        .entries()
78        .iter()
79        .find(|(k, _)| k == "Names")
80        .map(|(_, v)| v.clone());
81    let Some(names_obj) = names_obj else {
82        return Ok(Vec::new());
83    };
84    let names_dict = match reader.deref(names_obj)? {
85        Object::Dict(d) => d,
86        _ => return Ok(Vec::new()),
87    };
88
89    // /EmbeddedFiles entry.
90    let ef_obj = names_dict
91        .entries()
92        .iter()
93        .find(|(k, _)| k == "EmbeddedFiles")
94        .map(|(_, v)| v.clone());
95    let Some(ef_obj) = ef_obj else {
96        return Ok(Vec::new());
97    };
98    let root_node = match reader.deref(ef_obj)? {
99        Object::Dict(d) => d,
100        _ => return Ok(Vec::new()),
101    };
102
103    // Walk the name tree, collecting (name, filespec_ref) pairs.
104    let mut entries: Vec<(String, Object)> = Vec::new();
105    walk_name_tree(reader, &root_node, &mut entries, 0)?;
106
107    // Resolve each filespec → PdfAttachment.
108    let mut out = Vec::with_capacity(entries.len());
109    for (name, filespec_value) in entries {
110        // Resolve filespec dict.
111        let filespec_dict = match reader.deref(filespec_value)? {
112            Object::Dict(d) => d,
113            _ => continue, // skip malformed entry
114        };
115        // The /UF key in the filespec dict overrides the name-tree key
116        // when present (ISO 32000-1 §7.11.3). Fall back to /F, then to
117        // the name-tree key.
118        let resolved_name = decode_filespec_name(&filespec_dict).unwrap_or(name);
119
120        // /EF dict — pointer to the embedded-file stream.
121        let ef_entry = filespec_dict
122            .entries()
123            .iter()
124            .find(|(k, _)| k == "EF")
125            .map(|(_, v)| v.clone());
126        let Some(ef_entry) = ef_entry else {
127            continue;
128        };
129        let ef_dict = match reader.deref(ef_entry)? {
130            Object::Dict(d) => d,
131            _ => continue,
132        };
133        // Prefer /UF in the EF dict (PDF 1.7+); fall back to /F.
134        let stream_ref = ef_dict
135            .entries()
136            .iter()
137            .find(|(k, _)| k == "UF")
138            .or_else(|| ef_dict.entries().iter().find(|(k, _)| k == "F"))
139            .map(|(_, v)| v.clone());
140        let Some(stream_ref) = stream_ref else {
141            continue;
142        };
143        let stream_obj = match reader.deref(stream_ref)? {
144            Object::Stream(s) => s,
145            _ => continue,
146        };
147
148        let mime_type = stream_obj
149            .dict
150            .entries()
151            .iter()
152            .find(|(k, _)| k == "Subtype")
153            .and_then(|(_, v)| match v {
154                Object::Name(s) => Some(s.clone()),
155                _ => None,
156            });
157
158        let modified = read_params_moddate(&stream_obj.dict);
159
160        let af_relationship = read_af_relationship(&filespec_dict);
161
162        let bytes = decode_stream(&stream_obj)?;
163
164        out.push(PdfAttachment {
165            name: resolved_name,
166            mime_type,
167            bytes,
168            modified,
169            af_relationship,
170        });
171    }
172
173    Ok(out)
174}
175
176/// Walk a name-tree node — either an intermediate node (carrying
177/// `/Kids` whose entries are sub-node refs) or a leaf (carrying
178/// `/Names [key value …]`). Per §7.9.6.
179///
180/// Bounded recursion (depth ≤ 32) so a malformed tree can't blow the
181/// stack.
182fn walk_name_tree(
183    reader: &mut DocumentReader<'_>,
184    node: &Dict,
185    out: &mut Vec<(String, Object)>,
186    depth: usize,
187) -> Result<(), PdfError> {
188    if depth > 32 {
189        return Ok(()); // defensive — bound recursion
190    }
191    if out.len() > 100_000 {
192        return Ok(()); // defensive — bound output
193    }
194    // Leaf node: `/Names [key1 val1 key2 val2 …]`.
195    if let Some(Object::Array(items)) = node
196        .entries()
197        .iter()
198        .find(|(k, _)| k == "Names")
199        .map(|(_, v)| v)
200    {
201        let mut iter = items.iter();
202        while let (Some(key_obj), Some(val_obj)) = (iter.next(), iter.next()) {
203            let Some(key) = decode_text_obj(key_obj) else {
204                continue;
205            };
206            out.push((key, val_obj.clone()));
207        }
208        return Ok(());
209    }
210    // Intermediate node: `/Kids [child-ref child-ref …]`.
211    if let Some(kids_obj) = node
212        .entries()
213        .iter()
214        .find(|(k, _)| k == "Kids")
215        .map(|(_, v)| v.clone())
216    {
217        let kids = match reader.deref(kids_obj)? {
218            Object::Array(items) => items,
219            _ => return Ok(()),
220        };
221        for kid in kids {
222            let kid_dict = match reader.deref(kid)? {
223                Object::Dict(d) => d,
224                _ => continue,
225            };
226            walk_name_tree(reader, &kid_dict, out, depth + 1)?;
227        }
228    }
229    Ok(())
230}
231
232/// Decode the `/UF` (preferred, PDF 1.7+) or `/F` filename entry from
233/// a `/Filespec` dict. UTF-16BE-with-BOM hex strings decode to a
234/// String; ASCII literal strings pass through.
235fn decode_filespec_name(filespec: &Dict) -> Option<String> {
236    let pick = filespec
237        .entries()
238        .iter()
239        .find(|(k, _)| k == "UF")
240        .or_else(|| filespec.entries().iter().find(|(k, _)| k == "F"));
241    pick.and_then(|(_, v)| decode_text_obj(v))
242}
243
244/// Decode the optional `/AFRelationship` Name from a filespec dict
245/// (ISO 32000-2 §7.11.3 Table 44). Returns `None` when the entry is
246/// absent, isn't a Name, or carries a vendor / second-class Name that
247/// isn't one of the eight enumerated relationships — the reader
248/// refuses to coerce unknown names.
249fn read_af_relationship(filespec: &Dict) -> Option<AfRelationship> {
250    let val = filespec
251        .entries()
252        .iter()
253        .find(|(k, _)| k == "AFRelationship")
254        .map(|(_, v)| v)?;
255    match val {
256        Object::Name(s) => AfRelationship::from_pdf_name(s),
257        _ => None,
258    }
259}
260
261/// Decode a `/Params /ModDate` entry from the embedded-file stream
262/// dict. Returns the raw PDF date string with no parse.
263fn read_params_moddate(dict: &Dict) -> Option<String> {
264    let params = dict
265        .entries()
266        .iter()
267        .find(|(k, _)| k == "Params")
268        .map(|(_, v)| v)?;
269    let params_dict = match params {
270        Object::Dict(d) => d,
271        _ => return None,
272    };
273    params_dict
274        .entries()
275        .iter()
276        .find(|(k, _)| k == "ModDate")
277        .and_then(|(_, v)| decode_text_obj(v))
278}
279
280/// Decode a PDF text object into a `String`. Mirrors the round-25
281/// outline-title decoder: literal-string → UTF-8 lossy; hex-string →
282/// UTF-16BE when prefixed with the BOM, else UTF-8 lossy.
283fn decode_text_obj(obj: &Object) -> Option<String> {
284    match obj {
285        Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
286        Object::HexString(b) => {
287            if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
288                let utf16: Vec<u16> = b[2..]
289                    .chunks_exact(2)
290                    .map(|c| u16::from_be_bytes([c[0], c[1]]))
291                    .collect();
292                Some(String::from_utf16_lossy(&utf16))
293            } else {
294                Some(String::from_utf8_lossy(b).into_owned())
295            }
296        }
297        Object::Name(s) => Some(s.clone()),
298        _ => None,
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn decode_text_obj_literal_string_passes_through() {
308        let s = decode_text_obj(&Object::LiteralString(b"hello".to_vec()));
309        assert_eq!(s.as_deref(), Some("hello"));
310    }
311
312    #[test]
313    fn decode_text_obj_utf16be_hex_decodes() {
314        // FEFF + UTF-16BE "Hi" = FEFF 0048 0069
315        let s = decode_text_obj(&Object::HexString(vec![0xFE, 0xFF, 0x00, 0x48, 0x00, 0x69]));
316        assert_eq!(s.as_deref(), Some("Hi"));
317    }
318
319    #[test]
320    fn decode_text_obj_hex_without_bom_treated_as_utf8() {
321        let s = decode_text_obj(&Object::HexString(b"hello".to_vec()));
322        assert_eq!(s.as_deref(), Some("hello"));
323    }
324}