use crate::document::PdfDocument;
use crate::error::{Error, Result};
use crate::object::Object;
use quick_xml::Reader;
use quick_xml::events::{BytesText, Event};
use std::borrow::Cow;
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;
struct XmpEventReader<'x> {
reader: Reader<&'x [u8]>,
pending: Option<Event<'x>>,
}
impl<'x> XmpEventReader<'x> {
fn from_str(content: &'x str) -> Self {
Self {
reader: Reader::from_str(content),
pending: None,
}
}
fn read_event(&mut self) -> quick_xml::Result<Event<'x>> {
let first = match self.pending.take() {
Some(event) => event,
None => self.reader.read_event()?,
};
let mut text = match first {
Event::Text(text) => Cow::Borrowed(text.as_ref()).into_owned(),
Event::GeneralRef(reference) => resolve_xmp_reference(&reference),
other => return Ok(other),
};
loop {
match self.reader.read_event()? {
Event::Text(fragment) => text.push_str(&Cow::Borrowed(fragment.as_ref())),
Event::GeneralRef(reference) => {
text.push_str(&resolve_xmp_reference(&reference));
}
other => {
self.pending = Some(other);
break;
}
}
}
Ok(Event::Text(BytesText::from_escaped(text)))
}
}
fn resolve_xmp_reference(reference: &quick_xml::events::BytesRef<'_>) -> String {
if let Ok(Some(character)) = reference.resolve_char_ref() {
return character.to_string();
}
let name: Cow<'_, str> = Cow::Borrowed(reference.as_ref());
if let Some(resolved) = quick_xml::escape::resolve_predefined_entity(&name) {
return resolved.to_string();
}
format!("&{name};")
}
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 xmp_content = match Self::find_wrapped_section(xml, "<x:xmpmeta", "</x:xmpmeta>")
.or_else(|| Self::find_wrapped_section(xml, "<rdf:RDF", "</rdf:RDF>"))
{
Some(content) => content,
None => return Ok(None),
};
let mut metadata = XmpMetadata::new();
metadata.raw_xml = Some(xml.to_string());
let mut reader = XmpEventReader::from_str(xmp_content);
let mut element_stack: Vec<String> = Vec::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => {
let name = e.name().as_ref().to_string();
element_stack.push(name);
}
Ok(Event::Empty(_)) => {
}
Ok(Event::Text(e)) => {
let text = e.xml11_content().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(_) => {
tracing::warn!(
target: crate::LOG_TARGET_ROOT,
operation = "parse_xmp_metadata",
error_code = "malformed_xml",
error_offset = reader.reader.buffer_position(),
"stopping XMP metadata parsing"
);
break;
}
_ => {}
}
}
Ok(Some(metadata))
}
fn find_wrapped_section<'a>(xml: &'a str, open: &str, close: &str) -> Option<&'a str> {
let start = xml.find(open)?;
let end_rel = xml[start..].find(close)?;
Some(&xml[start..start + end_rel + close.len()])
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use tracing::Level;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt as _;
#[derive(Clone, Default)]
struct EventCapture(Arc<Mutex<Vec<(Level, String, BTreeMap<String, String>)>>>);
impl<S> Layer<S> for EventCapture
where
S: tracing::Subscriber,
{
fn on_event(&self, event: &tracing::Event<'_>, _context: tracing_subscriber::layer::Context<'_, S>) {
let mut fields = FieldCapture::default();
event.record(&mut fields);
self.0.lock().unwrap().push((
*event.metadata().level(),
event.metadata().target().to_string(),
fields.0,
));
}
}
#[derive(Default)]
struct FieldCapture(BTreeMap<String, String>);
impl tracing::field::Visit for FieldCapture {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.insert(field.name().to_string(), format!("{value:?}"));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.0.insert(field.name().to_string(), value.to_string());
}
}
#[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 parse_xmp_does_not_panic_on_closing_tag_before_real_content() {
let malformed = "</x:xmpmeta>x<rdf:RDF>hello</rdf:RDF>";
let result = XmpExtractor::parse_xmp(malformed);
assert!(result.is_ok(), "malformed XMP data must not panic: {result:?}");
}
#[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"]);
}
#[test]
fn parse_xmp_preserves_named_references_in_scalar_values() {
let xmp = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<rdf:Description>
<dc:description><rdf:Alt>
<rdf:li><div>alpha & beta</div></rdf:li>
</rdf:Alt></dc:description>
</rdf:Description>
</rdf:RDF>"#;
let metadata = XmpExtractor::parse_xmp(xmp).unwrap().unwrap();
assert_eq!(metadata.dc_description, Some("<div>alpha & beta</div>".to_string()));
}
#[test]
fn parse_xmp_preserves_numeric_references_in_scalar_values() {
let xmp = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
<rdf:Description>
<pdf:Keywords>alpha beta€!</pdf:Keywords>
</rdf:Description>
</rdf:RDF>"#;
let metadata = XmpExtractor::parse_xmp(xmp).unwrap().unwrap();
assert_eq!(metadata.pdf_keywords, Some("alpha beta€!".to_string()));
}
#[test]
fn parse_xmp_coalesces_fragmented_sequence_values() {
let xmp = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<rdf:Description>
<dc:creator><rdf:Seq>
<rdf:li>Jane & John #1</rdf:li>
<rdf:li>Smith</rdf:li>
</rdf:Seq></dc:creator>
</rdf:Description>
</rdf:RDF>"#;
let metadata = XmpExtractor::parse_xmp(xmp).unwrap().unwrap();
assert_eq!(metadata.dc_creator, vec!["Jane & John #1", "Smith"]);
}
#[test]
fn malformed_xmp_warning_has_stable_identity_without_parser_text() {
const CONFIDENTIAL_MARKER: &str = "CONFIDENTIAL_XMP_ELEMENT_2fd6";
let xmp = format!("<rdf:RDF><{CONFIDENTIAL_MARKER}></wrong></rdf:RDF>");
let capture = EventCapture::default();
let subscriber = tracing_subscriber::registry().with(capture.clone());
let result = tracing::subscriber::with_default(subscriber, || XmpExtractor::parse_xmp(&xmp));
assert!(result.is_ok());
let events = capture.0.lock().unwrap().clone();
let warnings: Vec<_> = events
.iter()
.filter(|(level, target, fields)| {
*level == Level::WARN
&& target == crate::LOG_TARGET_ROOT
&& fields.get("operation").map(String::as_str) == Some("parse_xmp_metadata")
})
.collect();
assert_eq!(warnings.len(), 1, "expected one XMP parser warning: {events:#?}");
let fields = &warnings[0].2;
assert_eq!(fields.get("error_code").map(String::as_str), Some("malformed_xml"));
assert!(fields.contains_key("error_offset"));
assert!(!fields.contains_key("error"));
assert!(!format!("{events:?}").contains(CONFIDENTIAL_MARKER));
}
}