Skip to main content

pdfrum_edit/
info.rs

1//! The document information dictionary (ISO 32000-1 §14.3.3) and the date
2//! strings it holds (§7.9.4).
3//!
4//! `/Info` hangs off the trailer rather than the catalog, which is the one
5//! thing that makes it different from every other dictionary an edit
6//! touches: a document without one needs a new object *and* a trailer that
7//! names it, and the trailer is built by the writer from the base document's.
8//! [`EditDoc`] carries that one override and the writer reads it back through
9//! `EditDoc::trailer`.
10
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use pdfrum_object::{Dict, Name, ObjRef, Object, PdfString, Resolve, encode_text, names};
14
15use crate::doc::EditDoc;
16
17/// Set or remove one text entry of the document's `/Info` dictionary.
18///
19/// `Some(text)` writes `key` as a text string — `PDFDocEncoding` when every
20/// character has a byte there, otherwise UTF-16BE behind a byte-order mark —
21/// and `None` or an empty string removes the key. A document without an
22/// `/Info` gains one, as a new indirect object the saved trailer names; an
23/// `/Info` that is not a dictionary is replaced by one.
24///
25/// ```
26/// use std::sync::Arc;
27/// use pdfrum_edit::{EditDoc, SaveOptions, save, set_info_entry};
28/// use pdfrum_object::names;
29/// use pdfrum_parser::{LoadOptions, load};
30///
31/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
32/// let doc = load(bytes, &LoadOptions::default())?;
33/// let mut edit = EditDoc::new(&doc);
34/// set_info_entry(&mut edit, names::TITLE, Some("Hello"));
35///
36/// let mut out = Vec::new();
37/// save(&edit, &SaveOptions::default(), &mut out)?;
38/// let reloaded = load(Arc::from(&out[..]), &LoadOptions::default())?;
39/// let info = reloaded.trailer().dict(names::INFO, &reloaded).expect("an /Info");
40/// assert_eq!(info.text(names::TITLE, &reloaded).as_deref(), Some("Hello"));
41/// # Ok::<(), Box<dyn std::error::Error>>(())
42/// ```
43pub fn set_info_entry(dest: &mut EditDoc<'_>, key: &Name, text: Option<&str>) {
44    let existing = dest.trailer().reference(names::INFO);
45    let mut info = existing
46        .and_then(|r| dest.fetch(r).ok())
47        .as_deref()
48        .and_then(Object::as_dict)
49        .cloned()
50        .unwrap_or_default();
51    match text.filter(|t| !t.is_empty()) {
52        Some(text) => info.insert(
53            key.clone(),
54            Object::Str(PdfString::literal(encode_text(text))),
55        ),
56        None => {
57            info.remove(key);
58        }
59    }
60    store_info(dest, existing, info);
61}
62
63/// Sets or removes an `/Info` entry whose value is a **name**.
64///
65/// `/Trapped` is the one such key the specification defines (ISO 32000-1
66/// table 317), and it is why this exists alongside [`set_info_entry`]: writing
67/// `Unknown` as a *string* is a different object, and a PDF/X validator reads
68/// the name.
69///
70/// `None` removes the key. `name` is written verbatim — no escaping and no
71/// validation, because a `Name` is already the parsed thing.
72///
73/// ```
74/// use std::sync::Arc;
75/// use pdfrum_edit::{EditDoc, SaveOptions, save, set_info_name};
76/// use pdfrum_object::{Name, Object, names};
77/// use pdfrum_parser::{LoadOptions, load};
78///
79/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
80/// let doc = load(bytes, &LoadOptions::default())?;
81/// let mut edit = EditDoc::new(&doc);
82/// set_info_name(&mut edit, &Name::from("Trapped"), Some(&Name::from("False")));
83///
84/// let mut out = Vec::new();
85/// save(&edit, &SaveOptions::default(), &mut out)?;
86/// let reloaded = load(Arc::from(&out[..]), &LoadOptions::default())?;
87/// let info = reloaded.trailer().dict(names::INFO, &reloaded).expect("an /Info");
88/// assert_eq!(
89///     info.raw(&Name::from("Trapped")).and_then(Object::as_name),
90///     Some(&Name::from("False")),
91/// );
92/// # Ok::<(), Box<dyn std::error::Error>>(())
93/// ```
94pub fn set_info_name(dest: &mut EditDoc<'_>, key: &Name, name: Option<&Name>) {
95    let existing = dest.trailer().reference(names::INFO);
96    let mut info = existing
97        .and_then(|r| dest.fetch(r).ok())
98        .as_deref()
99        .and_then(Object::as_dict)
100        .cloned()
101        .unwrap_or_default();
102    match name {
103        Some(name) => info.insert(key.clone(), Object::Name(name.clone())),
104        None => {
105            info.remove(key);
106        }
107    }
108    store_info(dest, existing, info);
109}
110
111/// Write `info` back where the trailer will find it.
112fn store_info(dest: &mut EditDoc<'_>, existing: Option<ObjRef>, info: Dict) {
113    let object = Object::Dict(info);
114    // A reference the trailer already carries — even one that pointed at
115    // something broken — is reused, so an incremental save appends one object
116    // and the trailer copies through unchanged.
117    if let Some(reference) = existing {
118        dest.replace(reference, object);
119    } else {
120        let reference = dest.add(object);
121        dest.set_info(reference);
122    }
123}
124
125/// `time` as a PDF date string (ISO 32000-1 §7.9.4): `D:YYYYMMDDHHmmSSZ00'00'`,
126/// always in UTC, which is the one zone every reader agrees on.
127///
128/// A time before 1970 reads as the epoch.
129///
130/// ```
131/// use std::time::{Duration, UNIX_EPOCH};
132/// use pdfrum_edit::pdf_date;
133///
134/// assert_eq!(pdf_date(UNIX_EPOCH), "D:19700101000000Z00'00'");
135/// assert_eq!(
136///     pdf_date(UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
137///     "D:20231114221320Z00'00'"
138/// );
139/// ```
140#[must_use]
141pub fn pdf_date(time: SystemTime) -> String {
142    let seconds = time
143        .duration_since(UNIX_EPOCH)
144        .map_or(0, |elapsed| elapsed.as_secs());
145    let days = i64::try_from(seconds / 86_400).unwrap_or(i64::MAX);
146    let second_of_day = seconds % 86_400;
147    let (year, month, day) = civil_from_days(days);
148    format!(
149        "D:{year:04}{month:02}{day:02}{:02}{:02}{:02}Z00'00'",
150        second_of_day / 3600,
151        second_of_day % 3600 / 60,
152        second_of_day % 60
153    )
154}
155
156/// The proleptic Gregorian date `days` after 1970-01-01, as (year, month,
157/// day) — the era arithmetic of Howard Hinnant's `civil_from_days`.
158fn civil_from_days(days: i64) -> (i64, i64, i64) {
159    let shifted = days.saturating_add(719_468);
160    let era = shifted.div_euclid(146_097);
161    let day_of_era = shifted.rem_euclid(146_097);
162    let year_of_era =
163        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
164    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
165    let shifted_month = (5 * day_of_year + 2) / 153;
166    let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
167    let month = if shifted_month < 10 {
168        shifted_month + 3
169    } else {
170        shifted_month - 9
171    };
172    let year = year_of_era + era * 400 + i64::from(month <= 2);
173    (year, month, day)
174}
175
176/// Sets or removes the document's XMP metadata stream (`/Metadata`).
177///
178/// XMP is the modern metadata channel; `/Info` is the older one, and a
179/// document that carries both is expected to keep them agreeing — PDF/A
180/// requires it, which is why [`to_pdfa`](crate::to_pdfa) regenerates the
181/// packet from `/Info` rather than trusting what is there.
182///
183/// `packet` is the XMP itself, as bytes: this writes it verbatim into a
184/// `/Type /Metadata /Subtype /XML` stream, which the writer knows never to
185/// compress — a reader that scans for the packet without parsing the PDF has
186/// to be able to find it. `None` removes the stream.
187///
188/// # Errors
189///
190/// [`Error::NoDestinationCatalog`](crate::Error::NoDestinationCatalog) when the document has no catalog to hold
191/// the metadata.
192///
193/// ```
194/// use std::sync::Arc;
195/// use pdfrum_edit::{EditDoc, SaveOptions, save, set_xmp_metadata};
196/// use pdfrum_parser::{LoadOptions, load};
197///
198/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
199/// let doc = load(bytes, &LoadOptions::default())?;
200/// let mut edit = EditDoc::new(&doc);
201/// set_xmp_metadata(&mut edit, Some(b"<x:xmpmeta xmlns:x='adobe:ns:meta/'/>"))?;
202/// # Ok::<(), Box<dyn std::error::Error>>(())
203/// ```
204pub fn set_xmp_metadata(dest: &mut EditDoc<'_>, packet: Option<&[u8]>) -> Result<(), crate::Error> {
205    let Some(root) = dest.base().trailer().reference(names::ROOT) else {
206        return Err(crate::Error::NoDestinationCatalog);
207    };
208    let Some(mut catalog) = dest
209        .fetch(root)
210        .ok()
211        .and_then(|object| object.as_dict().cloned())
212    else {
213        return Err(crate::Error::NoDestinationCatalog);
214    };
215
216    let Some(packet) = packet else {
217        catalog.remove(names::METADATA);
218        dest.replace(root, Object::Dict(catalog));
219        return Ok(());
220    };
221
222    let mut dict = Dict::new();
223    dict.insert(names::TYPE.clone(), Object::Name(names::METADATA.clone()));
224    dict.insert(names::SUBTYPE.clone(), Object::Name(names::XML.clone()));
225    let stream = pdfrum_object::Stream::new(dict, pdfrum_object::ByteSpan::from(packet.to_vec()));
226
227    // An existing `/Metadata` is replaced in place when it is indirect, so a
228    // reference held anywhere else still names the current packet.
229    if let Some(Object::Ref(existing)) = catalog.raw(names::METADATA).cloned() {
230        dest.replace(existing, Object::Stream(Box::new(stream)));
231    } else {
232        let reference = dest.add(Object::Stream(Box::new(stream)));
233        catalog.insert(names::METADATA.clone(), Object::Ref(reference));
234        dest.replace(root, Object::Dict(catalog));
235    }
236    Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241    use std::sync::Arc;
242    use std::time::{Duration, UNIX_EPOCH};
243
244    use pdfrum_object::{Name, ObjRef, Object, Resolve, names};
245    use pdfrum_parser::{Document, LoadOptions, load};
246
247    use super::{civil_from_days, pdf_date, set_info_entry};
248    use crate::doc::EditDoc;
249
250    const PAGE_TREE: &[u8] = b"%PDF-1.7\n\
2511 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
2522 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n\
2533 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n";
254
255    fn doc(with_info: bool) -> Document {
256        let mut file = PAGE_TREE.to_vec();
257        file.extend_from_slice(b"4 0 obj\n<< /Title (Old) /Author (Ann) >>\nendobj\n");
258        file.extend_from_slice(if with_info {
259            b"trailer\n<< /Root 1 0 R /Info 4 0 R /Size 5 >>\n"
260        } else {
261            b"trailer\n<< /Root 1 0 R /Size 5 >>\n"
262        });
263        load(Arc::from(file), &LoadOptions::default()).expect("opens")
264    }
265
266    fn info_of(edit: &EditDoc<'_>) -> pdfrum_object::Dict {
267        edit.trailer()
268            .dict(names::INFO, edit)
269            .expect("an /Info the trailer names")
270    }
271
272    #[test]
273    fn an_existing_info_is_edited_in_place() {
274        let base = doc(true);
275        let mut edit = EditDoc::new(&base);
276        set_info_entry(&mut edit, names::TITLE, Some("New"));
277        set_info_entry(&mut edit, names::AUTHOR, None);
278        set_info_entry(&mut edit, names::SUBJECT, Some("\u{7f51}\u{9875}"));
279        assert_eq!(
280            edit.trailer().reference(names::INFO),
281            Some(ObjRef::new(4, 0))
282        );
283        let info = info_of(&edit);
284        assert_eq!(info.text(names::TITLE, &edit).as_deref(), Some("New"));
285        assert!(!info.contains_key(names::AUTHOR));
286        assert_eq!(
287            info.string(names::SUBJECT).map(|s| s.as_bytes().to_vec()),
288            Some(b"\xFE\xFF\x7F\x51\x98\x75".to_vec()),
289            "outside PDFDocEncoding goes out as UTF-16BE with a mark"
290        );
291    }
292
293    #[test]
294    fn a_document_without_an_info_gains_one_the_trailer_names() {
295        let base = doc(false);
296        let mut edit = EditDoc::new(&base);
297        assert!(!edit.trailer().contains_key(names::INFO));
298        set_info_entry(&mut edit, names::TITLE, Some("T"));
299        set_info_entry(&mut edit, names::CREATOR, Some("C"));
300        let reference = edit.trailer().reference(names::INFO).expect("named");
301        assert!(reference.num > 4, "a fresh object, not a reused number");
302        let info = info_of(&edit);
303        assert_eq!(info.text(names::TITLE, &edit).as_deref(), Some("T"));
304        assert_eq!(info.text(names::CREATOR, &edit).as_deref(), Some("C"));
305        // The second entry landed in the same object as the first.
306        assert_eq!(edit.edited().count(), 1);
307    }
308
309    #[test]
310    fn an_empty_value_removes_and_a_missing_key_removes_nothing() {
311        let base = doc(true);
312        let mut edit = EditDoc::new(&base);
313        set_info_entry(&mut edit, names::TITLE, Some(""));
314        set_info_entry(&mut edit, names::KEYWORDS, None);
315        let info = info_of(&edit);
316        assert!(!info.contains_key(names::TITLE));
317        assert!(!info.contains_key(names::KEYWORDS));
318        assert_eq!(info.text(names::AUTHOR, &edit).as_deref(), Some("Ann"));
319    }
320
321    #[test]
322    fn an_info_that_is_not_a_dictionary_is_replaced() {
323        let mut file = PAGE_TREE.to_vec();
324        file.extend_from_slice(
325            b"4 0 obj\n42\nendobj\ntrailer\n<< /Root 1 0 R /Info 4 0 R /Size 5 >>\n",
326        );
327        let base = load(Arc::from(file), &LoadOptions::default()).expect("opens");
328        let mut edit = EditDoc::new(&base);
329        set_info_entry(&mut edit, &Name::from("Title"), Some("T"));
330        let object = edit.fetch(ObjRef::new(4, 0)).expect("replaced");
331        assert!(matches!(&*object, Object::Dict(d) if d.contains_key(names::TITLE)));
332    }
333
334    #[test]
335    fn dates_are_utc_and_civil() {
336        assert_eq!(pdf_date(UNIX_EPOCH), "D:19700101000000Z00'00'");
337        assert_eq!(
338            pdf_date(UNIX_EPOCH + Duration::from_hours(264_384)),
339            "D:20000229000000Z00'00'",
340            "a century leap day"
341        );
342        assert_eq!(
343            pdf_date(UNIX_EPOCH + Duration::from_hours(474_780)),
344            "D:20240229120000Z00'00'"
345        );
346        assert_eq!(
347            pdf_date(UNIX_EPOCH + Duration::from_secs(4_102_444_799)),
348            "D:20991231235959Z00'00'"
349        );
350        assert_eq!(civil_from_days(0), (1970, 1, 1));
351        assert_eq!(civil_from_days(-1), (1969, 12, 31));
352        assert_eq!(civil_from_days(-719_468), (0, 3, 1));
353    }
354}