use zpdf_parser::PdfFile;
use crate::obj_util::{name_value, resolve_dict, text};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DocInfo {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Option<String>,
pub creator: Option<String>,
pub producer: Option<String>,
pub creation_date: Option<String>,
pub mod_date: Option<String>,
pub trapped: Option<String>,
}
impl DocInfo {
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.author.is_none()
&& self.subject.is_none()
&& self.keywords.is_none()
&& self.creator.is_none()
&& self.producer.is_none()
&& self.creation_date.is_none()
&& self.mod_date.is_none()
&& self.trapped.is_none()
}
}
pub fn parse_info(file: &PdfFile) -> Option<DocInfo> {
let dict = resolve_dict(file, file.trailer.get("Info"))?;
let info = DocInfo {
title: text(file, &dict, "Title"),
author: text(file, &dict, "Author"),
subject: text(file, &dict, "Subject"),
keywords: text(file, &dict, "Keywords"),
creator: text(file, &dict, "Creator"),
producer: text(file, &dict, "Producer"),
creation_date: text(file, &dict, "CreationDate"),
mod_date: text(file, &dict, "ModDate"),
trapped: name_value(file, &dict, "Trapped").or_else(|| text(file, &dict, "Trapped")),
};
if info.is_empty() {
None
} else {
Some(info)
}
}
#[cfg(test)]
mod tests {
use crate::test_util::build_pdf;
use crate::PdfDocument;
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
fn build_with_info(objects: &[&str], info_obj: u32) -> Vec<u8> {
let mut buf = Vec::from(&b"%PDF-1.7\n"[..]);
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(buf.len());
buf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = buf.len();
buf.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
);
for off in &offsets {
buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
buf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R /Info {info_obj} 0 R >>\nstartxref\n{xref}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
buf
}
#[test]
fn no_info_dict_is_none() {
let doc = PdfDocument::open(build_pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>",
PAGES,
PAGE,
]))
.expect("open");
assert!(doc.info().is_none());
}
#[test]
fn all_fields_parsed() {
let doc = PdfDocument::open(build_with_info(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
PAGES,
PAGE,
"<< /Title (Annual Report) /Author (Jane Doe) /Subject (Finance) \
/Keywords (q4, revenue) /Creator (LibreOffice) /Producer (zpdf) \
/CreationDate (D:20240101120000Z) /ModDate (D:20240115093000Z) \
/Trapped /False >>",
],
4,
))
.expect("open");
let info = doc.info().expect("info");
assert_eq!(info.title.as_deref(), Some("Annual Report"));
assert_eq!(info.author.as_deref(), Some("Jane Doe"));
assert_eq!(info.subject.as_deref(), Some("Finance"));
assert_eq!(info.keywords.as_deref(), Some("q4, revenue"));
assert_eq!(info.creator.as_deref(), Some("LibreOffice"));
assert_eq!(info.producer.as_deref(), Some("zpdf"));
assert_eq!(info.creation_date.as_deref(), Some("D:20240101120000Z"));
assert_eq!(info.mod_date.as_deref(), Some("D:20240115093000Z"));
assert_eq!(info.trapped.as_deref(), Some("False"));
}
#[test]
fn partial_fields_and_utf16_title() {
let doc = PdfDocument::open(build_with_info(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
PAGES,
PAGE,
"<< /Title <FEFF00480069> /Producer (zpdf) >>",
],
4,
))
.expect("open");
let info = doc.info().expect("info");
assert_eq!(info.title.as_deref(), Some("Hi"));
assert_eq!(info.producer.as_deref(), Some("zpdf"));
assert!(info.author.is_none());
}
#[test]
fn direct_info_dict_in_trailer_is_read() {
let objects = ["<< /Type /Catalog /Pages 2 0 R >>", PAGES, PAGE];
let mut buf = Vec::from(&b"%PDF-1.7\n"[..]);
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(buf.len());
buf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = buf.len();
buf.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
);
for off in &offsets {
buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
buf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R /Info << /Title (Direct) /Producer (zpdf) >> >>\nstartxref\n{xref}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
let doc = PdfDocument::open(buf).expect("open");
let info = doc.info().expect("a direct /Info dict should be read");
assert_eq!(info.title.as_deref(), Some("Direct"));
assert_eq!(info.producer.as_deref(), Some("zpdf"));
}
#[test]
fn trapped_as_string_uses_text_fallback() {
let doc = PdfDocument::open(build_with_info(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
PAGES,
PAGE,
"<< /Trapped (Unknown) /Producer (zpdf) >>",
],
4,
))
.expect("open");
let info = doc.info().expect("info");
assert_eq!(info.trapped.as_deref(), Some("Unknown"));
}
#[test]
fn empty_info_dict_is_none() {
let doc = PdfDocument::open(build_with_info(
&["<< /Type /Catalog /Pages 2 0 R >>", PAGES, PAGE, "<< >>"],
4,
))
.expect("open");
assert!(
doc.info().is_none(),
"an /Info with no fields reads as None"
);
}
}