1use std::time::{SystemTime, UNIX_EPOCH};
12
13use pdfrum_object::{Dict, Name, ObjRef, Object, PdfString, Resolve, encode_text, names};
14
15use crate::doc::EditDoc;
16
17pub 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
63pub 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
111fn store_info(dest: &mut EditDoc<'_>, existing: Option<ObjRef>, info: Dict) {
113 let object = Object::Dict(info);
114 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#[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
156fn 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
176pub 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 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 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}