use crate::core::objects::{DictExt, Dictionary};
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Metadata {
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>,
}
impl Metadata {
pub fn from_info_dict(dict: &Dictionary) -> Self {
Metadata {
title: dict.get_text("Title"),
author: dict.get_text("Author"),
subject: dict.get_text("Subject"),
keywords: dict.get_text("Keywords"),
creator: dict.get_text("Creator"),
producer: dict.get_text("Producer"),
creation_date: dict.get_text("CreationDate"),
mod_date: dict.get_text("ModDate"),
}
}
pub fn from_xmp(xmp_data: &str) -> Self {
let doc = match roxmltree::Document::parse(xmp_data) {
Ok(d) => d,
Err(_) => return Metadata::default(),
};
let values = extract_all_xmp_values(&doc);
Metadata {
title: values.get(&(NS_DC, "title")).cloned(),
author: values.get(&(NS_DC, "creator")).cloned(),
subject: values.get(&(NS_DC, "description")).cloned(),
keywords: values.get(&(NS_PDF, "Keywords")).cloned(),
creator: values.get(&(NS_XMP, "CreatorTool")).cloned(),
producer: values.get(&(NS_PDF, "Producer")).cloned(),
creation_date: values.get(&(NS_XMP, "CreateDate")).cloned(),
mod_date: values.get(&(NS_XMP, "ModifyDate")).cloned(),
}
}
pub fn merge(self, other: &Metadata) -> Self {
Metadata {
title: other.title.clone().or(self.title),
author: other.author.clone().or(self.author),
subject: other.subject.clone().or(self.subject),
keywords: other.keywords.clone().or(self.keywords),
creator: other.creator.clone().or(self.creator),
producer: other.producer.clone().or(self.producer),
creation_date: other.creation_date.clone().or(self.creation_date),
mod_date: other.mod_date.clone().or(self.mod_date),
}
}
}
use std::collections::HashMap;
const NS_DC: &str = "http://purl.org/dc/elements/1.1/";
const NS_XMP: &str = "http://ns.adobe.com/xap/1.0/";
const NS_PDF: &str = "http://ns.adobe.com/pdf/1.3/";
const NS_RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
fn extract_all_xmp_values<'a>(
doc: &'a roxmltree::Document<'a>,
) -> HashMap<(&'a str, &'a str), String> {
let mut values = HashMap::new();
for node in doc.descendants() {
let tag = node.tag_name();
if tag.namespace() == Some(NS_RDF) && tag.name() == "Description" {
for attr in node.attributes() {
if let Some(ns) = attr.namespace() {
let val = attr.value().trim();
if !val.is_empty() {
values
.entry((ns, attr.name()))
.or_insert_with(|| val.to_string());
}
}
}
continue;
}
if let Some(ns) = tag.namespace() {
if values.contains_key(&(ns, tag.name())) {
continue;
}
let li_items: Vec<_> = node
.descendants()
.filter(|n| n.tag_name().namespace() == Some(NS_RDF) && n.tag_name().name() == "li")
.collect();
if !li_items.is_empty() {
let default_li = li_items
.iter()
.find(|n| n.attribute((roxmltree::NS_XML_URI, "lang")) == Some("x-default"));
let chosen = default_li.unwrap_or(&li_items[0]);
let text = chosen.text().unwrap_or("").trim();
if !text.is_empty() {
values.insert((ns, tag.name()), text.to_string());
continue;
}
}
let text = node.text().unwrap_or("").trim();
if !text.is_empty() {
values.insert((ns, tag.name()), text.to_string());
}
}
}
values
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{make_dict, str_obj};
#[test]
fn metadata_from_info_dict() {
let dict = make_dict(vec![
("Title", str_obj("My Document")),
("Author", str_obj("Jane Doe")),
("Producer", str_obj("PDFPurr 0.1")),
]);
let meta = Metadata::from_info_dict(&dict);
assert_eq!(meta.title, Some("My Document".to_string()));
assert_eq!(meta.author, Some("Jane Doe".to_string()));
assert_eq!(meta.producer, Some("PDFPurr 0.1".to_string()));
assert!(meta.subject.is_none());
}
#[test]
fn metadata_from_empty_dict() {
let dict = make_dict(vec![]);
let meta = Metadata::from_info_dict(&dict);
assert_eq!(meta, Metadata::default());
}
#[test]
fn metadata_from_xmp_simple() {
let xmp = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">XMP Title</rdf:li></rdf:Alt></dc:title>
<xmp:CreatorTool>TestApp</xmp:CreatorTool>
<pdf:Producer>PDFPurr</pdf:Producer>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
let meta = Metadata::from_xmp(xmp);
assert_eq!(meta.title, Some("XMP Title".to_string()));
assert_eq!(meta.creator, Some("TestApp".to_string()));
assert_eq!(meta.producer, Some("PDFPurr".to_string()));
}
#[test]
fn metadata_merge() {
let base = Metadata {
title: Some("Base Title".to_string()),
author: Some("Base Author".to_string()),
..Default::default()
};
let overlay = Metadata {
title: Some("Overlay Title".to_string()),
subject: Some("Overlay Subject".to_string()),
..Default::default()
};
let merged = base.merge(&overlay);
assert_eq!(merged.title, Some("Overlay Title".to_string()));
assert_eq!(merged.author, Some("Base Author".to_string()));
assert_eq!(merged.subject, Some("Overlay Subject".to_string()));
}
#[test]
fn xmp_extract_empty() {
let meta = Metadata::from_xmp("");
assert_eq!(meta, Metadata::default());
}
#[test]
fn xmp_extract_multiline_rdf_description() {
let xmp = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="en-US">English Title</rdf:li>
<rdf:li xml:lang="x-default">Default Title</rdf:li>
</rdf:Alt>
</dc:title>
<dc:creator>
<rdf:Seq>
<rdf:li>Author One</rdf:li>
<rdf:li>Author Two</rdf:li>
</rdf:Seq>
</dc:creator>
<xmp:CreateDate>2026-01-15T10:30:00Z</xmp:CreateDate>
<xmp:ModifyDate>2026-03-15T14:00:00Z</xmp:ModifyDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
let meta = Metadata::from_xmp(xmp);
assert_eq!(meta.title, Some("Default Title".to_string()));
assert_eq!(meta.author, Some("Author One".to_string()));
assert_eq!(meta.creation_date, Some("2026-01-15T10:30:00Z".to_string()));
assert_eq!(meta.mod_date, Some("2026-03-15T14:00:00Z".to_string()));
}
#[test]
fn metadata_from_info_dict_all_fields() {
let dict = make_dict(vec![
("Title", str_obj("Full Doc")),
("Author", str_obj("Alice")),
("Subject", str_obj("Testing")),
("Keywords", str_obj("pdf,rust,test")),
("Creator", str_obj("MyApp")),
("Producer", str_obj("PDFPurr")),
("CreationDate", str_obj("D:20260101120000Z")),
("ModDate", str_obj("D:20260315140000Z")),
]);
let meta = Metadata::from_info_dict(&dict);
assert_eq!(meta.title.as_deref(), Some("Full Doc"));
assert_eq!(meta.author.as_deref(), Some("Alice"));
assert_eq!(meta.subject.as_deref(), Some("Testing"));
assert_eq!(meta.keywords.as_deref(), Some("pdf,rust,test"));
assert_eq!(meta.creator.as_deref(), Some("MyApp"));
assert_eq!(meta.producer.as_deref(), Some("PDFPurr"));
assert_eq!(meta.creation_date.as_deref(), Some("D:20260101120000Z"));
assert_eq!(meta.mod_date.as_deref(), Some("D:20260315140000Z"));
}
#[test]
fn xmp_malformed_xml_returns_default() {
let meta = Metadata::from_xmp("<not valid xml");
assert_eq!(meta, Metadata::default());
}
#[test]
fn xmp_extract_subject_and_keywords() {
let xmp = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
<dc:description><rdf:Alt><rdf:li xml:lang="x-default">A test subject</rdf:li></rdf:Alt></dc:description>
<pdf:Keywords>pdf, accessibility, rust</pdf:Keywords>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
let meta = Metadata::from_xmp(xmp);
assert_eq!(meta.subject.as_deref(), Some("A test subject"));
assert_eq!(meta.keywords.as_deref(), Some("pdf, accessibility, rust"));
}
#[test]
fn xmp_extract_with_xml_attributes_on_rdf_description() {
let xmp = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
pdf:Producer="Acrobat Pro"
xmp:CreatorTool="InDesign 2025"/>
</rdf:RDF>
</x:xmpmeta>"#;
let meta = Metadata::from_xmp(xmp);
assert_eq!(meta.producer, Some("Acrobat Pro".to_string()));
assert_eq!(meta.creator, Some("InDesign 2025".to_string()));
}
}