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}