use crate::document::PdfDocument;
use crate::error::{Error, Result};
use crate::object::Object;
use quick_xml::Reader;
use quick_xml::events::Event;
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct XmpMetadata {
pub dc_title: Option<String>,
pub dc_creator: Vec<String>,
pub dc_description: Option<String>,
pub dc_subject: Vec<String>,
pub dc_language: Option<String>,
pub dc_rights: Option<String>,
pub dc_format: Option<String>,
pub xmp_creator_tool: Option<String>,
pub xmp_create_date: Option<String>,
pub xmp_modify_date: Option<String>,
pub xmp_metadata_date: Option<String>,
pub pdf_producer: Option<String>,
pub pdf_keywords: Option<String>,
pub pdf_version: Option<String>,
pub pdf_trapped: Option<String>,
pub xmp_rights_usage_terms: Option<String>,
pub xmp_rights_marked: Option<bool>,
pub xmp_rights_web_statement: Option<String>,
pub custom: HashMap<String, String>,
pub raw_xml: Option<String>,
}
impl XmpMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.dc_title.is_none()
&& self.dc_creator.is_empty()
&& self.dc_description.is_none()
&& self.dc_subject.is_empty()
&& self.xmp_creator_tool.is_none()
&& self.xmp_create_date.is_none()
&& self.xmp_modify_date.is_none()
&& self.pdf_producer.is_none()
&& self.pdf_keywords.is_none()
&& self.custom.is_empty()
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.dc_title = Some(title.into());
self
}
pub fn with_creator(mut self, creator: impl Into<String>) -> Self {
self.dc_creator.push(creator.into());
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.dc_description = Some(desc.into());
self
}
pub fn with_creator_tool(mut self, tool: impl Into<String>) -> Self {
self.xmp_creator_tool = Some(tool.into());
self
}
pub fn with_create_date(mut self, date: impl Into<String>) -> Self {
self.xmp_create_date = Some(date.into());
self
}
pub fn with_modify_date(mut self, date: impl Into<String>) -> Self {
self.xmp_modify_date = Some(date.into());
self
}
pub fn with_producer(mut self, producer: impl Into<String>) -> Self {
self.pdf_producer = Some(producer.into());
self
}
pub fn with_custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.custom.insert(key.into(), value.into());
self
}
}
pub struct XmpExtractor;
impl XmpExtractor {
fn resolve_object(doc: &PdfDocument, obj: &Object) -> Result<Object> {
if let Some(ref_val) = obj.as_reference() {
doc.load_object(ref_val)
} else {
Ok(obj.clone())
}
}
pub fn extract(doc: &PdfDocument) -> Result<Option<XmpMetadata>> {
let catalog = doc.catalog()?;
let catalog_dict = catalog
.as_dict()
.ok_or_else(|| Error::InvalidPdf("Catalog is not a dictionary".to_string()))?;
let metadata_obj = match catalog_dict.get("Metadata") {
Some(obj) => obj.clone(),
None => return Ok(None),
};
let metadata_resolved = Self::resolve_object(doc, &metadata_obj)?;
let xml_bytes = match &metadata_resolved {
Object::Stream { .. } => {
let decoded = metadata_resolved.decode_stream_data()?;
decoded.to_vec()
},
_ => return Err(Error::InvalidPdf("Metadata is not a stream".to_string())),
};
let xml_str = String::from_utf8_lossy(&xml_bytes).to_string();
Self::parse_xmp(&xml_str)
}
pub fn parse_xmp(xml: &str) -> Result<Option<XmpMetadata>> {
let start = xml.find("<x:xmpmeta").or_else(|| xml.find("<rdf:RDF"));
let end = xml
.rfind("</x:xmpmeta>")
.or_else(|| xml.rfind("</rdf:RDF>"));
let xmp_content = match (start, end) {
(Some(s), Some(e)) => {
let end_adjusted = if xml[e..].starts_with("</x:xmpmeta") {
e + "</x:xmpmeta>".len()
} else {
e + "</rdf:RDF>".len()
};
&xml[s..end_adjusted]
},
_ => return Ok(None),
};
let mut metadata = XmpMetadata::new();
metadata.raw_xml = Some(xml.to_string());
let mut reader = Reader::from_str(xmp_content);
reader.config_mut().trim_text(true);
let mut element_stack: Vec<String> = Vec::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
element_stack.push(name);
},
Ok(Event::Empty(_)) => {
},
Ok(Event::Text(e)) => {
let text = e.xml11_content().unwrap_or_default().trim().to_string();
if text.is_empty() {
continue;
}
let property = element_stack
.iter()
.rev()
.find(|el| !el.starts_with("rdf:") && !el.starts_with("x:"))
.cloned();
if let Some(prop) = property {
match prop.as_str() {
"dc:title" => {
if metadata.dc_title.is_none() {
metadata.dc_title = Some(text);
}
},
"dc:creator" => {
metadata.dc_creator.push(text);
},
"dc:description" => {
if metadata.dc_description.is_none() {
metadata.dc_description = Some(text);
}
},
"dc:subject" => {
metadata.dc_subject.push(text);
},
"dc:language" => metadata.dc_language = Some(text),
"dc:rights" => {
if metadata.dc_rights.is_none() {
metadata.dc_rights = Some(text);
}
},
"dc:format" => metadata.dc_format = Some(text),
"xmp:CreatorTool" => metadata.xmp_creator_tool = Some(text),
"xmp:CreateDate" => metadata.xmp_create_date = Some(text),
"xmp:ModifyDate" => metadata.xmp_modify_date = Some(text),
"xmp:MetadataDate" => metadata.xmp_metadata_date = Some(text),
"pdf:Producer" => metadata.pdf_producer = Some(text),
"pdf:Keywords" => metadata.pdf_keywords = Some(text),
"pdf:PDFVersion" => metadata.pdf_version = Some(text),
"pdf:Trapped" => metadata.pdf_trapped = Some(text),
"xmpRights:UsageTerms" => {
if metadata.xmp_rights_usage_terms.is_none() {
metadata.xmp_rights_usage_terms = Some(text);
}
},
"xmpRights:Marked" => {
metadata.xmp_rights_marked = Some(text.to_lowercase() == "true");
},
"xmpRights:WebStatement" => {
metadata.xmp_rights_web_statement = Some(text)
},
_ => {
metadata.custom.insert(prop.clone(), text);
},
}
}
},
Ok(Event::End(_)) => {
element_stack.pop();
},
Ok(Event::Eof) => break,
Err(e) => {
tracing::warn!("XMP parsing error: {:?}", e);
break;
},
_ => {},
}
}
Ok(Some(metadata))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_xmp_basic() {
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="x-default">Test Document</rdf:li>
</rdf:Alt>
</dc:title>
<dc:creator>
<rdf:Seq>
<rdf:li>John Doe</rdf:li>
<rdf:li>Jane Smith</rdf:li>
</rdf:Seq>
</dc:creator>
<xmp:CreatorTool>native_pdf</xmp:CreatorTool>
<xmp:CreateDate>2024-01-15T10:30:00Z</xmp:CreateDate>
<pdf:Producer>native_pdf 0.3.0</pdf:Producer>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
let metadata = XmpExtractor::parse_xmp(xmp).unwrap().unwrap();
assert_eq!(metadata.dc_title, Some("Test Document".to_string()));
assert_eq!(metadata.dc_creator, vec!["John Doe", "Jane Smith"]);
assert_eq!(metadata.xmp_creator_tool, Some("native_pdf".to_string()));
assert_eq!(metadata.xmp_create_date, Some("2024-01-15T10:30:00Z".to_string()));
assert_eq!(metadata.pdf_producer, Some("native_pdf 0.3.0".to_string()));
}
#[test]
fn test_xmp_metadata_builder() {
let metadata = XmpMetadata::new()
.with_title("My Document")
.with_creator("Author 1")
.with_creator("Author 2")
.with_description("A test document")
.with_creator_tool("native_pdf")
.with_producer("native_pdf 0.3.0");
assert_eq!(metadata.dc_title, Some("My Document".to_string()));
assert_eq!(metadata.dc_creator, vec!["Author 1", "Author 2"]);
assert_eq!(metadata.dc_description, Some("A test document".to_string()));
assert_eq!(metadata.xmp_creator_tool, Some("native_pdf".to_string()));
assert_eq!(metadata.pdf_producer, Some("native_pdf 0.3.0".to_string()));
}
#[test]
fn test_xmp_is_empty() {
let empty = XmpMetadata::new();
assert!(empty.is_empty());
let non_empty = XmpMetadata::new().with_title("Title");
assert!(!non_empty.is_empty());
}
#[test]
fn test_parse_xmp_with_subjects() {
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/">
<dc:subject>
<rdf:Bag>
<rdf:li>PDF</rdf:li>
<rdf:li>Rust</rdf:li>
<rdf:li>Metadata</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
let metadata = XmpExtractor::parse_xmp(xmp).unwrap().unwrap();
assert_eq!(metadata.dc_subject, vec!["PDF", "Rust", "Metadata"]);
}
}