1use std::collections::HashMap;
20
21use crate::objects::{PdfDict, PdfObj};
22use crate::resolver::Resolver;
23
24#[derive(Debug, Clone, Default)]
31pub struct DocumentMetadata {
32 pub title: Option<String>,
34 pub author: Option<String>,
36 pub subject: Option<String>,
38 pub keywords: Option<String>,
40 pub creator: Option<String>,
42 pub producer: Option<String>,
44 pub creation_date: Option<PdfDate>,
46 pub mod_date: Option<PdfDate>,
48 pub trapped: Option<TrappedFlag>,
50 pub custom: HashMap<String, String>,
52 pub xmp_xml: Option<String>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum TrappedFlag {
60 True,
62 False,
64 Unknown,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct PdfDate {
80 pub year: i32,
81 pub month: u8,
82 pub day: u8,
83 pub hour: u8,
84 pub minute: u8,
85 pub second: u8,
86 pub tz_offset_minutes: Option<i16>,
88}
89
90impl PdfDate {
91 pub fn parse(bytes: &[u8]) -> Option<Self> {
94 parse_date_string(bytes)
95 }
96}
97
98pub fn parse_document_metadata(resolver: &Resolver) -> DocumentMetadata {
104 let mut meta = DocumentMetadata::default();
105
106 if let Some(info_obj) = resolver.trailer().get(b"Info")
107 && let Ok(info) = resolver.deref(info_obj)
108 && let Some(dict) = info.as_dict()
109 {
110 fill_info_fields(&mut meta, dict);
111 }
112
113 meta.xmp_xml = parse_xmp_stream(resolver);
114
115 meta
116}
117
118fn fill_info_fields(meta: &mut DocumentMetadata, dict: &PdfDict) {
119 for (key, value) in dict.entries() {
120 match key.as_slice() {
121 b"Title" => meta.title = pdf_string_to_rust(value),
122 b"Author" => meta.author = pdf_string_to_rust(value),
123 b"Subject" => meta.subject = pdf_string_to_rust(value),
124 b"Keywords" => meta.keywords = pdf_string_to_rust(value),
125 b"Creator" => meta.creator = pdf_string_to_rust(value),
126 b"Producer" => meta.producer = pdf_string_to_rust(value),
127 b"CreationDate" => {
128 if let Some(s) = value.as_str() {
129 meta.creation_date = PdfDate::parse(s);
130 }
131 }
132 b"ModDate" => {
133 if let Some(s) = value.as_str() {
134 meta.mod_date = PdfDate::parse(s);
135 }
136 }
137 b"Trapped" => {
138 meta.trapped = match value.as_name() {
139 Some(b"True") => Some(TrappedFlag::True),
140 Some(b"False") => Some(TrappedFlag::False),
141 Some(b"Unknown") => Some(TrappedFlag::Unknown),
142 _ => None,
143 };
144 }
145 other => {
146 if let Some(s) = pdf_string_to_rust(value) {
147 let key_str = String::from_utf8_lossy(other).into_owned();
148 meta.custom.insert(key_str, s);
149 }
150 }
151 }
152 }
153}
154
155fn parse_xmp_stream(resolver: &Resolver) -> Option<String> {
157 let catalog_dict = catalog_dict_for_metadata(resolver)?;
158 let metadata_obj = catalog_dict.get(b"Metadata")?;
159 let bytes = resolver.stream_data_from_obj(metadata_obj).ok()?;
160 Some(String::from_utf8_lossy(&bytes).into_owned())
164}
165
166fn catalog_dict_for_metadata(resolver: &Resolver) -> Option<PdfDict> {
169 let trailer_root = resolver.trailer().get_ref(b"Root");
170 if let Some((num, gen_num)) = trailer_root
171 && let Ok(obj) = resolver.resolve(num, gen_num)
172 && let Some(dict) = obj.as_dict()
173 && (dict.get_name(b"Type") == Some(b"Catalog")
174 || dict.get(b"Pages").is_some()
175 || dict.get(b"Metadata").is_some())
176 {
177 return Some(dict.clone());
178 }
179 crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
180}
181
182fn pdf_string_to_rust(obj: &PdfObj) -> Option<String> {
195 pdf_string_to_rust_pub(obj)
196}
197
198pub(crate) fn pdf_string_to_rust_pub(obj: &PdfObj) -> Option<String> {
202 match obj {
203 PdfObj::Str(bytes) => Some(decode_pdf_text_string(bytes)),
204 PdfObj::Name(bytes) => Some(decode_pdf_text_string(bytes)),
205 _ => None,
206 }
207}
208
209pub(crate) fn decode_pdf_text_string_pub(bytes: &[u8]) -> String {
211 decode_pdf_text_string(bytes)
212}
213
214fn decode_pdf_text_string(bytes: &[u8]) -> String {
215 if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
216 let chars: Vec<u16> = bytes[2..]
218 .chunks_exact(2)
219 .map(|c| u16::from_be_bytes([c[0], c[1]]))
220 .collect();
221 return String::from_utf16_lossy(&chars);
222 }
223 if bytes.len() >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF {
224 return String::from_utf8_lossy(&bytes[3..]).into_owned();
226 }
227 let mut out = String::with_capacity(bytes.len());
229 for &b in bytes {
230 out.push(pdfdoc_encoding_to_char(b));
231 }
232 out
233}
234
235fn pdfdoc_encoding_to_char(b: u8) -> char {
241 if b < 0x80 {
243 return b as char;
244 }
245 const HIGH: [char; 128] = [
247 '\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', '\u{2014}', '\u{2013}', '\u{0192}',
249 '\u{2044}', '\u{2039}', '\u{203A}', '\u{2212}', '\u{2030}', '\u{201E}', '\u{201C}',
250 '\u{201D}', '\u{2018}', '\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', '\u{FB02}', '\u{0141}', '\u{0152}',
252 '\u{0160}', '\u{0178}', '\u{017D}', '\u{0131}', '\u{0142}', '\u{0153}', '\u{0161}',
253 '\u{017E}', '\u{FFFD}', '\u{20AC}', '\u{00A1}', '\u{00A2}', '\u{00A3}', '\u{00A4}', '\u{00A5}', '\u{00A6}',
256 '\u{00A7}', '\u{00A8}', '\u{00A9}', '\u{00AA}', '\u{00AB}', '\u{00AC}', '\u{00AD}',
257 '\u{00AE}', '\u{00AF}', '\u{00B0}', '\u{00B1}', '\u{00B2}', '\u{00B3}', '\u{00B4}', '\u{00B5}', '\u{00B6}',
259 '\u{00B7}', '\u{00B8}', '\u{00B9}', '\u{00BA}', '\u{00BB}', '\u{00BC}', '\u{00BD}',
260 '\u{00BE}', '\u{00BF}', '\u{00C0}', '\u{00C1}', '\u{00C2}', '\u{00C3}', '\u{00C4}', '\u{00C5}', '\u{00C6}',
262 '\u{00C7}', '\u{00C8}', '\u{00C9}', '\u{00CA}', '\u{00CB}', '\u{00CC}', '\u{00CD}',
263 '\u{00CE}', '\u{00CF}', '\u{00D0}', '\u{00D1}', '\u{00D2}', '\u{00D3}', '\u{00D4}', '\u{00D5}', '\u{00D6}',
265 '\u{00D7}', '\u{00D8}', '\u{00D9}', '\u{00DA}', '\u{00DB}', '\u{00DC}', '\u{00DD}',
266 '\u{00DE}', '\u{00DF}', '\u{00E0}', '\u{00E1}', '\u{00E2}', '\u{00E3}', '\u{00E4}', '\u{00E5}', '\u{00E6}',
268 '\u{00E7}', '\u{00E8}', '\u{00E9}', '\u{00EA}', '\u{00EB}', '\u{00EC}', '\u{00ED}',
269 '\u{00EE}', '\u{00EF}', '\u{00F0}', '\u{00F1}', '\u{00F2}', '\u{00F3}', '\u{00F4}', '\u{00F5}', '\u{00F6}',
271 '\u{00F7}', '\u{00F8}', '\u{00F9}', '\u{00FA}', '\u{00FB}', '\u{00FC}', '\u{00FD}',
272 '\u{00FE}', '\u{00FF}',
273 ];
274 HIGH[(b - 0x80) as usize]
275}
276
277fn parse_date_string(input: &[u8]) -> Option<PdfDate> {
283 let bytes = input.strip_prefix(b"D:").unwrap_or(input);
286
287 if bytes.len() < 4 {
288 return None;
289 }
290
291 let year_str = std::str::from_utf8(&bytes[0..4]).ok()?;
292 let year: i32 = year_str.parse().ok()?;
293
294 let read_2 = |off: usize, max: u8| -> Option<u8> {
295 if bytes.len() < off + 2 {
296 return None;
297 }
298 let s = std::str::from_utf8(&bytes[off..off + 2]).ok()?;
299 let v: u8 = s.parse().ok()?;
300 if v > max { None } else { Some(v) }
301 };
302
303 let month = read_2(4, 12).unwrap_or(1).max(1);
304 let day = read_2(6, 31).unwrap_or(1).max(1);
305 let hour = read_2(8, 23).unwrap_or(0);
306 let minute = read_2(10, 59).unwrap_or(0);
307 let second = read_2(12, 60).unwrap_or(0); let tz_offset_minutes = if bytes.len() > 14 {
311 match bytes[14] {
312 b'Z' => None,
313 sign @ (b'+' | b'-') => parse_tz(&bytes[15..], sign == b'-'),
314 _ => None,
315 }
316 } else {
317 None
318 };
319
320 Some(PdfDate {
321 year,
322 month,
323 day,
324 hour,
325 minute,
326 second,
327 tz_offset_minutes,
328 })
329}
330
331fn parse_tz(rest: &[u8], negative: bool) -> Option<i16> {
332 if rest.len() < 2 {
333 return None;
334 }
335 let h_str = std::str::from_utf8(&rest[0..2]).ok()?;
336 let hours: i16 = h_str.parse().ok()?;
337 let mins = if rest.len() >= 5 && rest[2] == b'\'' {
341 let m_str = std::str::from_utf8(&rest[3..5]).ok()?;
342 m_str.parse::<i16>().ok().unwrap_or(0)
343 } else if rest.len() >= 4 {
344 let m_str = std::str::from_utf8(&rest[2..4]).ok()?;
346 m_str.parse::<i16>().ok().unwrap_or(0)
347 } else {
348 0
349 };
350 let total = hours * 60 + mins;
351 Some(if negative { -total } else { total })
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn date_full_form() {
360 let d = PdfDate::parse(b"D:20261231235959-05'00'").unwrap();
361 assert_eq!(d.year, 2026);
362 assert_eq!(d.month, 12);
363 assert_eq!(d.day, 31);
364 assert_eq!(d.hour, 23);
365 assert_eq!(d.minute, 59);
366 assert_eq!(d.second, 59);
367 assert_eq!(d.tz_offset_minutes, Some(-300));
368 }
369
370 #[test]
371 fn date_utc() {
372 let d = PdfDate::parse(b"D:20260101120000Z").unwrap();
373 assert_eq!(d.tz_offset_minutes, None);
374 assert_eq!(d.hour, 12);
375 }
376
377 #[test]
378 fn date_truncated_year_only() {
379 let d = PdfDate::parse(b"D:2026").unwrap();
380 assert_eq!(d.year, 2026);
381 assert_eq!(d.month, 1);
382 assert_eq!(d.day, 1);
383 assert_eq!(d.hour, 0);
384 }
385
386 #[test]
387 fn date_truncated_year_month() {
388 let d = PdfDate::parse(b"D:202607").unwrap();
389 assert_eq!(d.year, 2026);
390 assert_eq!(d.month, 7);
391 assert_eq!(d.day, 1);
392 }
393
394 #[test]
395 fn date_no_d_prefix() {
396 let d = PdfDate::parse(b"20260101000000").unwrap();
397 assert_eq!(d.year, 2026);
398 }
399
400 #[test]
401 fn date_positive_tz_no_trailing_apostrophe() {
402 let d = PdfDate::parse(b"D:20260101000000+0530").unwrap();
403 assert_eq!(d.tz_offset_minutes, Some(330));
404 }
405
406 #[test]
407 fn date_invalid_year() {
408 assert!(PdfDate::parse(b"D:abcd").is_none());
409 }
410
411 #[test]
412 fn date_too_short() {
413 assert!(PdfDate::parse(b"D:202").is_none());
414 }
415
416 #[test]
417 fn date_invalid_month_clamps_to_jan() {
418 let d = PdfDate::parse(b"D:20261301").unwrap();
420 assert_eq!(d.month, 1);
421 }
422
423 #[test]
424 fn decode_utf16be_bom() {
425 let bytes = [0xFE, 0xFF, 0x00, b'H', 0x00, b'i'];
426 assert_eq!(decode_pdf_text_string(&bytes), "Hi");
427 }
428
429 #[test]
430 fn decode_utf8_bom() {
431 let bytes = [0xEF, 0xBB, 0xBF, b'H', b'i'];
432 assert_eq!(decode_pdf_text_string(&bytes), "Hi");
433 }
434
435 #[test]
436 fn decode_pdfdocencoding_ascii() {
437 assert_eq!(decode_pdf_text_string(b"Hello"), "Hello");
438 }
439
440 #[test]
441 fn decode_pdfdocencoding_high() {
442 let s = decode_pdf_text_string(&[0x80]);
444 assert_eq!(s, "\u{2022}");
445 }
446
447 #[test]
448 fn decode_pdfdocencoding_a4_euro() {
449 assert_eq!(decode_pdf_text_string(&[0xA0]), "\u{20AC}");
451 }
452
453 #[test]
454 fn pdf_string_to_rust_handles_str_and_name() {
455 let s = pdf_string_to_rust(&PdfObj::Str(b"Hi".to_vec())).unwrap();
456 assert_eq!(s, "Hi");
457 let n = pdf_string_to_rust(&PdfObj::Name(b"Foo".to_vec())).unwrap();
458 assert_eq!(n, "Foo");
459 assert!(pdf_string_to_rust(&PdfObj::Int(5)).is_none());
460 }
461
462 #[test]
463 fn trapped_flag_parsing() {
464 let mut dict = PdfDict::new();
466 dict.insert(b"Trapped".to_vec(), PdfObj::Name(b"True".to_vec()));
467 let mut meta = DocumentMetadata::default();
468 fill_info_fields(&mut meta, &dict);
469 assert_eq!(meta.trapped, Some(TrappedFlag::True));
470
471 let mut dict = PdfDict::new();
472 dict.insert(b"Trapped".to_vec(), PdfObj::Name(b"Unknown".to_vec()));
473 let mut meta = DocumentMetadata::default();
474 fill_info_fields(&mut meta, &dict);
475 assert_eq!(meta.trapped, Some(TrappedFlag::Unknown));
476 }
477
478 #[test]
479 fn custom_keys_collected() {
480 let mut dict = PdfDict::new();
481 dict.insert(b"MyCustom".to_vec(), PdfObj::Str(b"hello world".to_vec()));
482 let mut meta = DocumentMetadata::default();
483 fill_info_fields(&mut meta, &dict);
484 assert_eq!(
485 meta.custom.get("MyCustom").map(String::as_str),
486 Some("hello world")
487 );
488 }
489
490 #[test]
491 fn standard_info_fields() {
492 let mut dict = PdfDict::new();
493 dict.insert(b"Title".to_vec(), PdfObj::Str(b"My Doc".to_vec()));
494 dict.insert(b"Author".to_vec(), PdfObj::Str(b"Scott".to_vec()));
495 dict.insert(b"Producer".to_vec(), PdfObj::Str(b"stet".to_vec()));
496 dict.insert(
497 b"CreationDate".to_vec(),
498 PdfObj::Str(b"D:20260101000000Z".to_vec()),
499 );
500 let mut meta = DocumentMetadata::default();
501 fill_info_fields(&mut meta, &dict);
502 assert_eq!(meta.title.as_deref(), Some("My Doc"));
503 assert_eq!(meta.author.as_deref(), Some("Scott"));
504 assert_eq!(meta.producer.as_deref(), Some("stet"));
505 assert_eq!(meta.creation_date.unwrap().year, 2026);
506 }
507}