Skip to main content

pdfrum_edit/
associated.rs

1//! Associated files (ISO 32000-2 §14.13): `/AF`, the array that says an
2//! embedded file *belongs to* something rather than merely riding along in
3//! the document.
4//!
5//! An attachment in `/Names /EmbeddedFiles` is a file a reader offers to save.
6//! The same file listed in an `/AF` is a file a reader is told relates to the
7//! document, or to one page of it, with `/AFRelationship` saying how. That
8//! distinction is what PDF/A-3 and the electronic-invoice profiles built on
9//! it — Factur-X and its predecessors — are about: the XML invoice has to be
10//! **the source** of the rendered page, not an attachment that happens to sit
11//! beside it.
12//!
13//! So this does not embed anything. It associates a file the document already
14//! carries, which is why every entry here names an attachment by index.
15
16use pdfrum_common::{Limits, PageIndex};
17use pdfrum_object::{Array, Dict, Name, ObjRef, Object, Resolve, names};
18
19use crate::{attach, doc::EditDoc, error::Error};
20
21/// An association write either applies or names why it could not.
22type Result<T> = core::result::Result<T, Error>;
23
24/// What an associated file is to the thing it is associated with
25/// (`/AFRelationship`, ISO 32000-2 table 404).
26///
27/// The relationship is not decoration: a PDF/A-3 validator reads it, and an
28/// electronic-invoice profile requires the invoice XML to be
29/// [`Alternative`](Self::Alternative) — the machine-readable form of what the
30/// page shows — rather than [`Supplement`](Self::Supplement) or
31/// [`Unspecified`](Self::Unspecified).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum Relationship {
34    /// `/Source`: the file the visible content was generated from.
35    Source,
36    /// `/Data`: data the content presents — a spreadsheet behind a chart.
37    Data,
38    /// `/Alternative`: an alternative representation of the same content.
39    /// This is what an electronic invoice's XML is.
40    Alternative,
41    /// `/Supplement`: additional material the content does not itself show.
42    Supplement,
43    /// `/EncryptedPayload`: the file is an encrypted payload, and the
44    /// document around it is the wrapper.
45    EncryptedPayload,
46    /// `/FormData`: the data of a form the document presents.
47    FormData,
48    /// `/Schema`: a schema the associated data conforms to.
49    Schema,
50    /// `/Unspecified`: the relationship is not stated. The default, and what
51    /// a validator that requires one will reject.
52    #[default]
53    Unspecified,
54}
55
56impl Relationship {
57    /// The `/AFRelationship` name this relationship writes.
58    #[must_use]
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Self::Source => "Source",
62            Self::Data => "Data",
63            Self::Alternative => "Alternative",
64            Self::Supplement => "Supplement",
65            Self::EncryptedPayload => "EncryptedPayload",
66            Self::FormData => "FormData",
67            Self::Schema => "Schema",
68            Self::Unspecified => "Unspecified",
69        }
70    }
71
72    /// The relationship an `/AFRelationship` name means, or nothing for one
73    /// outside the table.
74    #[must_use]
75    pub fn from_bytes(bytes: &[u8]) -> Option<Relationship> {
76        match bytes {
77            b"Source" => Some(Self::Source),
78            b"Data" => Some(Self::Data),
79            b"Alternative" => Some(Self::Alternative),
80            b"Supplement" => Some(Self::Supplement),
81            b"EncryptedPayload" => Some(Self::EncryptedPayload),
82            b"FormData" => Some(Self::FormData),
83            b"Schema" => Some(Self::Schema),
84            b"Unspecified" => Some(Self::Unspecified),
85            _ => None,
86        }
87    }
88}
89
90/// Associates the attachment at `index` with the **document**, by adding it to
91/// the catalog's `/AF`.
92///
93/// The attachment stays where it is; this adds a second reference to the same
94/// file specification, which is what makes the file both savable from the
95/// attachments pane and declared as related to the document. `relationship`
96/// is written onto the specification as `/AFRelationship`.
97///
98/// Answers `false` when there is no attachment at `index`. An attachment
99/// already in the catalog's `/AF` has only its relationship updated rather
100/// than being listed twice.
101///
102/// # Errors
103///
104/// [`Error::NoDestinationCatalog`] when the document has no catalog.
105///
106/// ```
107/// use std::sync::Arc;
108/// use pdfrum_common::Limits;
109/// use pdfrum_edit::{
110///     AttachmentOptions, EditDoc, Relationship, SaveOptions, add_attachment,
111///     associate_file_with_document, save,
112/// };
113/// use pdfrum_parser::{LoadOptions, load};
114///
115/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
116/// let doc = load(bytes, &LoadOptions::default())?;
117/// let mut edit = EditDoc::new(&doc);
118/// let limits = Limits::default();
119///
120/// let index = add_attachment(
121///     &mut edit,
122///     &limits,
123///     "invoice.xml",
124///     b"<invoice/>",
125///     &AttachmentOptions::builder().mime_type("text/xml").build(),
126/// )?;
127/// associate_file_with_document(&mut edit, &limits, index, Relationship::Alternative)?;
128///
129/// let mut out = Vec::new();
130/// save(&edit, &SaveOptions::default(), &mut out)?;
131/// # Ok::<(), Box<dyn std::error::Error>>(())
132/// ```
133pub fn associate_file_with_document(
134    dest: &mut EditDoc<'_>,
135    limits: &Limits,
136    index: usize,
137    relationship: Relationship,
138) -> Result<bool> {
139    let Some(spec_ref) = tag_relationship(dest, limits, index, relationship)? else {
140        return Ok(false);
141    };
142    let Some(root) = dest.base().trailer().reference(names::ROOT) else {
143        return Err(Error::NoDestinationCatalog);
144    };
145    let Some(mut catalog) = dict_at(dest, root) else {
146        return Err(Error::NoDestinationCatalog);
147    };
148    let af = push_unique(dest, &catalog, spec_ref);
149    catalog.insert(af_key(), Object::Array(af));
150    dest.replace(root, Object::Dict(catalog));
151    Ok(true)
152}
153
154/// Associates the attachment at `index` with one **page**, by adding it to
155/// that page's `/AF`.
156///
157/// A page-level association says the file relates to that page in particular —
158/// the measurement data behind one chart, say — where the catalog-level one
159/// speaks for the document. A file may be in both.
160///
161/// Answers `false` when there is no attachment at `index`.
162///
163/// # Errors
164///
165/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page.
166/// - [`Error::NoDestinationCatalog`] when the document has no catalog.
167pub fn associate_file_with_page(
168    dest: &mut EditDoc<'_>,
169    limits: &Limits,
170    page: impl Into<PageIndex>,
171    index: usize,
172    relationship: Relationship,
173) -> Result<bool> {
174    let page = page.into();
175    let Some((page_ref, page_dict, _)) = dest.page_state(page)? else {
176        return Err(Error::InlinePage(page));
177    };
178    let Some(spec_ref) = tag_relationship(dest, limits, index, relationship)? else {
179        return Ok(false);
180    };
181    let mut page_dict = page_dict;
182    let af = push_unique(dest, &page_dict, spec_ref);
183    page_dict.insert(af_key(), Object::Array(af));
184    dest.replace(page_ref, Object::Dict(page_dict));
185    Ok(true)
186}
187
188/// The attachments the catalog's `/AF` names, by index into
189/// [`attachments`](crate::add_attachment)'s ordering, each with the
190/// relationship its specification states.
191///
192/// A specification in `/AF` that is not one of the document's attachments is
193/// skipped: there is no index to report it under, and the read side addresses
194/// attachments by index.
195///
196/// # Errors
197///
198/// [`Error::NoDestinationCatalog`] when the document has no catalog.
199pub fn document_associated_files(
200    dest: &EditDoc<'_>,
201    limits: &Limits,
202) -> Result<Vec<(usize, Relationship)>> {
203    let Some(root) = dest.base().trailer().reference(names::ROOT) else {
204        return Err(Error::NoDestinationCatalog);
205    };
206    let Some(catalog) = dict_at(dest, root) else {
207        return Err(Error::NoDestinationCatalog);
208    };
209    associated_in(dest, limits, &catalog)
210}
211
212/// As [`document_associated_files`], for one page's `/AF`.
213///
214/// # Errors
215///
216/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page.
217/// - [`Error::NoDestinationCatalog`] when the document has no catalog.
218pub fn page_associated_files(
219    dest: &EditDoc<'_>,
220    limits: &Limits,
221    page: impl Into<PageIndex>,
222) -> Result<Vec<(usize, Relationship)>> {
223    let page = page.into();
224    let Some((_, page_dict, _)) = dest.page_state(page)? else {
225        return Err(Error::InlinePage(page));
226    };
227    associated_in(dest, limits, &page_dict)
228}
229
230/// The `/AF` entries of one dictionary, resolved to attachment indices.
231fn associated_in(
232    dest: &EditDoc<'_>,
233    limits: &Limits,
234    holder: &Dict,
235) -> Result<Vec<(usize, Relationship)>> {
236    let Some(af) = holder.array(&af_key(), dest) else {
237        return Ok(Vec::new());
238    };
239    let specs = attachment_spec_refs(dest, limits)?;
240    let mut out = Vec::new();
241    for position in 0..af.len() {
242        let Some(reference) = af.reference_at(position) else {
243            continue;
244        };
245        let Some(index) = specs.iter().position(|spec| *spec == Some(reference)) else {
246            continue;
247        };
248        let relationship = dest
249            .fetch(reference)
250            .ok()
251            .and_then(|object| object.as_dict().cloned())
252            .and_then(|spec| {
253                spec.raw(&relationship_key())
254                    .and_then(Object::as_name)
255                    .cloned()
256            })
257            .and_then(|name| Relationship::from_bytes(name.as_bytes()))
258            .unwrap_or_default();
259        out.push((index, relationship));
260    }
261    Ok(out)
262}
263
264/// Writes `/AFRelationship` onto the attachment's specification and answers
265/// the reference that names it.
266///
267/// `None` when there is no attachment at `index`, or when its specification is
268/// written inline — an inline specification has no reference for an `/AF` to
269/// hold, which is the same limitation the rest of the writer has for inline
270/// pages.
271fn tag_relationship(
272    dest: &mut EditDoc<'_>,
273    limits: &Limits,
274    index: usize,
275    relationship: Relationship,
276) -> Result<Option<ObjRef>> {
277    let Some(spec_ref) = attach::attachment_spec_ref(dest, limits, index)? else {
278        return Ok(None);
279    };
280    let Some(mut spec) = dict_at(dest, spec_ref) else {
281        return Ok(None);
282    };
283    spec.insert(
284        relationship_key(),
285        Object::Name(Name::from(relationship.as_str().as_bytes())),
286    );
287    dest.replace(spec_ref, Object::Dict(spec));
288    Ok(Some(spec_ref))
289}
290
291/// Every attachment's specification reference, in attachment order.
292fn attachment_spec_refs(dest: &EditDoc<'_>, limits: &Limits) -> Result<Vec<Option<ObjRef>>> {
293    let count = attach::attachment_entries(dest, limits)?.len();
294    (0..count)
295        .map(|index| attach::attachment_spec_ref(dest, limits, index))
296        .collect()
297}
298
299/// `holder`'s `/AF` with `spec_ref` in it, added only if it is not already.
300///
301/// Listing one specification twice would have a reader offer the same file
302/// twice, and the relationship is on the specification rather than on the
303/// entry, so a second entry could not say anything the first does not.
304fn push_unique(dest: &EditDoc<'_>, holder: &Dict, spec_ref: ObjRef) -> Array {
305    let mut af: Array = holder
306        .array(&af_key(), dest)
307        .map(|existing| existing.iter().cloned().collect())
308        .unwrap_or_default();
309    let already = af
310        .iter()
311        .any(|entry| matches!(entry, Object::Ref(reference) if *reference == spec_ref));
312    if !already {
313        af.push(Object::Ref(spec_ref));
314    }
315    af
316}
317
318/// A dictionary as the edits leave it.
319fn dict_at(dest: &EditDoc<'_>, reference: ObjRef) -> Option<Dict> {
320    dest.fetch(reference)
321        .ok()
322        .and_then(|object| object.as_dict().cloned())
323}
324
325/// The `/AF` key.
326fn af_key() -> Name {
327    Name::from("AF")
328}
329
330/// The `/AFRelationship` key.
331fn relationship_key() -> Name {
332    Name::from("AFRelationship")
333}