1use zpdf_parser::PdfFile;
12
13use crate::obj_util::{name_value, resolve_dict, text};
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
18pub struct DocInfo {
19 pub title: Option<String>,
21 pub author: Option<String>,
23 pub subject: Option<String>,
25 pub keywords: Option<String>,
27 pub creator: Option<String>,
29 pub producer: Option<String>,
31 pub creation_date: Option<String>,
33 pub mod_date: Option<String>,
35 pub trapped: Option<String>,
38}
39
40impl DocInfo {
41 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
55pub fn parse_info(file: &PdfFile) -> Option<DocInfo> {
58 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: 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 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 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 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 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}