use crate::extractors::xmp::XmpMetadata;
const NS_X: &str = "adobe:ns:meta/";
const NS_RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
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_XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/";
pub struct XmpWriter {
metadata: XmpMetadata,
}
impl XmpWriter {
pub fn new(metadata: XmpMetadata) -> Self {
Self { metadata }
}
pub fn default_metadata() -> Self {
let mut metadata = XmpMetadata::new();
metadata.xmp_creator_tool = Some("pdf_oxide".to_string());
metadata.pdf_producer = Some(format!("pdf_oxide {}", env!("CARGO_PKG_VERSION")));
Self { metadata }
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.metadata.dc_title = Some(title.into());
self
}
pub fn creator(mut self, creator: impl Into<String>) -> Self {
self.metadata.dc_creator.push(creator.into());
self
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.metadata.dc_description = Some(desc.into());
self
}
pub fn subject(mut self, subject: impl Into<String>) -> Self {
self.metadata.dc_subject.push(subject.into());
self
}
pub fn creator_tool(mut self, tool: impl Into<String>) -> Self {
self.metadata.xmp_creator_tool = Some(tool.into());
self
}
pub fn create_date(mut self, date: impl Into<String>) -> Self {
self.metadata.xmp_create_date = Some(date.into());
self
}
pub fn modify_date(mut self, date: impl Into<String>) -> Self {
self.metadata.xmp_modify_date = Some(date.into());
self
}
pub fn producer(mut self, producer: impl Into<String>) -> Self {
self.metadata.pdf_producer = Some(producer.into());
self
}
pub fn keywords(mut self, keywords: impl Into<String>) -> Self {
self.metadata.pdf_keywords = Some(keywords.into());
self
}
pub fn pdf_version(mut self, version: impl Into<String>) -> Self {
self.metadata.pdf_version = Some(version.into());
self
}
pub fn usage_terms(mut self, terms: impl Into<String>) -> Self {
self.metadata.xmp_rights_usage_terms = Some(terms.into());
self
}
pub fn rights_marked(mut self, marked: bool) -> Self {
self.metadata.xmp_rights_marked = Some(marked);
self
}
pub fn build(self) -> String {
self.to_xml()
}
pub fn build_bytes(self) -> Vec<u8> {
self.to_xml().into_bytes()
}
fn to_xml(&self) -> String {
let mut xml = String::new();
xml.push_str(r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>"#);
xml.push('\n');
xml.push_str(&format!(r#"<x:xmpmeta xmlns:x="{}">"#, NS_X));
xml.push('\n');
xml.push_str(&format!(r#" <rdf:RDF xmlns:rdf="{}">"#, NS_RDF));
xml.push('\n');
xml.push_str(" <rdf:Description rdf:about=\"\"\n");
xml.push_str(&format!(" xmlns:dc=\"{}\"\n", NS_DC));
xml.push_str(&format!(" xmlns:xmp=\"{}\"\n", NS_XMP));
xml.push_str(&format!(" xmlns:pdf=\"{}\"\n", NS_PDF));
xml.push_str(&format!(" xmlns:xmpRights=\"{}\">\n", NS_XMP_RIGHTS));
if let Some(title) = &self.metadata.dc_title {
xml.push_str(" <dc:title>\n");
xml.push_str(" <rdf:Alt>\n");
xml.push_str(&format!(
" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n",
escape_xml(title)
));
xml.push_str(" </rdf:Alt>\n");
xml.push_str(" </dc:title>\n");
}
if !self.metadata.dc_creator.is_empty() {
xml.push_str(" <dc:creator>\n");
xml.push_str(" <rdf:Seq>\n");
for creator in &self.metadata.dc_creator {
xml.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape_xml(creator)));
}
xml.push_str(" </rdf:Seq>\n");
xml.push_str(" </dc:creator>\n");
}
if let Some(desc) = &self.metadata.dc_description {
xml.push_str(" <dc:description>\n");
xml.push_str(" <rdf:Alt>\n");
xml.push_str(&format!(
" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n",
escape_xml(desc)
));
xml.push_str(" </rdf:Alt>\n");
xml.push_str(" </dc:description>\n");
}
if !self.metadata.dc_subject.is_empty() {
xml.push_str(" <dc:subject>\n");
xml.push_str(" <rdf:Bag>\n");
for subject in &self.metadata.dc_subject {
xml.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape_xml(subject)));
}
xml.push_str(" </rdf:Bag>\n");
xml.push_str(" </dc:subject>\n");
}
if let Some(language) = &self.metadata.dc_language {
xml.push_str(&format!(" <dc:language>{}</dc:language>\n", escape_xml(language)));
}
if let Some(rights) = &self.metadata.dc_rights {
xml.push_str(" <dc:rights>\n");
xml.push_str(" <rdf:Alt>\n");
xml.push_str(&format!(
" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n",
escape_xml(rights)
));
xml.push_str(" </rdf:Alt>\n");
xml.push_str(" </dc:rights>\n");
}
if let Some(format) = &self.metadata.dc_format {
xml.push_str(&format!(" <dc:format>{}</dc:format>\n", escape_xml(format)));
}
if let Some(tool) = &self.metadata.xmp_creator_tool {
xml.push_str(&format!(
" <xmp:CreatorTool>{}</xmp:CreatorTool>\n",
escape_xml(tool)
));
}
if let Some(date) = &self.metadata.xmp_create_date {
xml.push_str(&format!(" <xmp:CreateDate>{}</xmp:CreateDate>\n", escape_xml(date)));
}
if let Some(date) = &self.metadata.xmp_modify_date {
xml.push_str(&format!(" <xmp:ModifyDate>{}</xmp:ModifyDate>\n", escape_xml(date)));
}
if let Some(date) = &self.metadata.xmp_metadata_date {
xml.push_str(&format!(
" <xmp:MetadataDate>{}</xmp:MetadataDate>\n",
escape_xml(date)
));
}
if let Some(producer) = &self.metadata.pdf_producer {
xml.push_str(&format!(" <pdf:Producer>{}</pdf:Producer>\n", escape_xml(producer)));
}
if let Some(keywords) = &self.metadata.pdf_keywords {
xml.push_str(&format!(" <pdf:Keywords>{}</pdf:Keywords>\n", escape_xml(keywords)));
}
if let Some(version) = &self.metadata.pdf_version {
xml.push_str(&format!(
" <pdf:PDFVersion>{}</pdf:PDFVersion>\n",
escape_xml(version)
));
}
if let Some(trapped) = &self.metadata.pdf_trapped {
xml.push_str(&format!(" <pdf:Trapped>{}</pdf:Trapped>\n", escape_xml(trapped)));
}
if let Some(terms) = &self.metadata.xmp_rights_usage_terms {
xml.push_str(" <xmpRights:UsageTerms>\n");
xml.push_str(" <rdf:Alt>\n");
xml.push_str(&format!(
" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n",
escape_xml(terms)
));
xml.push_str(" </rdf:Alt>\n");
xml.push_str(" </xmpRights:UsageTerms>\n");
}
if let Some(marked) = self.metadata.xmp_rights_marked {
xml.push_str(&format!(
" <xmpRights:Marked>{}</xmpRights:Marked>\n",
if marked { "True" } else { "False" }
));
}
if let Some(url) = &self.metadata.xmp_rights_web_statement {
xml.push_str(&format!(
" <xmpRights:WebStatement>{}</xmpRights:WebStatement>\n",
escape_xml(url)
));
}
for (key, value) in &self.metadata.custom {
xml.push_str(&format!(" <{}>{}</{}>\n", key, escape_xml(value), key));
}
xml.push_str(" </rdf:Description>\n");
xml.push_str(" </rdf:RDF>\n");
xml.push_str("</x:xmpmeta>\n");
for _ in 0..40 {
xml.push_str(" \n");
}
xml.push_str(r#"<?xpacket end="w"?>"#);
xml
}
}
impl Default for XmpWriter {
fn default() -> Self {
Self::default_metadata()
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub fn iso_timestamp() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xmp_writer_basic() {
let writer = XmpWriter::default_metadata()
.title("Test Document")
.creator("John Doe")
.description("A test document");
let xml = writer.build();
assert!(xml.contains("<?xpacket begin"));
assert!(xml.contains("<dc:title>"));
assert!(xml.contains("Test Document"));
assert!(xml.contains("<dc:creator>"));
assert!(xml.contains("John Doe"));
assert!(xml.contains("<dc:description>"));
assert!(xml.contains("A test document"));
assert!(xml.contains("<?xpacket end"));
}
#[test]
fn test_xmp_writer_multiple_creators() {
let writer = XmpWriter::new(XmpMetadata::new())
.creator("Author 1")
.creator("Author 2")
.creator("Author 3");
let xml = writer.build();
assert!(xml.contains("<rdf:Seq>"));
assert!(xml.contains("Author 1"));
assert!(xml.contains("Author 2"));
assert!(xml.contains("Author 3"));
}
#[test]
fn test_xmp_writer_subjects() {
let writer = XmpWriter::new(XmpMetadata::new())
.subject("PDF")
.subject("Rust")
.subject("Metadata");
let xml = writer.build();
assert!(xml.contains("<dc:subject>"));
assert!(xml.contains("<rdf:Bag>"));
assert!(xml.contains("PDF"));
assert!(xml.contains("Rust"));
assert!(xml.contains("Metadata"));
}
#[test]
fn test_xmp_writer_xml_escape() {
let writer = XmpWriter::new(XmpMetadata::new()).title("Test & Document <special>");
let xml = writer.build();
assert!(xml.contains("Test & Document <special>"));
}
#[test]
fn test_xmp_writer_dates() {
let writer = XmpWriter::new(XmpMetadata::new())
.create_date("2024-01-15T10:30:00Z")
.modify_date("2024-01-16T14:00:00Z");
let xml = writer.build();
assert!(xml.contains("<xmp:CreateDate>2024-01-15T10:30:00Z</xmp:CreateDate>"));
assert!(xml.contains("<xmp:ModifyDate>2024-01-16T14:00:00Z</xmp:ModifyDate>"));
}
#[test]
fn test_xmp_writer_pdf_properties() {
let writer = XmpWriter::new(XmpMetadata::new())
.producer("pdf_oxide 0.3.0")
.keywords("test, pdf, metadata")
.pdf_version("1.7");
let xml = writer.build();
assert!(xml.contains("<pdf:Producer>pdf_oxide 0.3.0</pdf:Producer>"));
assert!(xml.contains("<pdf:Keywords>test, pdf, metadata</pdf:Keywords>"));
assert!(xml.contains("<pdf:PDFVersion>1.7</pdf:PDFVersion>"));
}
#[test]
fn test_xmp_writer_rights() {
let writer = XmpWriter::new(XmpMetadata::new())
.usage_terms("All rights reserved")
.rights_marked(true);
let xml = writer.build();
assert!(xml.contains("<xmpRights:UsageTerms>"));
assert!(xml.contains("All rights reserved"));
assert!(xml.contains("<xmpRights:Marked>True</xmpRights:Marked>"));
}
}