use crate::pdf::Metadata;
const PACKET_ID: &str = "W5M0MpCehiHzreSzNTczkc9d";
pub(crate) fn packet(meta: &Metadata) -> Vec<u8> {
let mut elements = String::new();
if let Some(title) = &meta.title {
elements.push_str(&alt_element("dc:title", title));
}
if let Some(author) = &meta.author {
elements.push_str(&seq_element("dc:creator", author));
}
if let Some(subject) = &meta.subject {
elements.push_str(&alt_element("dc:description", subject));
}
if let Some(keywords) = &meta.keywords {
elements.push_str(&leaf_element("pdf:Keywords", keywords));
}
if let Some(producer) = &meta.producer {
elements.push_str(&leaf_element("pdf:Producer", producer));
}
if let Some(creator) = &meta.creator {
elements.push_str(&leaf_element("xmp:CreatorTool", creator));
}
if let Some(date) = meta.creation_date {
elements.push_str(&leaf_element("xmp:CreateDate", &date.to_iso8601()));
}
if let Some(date) = meta.modification_date {
elements.push_str(&leaf_element("xmp:ModifyDate", &date.to_iso8601()));
}
format!(
"<?xpacket begin=\"\u{feff}\" id=\"{PACKET_ID}\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n\
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\" \
xmlns:dc=\"http://purl.org/dc/elements/1.1/\" \
xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\" \
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\">\n\
{elements}</rdf:Description>\n\
</rdf:RDF>\n\
</x:xmpmeta>\n\
<?xpacket end=\"w\"?>"
)
.into_bytes()
}
fn leaf_element(tag: &str, value: &str) -> String {
format!("<{tag}>{}</{tag}>\n", escape(value))
}
fn alt_element(tag: &str, value: &str) -> String {
format!(
"<{tag}><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></{tag}>\n",
escape(value)
)
}
fn seq_element(tag: &str, value: &str) -> String {
format!(
"<{tag}><rdf:Seq><rdf:li>{}</rdf:li></rdf:Seq></{tag}>\n",
escape(value)
)
}
fn escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pdf::Date;
fn full_metadata() -> Metadata {
Metadata {
title: Some("Report".to_string()),
author: Some("Jane Doe".to_string()),
subject: Some("Quarterly numbers".to_string()),
keywords: Some("finance, quarterly".to_string()),
creator: Some("pdfboss".to_string()),
producer: Some("pdfboss-write".to_string()),
creation_date: Some(Date {
year: 2026,
month: 8,
day: 27,
hour: 12,
minute: 30,
second: 15,
utc_offset_minutes: 0,
}),
modification_date: Some(Date {
year: 2026,
month: 8,
day: 28,
hour: 9,
minute: 0,
second: 0,
utc_offset_minutes: 120,
}),
}
}
fn text(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).expect("packet is valid UTF-8")
}
#[test]
fn full_metadata_maps_every_field() {
let xml = text(packet(&full_metadata()));
assert!(xml.contains(
"<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Report</rdf:li></rdf:Alt></dc:title>"
));
assert!(
xml.contains("<dc:creator><rdf:Seq><rdf:li>Jane Doe</rdf:li></rdf:Seq></dc:creator>")
);
assert!(xml.contains(
"<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">Quarterly numbers</rdf:li></rdf:Alt></dc:description>"
));
assert!(xml.contains("<pdf:Keywords>finance, quarterly</pdf:Keywords>"));
assert!(xml.contains("<pdf:Producer>pdfboss-write</pdf:Producer>"));
assert!(xml.contains("<xmp:CreatorTool>pdfboss</xmp:CreatorTool>"));
assert!(xml.contains("<xmp:CreateDate>2026-08-27T12:30:15Z</xmp:CreateDate>"));
assert!(xml.contains("<xmp:ModifyDate>2026-08-28T09:00:00+02:00</xmp:ModifyDate>"));
}
#[test]
fn absent_fields_write_no_element() {
let xml = text(packet(&Metadata::default()));
assert!(!xml.contains("dc:title"));
assert!(!xml.contains("dc:creator"));
assert!(!xml.contains("dc:description"));
assert!(!xml.contains("pdf:Keywords"));
assert!(!xml.contains("pdf:Producer"));
assert!(!xml.contains("xmp:CreatorTool"));
assert!(!xml.contains("xmp:CreateDate"));
assert!(!xml.contains("xmp:ModifyDate"));
}
#[test]
fn never_contains_instance_or_document_ids() {
let xml = text(packet(&full_metadata()));
assert!(!xml.contains("InstanceID"));
assert!(!xml.contains("DocumentID"));
assert!(!xml.contains("xmpMM"));
}
#[test]
fn special_characters_are_escaped() {
let meta = Metadata {
title: Some("<&>\"'".to_string()),
..Metadata::default()
};
let xml = text(packet(&meta));
assert!(xml.contains("<&>"'"));
assert!(!xml.contains("<&>\"'"));
}
#[test]
fn same_metadata_produces_identical_packets() {
assert_eq!(packet(&full_metadata()), packet(&full_metadata()));
}
}