use std::io::Read;
use lopdf::{Document, Object};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DocumentMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_modified_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creator_tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub producer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub company: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keywords: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format_version: Option<String>,
}
impl DocumentMetadata {
pub fn is_empty(&self) -> bool {
*self == DocumentMetadata::default()
}
}
fn decode_pdf_text(bytes: &[u8]) -> String {
let s = if bytes.starts_with(&[0xFE, 0xFF]) {
let units: Vec<u16> =
bytes[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
String::from_utf16_lossy(&units)
} else {
bytes.iter().map(|&b| b as char).collect()
};
s.trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string()
}
fn tidy_pdf_date(raw: &str) -> String {
let d = raw.strip_prefix("D:").unwrap_or(raw);
let digits: String = d.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.len() >= 8 {
let g = |a: usize, b: usize| &digits[a..b.min(digits.len())];
let date = format!("{}-{}-{}", g(0, 4), g(4, 6), g(6, 8));
if digits.len() >= 14 {
return format!("{date} {}:{}:{}", g(8, 10), g(10, 12), g(12, 14));
}
return date;
}
raw.to_string()
}
fn opt(s: String) -> Option<String> {
(!s.is_empty()).then_some(s)
}
pub fn pdf_metadata(doc: &Document) -> DocumentMetadata {
let mut m = DocumentMetadata { format_version: opt(doc.version.clone()), ..Default::default() };
let info = doc
.trailer
.get(b"Info")
.ok()
.and_then(|o| match o {
Object::Reference(id) => doc.get_object(*id).ok(),
other => Some(other),
})
.and_then(|o| o.as_dict().ok());
let Some(info) = info else { return m };
let get = |key: &[u8]| -> Option<String> {
match info.get(key) {
Ok(Object::String(bytes, _)) => opt(decode_pdf_text(bytes)),
_ => None,
}
};
m.title = get(b"Title");
m.author = get(b"Author");
m.creator_tool = get(b"Creator");
m.producer = get(b"Producer");
m.keywords = get(b"Keywords");
m.created = get(b"CreationDate").map(|d| tidy_pdf_date(&d));
m.modified = get(b"ModDate").map(|d| tidy_pdf_date(&d));
m
}
fn xml_text(xml: &str, local: &str) -> Option<String> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_str(xml);
let mut capture = false;
let mut out = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => {
let name = e.name();
let n = name.as_ref();
let localname = n.rsplit(|&b| b == b':').next().unwrap_or(n);
if localname == local.as_bytes() {
capture = true;
out.clear();
}
}
Ok(Event::Text(e)) if capture => {
if let Ok(t) = e.xml_content(quick_xml::XmlVersion::Implicit1_0) {
out.push_str(&t);
}
}
Ok(Event::End(_)) if capture => break,
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
}
opt(out.trim().to_string())
}
pub fn docx_metadata(zip: &mut zip::ZipArchive<std::fs::File>) -> DocumentMetadata {
let mut m = DocumentMetadata::default();
let read = |zip: &mut zip::ZipArchive<std::fs::File>, name: &str| -> Option<String> {
let mut s = String::new();
zip.by_name(name).ok()?.read_to_string(&mut s).ok()?;
Some(s)
};
if let Some(core) = read(zip, "docProps/core.xml") {
m.title = xml_text(&core, "title");
m.author = xml_text(&core, "creator");
m.last_modified_by = xml_text(&core, "lastModifiedBy");
m.created = xml_text(&core, "created");
m.modified = xml_text(&core, "modified");
m.keywords = xml_text(&core, "keywords");
m.revision = xml_text(&core, "revision");
}
if let Some(app) = read(zip, "docProps/app.xml") {
m.creator_tool = xml_text(&app, "Application");
m.company = xml_text(&app, "Company");
m.app_version = xml_text(&app, "AppVersion");
}
m
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pdf_string_decoding_and_dates() {
assert_eq!(decode_pdf_text(b"Adobe Acrobat\0"), "Adobe Acrobat");
assert_eq!(decode_pdf_text(&[0xFE, 0xFF, 0x00, 0x48, 0x00, 0x69]), "Hi");
assert_eq!(tidy_pdf_date("D:20240115103000Z"), "2024-01-15 10:30:00");
assert_eq!(tidy_pdf_date("D:20240115"), "2024-01-15");
}
#[test]
fn docx_core_and_app_parsing() {
let core = r#"<?xml version="1.0"?>
<cp:coreProperties xmlns:cp="x" xmlns:dc="y" xmlns:cp2="z">
<dc:title>Contract</dc:title>
<dc:creator>Jane Doe</dc:creator>
<cp:lastModifiedBy>Mallory</cp:lastModifiedBy>
<cp:revision>1</cp:revision>
</cp:coreProperties>"#;
assert_eq!(xml_text(core, "creator").as_deref(), Some("Jane Doe"));
assert_eq!(xml_text(core, "lastModifiedBy").as_deref(), Some("Mallory"));
assert_eq!(xml_text(core, "revision").as_deref(), Some("1"));
assert_eq!(xml_text(core, "title").as_deref(), Some("Contract"));
let app = r#"<Properties><Application>Microsoft Office Word</Application><Company>ACME</Company></Properties>"#;
assert_eq!(xml_text(app, "Application").as_deref(), Some("Microsoft Office Word"));
assert_eq!(xml_text(app, "Company").as_deref(), Some("ACME"));
}
#[test]
fn real_docs_carry_metadata_and_assessment() {
use std::path::PathBuf;
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
for rel in [
"data/fp-corpus/clean/us/education/arxiv_1706.03762_attention.pdf",
"data/fp-corpus/clean/misc/docx/calibre_demo.docx",
"data/attack-fixtures/replaced.docx",
] {
let p = base.join(rel);
if !p.exists() {
eprintln!("skip: {rel} absent");
continue;
}
let r = crate::scan::scan_path(&p, None).expect("scan");
eprintln!("\n{rel}\n assessment = {:?}\n metadata = {:?}", r.assessment, r.metadata);
assert!(r.assessment.is_some(), "{rel}: assessment must be computed");
}
}
}