Skip to main content

zpdf_document/
doc_info.rs

1//! The document information dictionary (ISO 32000-1 §14.3.3): the trailer's
2//! `/Info` entry, carrying human-authored metadata — title, author, subject,
3//! keywords, the producing software, and creation/modification timestamps.
4//!
5//! This is the classic `pdfinfo`-style surface. (PDF 2.0 deprecates `/Info` in
6//! favour of the catalog's XMP `/Metadata` stream for most keys, but `/Info` is
7//! still ubiquitous; an XMP reader can be layered on later.) Dates are reported
8//! as their raw PDF date strings (`D:YYYYMMDDHHmmSSOHH'mm'`); no date parsing is
9//! attempted here.
10
11use zpdf_parser::PdfFile;
12
13use crate::obj_util::{name_value, resolve_dict, text};
14
15/// Metadata from the document information dictionary. Every field is optional —
16/// producers populate an arbitrary subset.
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
18pub struct DocInfo {
19    /// `/Title` — the document's title.
20    pub title: Option<String>,
21    /// `/Author` — the name of the person who created the document.
22    pub author: Option<String>,
23    /// `/Subject` — the subject of the document.
24    pub subject: Option<String>,
25    /// `/Keywords` — keywords associated with the document.
26    pub keywords: Option<String>,
27    /// `/Creator` — the application that created the original document.
28    pub creator: Option<String>,
29    /// `/Producer` — the application that produced the PDF (often a converter).
30    pub producer: Option<String>,
31    /// `/CreationDate` — the raw PDF date string the document was created.
32    pub creation_date: Option<String>,
33    /// `/ModDate` — the raw PDF date string of the most recent modification.
34    pub mod_date: Option<String>,
35    /// `/Trapped` — `True` / `False` / `Unknown` (whether the document has been
36    /// trapped for printing). Carried as the raw name.
37    pub trapped: Option<String>,
38}
39
40impl DocInfo {
41    /// Whether any metadata field is present.
42    pub fn is_empty(&self) -> bool {
43        self.title.is_none()
44            && self.author.is_none()
45            && self.subject.is_none()
46            && self.keywords.is_none()
47            && self.creator.is_none()
48            && self.producer.is_none()
49            && self.creation_date.is_none()
50            && self.mod_date.is_none()
51            && self.trapped.is_none()
52    }
53}
54
55/// Parse the trailer's `/Info` dictionary. Returns `None` when the document
56/// carries no `/Info`, or it resolves to nothing usable (no populated fields).
57pub fn parse_info(file: &PdfFile) -> Option<DocInfo> {
58    // /Info SHALL be an indirect reference per spec, but accept a direct dict in
59    // the trailer too (lax producers — mirroring the direct-/Encrypt tolerance
60    // already in this codebase). resolve_dict handles both shapes.
61    let dict = resolve_dict(file, file.trailer.get("Info"))?;
62
63    let info = DocInfo {
64        title: text(file, &dict, "Title"),
65        author: text(file, &dict, "Author"),
66        subject: text(file, &dict, "Subject"),
67        keywords: text(file, &dict, "Keywords"),
68        creator: text(file, &dict, "Creator"),
69        producer: text(file, &dict, "Producer"),
70        creation_date: text(file, &dict, "CreationDate"),
71        mod_date: text(file, &dict, "ModDate"),
72        // /Trapped is a name (/True /False /Unknown); some producers write it as
73        // a string, so fall back to a text read.
74        trapped: name_value(file, &dict, "Trapped").or_else(|| text(file, &dict, "Trapped")),
75    };
76
77    if info.is_empty() {
78        None
79    } else {
80        Some(info)
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use crate::test_util::build_pdf;
87    use crate::PdfDocument;
88
89    const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
90    const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
91
92    /// Build a PDF whose trailer references an `/Info` object. The standard
93    /// `build_pdf` helper writes a fixed trailer, so this variant adds `/Info`.
94    fn build_with_info(objects: &[&str], info_obj: u32) -> Vec<u8> {
95        let mut buf = Vec::from(&b"%PDF-1.7\n"[..]);
96        let mut offsets = Vec::new();
97        for (i, body) in objects.iter().enumerate() {
98            offsets.push(buf.len());
99            buf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
100        }
101        let xref = buf.len();
102        buf.extend_from_slice(
103            format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
104        );
105        for off in &offsets {
106            buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
107        }
108        buf.extend_from_slice(
109            format!(
110                "trailer\n<< /Size {} /Root 1 0 R /Info {info_obj} 0 R >>\nstartxref\n{xref}\n%%EOF\n",
111                objects.len() + 1
112            )
113            .as_bytes(),
114        );
115        buf
116    }
117
118    #[test]
119    fn no_info_dict_is_none() {
120        let doc = PdfDocument::open(build_pdf(&[
121            "<< /Type /Catalog /Pages 2 0 R >>",
122            PAGES,
123            PAGE,
124        ]))
125        .expect("open");
126        assert!(doc.info().is_none());
127    }
128
129    #[test]
130    fn all_fields_parsed() {
131        let doc = PdfDocument::open(build_with_info(
132            &[
133                "<< /Type /Catalog /Pages 2 0 R >>",
134                PAGES,
135                PAGE,
136                "<< /Title (Annual Report) /Author (Jane Doe) /Subject (Finance) \
137                 /Keywords (q4, revenue) /Creator (LibreOffice) /Producer (zpdf) \
138                 /CreationDate (D:20240101120000Z) /ModDate (D:20240115093000Z) \
139                 /Trapped /False >>",
140            ],
141            4,
142        ))
143        .expect("open");
144        let info = doc.info().expect("info");
145        assert_eq!(info.title.as_deref(), Some("Annual Report"));
146        assert_eq!(info.author.as_deref(), Some("Jane Doe"));
147        assert_eq!(info.subject.as_deref(), Some("Finance"));
148        assert_eq!(info.keywords.as_deref(), Some("q4, revenue"));
149        assert_eq!(info.creator.as_deref(), Some("LibreOffice"));
150        assert_eq!(info.producer.as_deref(), Some("zpdf"));
151        assert_eq!(info.creation_date.as_deref(), Some("D:20240101120000Z"));
152        assert_eq!(info.mod_date.as_deref(), Some("D:20240115093000Z"));
153        assert_eq!(info.trapped.as_deref(), Some("False"));
154    }
155
156    #[test]
157    fn partial_fields_and_utf16_title() {
158        // /Title <FEFF0048 0069> = "Hi"; only a couple of fields present.
159        let doc = PdfDocument::open(build_with_info(
160            &[
161                "<< /Type /Catalog /Pages 2 0 R >>",
162                PAGES,
163                PAGE,
164                "<< /Title <FEFF00480069> /Producer (zpdf) >>",
165            ],
166            4,
167        ))
168        .expect("open");
169        let info = doc.info().expect("info");
170        assert_eq!(info.title.as_deref(), Some("Hi"));
171        assert_eq!(info.producer.as_deref(), Some("zpdf"));
172        assert!(info.author.is_none());
173    }
174
175    #[test]
176    fn direct_info_dict_in_trailer_is_read() {
177        // Some lax producers inline /Info as a direct dictionary in the trailer
178        // rather than as an indirect reference; we should still read it (parity
179        // with the direct-/Encrypt tolerance elsewhere in the codebase).
180        let objects = ["<< /Type /Catalog /Pages 2 0 R >>", PAGES, PAGE];
181        let mut buf = Vec::from(&b"%PDF-1.7\n"[..]);
182        let mut offsets = Vec::new();
183        for (i, body) in objects.iter().enumerate() {
184            offsets.push(buf.len());
185            buf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
186        }
187        let xref = buf.len();
188        buf.extend_from_slice(
189            format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
190        );
191        for off in &offsets {
192            buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
193        }
194        buf.extend_from_slice(
195            format!(
196                "trailer\n<< /Size {} /Root 1 0 R /Info << /Title (Direct) /Producer (zpdf) >> >>\nstartxref\n{xref}\n%%EOF\n",
197                objects.len() + 1
198            )
199            .as_bytes(),
200        );
201        let doc = PdfDocument::open(buf).expect("open");
202        let info = doc.info().expect("a direct /Info dict should be read");
203        assert_eq!(info.title.as_deref(), Some("Direct"));
204        assert_eq!(info.producer.as_deref(), Some("zpdf"));
205    }
206
207    #[test]
208    fn trapped_as_string_uses_text_fallback() {
209        // /Trapped is a name (/True /False /Unknown), but some producers write
210        // it as a string; the name_value-then-text fallback must catch it.
211        let doc = PdfDocument::open(build_with_info(
212            &[
213                "<< /Type /Catalog /Pages 2 0 R >>",
214                PAGES,
215                PAGE,
216                "<< /Trapped (Unknown) /Producer (zpdf) >>",
217            ],
218            4,
219        ))
220        .expect("open");
221        let info = doc.info().expect("info");
222        assert_eq!(info.trapped.as_deref(), Some("Unknown"));
223    }
224
225    #[test]
226    fn empty_info_dict_is_none() {
227        let doc = PdfDocument::open(build_with_info(
228            &["<< /Type /Catalog /Pages 2 0 R >>", PAGES, PAGE, "<< >>"],
229            4,
230        ))
231        .expect("open");
232        assert!(
233            doc.info().is_none(),
234            "an /Info with no fields reads as None"
235        );
236    }
237}