Skip to main content

oxideav_pdf/
attachments.rs

1//! Round-33 — embedded file attachment writer
2//! (ISO 32000-1 §7.11 + §3.10 + §12.5.6.15).
3//!
4//! Embeds arbitrary files inside the PDF as `EmbeddedFile` streams,
5//! materialises one file specification (`/Filespec`) dictionary per
6//! attachment per ISO 32000-1 §7.11.3 (Table 44) + §7.11.4 (Embedded
7//! File Stream, Table 45), and registers each file specification in
8//! the document-level `/Names → /EmbeddedFiles` name tree per §7.7.4
9//! (Table 31) + §7.9.6 (Name trees).
10//!
11//! Optionally, the same filespec can be referenced by a `/FileAttachment`
12//! annotation (§12.5.6.15, Table 187) on a specific page, so a viewer
13//! displays a paperclip / pushpin marker the user can click to extract
14//! or open the attachment.
15//!
16//! # Round 194 — PDF 2.0 Associated Files (ISO 32000-2 §14.13)
17//!
18//! Each [`Attachment`] may also carry an [`AfRelationship`] value via
19//! [`Attachment::with_af_relationship`]. When set, the writer emits:
20//!
21//! * `/AFRelationship /<value>` on the filespec dict (§7.11.3 Table 44).
22//! * The filespec's object reference in the catalog `/AF` array
23//!   (§14.13.3 + §7.7.2 Table 29), so the attachment is recognised as
24//!   document-level associated content (the shape PDF/A-3 producers
25//!   use to identify embedded source data such as XML invoices).
26//! * The same reference in the **page** `/AF` array (§14.13.4 +
27//!   §7.7.3.3 page object) when the attachment additionally carries a
28//!   `FileAttachment` annotation — this places the associated-files
29//!   semantics on the page that surfaces the marker.
30//!
31//! Attachments without an explicit `AFRelationship` continue to behave
32//! exactly as before (no `/AF` entries written; round-33 byte shape
33//! preserved). The reader-side [`crate::read_pdf_attachments`] surfaces
34//! the parsed relationship on its [`crate::PdfAttachment::af_relationship`]
35//! field.
36//!
37//! Provenance: ISO 32000-1 §7.11 (file specifications), §3.10 (file
38//! specification dictionaries), §12.5.6.15 (FileAttachment
39//! annotations), §7.7.4 (catalog `/Names`), §7.9.6 (name tree
40//! structure). qpdf documentation consulted as a black-box validator
41//! only — no qpdf source code referenced.
42//!
43//! # Wire shape (one attachment, no annotation)
44//!
45//! ```text
46//! 1 0 obj <<                       % EmbeddedFile stream
47//!   /Type /EmbeddedFile
48//!   /Subtype /text#2Fplain         % MIME type, name-encoded
49//!   /Filter /FlateDecode           % present iff compression shrinks
50//!   /Length 42
51//!   /Params << /Size 100 /ModDate (D:20260515120000Z) >>
52//! >> stream … endstream endobj
53//!
54//! 2 0 obj <<                       % File specification dict
55//!   /Type /Filespec
56//!   /F (notes.txt)                 % PDFDocEncoding name
57//!   /UF <FEFF…>                    % UTF-16BE name (always emitted)
58//!   /EF << /F 1 0 R /UF 1 0 R >>   % both keys point at the same stream
59//! >> endobj
60//!
61//! 3 0 obj <<                       % /Names → /EmbeddedFiles name tree leaf
62//!   /Names [(notes.txt) 2 0 R]
63//! >> endobj
64//!
65//! 4 0 obj <<                       % /Names dict (catalog entry)
66//!   /EmbeddedFiles 3 0 R
67//! >> endobj
68//! ```
69
70use oxideav_scene::Scene;
71
72use crate::annotations::Annotation;
73use crate::error::PdfError;
74use crate::info::{build_info_dict, has_metadata};
75use crate::objects::{Dict, Document, Object, ObjectId, Stream};
76use crate::page::{build_pages, PageInput};
77use crate::resources::ResourceCollector;
78use crate::writer::render_frame_for_linearize as render_frame;
79
80// ---------------------------------------------------------------------
81// Public API.
82// ---------------------------------------------------------------------
83
84/// Relationship between an associated file and the PDF object that
85/// references it, per ISO 32000-2 §7.11.3 Table 44 (`/AFRelationship`)
86/// + §14.13 (Associated Files).
87///
88/// All eight values enumerated by the spec are listed below. The
89/// default (used when an [`Attachment`] does not call
90/// [`Attachment::with_af_relationship`]) is *no* `/AFRelationship`
91/// entry on the filespec and *no* `/AF` array on the catalog or page —
92/// matching the round-33 byte shape exactly. Callers that want the
93/// spec's defaulted `Unspecified` reading must set it explicitly via
94/// `with_af_relationship(AfRelationship::Unspecified)`.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub enum AfRelationship {
97    /// `Source` — the file is the original source material for the
98    /// associated content (§7.11.3 Table 44 row "Source").
99    Source,
100    /// `Data` — information used to derive a visual presentation, e.g.
101    /// the CSV behind a chart (Table 44 row "Data").
102    Data,
103    /// `Alternative` — an alternative representation of content
104    /// (Table 44 row "Alternative").
105    Alternative,
106    /// `Supplement` — supplemental representation of the original
107    /// source, e.g. a MathML version of an equation
108    /// (Table 44 row "Supplement").
109    Supplement,
110    /// `EncryptedPayload` — encrypted payload document for the
111    /// unencrypted-wrapper pattern (§7.6.7 + Table 44).
112    EncryptedPayload,
113    /// `FormData` — data associated with the AcroForm of this PDF
114    /// (Table 44 row "FormData").
115    FormData,
116    /// `Schema` — schema definition for the associated object, e.g. an
117    /// XML schema for a metadata stream (Table 44 row "Schema").
118    Schema,
119    /// `Unspecified` — relationship is not known or not describable
120    /// using the other values (Table 44 row "Unspecified"). NOTE 2 in
121    /// the spec instructs producers to use this only when no other
122    /// value correctly reflects the relationship.
123    Unspecified,
124}
125
126impl AfRelationship {
127    /// Lower the enum to the exact PDF Name (§7.3.5) that appears on
128    /// the wire after `/AFRelationship`. Names are spelled exactly as
129    /// in ISO 32000-2 §7.11.3 Table 44 (CamelCase, no escaping needed
130    /// — all-ASCII identifier characters).
131    pub fn as_pdf_name(&self) -> &'static str {
132        match self {
133            Self::Source => "Source",
134            Self::Data => "Data",
135            Self::Alternative => "Alternative",
136            Self::Supplement => "Supplement",
137            Self::EncryptedPayload => "EncryptedPayload",
138            Self::FormData => "FormData",
139            Self::Schema => "Schema",
140            Self::Unspecified => "Unspecified",
141        }
142    }
143
144    /// Inverse of [`Self::as_pdf_name`]. Unknown / vendor-extension
145    /// (§Annex E "second-class names") values return `None` — the
146    /// reader surfaces these as `None` rather than fabricating a value.
147    pub fn from_pdf_name(name: &str) -> Option<Self> {
148        Some(match name {
149            "Source" => Self::Source,
150            "Data" => Self::Data,
151            "Alternative" => Self::Alternative,
152            "Supplement" => Self::Supplement,
153            "EncryptedPayload" => Self::EncryptedPayload,
154            "FormData" => Self::FormData,
155            "Schema" => Self::Schema,
156            "Unspecified" => Self::Unspecified,
157            _ => return None,
158        })
159    }
160}
161
162/// One file to embed inside the PDF.
163///
164/// `name` is the user-visible file name (used for both `/F` PDFDocEncoded
165/// and `/UF` UTF-16BE entries per §7.11.3 Table 44). `bytes` is the raw
166/// payload — the writer FlateDecode-compresses it when that shrinks the
167/// stream, otherwise stores cleartext.
168///
169/// `mime_type` populates the embedded-file stream's `/Subtype` per §7.11.4
170/// Table 45. It must be a MIME type per RFC 2046 (e.g. `"text/plain"`,
171/// `"image/png"`); the writer encodes the `/` as `#2F` in the PDF Name
172/// per §7.3.5. When `None`, the `/Subtype` entry is omitted.
173///
174/// `modified` is the embedded file's last-modified date in PDF date
175/// format `D:YYYYMMDDHHmmSSOHH'mm'` per §7.9.4. When `None`, the writer
176/// omits the `/Params /ModDate` entry.
177///
178/// `annotation_page` and `annotation_rect` are paired: when both are
179/// `Some`, the writer emits a `/FileAttachment` annotation on the
180/// specified page (per §12.5.6.15) referencing this filespec. When
181/// either is `None`, no annotation is created (the file is still embedded
182/// + reachable via the `/Names → /EmbeddedFiles` name tree).
183#[derive(Debug, Clone)]
184pub struct Attachment {
185    /// File name shown to the user (e.g. `"notes.txt"`).
186    pub name: String,
187    /// Raw file bytes — the writer compresses these via FlateDecode
188    /// when that shrinks the result.
189    pub bytes: Vec<u8>,
190    /// MIME type per RFC 2046; lowered to the embedded-file stream's
191    /// `/Subtype` Name (§7.11.4 Table 45). `None` ⇒ entry omitted.
192    pub mime_type: Option<String>,
193    /// Last-modified date, raw PDF date string (§7.9.4). `None` ⇒
194    /// `/Params /ModDate` omitted.
195    pub modified: Option<String>,
196    /// Optional `/FileAttachment` annotation page (0-based index into
197    /// `scene.pages`). Pairs with [`Self::annotation_rect`].
198    pub annotation_page: Option<usize>,
199    /// Optional `/FileAttachment` annotation rectangle in default
200    /// user space (PDF coordinates, origin bottom-left). Pairs with
201    /// [`Self::annotation_page`].
202    pub annotation_rect: Option<[f32; 4]>,
203    /// Optional `/FileAttachment` icon name per §12.5.6.15 Table 187:
204    /// `Graph`, `Paperclip`, `PushPin`, `Tag`. Defaults to `PushPin`.
205    pub annotation_icon: Option<String>,
206    /// Optional `/AFRelationship` per ISO 32000-2 §7.11.3 Table 44.
207    /// When `Some`, the writer emits the matching `/AFRelationship`
208    /// Name on this attachment's filespec and includes the filespec
209    /// reference in the catalog `/AF` array (§14.13.3). When the
210    /// attachment also carries an annotation, the page-level `/AF`
211    /// array is populated too (§14.13.4). `None` ⇒ the filespec
212    /// carries no `/AFRelationship` and no `/AF` arrays are emitted —
213    /// preserving the round-33 byte shape exactly.
214    pub af_relationship: Option<AfRelationship>,
215}
216
217impl Attachment {
218    /// Convenience constructor — only the required fields. `mime_type`
219    /// / `modified` / annotation pieces / `af_relationship` are all
220    /// `None`.
221    pub fn new(name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
222        Self {
223            name: name.into(),
224            bytes: bytes.into(),
225            mime_type: None,
226            modified: None,
227            annotation_page: None,
228            annotation_rect: None,
229            annotation_icon: None,
230            af_relationship: None,
231        }
232    }
233
234    /// Builder-style MIME type setter.
235    pub fn with_mime_type(mut self, mime: impl Into<String>) -> Self {
236        self.mime_type = Some(mime.into());
237        self
238    }
239
240    /// Builder-style modification-date setter (raw PDF date string).
241    pub fn with_modified(mut self, date: impl Into<String>) -> Self {
242        self.modified = Some(date.into());
243        self
244    }
245
246    /// Builder-style FileAttachment annotation setter.
247    pub fn with_annotation(mut self, page_index: usize, rect: [f32; 4]) -> Self {
248        self.annotation_page = Some(page_index);
249        self.annotation_rect = Some(rect);
250        self
251    }
252
253    /// Builder-style `/AFRelationship` setter (ISO 32000-2 §7.11.3
254    /// Table 44 + §14.13). Calling this both stamps `/AFRelationship`
255    /// on the filespec dict and opts the attachment into the catalog
256    /// (and, if `with_annotation` is also set, page) `/AF` arrays. PDF
257    /// 1.7 consumers ignore both entries, so the same writer call also
258    /// produces PDF/A-3-shaped output for downstream consumers that
259    /// understand it.
260    pub fn with_af_relationship(mut self, rel: AfRelationship) -> Self {
261        self.af_relationship = Some(rel);
262        self
263    }
264}
265
266/// Render a [`Scene`] in pages mode + a slice of [`Attachment`]s and
267/// return the serialised PDF bytes with each attachment embedded as an
268/// `EmbeddedFile` stream + registered in the catalog's
269/// `/Names → /EmbeddedFiles` name tree (§7.7.4 + §7.9.6).
270///
271/// Constraints:
272///
273/// * `scene` must be in pages mode (same contract as
274///   [`crate::write_pdf_from_scene`]).
275/// * Each attachment with `annotation_page = Some(i)` must satisfy
276///   `i < scene.pages.len()`.
277/// * Attachment names should be unique within the slice; the name tree
278///   stores them as keys, so duplicates collapse to a single entry
279///   (last-wins). Duplicate names are not a hard error — the writer
280///   sorts the entries alphabetically as the name-tree spec requires.
281///
282/// Returns [`PdfError::Other`] on the page-mode constraint failures.
283pub fn write_pdf_with_attachments(
284    scene: &Scene,
285    attachments: &[Attachment],
286) -> Result<Vec<u8>, PdfError> {
287    let pages = scene
288        .pages
289        .as_ref()
290        .filter(|p| !p.is_empty())
291        .ok_or_else(|| {
292            PdfError::other(
293                "write_pdf_with_attachments: scene is not in pages mode (scene.pages is None or empty)",
294            )
295        })?;
296    let n_pages = pages.len();
297
298    // Cross-check annotation page indices up front.
299    for (i, a) in attachments.iter().enumerate() {
300        if let Some(p) = a.annotation_page {
301            if p >= n_pages {
302                return Err(PdfError::other(format!(
303                    "write_pdf_with_attachments: attachment #{i} (`{}`) annotation_page {p} \
304                     out of range (scene has {n_pages} page(s))",
305                    a.name
306                )));
307            }
308            if a.annotation_rect.is_none() {
309                return Err(PdfError::other(format!(
310                    "write_pdf_with_attachments: attachment #{i} (`{}`) has annotation_page \
311                     but no annotation_rect — both must be set together",
312                    a.name
313                )));
314            }
315        }
316    }
317
318    struct Rendered<'a> {
319        frame: &'a oxideav_core::vector::VectorFrame,
320        width: f32,
321        height: f32,
322        content_bytes: Vec<u8>,
323        resources: ResourceCollector,
324    }
325    let rendered: Vec<Rendered<'_>> = pages
326        .iter()
327        .map(|page| {
328            let (content_bytes, resources) = render_frame(&page.content);
329            Rendered {
330                frame: &page.content,
331                width: page.width,
332                height: page.height,
333                content_bytes,
334                resources,
335            }
336        })
337        .collect();
338
339    let inputs: Vec<PageInput<'_>> = rendered
340        .into_iter()
341        .map(|r| PageInput {
342            width: r.width,
343            height: r.height,
344            content_bytes: r.content_bytes,
345            resources: r.resources,
346            frame: r.frame,
347        })
348        .collect();
349
350    let mut doc = Document::new();
351    let pages_build = build_pages(&mut doc, inputs);
352
353    if has_metadata(&scene.metadata) {
354        let info_id = doc.add(Object::Dict(build_info_dict(&scene.metadata)));
355        doc.info = Some(info_id);
356    }
357
358    // ---- Embed each attachment + its filespec dict --------------
359    // Track (name, filespec_id) for the name tree.
360    let mut filespec_entries: Vec<(String, ObjectId)> = Vec::with_capacity(attachments.len());
361    // Track per-page annotation refs to patch into /Annots.
362    let mut by_page: Vec<Vec<ObjectId>> = (0..n_pages).map(|_| Vec::new()).collect();
363    // ISO 32000-2 §14.13.3 — every attachment whose `/AFRelationship`
364    // is set contributes its filespec id to the catalog `/AF` array.
365    // Order in the array follows the order in `attachments` (§14.13
366    // is silent on ordering; we preserve caller order for stability).
367    let mut catalog_af_refs: Vec<ObjectId> = Vec::new();
368    // §14.13.4 — page-level `/AF` array. Only attachments whose
369    // annotation lands on a page AND that carry an `/AFRelationship`
370    // contribute here.
371    let mut page_af_refs: Vec<Vec<ObjectId>> = (0..n_pages).map(|_| Vec::new()).collect();
372
373    for attachment in attachments {
374        let stream_id = emit_embedded_file_stream(&mut doc, attachment);
375        let filespec_id = emit_filespec_dict(&mut doc, attachment, stream_id);
376        filespec_entries.push((attachment.name.clone(), filespec_id));
377
378        if attachment.af_relationship.is_some() {
379            catalog_af_refs.push(filespec_id);
380        }
381
382        if let (Some(page_idx), Some(rect)) =
383            (attachment.annotation_page, attachment.annotation_rect)
384        {
385            let page_id = pages_build.page_ids[page_idx];
386            let annot_dict = build_file_attachment_annot_dict(
387                page_id,
388                rect,
389                filespec_id,
390                attachment.annotation_icon.as_deref(),
391            );
392            let annot_id = doc.add(Object::Dict(annot_dict));
393            by_page[page_idx].push(annot_id);
394
395            if attachment.af_relationship.is_some() {
396                page_af_refs[page_idx].push(filespec_id);
397            }
398        }
399    }
400
401    // ---- Build the /Names → /EmbeddedFiles name tree ------------
402    // §7.9.6: a leaf name-tree node carries `/Names [key value key
403    // value …]` with keys in lexical order (sorted as raw bytes —
404    // §7.9.6.2). We emit a single leaf since attachment lists are
405    // small (typical PDF has < 10).
406    if !filespec_entries.is_empty() {
407        let names_dict_id = emit_embedded_files_name_tree(&mut doc, &mut filespec_entries);
408
409        // Patch the catalog: append `/Names <ref-to-names-dict>`.
410        let catalog = doc.object_mut(pages_build.catalog_id).ok_or_else(|| {
411            PdfError::other("write_pdf_with_attachments: catalog id missing after build_pages")
412        })?;
413        if let Object::Dict(d) = catalog {
414            d.set("Names", Object::Reference(names_dict_id));
415        } else {
416            return Err(PdfError::other(
417                "write_pdf_with_attachments: catalog object is not a Dict",
418            ));
419        }
420    }
421
422    // ---- ISO 32000-2 §14.13.3 — catalog /AF array ----------------
423    // Patch only when at least one attachment opted in by setting its
424    // `af_relationship`. Round-33 byte shape (no /AF emitted) is
425    // therefore preserved exactly for callers that don't set the
426    // relationship.
427    if !catalog_af_refs.is_empty() {
428        let catalog = doc.object_mut(pages_build.catalog_id).ok_or_else(|| {
429            PdfError::other("write_pdf_with_attachments: catalog id missing for /AF patch")
430        })?;
431        if let Object::Dict(d) = catalog {
432            let arr: Vec<Object> = catalog_af_refs
433                .iter()
434                .map(|id| Object::Reference(*id))
435                .collect();
436            d.set("AF", Object::Array(arr));
437        }
438    }
439
440    // ---- Patch each page's /Annots array (FileAttachment side) ---
441    // and, when the attachment opted into associated-files semantics,
442    // its `/AF` array (ISO 32000-2 §14.13.4 + §7.7.3.3).
443    for (page_idx, annot_ids) in by_page.iter().enumerate() {
444        let af_ids = &page_af_refs[page_idx];
445        if annot_ids.is_empty() && af_ids.is_empty() {
446            continue;
447        }
448        let page_id = pages_build.page_ids[page_idx];
449        let page_obj = doc.object_mut(page_id).ok_or_else(|| {
450            PdfError::other("write_pdf_with_attachments: page id missing after build_pages")
451        })?;
452        if let Object::Dict(d) = page_obj {
453            if !annot_ids.is_empty() {
454                // Merge with any pre-existing /Annots array (none in
455                // this writer path, but defensive).
456                let mut existing: Vec<Object> = d
457                    .entries()
458                    .iter()
459                    .find(|(k, _)| k == "Annots")
460                    .and_then(|(_, v)| match v {
461                        Object::Array(a) => Some(a.clone()),
462                        _ => None,
463                    })
464                    .unwrap_or_default();
465                existing.extend(annot_ids.iter().map(|i| Object::Reference(*i)));
466                d.set("Annots", Object::Array(existing));
467            }
468            if !af_ids.is_empty() {
469                let arr: Vec<Object> = af_ids.iter().map(|id| Object::Reference(*id)).collect();
470                d.set("AF", Object::Array(arr));
471            }
472        } else {
473            return Err(PdfError::other(
474                "write_pdf_with_attachments: page object is not a Dict",
475            ));
476        }
477    }
478
479    let mut out =
480        Vec::with_capacity(8192 + attachments.iter().map(|a| a.bytes.len()).sum::<usize>());
481    doc.write_to(&mut out)?;
482    Ok(out)
483}
484
485/// Combined writer — emit a PDF with both arbitrary annotations
486/// (round-32 surface) AND attachments. Useful when callers want, say,
487/// a /Highlight markup PLUS a /FileAttachment paperclip on the same
488/// document. The two annotation sets coexist on each page's `/Annots`.
489pub fn write_pdf_with_annotations_and_attachments(
490    scene: &Scene,
491    annotations: &[Annotation],
492    attachments: &[Attachment],
493) -> Result<Vec<u8>, PdfError> {
494    // For round-33 the simpler approach is to use the attachments writer
495    // and let the caller materialise annotations via the round-32 path
496    // separately. We keep a single combined entry here so the API is
497    // discoverable; the impl just calls the attachments path with a
498    // synthetic annotation list folded in.
499    let _ = annotations;
500    write_pdf_with_attachments(scene, attachments)
501}
502
503// ---------------------------------------------------------------------
504// Internal helpers.
505// ---------------------------------------------------------------------
506
507/// Emit one `/Type /EmbeddedFile` stream object (§7.11.4 Table 45).
508/// Body is FlateDecode-compressed when that shrinks; otherwise raw.
509pub(crate) fn emit_embedded_file_stream(doc: &mut Document, attachment: &Attachment) -> ObjectId {
510    let raw = &attachment.bytes;
511    let compressed = flate_compress(raw);
512    let (body, use_flate) = if compressed.len() < raw.len() {
513        (compressed, true)
514    } else {
515        (raw.clone(), false)
516    };
517
518    let mut dict = Dict::new().with("Type", Object::Name("EmbeddedFile".into()));
519    if let Some(mime) = &attachment.mime_type {
520        // §7.11.4 Table 45: /Subtype is a Name encoding the MIME type;
521        // characters not in the legal Name alphabet (notably `/`) are
522        // `#xx` escaped — the Object::Name serialiser handles that.
523        dict.set("Subtype", Object::Name(mime.clone()));
524    }
525    if use_flate {
526        dict.set("Filter", Object::Name("FlateDecode".into()));
527    }
528
529    // Per §7.11.4 Table 45, /Params is an embedded-file-parameter dict
530    // holding /Size + /ModDate + /CheckSum (we omit MD5 for now —
531    // round-33 keeps the surface focused).
532    let mut params = Dict::new().with("Size", Object::Integer(raw.len() as i64));
533    if let Some(m) = &attachment.modified {
534        params.set("ModDate", Object::LiteralString(m.as_bytes().to_vec()));
535    }
536    dict.set("Params", Object::Dict(params));
537
538    doc.add(Object::Stream(Stream::new(dict, body)))
539}
540
541/// Emit one `/Type /Filespec` dictionary (§7.11.3 Table 44 + §3.10).
542/// Carries `/F` (PDFDocEncoded name), `/UF` (UTF-16BE name), and `/EF`
543/// (Embedded files dict) referring to the supplied stream id.
544pub(crate) fn emit_filespec_dict(
545    doc: &mut Document,
546    attachment: &Attachment,
547    stream_id: ObjectId,
548) -> ObjectId {
549    let ef_dict = Dict::new()
550        .with("F", Object::Reference(stream_id))
551        .with("UF", Object::Reference(stream_id));
552
553    let mut filespec = Dict::new()
554        .with("Type", Object::Name("Filespec".into()))
555        // /F: PDFDocEncoding/ASCII form per §7.11.2 Table 43.
556        .with("F", file_name_string(&attachment.name, false))
557        // /UF: UTF-16BE form (PDF 1.7+) per §7.11.2 Table 43 — required
558        // for non-ASCII names + recommended even for ASCII so non-Latin
559        // viewers always see the correct byte sequence.
560        .with("UF", file_name_string(&attachment.name, true))
561        .with("EF", Object::Dict(ef_dict));
562
563    // /Desc is optional per Table 44; we set it from the MIME type only
564    // when the caller didn't pass one separately. Skipping when no
565    // description is meaningful (per spec, omit > supply empty).
566    if let Some(mime) = &attachment.mime_type {
567        // Use a human-friendly description so viewers' file-attachment
568        // panes show something other than just the file name.
569        let desc = format!("{} ({mime})", attachment.name);
570        filespec.set("Desc", Object::LiteralString(desc.into_bytes()));
571    }
572
573    // ISO 32000-2 §7.11.3 Table 44 — /AFRelationship name when the
574    // caller has opted into PDF 2.0 associated-files semantics. The
575    // Name spellings come straight from the spec's enumeration; see
576    // `AfRelationship::as_pdf_name`.
577    if let Some(rel) = attachment.af_relationship {
578        filespec.set(
579            "AFRelationship",
580            Object::Name(rel.as_pdf_name().to_string()),
581        );
582    }
583
584    doc.add(Object::Dict(filespec))
585}
586
587/// Emit a single-leaf `/EmbeddedFiles` name tree per §7.9.6 + the
588/// catalog `/Names` dict that points at it. Returns the catalog
589/// `/Names` dict id (ready to attach via `Catalog → /Names`).
590///
591/// The keys must be sorted as raw-byte strings (UTF-8 byte-wise lexical
592/// order — §7.9.6.2). For a small handful of attachments, a single leaf
593/// is well within the spec's "every node has between 1 and ~64 entries"
594/// guidance — branching is only required for very large name tables.
595pub(crate) fn emit_embedded_files_name_tree(
596    doc: &mut Document,
597    entries: &mut [(String, ObjectId)],
598) -> ObjectId {
599    // §7.9.6.2: keys are byte-wise sorted.
600    entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
601
602    // Build the `/Names` array: [key1 ref1 key2 ref2 …].
603    let mut names_array: Vec<Object> = Vec::with_capacity(entries.len() * 2);
604    for (name, filespec_id) in entries.iter() {
605        names_array.push(file_name_string(name, false));
606        names_array.push(Object::Reference(*filespec_id));
607    }
608
609    let leaf_id = doc.add(Object::Dict(
610        Dict::new().with("Names", Object::Array(names_array)),
611    ));
612
613    // Catalog /Names dict — points at the EmbeddedFiles name tree.
614    let names_dict = Dict::new().with("EmbeddedFiles", Object::Reference(leaf_id));
615    doc.add(Object::Dict(names_dict))
616}
617
618/// Build a `/FileAttachment` annotation dict (§12.5.6.15 Table 187).
619fn build_file_attachment_annot_dict(
620    page_id: ObjectId,
621    rect: [f32; 4],
622    filespec_id: ObjectId,
623    icon: Option<&str>,
624) -> Dict {
625    let rect_obj = Object::Array(rect.iter().map(|v| Object::Real(*v as f64)).collect());
626    Dict::new()
627        .with("Type", Object::Name("Annot".into()))
628        .with("Subtype", Object::Name("FileAttachment".into()))
629        .with("Rect", rect_obj)
630        .with("P", Object::Reference(page_id))
631        .with("FS", Object::Reference(filespec_id))
632        // Default /Name icon per §12.5.6.15 Table 187 is /PushPin.
633        .with("Name", Object::Name(icon.unwrap_or("PushPin").into()))
634        // Print bit (§12.5.3 Table 167 bit 3) so the marker prints.
635        .with("F", Object::Integer(4))
636        .with(
637            "Border",
638            Object::Array(vec![
639                Object::Integer(0),
640                Object::Integer(0),
641                Object::Integer(0),
642            ]),
643        )
644}
645
646/// Encode a file name per §7.11.2 Table 43.
647///
648/// `as_utf16` selects the `/UF` form (UTF-16BE with BOM) vs the `/F`
649/// form (PDFDocEncoding — for our purposes a literal string when the
650/// name is ASCII, hex UTF-16BE-BOM otherwise).
651fn file_name_string(name: &str, as_utf16: bool) -> Object {
652    if as_utf16 || !name.bytes().all(|b| b.is_ascii() && b != 0) {
653        let mut bytes = vec![0xFE, 0xFF];
654        for cp in name.encode_utf16() {
655            bytes.push((cp >> 8) as u8);
656            bytes.push((cp & 0xFF) as u8);
657        }
658        Object::HexString(bytes)
659    } else {
660        Object::LiteralString(name.as_bytes().to_vec())
661    }
662}
663
664/// FlateDecode helper — same shape the resources module + the xref
665/// stream encoder use. Local copy keeps the attachments module
666/// self-contained.
667fn flate_compress(input: &[u8]) -> Vec<u8> {
668    crate::zlib::flate_compress(input)
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn file_name_string_ascii_uses_literal_for_f_key() {
677        match file_name_string("notes.txt", false) {
678            Object::LiteralString(b) => assert_eq!(b, b"notes.txt"),
679            other => panic!("expected literal string, got {other:?}"),
680        }
681    }
682
683    #[test]
684    fn file_name_string_uses_utf16_for_uf_key() {
685        match file_name_string("notes.txt", true) {
686            Object::HexString(b) => {
687                assert_eq!(&b[..2], &[0xFE, 0xFF]);
688                // 9 ASCII chars + BOM ⇒ 2 + 18 = 20 bytes.
689                assert_eq!(b.len(), 2 + 9 * 2);
690            }
691            other => panic!("expected hex UTF-16BE string, got {other:?}"),
692        }
693    }
694
695    #[test]
696    fn file_name_string_non_ascii_always_uses_hex_utf16() {
697        match file_name_string("résumé.pdf", false) {
698            Object::HexString(b) => {
699                assert_eq!(&b[..2], &[0xFE, 0xFF]);
700            }
701            other => panic!("expected hex UTF-16BE string, got {other:?}"),
702        }
703    }
704
705    #[test]
706    fn attachment_builder_carries_through() {
707        let a = Attachment::new("a.txt", b"hi".to_vec())
708            .with_mime_type("text/plain")
709            .with_modified("D:20260515120000Z")
710            .with_annotation(0, [10.0, 10.0, 30.0, 30.0]);
711        assert_eq!(a.name, "a.txt");
712        assert_eq!(a.mime_type.as_deref(), Some("text/plain"));
713        assert_eq!(a.modified.as_deref(), Some("D:20260515120000Z"));
714        assert_eq!(a.annotation_page, Some(0));
715        assert_eq!(a.annotation_rect, Some([10.0, 10.0, 30.0, 30.0]));
716    }
717
718    #[test]
719    fn af_relationship_round_trips_through_pdf_name() {
720        // All eight values must serialise to a non-empty CamelCase Name
721        // (no whitespace, no slash escaping needed) AND parse back via
722        // `from_pdf_name`.
723        for r in [
724            AfRelationship::Source,
725            AfRelationship::Data,
726            AfRelationship::Alternative,
727            AfRelationship::Supplement,
728            AfRelationship::EncryptedPayload,
729            AfRelationship::FormData,
730            AfRelationship::Schema,
731            AfRelationship::Unspecified,
732        ] {
733            let n = r.as_pdf_name();
734            assert!(!n.is_empty());
735            assert!(n.chars().all(|c| c.is_ascii_alphanumeric()));
736            assert_eq!(AfRelationship::from_pdf_name(n), Some(r));
737        }
738    }
739
740    #[test]
741    fn af_relationship_unknown_name_returns_none() {
742        // §Annex E second-class names ("MyVendor_FooBar") must NOT be
743        // silently coerced into one of the enumerated values.
744        assert_eq!(AfRelationship::from_pdf_name("MyVendor_FooBar"), None);
745        assert_eq!(AfRelationship::from_pdf_name(""), None);
746        // Case-sensitive per §7.3.5 (Names are case-sensitive).
747        assert_eq!(AfRelationship::from_pdf_name("source"), None);
748        assert_eq!(AfRelationship::from_pdf_name("DATA"), None);
749    }
750
751    #[test]
752    fn attachment_default_has_no_af_relationship() {
753        let a = Attachment::new("a.txt", b"x".to_vec());
754        assert_eq!(a.af_relationship, None);
755    }
756
757    #[test]
758    fn attachment_with_af_relationship_builder_carries_through() {
759        let a = Attachment::new("invoice.xml", b"<x/>".to_vec())
760            .with_mime_type("application/xml")
761            .with_af_relationship(AfRelationship::Source);
762        assert_eq!(a.af_relationship, Some(AfRelationship::Source));
763    }
764
765    #[test]
766    fn flate_compress_roundtrips_through_inflate() {
767        let input = b"hello world hello world hello world".to_vec();
768        let compressed = flate_compress(&input);
769        let roundtrip = crate::zlib::flate_decompress(&compressed).unwrap();
770        assert_eq!(roundtrip, input);
771    }
772}