Skip to main content

pdfrum_edit/
attach.rs

1//! The attachment writers: embedded files added, replaced, described and
2//! removed through the document editor. The `/EmbeddedFiles` name tree is
3//! rewritten flat on every change, a shape every reader accepts.
4
5use crate::{EditDoc, Error};
6use pdfrum_common::{Diagnostics, Limits};
7use pdfrum_object::{
8    Array, ByteSpan, Dict, Name, ObjRef, Object, PdfString, Resolve, Stream, encode_text,
9};
10
11/// An attachment write either applies or names why it could not.
12type Result<T> = core::result::Result<T, Error>;
13
14/// What an attachment carries besides its name and bytes.
15///
16/// A config struct with [`Default`]. `#[non_exhaustive]` so a field added
17/// later is not a major break; fill one in with [`AttachmentOptions::builder`].
18/// Every field is optional and an absent one writes no key.
19///
20/// ```
21/// use pdfrum_edit::AttachmentOptions;
22///
23/// let options = AttachmentOptions::builder()
24///     .description("The source data")
25///     .mime_type("text/csv")
26///     .build();
27/// assert!(options.modified.is_none());
28/// ```
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
30#[non_exhaustive]
31pub struct AttachmentOptions {
32    /// The file specification's `/Desc`, the text a viewer shows beside the
33    /// name. Read back by `Attachment::description`.
34    pub description: Option<String>,
35    /// The embedded file's MIME type — `text/plain`, `application/pdf` —
36    /// written as the stream's `/Subtype` name. Read back by
37    /// the facade's `Attachment::subtype`.
38    pub mime_type: Option<String>,
39    /// The file's own modification time as a PDF date string
40    /// (`D:YYYYMMDDHHmmSS…`, ISO 32000-1 §7.9.4), written to `/Params
41    /// /ModDate`. [`pdf_date`](crate::pdf_date) spells a `SystemTime` that
42    /// way. Read back by the facade's `Attachment::param`.
43    pub modified: Option<String>,
44}
45
46/// Builds an [`AttachmentOptions`] a setting at a time.
47///
48/// The way to change one field from outside this crate: the type is
49/// `#[non_exhaustive]`, so struct-update syntax is a same-crate spelling.
50/// Each method takes an `impl Into<String>`, so the `Some(…into())` the
51/// fields need is written once here rather than at every call site.
52///
53/// ```
54/// use pdfrum_edit::AttachmentOptions;
55///
56/// let options = AttachmentOptions::builder()
57///     .description("The source data")
58///     .mime_type("text/csv")
59///     .build();
60///
61/// assert_eq!(options.description.as_deref(), Some("The source data"));
62/// assert!(options.modified.is_none());
63/// ```
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65#[must_use]
66pub struct AttachmentOptionsBuilder(AttachmentOptions);
67
68impl AttachmentOptionsBuilder {
69    /// The text a viewer shows beside the name —
70    /// [`AttachmentOptions::description`].
71    ///
72    /// ```
73    /// let options = pdfrum::AttachmentOptions::builder().description("notes").build();
74    /// assert_eq!(options.description.as_deref(), Some("notes"));
75    /// ```
76    pub fn description(mut self, description: impl Into<String>) -> Self {
77        self.0.description = Some(description.into());
78        self
79    }
80
81    /// The embedded file's MIME type — [`AttachmentOptions::mime_type`].
82    ///
83    /// ```
84    /// let options = pdfrum::AttachmentOptions::builder().mime_type("text/csv").build();
85    /// assert_eq!(options.mime_type.as_deref(), Some("text/csv"));
86    /// ```
87    pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
88        self.0.mime_type = Some(mime_type.into());
89        self
90    }
91
92    /// The file's modification time as a PDF date string —
93    /// [`AttachmentOptions::modified`]. [`pdf_date`](crate::pdf_date) spells
94    /// a `SystemTime` that way.
95    ///
96    /// ```
97    /// let options = pdfrum::AttachmentOptions::builder()
98    ///     .modified("D:20260906120000Z")
99    ///     .build();
100    /// assert!(options.modified.is_some());
101    /// ```
102    pub fn modified(mut self, modified: impl Into<String>) -> Self {
103        self.0.modified = Some(modified.into());
104        self
105    }
106
107    /// The options as built.
108    ///
109    /// ```
110    /// let options = pdfrum::AttachmentOptions::builder().build();
111    /// assert_eq!(options, pdfrum::AttachmentOptions::default());
112    /// ```
113    #[must_use]
114    pub fn build(self) -> AttachmentOptions {
115        self.0
116    }
117}
118
119impl AttachmentOptions {
120    /// A builder starting from the defaults.
121    ///
122    /// ```
123    /// let options = pdfrum::AttachmentOptions::builder().mime_type("text/csv").build();
124    /// ```
125    pub fn builder() -> AttachmentOptionsBuilder {
126        AttachmentOptionsBuilder::default()
127    }
128}
129
130/// The embedded file stream (ISO 32000-1 §7.11.4): `/Type /EmbeddedFile`,
131/// the MIME type as `/Subtype`, `/DL` and `/Params` with the size, an MD5
132/// `/CheckSum` and the modification date when one was given. The save
133/// flate-compresses it like every other filterless stream it writes.
134fn embedded_file(bytes: &[u8], mime_type: Option<&str>, modified: Option<&str>) -> Stream {
135    let len = i64::try_from(bytes.len()).unwrap_or(i64::MAX);
136    let mut params = Dict::new();
137    params.insert(Name::from("Size"), Object::Int(len));
138    params.insert(
139        Name::from("CheckSum"),
140        Object::Str(PdfString::hex(pdfrum_crypt::md5(bytes))),
141    );
142    if let Some(modified) = modified.filter(|date| !date.is_empty()) {
143        params.insert(
144            Name::from("ModDate"),
145            Object::Str(PdfString::literal(encode_text(modified))),
146        );
147    }
148    let mut dict = Dict::new();
149    dict.insert(Name::from("Type"), Object::Name(Name::from("EmbeddedFile")));
150    if let Some(mime_type) = mime_type.filter(|mime| !mime.is_empty()) {
151        dict.insert(
152            Name::from("Subtype"),
153            Object::Name(Name::from(mime_type.as_bytes())),
154        );
155    }
156    dict.insert(Name::from("DL"), Object::Int(len));
157    dict.insert(Name::from("Params"), Object::Dict(params));
158    Stream::new(dict, ByteSpan::from(bytes.to_vec()))
159}
160
161/// The current attachments as (name, value) pairs, read through the
162/// edits so far; empty without a tree.
163pub(crate) fn attachment_entries(
164    edit: &EditDoc<'_>,
165    limits: &Limits,
166) -> Result<Vec<(String, Object)>> {
167    let Some(root) = edit.base().trailer().reference(&Name::from("Root")) else {
168        return Err(Error::NoDestinationCatalog);
169    };
170    let Ok(catalog) = edit.fetch(root) else {
171        return Ok(Vec::new());
172    };
173    let Some(catalog) = catalog.as_dict() else {
174        return Ok(Vec::new());
175    };
176    let Some(names) = catalog.dict(&Name::from("Names"), edit) else {
177        return Ok(Vec::new());
178    };
179    let Some(files) = names.dict(&Name::from("EmbeddedFiles"), edit) else {
180        return Ok(Vec::new());
181    };
182    let tree = pdfrum_doc::NameTree { root: files };
183    let mut diags = Diagnostics::default();
184    let count = tree.count(edit, limits, &mut diags);
185    Ok((0..count)
186        .filter_map(|index| tree.lookup_by_index(index, edit, limits, &mut diags))
187        .collect())
188}
189
190/// The file specification of attachment `index` — its reference when it
191/// is indirect — and its dictionary; `None` when out of range or not a
192/// dictionary.
193pub(crate) fn attachment_spec(
194    edit: &EditDoc<'_>,
195    limits: &Limits,
196    index: usize,
197) -> Result<Option<(Option<ObjRef>, Dict)>> {
198    let entries = attachment_entries(edit, limits)?;
199    let Some(entry) = entries.get(index) else {
200        return Ok(None);
201    };
202    Ok(match &entry.1 {
203        Object::Ref(reference) => {
204            let Ok(object) = edit.fetch(*reference) else {
205                return Ok(None);
206            };
207            object
208                .as_dict()
209                .map(|dict| (Some(*reference), dict.clone()))
210        }
211        Object::Dict(dict) => Some((None, dict.clone())),
212        _ => None,
213    })
214}
215
216/// Writes `entries` back as a flat `/Names` array. `/Names` and
217/// `/EmbeddedFiles` are created as new indirect objects when missing,
218/// referenced from their parent; the innermost *indirect* holder is
219/// replaced, and an inline holder is rewritten inside its parent, outward
220/// to the catalog.
221pub(crate) fn write_attachment_entries(
222    edit: &mut EditDoc<'_>,
223    entries: Vec<(String, Object)>,
224) -> Result<()> {
225    let k_root = Name::from("Root");
226    let k_names = Name::from("Names");
227    let k_ef = Name::from("EmbeddedFiles");
228    let k_kids = Name::from("Kids");
229    let k_limits = Name::from("Limits");
230    let Some(root) = edit.base().trailer().reference(&k_root) else {
231        return Err(Error::NoDestinationCatalog);
232    };
233    let dict_at = |edit: &EditDoc<'_>, reference: ObjRef| {
234        edit.fetch(reference)
235            .ok()
236            .as_deref()
237            .and_then(Object::as_dict)
238            .cloned()
239    };
240    let mut catalog = dict_at(edit, root).unwrap_or_default();
241    let mut names_array = Array::new();
242    for (name, value) in entries {
243        names_array.push(Object::Str(PdfString::literal(encode_text(&name))));
244        names_array.push(value);
245    }
246    let tree_of = |existing: Option<Dict>| {
247        let mut tree = existing.unwrap_or_default();
248        tree.remove(&k_kids);
249        tree.remove(&k_limits);
250        tree.insert(k_names.clone(), Object::Array(names_array.clone()));
251        tree
252    };
253    match catalog.raw(&k_names).cloned() {
254        Some(Object::Ref(names_ref)) => {
255            let mut names = dict_at(edit, names_ref).unwrap_or_default();
256            match names.raw(&k_ef).cloned() {
257                Some(Object::Ref(tree_ref)) => {
258                    let existing = dict_at(edit, tree_ref);
259                    edit.replace(tree_ref, Object::Dict(tree_of(existing)));
260                }
261                Some(Object::Dict(inline)) => {
262                    names.insert(k_ef.clone(), Object::Dict(tree_of(Some(inline))));
263                    edit.replace(names_ref, Object::Dict(names));
264                }
265                _ => {
266                    let tree_ref = edit.add(Object::Dict(tree_of(None)));
267                    names.insert(k_ef.clone(), Object::Ref(tree_ref));
268                    edit.replace(names_ref, Object::Dict(names));
269                }
270            }
271        }
272        Some(Object::Dict(mut names)) => match names.raw(&k_ef).cloned() {
273            Some(Object::Ref(tree_ref)) => {
274                let existing = dict_at(edit, tree_ref);
275                edit.replace(tree_ref, Object::Dict(tree_of(existing)));
276            }
277            Some(Object::Dict(inline)) => {
278                names.insert(k_ef.clone(), Object::Dict(tree_of(Some(inline))));
279                catalog.insert(k_names.clone(), Object::Dict(names));
280                edit.replace(root, Object::Dict(catalog));
281            }
282            _ => {
283                let tree_ref = edit.add(Object::Dict(tree_of(None)));
284                names.insert(k_ef.clone(), Object::Ref(tree_ref));
285                catalog.insert(k_names.clone(), Object::Dict(names));
286                edit.replace(root, Object::Dict(catalog));
287            }
288        },
289        _ => {
290            let tree_ref = edit.add(Object::Dict(tree_of(None)));
291            let mut names = Dict::new();
292            names.insert(k_ef.clone(), Object::Ref(tree_ref));
293            let names_ref = edit.add(Object::Dict(names));
294            catalog.insert(k_names.clone(), Object::Ref(names_ref));
295            edit.replace(root, Object::Dict(catalog));
296        }
297    }
298    Ok(())
299}
300
301/// Stores a rewritten file specification for attachment `index`:
302/// replaces it when it is indirect, otherwise rewrites the tree entry
303/// inline.
304pub(crate) fn store_attachment_spec(
305    edit: &mut EditDoc<'_>,
306    limits: &Limits,
307    index: usize,
308    reference: Option<ObjRef>,
309    spec: Dict,
310) -> Result<()> {
311    if let Some(reference) = reference {
312        edit.replace(reference, Object::Dict(spec));
313        return Ok(());
314    }
315    let mut entries = attachment_entries(edit, limits)?;
316    if let Some(entry) = entries.get_mut(index) {
317        entry.1 = Object::Dict(spec);
318    }
319    write_attachment_entries(edit, entries)
320}
321
322/// Removes attachment `index` from the name tree; `Ok(false)` when there
323/// is no such attachment.
324///
325/// # Errors
326///
327/// When the document has no catalog to hold the tree.
328pub fn delete_attachment(edit: &mut EditDoc<'_>, limits: &Limits, index: usize) -> Result<bool> {
329    let mut entries = attachment_entries(edit, limits)?;
330    if index >= entries.len() {
331        return Ok(false);
332    }
333    entries.remove(index);
334    write_attachment_entries(edit, entries)?;
335    Ok(true)
336}
337
338/// Adds an embedded file named `name`, sorted into the `/EmbeddedFiles`
339/// name tree by name — creating the tree when the document has none —
340/// and returns its index among the attachments.
341///
342/// The file specification is `<< /Type /Filespec /UF (name) /F (name)
343/// /Desc (…) /EF << /F stream >> >>`, a new indirect object; the stream
344/// is `/Type /EmbeddedFile` with `/Subtype` as the MIME type, `/DL`, and
345/// `/Params` holding `/Size`, an MD5 `/CheckSum` and `/ModDate`, and the
346/// save flate-compresses it. A second attachment with the same name is
347/// a second entry, not a replacement.
348///
349/// ```
350/// use pdfrum::{AttachmentOptions, Document, SaveOptions};
351///
352/// let doc = Document::open("tests/fixtures/hello_world.pdf")?;
353/// let mut edit = doc.edit();
354/// edit.add_attachment(
355///     "notes.txt",
356///     b"Read me",
357///     &AttachmentOptions::builder().mime_type("text/plain").build(),
358/// )?;
359/// let mut bytes = Vec::new();
360/// edit.write_to(&mut bytes, &SaveOptions::default())?;
361///
362/// let saved = Document::from_bytes(bytes)?;
363/// let attachment = &saved.attachments()[0];
364/// assert_eq!(attachment.file_name(), "notes.txt");
365/// assert_eq!(attachment.data().as_deref(), Some(&b"Read me"[..]));
366/// assert_eq!(attachment.subtype().as_deref(), Some("text/plain"));
367/// # Ok::<(), pdfrum::Error>(())
368/// ```
369///
370/// # Errors
371///
372/// When the document has no catalog to hold the tree.
373pub fn add_attachment(
374    edit: &mut EditDoc<'_>,
375    limits: &Limits,
376    name: &str,
377    bytes: &[u8],
378    options: &AttachmentOptions,
379) -> Result<usize> {
380    let mut entries = attachment_entries(edit, limits)?;
381    let stream_ref = edit.add(Object::Stream(Box::new(embedded_file(
382        bytes,
383        options.mime_type.as_deref(),
384        options.modified.as_deref(),
385    ))));
386    let mut spec = Dict::new();
387    spec.insert(Name::from("Type"), Object::Name(Name::from("Filespec")));
388    spec.insert(
389        Name::from("UF"),
390        Object::Str(PdfString::literal(encode_text(name))),
391    );
392    spec.insert(
393        Name::from("F"),
394        Object::Str(PdfString::literal(encode_text(name))),
395    );
396    if let Some(description) = options.description.as_deref().filter(|d| !d.is_empty()) {
397        spec.insert(
398            Name::from("Desc"),
399            Object::Str(PdfString::literal(encode_text(description))),
400        );
401    }
402    let mut ef = Dict::new();
403    ef.insert(Name::from("F"), Object::Ref(stream_ref));
404    spec.insert(Name::from("EF"), Object::Dict(ef));
405    let reference = edit.add(Object::Dict(spec));
406    let index = entries
407        .iter()
408        .position(|(existing, _)| existing.as_str() > name)
409        .unwrap_or(entries.len());
410    entries.insert(index, (name.to_owned(), Object::Ref(reference)));
411    write_attachment_entries(edit, entries)?;
412    Ok(index)
413}
414
415/// Removes every attachment named `name` from the name tree; `Ok(false)`
416/// when there is none. The objects go with the next full save's garbage
417/// collection.
418///
419/// ```
420/// use pdfrum::Document;
421///
422/// let doc = Document::open("tests/fixtures/embedded_attachments.pdf")?;
423/// let mut edit = doc.edit();
424/// assert!(edit.remove_attachment("1.txt")?);
425/// assert!(!edit.remove_attachment("1.txt")?, "already gone");
426/// # Ok::<(), pdfrum::Error>(())
427/// ```
428///
429/// # Errors
430///
431/// When the document has no catalog to hold the tree.
432pub fn remove_attachment(edit: &mut EditDoc<'_>, limits: &Limits, name: &str) -> Result<bool> {
433    let mut entries = attachment_entries(edit, limits)?;
434    let before = entries.len();
435    entries.retain(|(existing, _)| existing != name);
436    if entries.len() == before {
437        return Ok(false);
438    }
439    write_attachment_entries(edit, entries)?;
440    Ok(true)
441}
442
443/// Replaces attachment `index`'s embedded file with a new stream carrying
444/// `/Type /EmbeddedFile`, `/DL <len>` and `/Params << /Size <len>
445/// /CheckSum <md5> >>`, linked as `/EF << /F <ref> >>` on the file
446/// specification; a MIME type or date the old stream had is not carried
447/// over. `Ok(false)` when there is no such attachment.
448///
449/// # Errors
450///
451/// When the document has no catalog to hold the tree.
452pub fn set_attachment_file(
453    edit: &mut EditDoc<'_>,
454    limits: &Limits,
455    index: usize,
456    bytes: &[u8],
457) -> Result<bool> {
458    let Some((reference, mut spec)) = attachment_spec(edit, limits, index)? else {
459        return Ok(false);
460    };
461    let stream_ref = edit.add(Object::Stream(Box::new(embedded_file(bytes, None, None))));
462    let mut ef = Dict::new();
463    ef.insert(Name::from("F"), Object::Ref(stream_ref));
464    spec.insert(Name::from("EF"), Object::Dict(ef));
465    store_attachment_spec(edit, limits, index, reference, spec)?;
466    Ok(true)
467}
468
469/// Sets a `/Params` text entry on attachment `index`'s embedded file —
470/// `CreationDate`, `ModDate`, any key — creating `/Params` when missing;
471/// a `CheckSum` given as `<HEX…>` is stored as that hex string.
472/// `Ok(false)` when the attachment has no embedded file.
473///
474/// # Errors
475///
476/// When the document has no catalog to hold the tree.
477pub fn set_attachment_param(
478    edit: &mut EditDoc<'_>,
479    limits: &Limits,
480    index: usize,
481    key: &str,
482    text: &str,
483) -> Result<bool> {
484    let Some((_, spec)) = attachment_spec(edit, limits, index)? else {
485        return Ok(false);
486    };
487    let Some(ef) = spec.dict(&Name::from("EF"), edit) else {
488        return Ok(false);
489    };
490    let Some(stream_ref) = ef.reference(&Name::from("F")) else {
491        return Ok(false);
492    };
493    let Some(stream) = edit
494        .fetch(stream_ref)
495        .ok()
496        .and_then(|object| object.as_stream().cloned())
497    else {
498        return Ok(false);
499    };
500    let mut params = stream
501        .dict
502        .dict(&Name::from("Params"), edit)
503        .unwrap_or_default();
504    let hex = if key == "CheckSum" {
505        hex_bytes(text)
506    } else {
507        None
508    };
509    let value = hex.map_or_else(
510        || Object::Str(PdfString::literal(encode_text(text))),
511        |bytes| Object::Str(PdfString::hex(bytes)),
512    );
513    params.insert(Name::from(key), value);
514    let mut dict = stream.dict.clone();
515    dict.insert(Name::from("Params"), Object::Dict(params));
516    edit.replace(
517        stream_ref,
518        Object::Stream(Box::new(Stream::new(dict, stream.data.clone()))),
519    );
520    Ok(true)
521}
522
523/// Sets the file specification's `/Desc`; `Ok(false)` when there is no
524/// such attachment.
525///
526/// # Errors
527///
528/// When the document has no catalog to hold the tree.
529pub fn set_attachment_description(
530    edit: &mut EditDoc<'_>,
531    limits: &Limits,
532    index: usize,
533    text: &str,
534) -> Result<bool> {
535    let Some((reference, mut spec)) = attachment_spec(edit, limits, index)? else {
536        return Ok(false);
537    };
538    spec.insert(
539        Name::from("Desc"),
540        Object::Str(PdfString::literal(encode_text(text))),
541    );
542    store_attachment_spec(edit, limits, index, reference, spec)?;
543    Ok(true)
544}
545
546/// `<HEXPAIRS>` — angle brackets, an even count of hex digits, whitespace
547/// ignored — as bytes; `None` for anything else.
548fn hex_bytes(text: &str) -> Option<Vec<u8>> {
549    let mut digits = Vec::new();
550    for c in text.strip_prefix('<')?.strip_suffix('>')?.chars() {
551        if c.is_ascii_whitespace() {
552            continue;
553        }
554        digits.push(u8::try_from(c.to_digit(16)?).ok()?);
555    }
556    if !digits.len().is_multiple_of(2) {
557        return None;
558    }
559    Some(
560        digits
561            .as_chunks::<2>()
562            .0
563            .iter()
564            .map(|&[hi, lo]| (hi << 4) | lo)
565            .collect(),
566    )
567}