Skip to main content

stet_pdf_reader/
metadata.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Document-level metadata: the Info dict and the XMP metadata stream.
6//!
7//! PDF documents carry author/title/etc. in two places:
8//!
9//! 1. The trailer's `/Info` dict (legacy, present in nearly all PDFs).
10//! 2. The catalog's `/Metadata` stream (XMP XML, PDF 1.4+, increasingly the
11//!    canonical form).
12//!
13//! [`DocumentMetadata`] returns both: parsed Info-dict fields plus the raw
14//! XMP XML for callers that want to consume it themselves.
15//!
16//! Date strings come in PDF's own format (`D:YYYYMMDDHHmmSSOHH'mm'`); we
17//! parse them into a typed [`PdfDate`].
18
19use std::collections::HashMap;
20
21use crate::objects::{PdfDict, PdfObj};
22use crate::resolver::Resolver;
23
24/// Information about a PDF document, drawn from the trailer's `/Info` dict
25/// and the catalog's `/Metadata` stream.
26///
27/// All fields are optional — a PDF may have no `/Info` dict, or a partial
28/// one. `custom` collects any non-standard `/Info` keys for callers that
29/// need to inspect them (e.g. preservation tools).
30#[derive(Debug, Clone, Default)]
31pub struct DocumentMetadata {
32    /// `/Info /Title`.
33    pub title: Option<String>,
34    /// `/Info /Author`.
35    pub author: Option<String>,
36    /// `/Info /Subject`.
37    pub subject: Option<String>,
38    /// `/Info /Keywords`.
39    pub keywords: Option<String>,
40    /// `/Info /Creator` — the application that authored the source document.
41    pub creator: Option<String>,
42    /// `/Info /Producer` — the application that produced the PDF.
43    pub producer: Option<String>,
44    /// `/Info /CreationDate`, parsed from PDF date format.
45    pub creation_date: Option<PdfDate>,
46    /// `/Info /ModDate`, parsed from PDF date format.
47    pub mod_date: Option<PdfDate>,
48    /// `/Info /Trapped` — whether the document has been pre-trapped for press.
49    pub trapped: Option<TrappedFlag>,
50    /// Non-standard `/Info` entries, decoded as strings where possible.
51    pub custom: HashMap<String, String>,
52    /// Raw XMP metadata XML from the catalog's `/Metadata` stream, if present.
53    pub xmp_xml: Option<String>,
54}
55
56/// `/Trapped` flag value.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum TrappedFlag {
60    /// `/True` — document has been trapped.
61    True,
62    /// `/False` — document has not been trapped.
63    False,
64    /// `/Unknown` — trapping state is unknown (PDF default when `/Trapped` is absent).
65    Unknown,
66}
67
68/// A parsed PDF date string.
69///
70/// PDF dates use the format `D:YYYYMMDDHHmmSSOHH'mm'` where each component
71/// after the year is optional. Truncated forms (`D:2026`, `D:202612`,
72/// `D:20261231`) are accepted and produce a [`PdfDate`] with the
73/// missing-from-the-right components defaulted to spec-correct minima
74/// (month → 1, day → 1, hour/minute/second → 0).
75///
76/// Timezone offset (`O`) is one of `+`, `-`, `Z`. `Z` and absent both
77/// produce `tz_offset_minutes = None` (UTC / unspecified).
78#[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    /// Timezone offset in minutes east of UTC. `None` = UTC or unspecified.
87    pub tz_offset_minutes: Option<i16>,
88}
89
90impl PdfDate {
91    /// Parse a PDF date string. Returns `None` if the input cannot be
92    /// recognised even as a truncated form.
93    pub fn parse(bytes: &[u8]) -> Option<Self> {
94        parse_date_string(bytes)
95    }
96}
97
98/// Parse the trailer's `/Info` dict and the catalog's `/Metadata` stream
99/// into a [`DocumentMetadata`].
100///
101/// Always returns a value — missing or malformed inputs simply leave
102/// fields as `None` / `Default`.
103pub 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
155/// Extract the catalog's `/Metadata` stream content as XMP XML text.
156fn 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    // XMP is XML; PDF 2.0 allows UTF-8/16/32 with BOM. Lossy-decode to keep
161    // callers unburdened by encoding errors — they get the raw string and
162    // can do strict parsing themselves.
163    Some(String::from_utf8_lossy(&bytes).into_owned())
164}
165
166/// Resolve the catalog dict, with the same /Root-vs-find_catalog fallback
167/// logic used elsewhere in the crate.
168fn 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
182// --- string decoding ---
183
184/// Decode a PDF object that should be a textual string into a Rust `String`.
185///
186/// Handles:
187///
188/// - Direct PDF strings with UTF-16BE BOM (`FE FF`)
189/// - Direct PDF strings with UTF-8 BOM (`EF BB BF`, PDF 2.0)
190/// - Direct PDF strings without a BOM, decoded via PDFDocEncoding
191/// - Names (rare in /Info but handled)
192///
193/// Returns `None` for non-string-like objects.
194fn pdf_string_to_rust(obj: &PdfObj) -> Option<String> {
195    pdf_string_to_rust_pub(obj)
196}
197
198/// Crate-internal alias for [`pdf_string_to_rust`], usable from sibling
199/// modules that need the same Info-string decoding (destinations,
200/// outline titles, etc.).
201pub(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
209/// Crate-internal alias for [`decode_pdf_text_string`].
210pub(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        // UTF-16BE
217        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        // UTF-8 BOM
225        return String::from_utf8_lossy(&bytes[3..]).into_owned();
226    }
227    // PDFDocEncoding: ASCII range matches; high range mapped per Annex D.
228    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
235/// Map a PDFDocEncoding byte (0–255) to its Unicode code point.
236///
237/// Per ISO 32000-2 Annex D.2. The 0x80..=0x9F range carries glyphs that in
238/// most other 8-bit encodings are control characters; PDF assigns them
239/// printable Unicode characters.
240fn pdfdoc_encoding_to_char(b: u8) -> char {
241    // Fast path: ASCII printable + common controls map identically.
242    if b < 0x80 {
243        return b as char;
244    }
245    // PDFDocEncoding 0x80..=0xFF table per ISO 32000-2 Annex D.2.
246    const HIGH: [char; 128] = [
247        // 0x80..=0x8F
248        '\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}', // 0x90..=0x9F
251        '\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}', // 0x9F undefined in PDFDocEncoding → REPLACEMENT
254        // 0xA0..=0xAF
255        '\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}', // 0xB0..=0xBF
258        '\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}', // 0xC0..=0xCF
261        '\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}', // 0xD0..=0xDF
264        '\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}', // 0xE0..=0xEF
267        '\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}', // 0xF0..=0xFF
270        '\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
277// --- date parsing ---
278
279/// Parse a PDF date string (`D:YYYYMMDDHHmmSSOHH'mm'`).
280///
281/// Tolerates truncated forms; rejects strings whose year cannot be parsed.
282fn parse_date_string(input: &[u8]) -> Option<PdfDate> {
283    // Strip optional "D:" prefix; PDF dates may omit it (some authoring
284    // tools do, even though the spec requires it).
285    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); // 60 to admit leap second
308
309    // Timezone is at offset 14 if present.
310    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    // After hours, the spec calls for an apostrophe then minutes then a
338    // trailing apostrophe. Real-world files vary: some skip the trailing
339    // apostrophe, some skip both, some use a literal `'` byte (0x27).
340    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        // No apostrophe; some authors emit HHMM directly.
345        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        // Month 13 is invalid; we default to 1 rather than failing.
419        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        // 0x80 maps to U+2022 (BULLET).
443        let s = decode_pdf_text_string(&[0x80]);
444        assert_eq!(s, "\u{2022}");
445    }
446
447    #[test]
448    fn decode_pdfdocencoding_a4_euro() {
449        // 0xA0 maps to U+20AC (EURO SIGN).
450        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        // Standalone test: build a small Info dict and run fill_info_fields.
465        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}