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 tree's entries with their values **unresolved**.
191///
192/// [`attachment_entries`] goes through the name tree's `lookup_by_index`,
193/// which resolves each value — so a specification written as a reference comes
194/// back as a dictionary. That is right for reading, and wrong for every write
195/// path here: rewriting the tree from resolved entries *inlines* every
196/// specification that was indirect, which loses the object identity `/AF`
197/// needs and duplicates the dictionary on the next save.
198///
199/// So the write paths read through here instead, keeping whatever spelling the
200/// tree already had.
201pub(crate) fn raw_attachment_entries(
202    edit: &EditDoc<'_>,
203    limits: &Limits,
204) -> Result<Vec<(String, Object)>> {
205    let named = attachment_entries(edit, limits)?;
206    let mut out = Vec::with_capacity(named.len());
207    for (index, (name, resolved)) in named.into_iter().enumerate() {
208        let value = match attachment_spec_ref(edit, limits, index)? {
209            Some(reference) => Object::Ref(reference),
210            None => resolved,
211        };
212        out.push((name, value));
213    }
214    Ok(out)
215}
216
217/// The reference naming attachment `index`'s file specification, when the
218/// tree holds one indirectly.
219///
220/// [`attachment_entries`] goes through the name tree's `lookup_by_index`,
221/// which **resolves** each value — so a specification written as a reference
222/// comes back as a dictionary and its identity is lost. `/AF` needs that
223/// identity: an associated file is the *same object* as the attachment, not a
224/// copy of it. So this walks the leaves itself and keeps the raw entry.
225///
226/// `None` when there is no such attachment, or when its specification really
227/// is written inline.
228pub(crate) fn attachment_spec_ref(
229    edit: &EditDoc<'_>,
230    limits: &Limits,
231    index: usize,
232) -> Result<Option<ObjRef>> {
233    let Some(root) = edit.base().trailer().reference(&Name::from("Root")) else {
234        return Err(Error::NoDestinationCatalog);
235    };
236    let Ok(catalog) = edit.fetch(root) else {
237        return Ok(None);
238    };
239    let Some(files) = catalog
240        .as_dict()
241        .and_then(|catalog| catalog.dict(&Name::from("Names"), edit))
242        .and_then(|names| names.dict(&Name::from("EmbeddedFiles"), edit))
243    else {
244        return Ok(None);
245    };
246    let mut cursor = 0;
247    Ok(raw_entry_at(edit, limits, &files, index, &mut cursor, 0))
248}
249
250/// The raw value of the `index`-th entry of a name tree, without resolving it.
251fn raw_entry_at(
252    edit: &EditDoc<'_>,
253    limits: &Limits,
254    node: &Dict,
255    target: usize,
256    cursor: &mut usize,
257    depth: u32,
258) -> Option<ObjRef> {
259    if depth > limits.max_name_tree_depth {
260        return None;
261    }
262    if let Some(leaf) = node.array(&Name::from("Names"), edit) {
263        let count = leaf.len() / 2;
264        if target >= *cursor + count {
265            *cursor += count;
266            return None;
267        }
268        let slot = (target - *cursor) * 2;
269        return leaf.reference_at(slot + 1);
270    }
271    let kids = node.array(&Name::from("Kids"), edit)?;
272    for slot in 0..kids.len() {
273        let Some(kid) = kids.dict_at(slot, edit) else {
274            continue;
275        };
276        if let Some(found) = raw_entry_at(edit, limits, &kid, target, cursor, depth + 1) {
277            return Some(found);
278        }
279    }
280    None
281}
282
283/// The file specification of attachment `index` — its reference when it
284/// is indirect — and its dictionary; `None` when out of range or not a
285/// dictionary.
286pub(crate) fn attachment_spec(
287    edit: &EditDoc<'_>,
288    limits: &Limits,
289    index: usize,
290) -> Result<Option<(Option<ObjRef>, Dict)>> {
291    let entries = raw_attachment_entries(edit, limits)?;
292    let Some(entry) = entries.get(index) else {
293        return Ok(None);
294    };
295    Ok(match &entry.1 {
296        Object::Ref(reference) => {
297            let Ok(object) = edit.fetch(*reference) else {
298                return Ok(None);
299            };
300            object
301                .as_dict()
302                .map(|dict| (Some(*reference), dict.clone()))
303        }
304        Object::Dict(dict) => Some((None, dict.clone())),
305        _ => None,
306    })
307}
308
309/// Writes `entries` back as a flat `/Names` array. `/Names` and
310/// `/EmbeddedFiles` are created as new indirect objects when missing,
311/// referenced from their parent; the innermost *indirect* holder is
312/// replaced, and an inline holder is rewritten inside its parent, outward
313/// to the catalog.
314pub(crate) fn write_attachment_entries(
315    edit: &mut EditDoc<'_>,
316    entries: Vec<(String, Object)>,
317) -> Result<()> {
318    let k_root = Name::from("Root");
319    let k_names = Name::from("Names");
320    let k_ef = Name::from("EmbeddedFiles");
321    let k_kids = Name::from("Kids");
322    let k_limits = Name::from("Limits");
323    let Some(root) = edit.base().trailer().reference(&k_root) else {
324        return Err(Error::NoDestinationCatalog);
325    };
326    let dict_at = |edit: &EditDoc<'_>, reference: ObjRef| {
327        edit.fetch(reference)
328            .ok()
329            .as_deref()
330            .and_then(Object::as_dict)
331            .cloned()
332    };
333    let mut catalog = dict_at(edit, root).unwrap_or_default();
334    let mut names_array = Array::new();
335    for (name, value) in entries {
336        names_array.push(Object::Str(PdfString::literal(encode_text(&name))));
337        names_array.push(value);
338    }
339    let tree_of = |existing: Option<Dict>| {
340        let mut tree = existing.unwrap_or_default();
341        tree.remove(&k_kids);
342        tree.remove(&k_limits);
343        tree.insert(k_names.clone(), Object::Array(names_array.clone()));
344        tree
345    };
346    match catalog.raw(&k_names).cloned() {
347        Some(Object::Ref(names_ref)) => {
348            let mut names = dict_at(edit, names_ref).unwrap_or_default();
349            match names.raw(&k_ef).cloned() {
350                Some(Object::Ref(tree_ref)) => {
351                    let existing = dict_at(edit, tree_ref);
352                    edit.replace(tree_ref, Object::Dict(tree_of(existing)));
353                }
354                Some(Object::Dict(inline)) => {
355                    names.insert(k_ef.clone(), Object::Dict(tree_of(Some(inline))));
356                    edit.replace(names_ref, Object::Dict(names));
357                }
358                _ => {
359                    let tree_ref = edit.add(Object::Dict(tree_of(None)));
360                    names.insert(k_ef.clone(), Object::Ref(tree_ref));
361                    edit.replace(names_ref, Object::Dict(names));
362                }
363            }
364        }
365        Some(Object::Dict(mut names)) => match names.raw(&k_ef).cloned() {
366            Some(Object::Ref(tree_ref)) => {
367                let existing = dict_at(edit, tree_ref);
368                edit.replace(tree_ref, Object::Dict(tree_of(existing)));
369            }
370            Some(Object::Dict(inline)) => {
371                names.insert(k_ef.clone(), Object::Dict(tree_of(Some(inline))));
372                catalog.insert(k_names.clone(), Object::Dict(names));
373                edit.replace(root, Object::Dict(catalog));
374            }
375            _ => {
376                let tree_ref = edit.add(Object::Dict(tree_of(None)));
377                names.insert(k_ef.clone(), Object::Ref(tree_ref));
378                catalog.insert(k_names.clone(), Object::Dict(names));
379                edit.replace(root, Object::Dict(catalog));
380            }
381        },
382        _ => {
383            let tree_ref = edit.add(Object::Dict(tree_of(None)));
384            let mut names = Dict::new();
385            names.insert(k_ef.clone(), Object::Ref(tree_ref));
386            let names_ref = edit.add(Object::Dict(names));
387            catalog.insert(k_names.clone(), Object::Ref(names_ref));
388            edit.replace(root, Object::Dict(catalog));
389        }
390    }
391    Ok(())
392}
393
394/// Stores a rewritten file specification for attachment `index`:
395/// replaces it when it is indirect, otherwise rewrites the tree entry
396/// inline.
397pub(crate) fn store_attachment_spec(
398    edit: &mut EditDoc<'_>,
399    limits: &Limits,
400    index: usize,
401    reference: Option<ObjRef>,
402    spec: Dict,
403) -> Result<()> {
404    if let Some(reference) = reference {
405        edit.replace(reference, Object::Dict(spec));
406        return Ok(());
407    }
408    let mut entries = raw_attachment_entries(edit, limits)?;
409    if let Some(entry) = entries.get_mut(index) {
410        entry.1 = Object::Dict(spec);
411    }
412    write_attachment_entries(edit, entries)
413}
414
415/// Removes attachment `index` from the name tree; `Ok(false)` when there
416/// is no such attachment.
417///
418/// # Errors
419///
420/// When the document has no catalog to hold the tree.
421pub fn delete_attachment(edit: &mut EditDoc<'_>, limits: &Limits, index: usize) -> Result<bool> {
422    let mut entries = raw_attachment_entries(edit, limits)?;
423    if index >= entries.len() {
424        return Ok(false);
425    }
426    entries.remove(index);
427    write_attachment_entries(edit, entries)?;
428    Ok(true)
429}
430
431/// Adds an embedded file named `name`, sorted into the `/EmbeddedFiles`
432/// name tree by name — creating the tree when the document has none —
433/// and returns its index among the attachments.
434///
435/// The file specification is `<< /Type /Filespec /UF (name) /F (name)
436/// /Desc (…) /EF << /F stream >> >>`, a new indirect object; the stream
437/// is `/Type /EmbeddedFile` with `/Subtype` as the MIME type, `/DL`, and
438/// `/Params` holding `/Size`, an MD5 `/CheckSum` and `/ModDate`, and the
439/// save flate-compresses it. A second attachment with the same name is
440/// a second entry, not a replacement.
441///
442/// ```
443/// use pdfrum::{AttachmentOptions, Document, SaveOptions};
444///
445/// let doc = Document::open("tests/fixtures/hello_world.pdf")?;
446/// let mut edit = doc.edit();
447/// edit.add_attachment(
448///     "notes.txt",
449///     b"Read me",
450///     &AttachmentOptions::builder().mime_type("text/plain").build(),
451/// )?;
452/// let mut bytes = Vec::new();
453/// edit.write_to(&mut bytes, &SaveOptions::default())?;
454///
455/// let saved = Document::from_bytes(bytes)?;
456/// let attachment = &saved.attachments()[0];
457/// assert_eq!(attachment.file_name(), "notes.txt");
458/// assert_eq!(attachment.data().as_deref(), Some(&b"Read me"[..]));
459/// assert_eq!(attachment.subtype().as_deref(), Some("text/plain"));
460/// # Ok::<(), pdfrum::Error>(())
461/// ```
462///
463/// # Errors
464///
465/// When the document has no catalog to hold the tree.
466pub fn add_attachment(
467    edit: &mut EditDoc<'_>,
468    limits: &Limits,
469    name: &str,
470    bytes: &[u8],
471    options: &AttachmentOptions,
472) -> Result<usize> {
473    let mut entries = raw_attachment_entries(edit, limits)?;
474    let stream_ref = edit.add(Object::Stream(Box::new(embedded_file(
475        bytes,
476        options.mime_type.as_deref(),
477        options.modified.as_deref(),
478    ))));
479    let mut spec = Dict::new();
480    spec.insert(Name::from("Type"), Object::Name(Name::from("Filespec")));
481    spec.insert(
482        Name::from("UF"),
483        Object::Str(PdfString::literal(encode_text(name))),
484    );
485    spec.insert(
486        Name::from("F"),
487        Object::Str(PdfString::literal(encode_text(name))),
488    );
489    if let Some(description) = options.description.as_deref().filter(|d| !d.is_empty()) {
490        spec.insert(
491            Name::from("Desc"),
492            Object::Str(PdfString::literal(encode_text(description))),
493        );
494    }
495    let mut ef = Dict::new();
496    ef.insert(Name::from("F"), Object::Ref(stream_ref));
497    spec.insert(Name::from("EF"), Object::Dict(ef));
498    let reference = edit.add(Object::Dict(spec));
499    let index = entries
500        .iter()
501        .position(|(existing, _)| existing.as_str() > name)
502        .unwrap_or(entries.len());
503    entries.insert(index, (name.to_owned(), Object::Ref(reference)));
504    write_attachment_entries(edit, entries)?;
505    Ok(index)
506}
507
508/// Removes every attachment named `name` from the name tree; `Ok(false)`
509/// when there is none. The objects go with the next full save's garbage
510/// collection.
511///
512/// ```
513/// use pdfrum::Document;
514///
515/// let doc = Document::open("tests/fixtures/embedded_attachments.pdf")?;
516/// let mut edit = doc.edit();
517/// assert!(edit.remove_attachment("1.txt")?);
518/// assert!(!edit.remove_attachment("1.txt")?, "already gone");
519/// # Ok::<(), pdfrum::Error>(())
520/// ```
521///
522/// # Errors
523///
524/// When the document has no catalog to hold the tree.
525pub fn remove_attachment(edit: &mut EditDoc<'_>, limits: &Limits, name: &str) -> Result<bool> {
526    let mut entries = raw_attachment_entries(edit, limits)?;
527    let before = entries.len();
528    entries.retain(|(existing, _)| existing != name);
529    if entries.len() == before {
530        return Ok(false);
531    }
532    write_attachment_entries(edit, entries)?;
533    Ok(true)
534}
535
536/// Replaces attachment `index`'s embedded file with a new stream carrying
537/// `/Type /EmbeddedFile`, `/DL <len>` and `/Params << /Size <len>
538/// /CheckSum <md5> >>`, linked as `/EF << /F <ref> >>` on the file
539/// specification; a MIME type or date the old stream had is not carried
540/// over. `Ok(false)` when there is no such attachment.
541///
542/// # Errors
543///
544/// When the document has no catalog to hold the tree.
545pub fn set_attachment_file(
546    edit: &mut EditDoc<'_>,
547    limits: &Limits,
548    index: usize,
549    bytes: &[u8],
550) -> Result<bool> {
551    let Some((reference, mut spec)) = attachment_spec(edit, limits, index)? else {
552        return Ok(false);
553    };
554    let stream_ref = edit.add(Object::Stream(Box::new(embedded_file(bytes, None, None))));
555    let mut ef = Dict::new();
556    ef.insert(Name::from("F"), Object::Ref(stream_ref));
557    spec.insert(Name::from("EF"), Object::Dict(ef));
558    store_attachment_spec(edit, limits, index, reference, spec)?;
559    Ok(true)
560}
561
562/// Replaces attachment `index`'s embedded file, carrying the MIME type and
563/// date `options` names.
564///
565/// [`set_attachment_file`] drops both, which is documented but rarely what a
566/// caller means: an attachment that had a `/Subtype` of `text/csv` and a
567/// `/Params /ModDate` loses them on a replace, so a viewer stops offering the
568/// right application and the file's own time is gone. This is that function
569/// with somewhere to put them.
570///
571/// `Ok(false)` when there is no such attachment.
572///
573/// # Errors
574///
575/// When the document has no catalog to hold the tree.
576pub fn set_attachment_file_with(
577    edit: &mut EditDoc<'_>,
578    limits: &Limits,
579    index: usize,
580    bytes: &[u8],
581    options: &AttachmentOptions,
582) -> Result<bool> {
583    let Some((reference, mut spec)) = attachment_spec(edit, limits, index)? else {
584        return Ok(false);
585    };
586    let stream_ref = edit.add(Object::Stream(Box::new(embedded_file(
587        bytes,
588        options.mime_type.as_deref(),
589        options.modified.as_deref(),
590    ))));
591    let mut ef = Dict::new();
592    ef.insert(Name::from("F"), Object::Ref(stream_ref));
593    spec.insert(Name::from("EF"), Object::Dict(ef));
594    // `/Desc` lives on the file specification, not on the stream, so it is
595    // set here rather than passed to `embedded_file`.
596    if let Some(description) = options.description.as_deref() {
597        spec.insert(
598            Name::from("Desc"),
599            Object::Str(PdfString::literal(encode_text(description))),
600        );
601    }
602    store_attachment_spec(edit, limits, index, reference, spec)?;
603    Ok(true)
604}
605
606/// Renames attachment `index`, answering whether there was one to rename.
607///
608/// The name is the tree **key**, and it is also written to the specification's
609/// `/F` and `/UF` so that the two agree — a reader shows one and looks the
610/// attachment up by the other, and a document where they disagree is a
611/// document whose attachment cannot be saved under the name it displays.
612///
613/// A name already in use is refused with [`Error::DuplicateAttachmentName`]:
614/// the tree is keyed by name, so writing it would make one of the two
615/// unreachable.
616///
617/// # Errors
618///
619/// - When the document has no catalog to hold the tree.
620/// - [`Error::DuplicateAttachmentName`] when another attachment has that name.
621pub fn set_attachment_name(
622    edit: &mut EditDoc<'_>,
623    limits: &Limits,
624    index: usize,
625    name: &str,
626) -> Result<bool> {
627    let mut entries = raw_attachment_entries(edit, limits)?;
628    let Some((existing, _)) = entries.get(index) else {
629        return Ok(false);
630    };
631    if existing == name {
632        return Ok(true);
633    }
634    if entries
635        .iter()
636        .enumerate()
637        .any(|(other, (taken, _))| other != index && taken == name)
638    {
639        return Err(Error::DuplicateAttachmentName(name.to_owned()));
640    }
641
642    // The specification's own `/F` and `/UF` are rewritten alongside the key,
643    // so the displayed name and the lookup name stay the same string.
644    if let Some((reference, mut spec)) = attachment_spec(edit, limits, index)? {
645        let value = Object::Str(PdfString::literal(encode_text(name)));
646        spec.insert(Name::from("F"), value.clone());
647        spec.insert(Name::from("UF"), value);
648        store_attachment_spec(edit, limits, index, reference, spec)?;
649        entries = raw_attachment_entries(edit, limits)?;
650    }
651
652    if let Some(slot) = entries.get_mut(index) {
653        name.clone_into(&mut slot.0);
654    }
655    // The tree is sorted by key, so a rename can move the entry; rewriting the
656    // whole tree is what keeps it ordered.
657    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
658    write_attachment_entries(edit, entries)?;
659    Ok(true)
660}
661
662/// Sets a `/Params` text entry on attachment `index`'s embedded file —
663/// `CreationDate`, `ModDate`, any key — creating `/Params` when missing;
664/// a `CheckSum` given as `<HEX…>` is stored as that hex string.
665/// `Ok(false)` when the attachment has no embedded file.
666///
667/// # Errors
668///
669/// When the document has no catalog to hold the tree.
670pub fn set_attachment_param(
671    edit: &mut EditDoc<'_>,
672    limits: &Limits,
673    index: usize,
674    key: &str,
675    text: &str,
676) -> Result<bool> {
677    let Some((_, spec)) = attachment_spec(edit, limits, index)? else {
678        return Ok(false);
679    };
680    let Some(ef) = spec.dict(&Name::from("EF"), edit) else {
681        return Ok(false);
682    };
683    let Some(stream_ref) = ef.reference(&Name::from("F")) else {
684        return Ok(false);
685    };
686    let Some(stream) = edit
687        .fetch(stream_ref)
688        .ok()
689        .and_then(|object| object.as_stream().cloned())
690    else {
691        return Ok(false);
692    };
693    let mut params = stream
694        .dict
695        .dict(&Name::from("Params"), edit)
696        .unwrap_or_default();
697    let hex = if key == "CheckSum" {
698        hex_bytes(text)
699    } else {
700        None
701    };
702    let value = hex.map_or_else(
703        || Object::Str(PdfString::literal(encode_text(text))),
704        |bytes| Object::Str(PdfString::hex(bytes)),
705    );
706    params.insert(Name::from(key), value);
707    let mut dict = stream.dict.clone();
708    dict.insert(Name::from("Params"), Object::Dict(params));
709    edit.replace(
710        stream_ref,
711        Object::Stream(Box::new(Stream::new(dict, stream.data.clone()))),
712    );
713    Ok(true)
714}
715
716/// Sets the file specification's `/Desc`; `Ok(false)` when there is no
717/// such attachment.
718///
719/// # Errors
720///
721/// When the document has no catalog to hold the tree.
722pub fn set_attachment_description(
723    edit: &mut EditDoc<'_>,
724    limits: &Limits,
725    index: usize,
726    text: &str,
727) -> Result<bool> {
728    let Some((reference, mut spec)) = attachment_spec(edit, limits, index)? else {
729        return Ok(false);
730    };
731    spec.insert(
732        Name::from("Desc"),
733        Object::Str(PdfString::literal(encode_text(text))),
734    );
735    store_attachment_spec(edit, limits, index, reference, spec)?;
736    Ok(true)
737}
738
739/// `<HEXPAIRS>` — angle brackets, an even count of hex digits, whitespace
740/// ignored — as bytes; `None` for anything else.
741fn hex_bytes(text: &str) -> Option<Vec<u8>> {
742    let mut digits = Vec::new();
743    for c in text.strip_prefix('<')?.strip_suffix('>')?.chars() {
744        if c.is_ascii_whitespace() {
745            continue;
746        }
747        digits.push(u8::try_from(c.to_digit(16)?).ok()?);
748    }
749    if !digits.len().is_multiple_of(2) {
750        return None;
751    }
752    Some(
753        digits
754            .as_chunks::<2>()
755            .0
756            .iter()
757            .map(|&[hi, lo]| (hi << 4) | lo)
758            .collect(),
759    )
760}